From 70a01556ac553c6a475163c886f0bf9b9f88a580 Mon Sep 17 00:00:00 2001 From: shaokaiye Date: Thu, 6 Apr 2023 13:42:24 +0000 Subject: [PATCH 001/293] spatial pyramid for video adaptation by default, with a small range of scales --- .../modelzoo/api/spatiotemporal_adapt.py | 45 ++++++++++++++----- .../modelzoo/api/superanimal_inference.py | 7 ++- .../core/train_multianimal.py | 7 ++- .../datasets/pose_multianimal_imgaug.py | 36 +++++++++++---- .../predict_supermodel.py | 28 +++++++++--- examples/testscript_superanimal_adaptation.py | 12 ----- 6 files changed, 94 insertions(+), 41 deletions(-) diff --git a/deeplabcut/modelzoo/api/spatiotemporal_adapt.py b/deeplabcut/modelzoo/api/spatiotemporal_adapt.py index 656eae9ddc..0436e1bc45 100644 --- a/deeplabcut/modelzoo/api/spatiotemporal_adapt.py +++ b/deeplabcut/modelzoo/api/spatiotemporal_adapt.py @@ -97,9 +97,10 @@ def __init__( else: self.customized_pose_config = customized_pose_config - def before_adapt_inference(self, make_video=False, **kwargs): + def before_adapt_inference(self, make_video=False, **kwargs): if self.init_weights != "": - _ = superanimal_inference.video_inference( + print ('using customized weights', self.init_weights) + _, datafiles = superanimal_inference.video_inference( [self.video_path], self.supermodel_name, videotype=self.videotype, @@ -108,13 +109,16 @@ def before_adapt_inference(self, make_video=False, **kwargs): customized_test_config=self.customized_pose_config, ) else: - self.init_weights, _ = superanimal_inference.video_inference( + self.init_weights, datafiles = superanimal_inference.video_inference( [self.video_path], self.supermodel_name, videotype=self.videotype, scale_list=self.scale_list, customized_test_config=self.customized_pose_config, ) + if kwargs.pop('plot_trajectories', True): + _plot_trajectories(datafiles[0]) + if make_video: deeplabcut.create_labeled_video( "", @@ -127,11 +131,15 @@ def before_adapt_inference(self, make_video=False, **kwargs): **kwargs, ) - def train_without_project(self, pseudo_label_path, **kwargs): + def train_without_project(self, + pseudo_label_path, + **kwargs): from deeplabcut.pose_estimation_tensorflow.core.train_multianimal import train - displayiters = kwargs.pop("displayiters", 500) saveiters = kwargs.pop("saveiters", 1000) + adapt_iterations = kwargs.pop('adapt_iterations', 1000) + self.adapt_iterations = adapt_iterations + train( self.customized_pose_config, displayiters=displayiters, @@ -141,10 +149,14 @@ def train_without_project(self, pseudo_label_path, **kwargs): init_weights=self.init_weights, pseudo_labels=pseudo_label_path, video_path=self.video_path, + topview = 'topview' in self.supermodel_name, **kwargs, ) - def adaptation_training(self, displayiters=500, saveiters=1000, **kwargs): + def adaptation_training(self, + displayiters=500, + saveiters=1000, + **kwargs): """ There should be two choices, either taking a config, with is then assuming there is a DLC project. Or we make up a fake one, then we use a light way convention to do adaptation @@ -161,12 +173,18 @@ def adaptation_training(self, displayiters=500, saveiters=1000, **kwargs): if self.modelfolder != "": os.makedirs(self.modelfolder, exist_ok=True) - self.train_without_project( - pseudo_label_path, - displayiters=displayiters, - saveiters=saveiters, - **kwargs, - ) + self.adapt_iterations = kwargs['adapt_iterations'] + + + if os.path.exists(os.path.join(self.modelfolder, f"snapshot-{self.adapt_iterations}.index")): + print (f'model checkpoint snapshot-{self.adapt_iterations}.index exists, skipping the video adaptation') + else: + self.train_without_project( + pseudo_label_path, + displayiters=displayiters, + saveiters=saveiters, + **kwargs, + ) def after_adapt_inference(self, **kwargs): @@ -185,6 +203,9 @@ def after_adapt_inference(self, **kwargs): # spatial pyramid is not for adapted model scale_list = kwargs.pop("scale_list", []) + + # spatial pyramid can still be useful for reducing jittering and quantization error + _, datafiles = superanimal_inference.video_inference( [self.video_path], self.supermodel_name, diff --git a/deeplabcut/modelzoo/api/superanimal_inference.py b/deeplabcut/modelzoo/api/superanimal_inference.py index e60139ad7e..7f6191e294 100644 --- a/deeplabcut/modelzoo/api/superanimal_inference.py +++ b/deeplabcut/modelzoo/api/superanimal_inference.py @@ -88,6 +88,9 @@ def _average_multiple_scale_preds( xyp = np.zeros((len(scale_list), num_kpts, 3)) for scale_id, pred in enumerate(preds): + # empty prediction if pred is not a dict + if isinstance(pred, list): + continue coordinates = pred["coordinates"][0] confidence = pred["confidence"] for i, (coords, conf) in enumerate(zip(coordinates, confidence)): @@ -336,8 +339,8 @@ def video_inference( print("Loading ", video) vid = VideoWriter(video) if len(scale_list) == 0: - # if the scale_list is empty, by default we use the original one - scale_list = [vid.height] + # spatial pyramid can still be useful for reducing jittering and quantization error + scale_list = [vid.height - 50, vid.height, vid.height + 50] if robust_nframes: nframes = vid.get_n_frames(robust=True) duration = vid.calc_duration(robust=True) diff --git a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py index 9c589c1ff3..412d12a7ba 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py +++ b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py @@ -40,10 +40,12 @@ def train( allow_growth=True, pseudo_labels="", init_weights="", - pseudo_threshold=0, + pseudo_threshold=0.1, modelfolder="", traintime_resize=False, video_path="", + topview = False, + trim_ends = None #trim the both ends of the video for video adaptation ): # in case there was already a graph tf.compat.v1.reset_default_graph() @@ -63,7 +65,8 @@ def train( cfg["pseudo_threshold"] = pseudo_threshold cfg["video_path"] = video_path cfg["traintime_resize"] = traintime_resize - + cfg["topview"] = topview + if pseudo_labels != "": cfg["pseudo_label"] = pseudo_labels diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py index 05d9eb953c..9ddeef625b 100644 --- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py +++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py @@ -82,7 +82,9 @@ def load_dataset(self): if cfg["pseudo_label"].endswith(".h5"): pseudo_threshold = cfg.get("pseudo_threshold", 0) print(f"Loading pseudo labels with threshold > {pseudo_threshold}") - return self._load_pseudo_data_from_h5(cfg, threshold=pseudo_threshold) + return self._load_pseudo_data_from_h5(cfg, + threshold=pseudo_threshold, + topview = cfg.get('topview', False)) file_name = os.path.join(self.cfg["project_path"], cfg["dataset"]) with open(os.path.join(self.cfg["project_path"], file_name), "rb") as f: @@ -124,12 +126,12 @@ def load_dataset(self): self.has_gt = has_gt return data - def _load_pseudo_data_from_h5(self, cfg, threshold=0.5): + def _load_pseudo_data_from_h5(self, cfg, threshold=0.5, topview=False): gt_file = cfg["pseudo_label"] assert os.path.exists(gt_file) path_ = Path(gt_file) print("Using gt file:", path_.name) - + num_kpts = len(cfg['all_joints_names']) df = pd.read_hdf(gt_file) video_name = path_.name.split("DLC")[0] video_root = str(path_.parents[0] / video_name) @@ -153,8 +155,20 @@ def _load_pseudo_data_from_h5(self, cfg, threshold=0.5): ) item.joints = {} - joints = np.concatenate([joint_ids, kpts], axis=1) - joints = np.nan_to_num(joints, nan=0) + + if not topview: + joints = np.concatenate([joint_ids, kpts], axis=1) + joints = np.nan_to_num(joints, nan=0) + else: + # for topview, it's safe to mask keypoints under threshold + for kpt_id, kpt in enumerate(kpts): + if kpt[-1] < threshold: + kpts[kpt_id][:-1] = -1 + if np.isnan(kpt[0]): + kpts[kpt_id][:-1] = -1 + kpts[kpt_id][-1] = 1 + joints = np.concatenate([joint_ids, kpts], axis=1) + sparse_joints = [] for coord in joints: @@ -337,14 +351,20 @@ def get_batch_from_video(self): joint_ids = [] inds_visible = [] data_items = [] - img_idx = np.random.choice(num_images, size=self.batch_size, replace=True) + trim_ends = self.cfg.get('trim_ends', None) + if trim_ends is None: + trim_ends = 0 + # because of the existence of threshold, sampling population is adjusted to len(self.data) + img_idx = np.random.choice(len(self.data) - trim_ends *2, size=self.batch_size, replace=True) for i in range(self.batch_size): - data_item = self.data[img_idx[i]] + index = img_idx[i] + offset = trim_ends + data_item = self.data[index + offset] data_items.append(data_item) im_file = data_item.im_path logging.debug("image %s", im_file) - self.vid.set_to_frame(img_idx[i]) + self.vid.set_to_frame(index + offset) image = self.vid.read_frame() if self.has_gt: Joints = data_item.joints diff --git a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py b/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py index ed4af8b96e..e5850b4128 100644 --- a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py +++ b/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py @@ -20,6 +20,10 @@ def video_inference_superanimal( video_adapt=False, plot_trajectories=True, pcutoff=0.1, + init_weights = '', + adapt_iterations = 1000, + pseudo_threshold = 0.1, + trim_ends = None ): """ Makes prediction based on a super animal model. Note right now we only support single animal video inference @@ -52,6 +56,18 @@ def video_inference_superanimal( pcutoff: float, optional Keypoints confidence that are under pcutoff will not be shown in the resulted video + init_weights: str, optional: + Path to customized weights. Only for developing purpose + + adapt_iterations: int, optional: + Number of iterations for adaptation training + + pseudo_threshold: float, default 0.1 + Video adaptation only uses predictions that are above pseudo_threshold + + trim_ends: int, optional: + In cases where the beginning and ending of the videos have very messy background that impacts predictions of the model, we trim those from adaptation training + Given a list of scales for spatial pyramid, i.e. [600, 700] scale_list = range(600,800,100) @@ -66,8 +82,6 @@ def video_inference_superanimal( scale_list = scale_list, ) >>> - - """ for video in videos: @@ -80,13 +94,17 @@ def video_inference_superanimal( videotype=videotype, scale_list=scale_list, ) - if not video_adapt: - adapter.before_adapt_inference(make_video=True, pcutoff=pcutoff) + adapter.before_adapt_inference(make_video=True, + pcutoff=pcutoff, + plot_trajectories = plot_trajectories) else: adapter.before_adapt_inference(make_video=False) - adapter.adaptation_training() + adapter.adaptation_training(adapt_iterations = adapt_iterations, + pseudo_threshold = pseudo_threshold, + trim_ends = trim_ends) adapter.after_adapt_inference( pcutoff=pcutoff, plot_trajectories=plot_trajectories, ) + diff --git a/examples/testscript_superanimal_adaptation.py b/examples/testscript_superanimal_adaptation.py index 4cb1c658d3..303b4f5b6e 100644 --- a/examples/testscript_superanimal_adaptation.py +++ b/examples/testscript_superanimal_adaptation.py @@ -41,15 +41,3 @@ scale_list=scale_list, pcutoff=0.1, ) - - print("adaptation training for superanimal_quadruped") - - superanimal_name = "superanimal_quadruped" - deeplabcut.video_inference_superanimal( - [video], - superanimal_name, - videotype=".mp4", - video_adapt=True, - scale_list=scale_list, - pcutoff=0.3, - ) From 8441909d7a56caae379cd0b2cb1512665bad6a8e Mon Sep 17 00:00:00 2001 From: shaokai Date: Thu, 20 Apr 2023 16:46:33 +0200 Subject: [PATCH 002/293] Update test_crossvalutils.py --- tests/test_crossvalutils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_crossvalutils.py b/tests/test_crossvalutils.py index dd0e6f4e5c..61c5af043f 100644 --- a/tests/test_crossvalutils.py +++ b/tests/test_crossvalutils.py @@ -9,7 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # import numpy as np -import pickle +import pandas as pd from deeplabcut.pose_estimation_tensorflow.lib import crossvalutils @@ -98,8 +98,7 @@ def test_benchmark_paf_graphs_montblanc(evaluation_data_and_metadata_montblanc): [BEST_GRAPH_MONTBLANC], split_inds=[metadata["data"]["trainIndices"], metadata["data"]["testIndices"]], ) - with open("tests/data/montblanc_map.pickle", "rb") as file: - results_gt = pickle.load(file) + results_gt = pd.read_pickle("tests/data/montblanc_map.pickle") np.testing.assert_equal( results[1].loc["purity"].to_numpy().squeeze(), results_gt[0].loc["purity", 6].to_numpy(), From d5febb18a5b2a740f4048791689827e7d8c5e160 Mon Sep 17 00:00:00 2001 From: shaokaiye Date: Tue, 25 Apr 2023 07:59:44 +0000 Subject: [PATCH 003/293] Handled empty prediction --- deeplabcut/modelzoo/api/superanimal_inference.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deeplabcut/modelzoo/api/superanimal_inference.py b/deeplabcut/modelzoo/api/superanimal_inference.py index 376387e89e..6589d40ac6 100644 --- a/deeplabcut/modelzoo/api/superanimal_inference.py +++ b/deeplabcut/modelzoo/api/superanimal_inference.py @@ -88,7 +88,8 @@ def _average_multiple_scale_preds( xyp = np.zeros((len(scale_list), num_kpts, 3)) for scale_id, pred in enumerate(preds): # empty prediction if pred is not a dict - if isinstance(pred, list): + if not isinstance(pred, dict): + xyp[scale_id] = np.nan continue coordinates = pred["coordinates"][0] confidence = pred["confidence"] From f32d317a58385bc00cfcc9024c0e27b61b506ca2 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Tue, 23 May 2023 19:23:20 +0200 Subject: [PATCH 004/293] Removed unused parameter. Updated config to use superanimal instead of topview. --- deeplabcut/modelzoo/api/spatiotemporal_adapt.py | 2 +- .../core/train_multianimal.py | 10 ++++++---- .../datasets/pose_multianimal_imgaug.py | 16 ++++++++++------ .../predict_supermodel.py | 4 ---- examples/testscript_superanimal_adaptation.py | 1 + 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/deeplabcut/modelzoo/api/spatiotemporal_adapt.py b/deeplabcut/modelzoo/api/spatiotemporal_adapt.py index 0436e1bc45..b851c1ff44 100644 --- a/deeplabcut/modelzoo/api/spatiotemporal_adapt.py +++ b/deeplabcut/modelzoo/api/spatiotemporal_adapt.py @@ -149,7 +149,7 @@ def train_without_project(self, init_weights=self.init_weights, pseudo_labels=pseudo_label_path, video_path=self.video_path, - topview = 'topview' in self.supermodel_name, + superanimal=self.supermodel_name, **kwargs, ) diff --git a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py index 412d12a7ba..f9f2caed29 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py +++ b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py @@ -44,8 +44,8 @@ def train( modelfolder="", traintime_resize=False, video_path="", - topview = False, - trim_ends = None #trim the both ends of the video for video adaptation + superanimal=None, + trim_ends = None # trim the both ends of the video for video adaptation ): # in case there was already a graph tf.compat.v1.reset_default_graph() @@ -65,8 +65,10 @@ def train( cfg["pseudo_threshold"] = pseudo_threshold cfg["video_path"] = video_path cfg["traintime_resize"] = traintime_resize - cfg["topview"] = topview - + + if superanimal is not None: + cfg["superanimal"] = superanimal + if pseudo_labels != "": cfg["pseudo_label"] = pseudo_labels diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py index 9ddeef625b..6d7b94a999 100644 --- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py +++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py @@ -82,9 +82,14 @@ def load_dataset(self): if cfg["pseudo_label"].endswith(".h5"): pseudo_threshold = cfg.get("pseudo_threshold", 0) print(f"Loading pseudo labels with threshold > {pseudo_threshold}") - return self._load_pseudo_data_from_h5(cfg, - threshold=pseudo_threshold, - topview = cfg.get('topview', False)) + + # for topview, it's safe to mask keypoints under threshold + mask_kpts_below_thresh = "topview" in cfg.get("superanimal", "") + return self._load_pseudo_data_from_h5( + cfg, + threshold=pseudo_threshold, + mask_kpts_below_thresh=mask_kpts_below_thresh, + ) file_name = os.path.join(self.cfg["project_path"], cfg["dataset"]) with open(os.path.join(self.cfg["project_path"], file_name), "rb") as f: @@ -126,7 +131,7 @@ def load_dataset(self): self.has_gt = has_gt return data - def _load_pseudo_data_from_h5(self, cfg, threshold=0.5, topview=False): + def _load_pseudo_data_from_h5(self, cfg, threshold=0.5, mask_kpts_below_thresh=False): gt_file = cfg["pseudo_label"] assert os.path.exists(gt_file) path_ = Path(gt_file) @@ -156,11 +161,10 @@ def _load_pseudo_data_from_h5(self, cfg, threshold=0.5, topview=False): item.joints = {} - if not topview: + if not mask_kpts_below_thresh: joints = np.concatenate([joint_ids, kpts], axis=1) joints = np.nan_to_num(joints, nan=0) else: - # for topview, it's safe to mask keypoints under threshold for kpt_id, kpt in enumerate(kpts): if kpt[-1] < threshold: kpts[kpt_id][:-1] = -1 diff --git a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py b/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py index ba9af7fcaf..07b998a354 100644 --- a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py +++ b/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py @@ -20,7 +20,6 @@ def video_inference_superanimal( video_adapt=False, plot_trajectories=True, pcutoff=0.1, - init_weights = '', adapt_iterations = 1000, pseudo_threshold = 0.1, trim_ends = None @@ -56,9 +55,6 @@ def video_inference_superanimal( pcutoff: float, optional Keypoints confidence that are under pcutoff will not be shown in the resulted video - init_weights: str, optional: - Path to customized weights. Only for developing purpose - adapt_iterations: int, optional: Number of iterations for adaptation training diff --git a/examples/testscript_superanimal_adaptation.py b/examples/testscript_superanimal_adaptation.py index 303b4f5b6e..02a660313d 100644 --- a/examples/testscript_superanimal_adaptation.py +++ b/examples/testscript_superanimal_adaptation.py @@ -40,4 +40,5 @@ video_adapt=True, scale_list=scale_list, pcutoff=0.1, + adapt_iterations=50, ) From f5ae46aa5d6548e42a5e261e324058edce717aa1 Mon Sep 17 00:00:00 2001 From: nastya236 Date: Fri, 4 Nov 2022 23:49:06 +0100 Subject: [PATCH 005/293] Initial Commit: DLC PyTorch --- deeplabcut/pose_estimation_pytorch/README.md | 0 .../pose_estimation_pytorch/__init__.py | 3 + .../pose_estimation_pytorch/apis/__init__.py | 0 .../pose_estimation_pytorch/apis/config.yaml | 44 ++++ .../pose_estimation_pytorch/apis/test.py | 20 ++ .../pose_estimation_pytorch/apis/train.py | 38 +++ .../pose_estimation_pytorch/apis/utils.py | 60 +++++ .../pose_estimation_pytorch/data/__init__.py | 0 .../pose_estimation_pytorch/data/base.py | 9 + .../pose_estimation_pytorch/data/dataset.py | 120 +++++++++ .../pose_estimation_pytorch/data/project.py | 36 +++ .../models/__init__.py | 5 + .../models/backbones/__init__.py | 1 + .../models/backbones/base.py | 36 +++ .../models/backbones/resnet.py | 24 ++ .../models/criterion.py | 69 +++++ .../models/heads/__init__.py | 2 + .../models/heads/base.py | 23 ++ .../models/heads/simple_head.py | 87 ++++++ .../pose_estimation_pytorch/models/model.py | 116 ++++++++ .../models/necks/__init__.py | 0 .../models/necks/layers.py | 102 ++++++++ .../models/necks/transformer.py | 134 ++++++++++ .../models/necks/utils.py | 26 ++ .../pose_estimation_pytorch/models/utils.py | 121 +++++++++ .../pose_estimation_pytorch/registry.py | 226 ++++++++++++++++ .../solvers/__init__.py | 3 + .../pose_estimation_pytorch/solvers/base.py | 247 ++++++++++++++++++ .../pose_estimation_pytorch/solvers/logger.py | 62 +++++ .../solvers/single_animal.py | 83 ++++++ .../pose_estimation_pytorch/solvers/utils.py | 96 +++++++ .../pose_estimation_pytorch/tests/__init__.py | 0 .../tests/test_dataset.py | 45 ++++ .../tests/test_helper.py | 11 + .../tests/test_pose_model.py | 13 + .../tests/test_utils.py | 23 ++ deeplabcut/pose_estimation_pytorch/utils.py | 154 +++++++++++ docs/pytorch_dlc.md | 9 + 38 files changed, 2048 insertions(+) create mode 100644 deeplabcut/pose_estimation_pytorch/README.md create mode 100644 deeplabcut/pose_estimation_pytorch/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/apis/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/apis/config.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/apis/test.py create mode 100644 deeplabcut/pose_estimation_pytorch/apis/train.py create mode 100644 deeplabcut/pose_estimation_pytorch/apis/utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/dataset.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/project.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/backbones/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/criterion.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/heads/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/heads/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/model.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/necks/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/necks/layers.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/necks/transformer.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/necks/utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/registry.py create mode 100644 deeplabcut/pose_estimation_pytorch/solvers/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/solvers/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/solvers/logger.py create mode 100644 deeplabcut/pose_estimation_pytorch/solvers/single_animal.py create mode 100644 deeplabcut/pose_estimation_pytorch/solvers/utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_dataset.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_helper.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/utils.py create mode 100644 docs/pytorch_dlc.md diff --git a/deeplabcut/pose_estimation_pytorch/README.md b/deeplabcut/pose_estimation_pytorch/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py new file mode 100644 index 0000000000..12bfb2412b --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -0,0 +1,3 @@ +from deeplabcut.pose_estimation_pytorch.data.project import Project +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset +from deeplabcut.pose_estimation_pytorch.utils import fix_seeds diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml new file mode 100644 index 0000000000..6a27d2702e --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -0,0 +1,44 @@ +project_root: '/mnt/md0/anastasiia/ModelZoo/data/all_topview/openfield-Pranav-2018-08-20' +pose_cfg_path: '/mnt/md0/anastasiia/ModelZoo/data/all_topview/openfield-Pranav-2018-08-20/dlc-models/iteration-0/openfieldAug20-trainset95shuffle0/train/pose_cfg.yaml' +cfg_path: '/mnt/md0/anastasiia/ModelZoo/data/all_topview/openfield-Pranav-2018-08-20/config.yaml' + +seed: 42 +device: 'cuda:0' +model: + backbone: + type: 'ResNet' + pretrained: 'https://download.pytorch.org/models/resnet50-19c8e357.pth' + heatmap_head: + type: 'SimpleHead' + channels: [ 2048, 1024, 4 ] + kernel_size: [ 2, 2 ] + strides: [ 2, 2 ] + locref_head: + type: 'SimpleHead' + channels: [ 2048, 1024, 8 ] + kernel_size: [ 2, 2 ] + strides: [ 2, 2 ] + pose_model: + stride: 8 + heatmap_type: 'gaussian' +optimizer: + type: 'Adam' + params: + lr: 0.0001 +scheduler: + type: "StepLR" + params: + step_size: 30 + gamma: 0.1 +criterion: + type: 'PoseLoss' + loss_weight_locref: 0.1 + locref_huber_loss: False +#logger: +# type: 'WandbLogger' +# project_name: 'deeplabcut' +# run_name: 'tmp' +solver: + type: 'BottomUpSingleAnimalSolver' +batch_size: 1 +epochs: 1 diff --git a/deeplabcut/pose_estimation_pytorch/apis/test.py b/deeplabcut/pose_estimation_pytorch/apis/test.py new file mode 100644 index 0000000000..e82dfa295f --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/test.py @@ -0,0 +1,20 @@ +import deeplabcut.pose_estimation_pytorch as dlc +from deeplabcut import auxiliaryfunctions +from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver + +config = auxiliaryfunctions.read_config('config.yaml') +batch_size = config['batch_size'] +device = config['device'] + +transform = None +dlc.fix_seeds(config['seed']) +project = dlc.Project(proj_root=config['project_root']) +project.train_test_split() +solver = build_solver(config) + +test_dataset = dlc.PoseDataset(project, + mode='test') + +solver.evaluate(test_dataset, + train_iterations=49, + plotting=True) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py new file mode 100644 index 0000000000..2d7e81bcc5 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -0,0 +1,38 @@ +from torch.utils.data import DataLoader + +import deeplabcut.pose_estimation_pytorch as dlc +from deeplabcut import auxiliaryfunctions +from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver + +config = auxiliaryfunctions.read_config('config.yaml') +batch_size = config['batch_size'] +device = config['device'] +epochs = config['epochs'] + +transform = None +dlc.fix_seeds(config['seed']) +project = dlc.Project(proj_root=config['project_root']) +project.train_test_split() + +train_dataset = dlc.PoseDataset(project, + transform=transform, + mode='train') +valid_dataset = dlc.PoseDataset(project, + transform=transform, + mode='test') + +train_dataloader = DataLoader(train_dataset, + batch_size=batch_size, + shuffle=True) + +valid_dataloader = DataLoader(valid_dataset, + batch_size=batch_size, + shuffle=False) + +solver = build_solver(config) + +solver.fit(train_dataloader, valid_dataloader, epochs=epochs) + +solver.evaluate(valid_dataset, + plotting=False, + train_iterations=epochs - 1) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py new file mode 100644 index 0000000000..5cd200cfba --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -0,0 +1,60 @@ +from typing import Dict + +import torch + +from deeplabcut.pose_estimation_pytorch.models import PoseModel, BACKBONES, HEADS, LOSSES +from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SINGLE_ANIMAL_SOLVER +from deeplabcut.utils import auxiliaryfunctions + + +def build_pose_model(cfg: Dict, + pose_cfg: Dict): + backbone = BACKBONES.build(dict(cfg['backbone'])) + head_heatmaps = HEADS.build(dict(cfg['heatmap_head'])) + head_locref = HEADS.build(dict(cfg['locref_head'])) + if cfg.get('neck'): + neck = None + else: + neck = None + pose_model = PoseModel(cfg=pose_cfg, + backbone=backbone, + head_heatmaps=head_heatmaps, + head_locref=head_locref, + neck=neck, + **cfg['pose_model']) + + return pose_model + + +def build_solver(cfg: Dict): + pose_cfg = auxiliaryfunctions.read_config(cfg['pose_cfg_path']) + pose_model = build_pose_model(cfg['model'], pose_cfg) + + get_optimizer = getattr(torch.optim, cfg['optimizer']['type']) + optimizer = get_optimizer(params=pose_model.parameters(), **cfg['optimizer']['params']) + + criterion = LOSSES.build(cfg['criterion']) + + if cfg.get('scheduler'): + _scheduler = getattr(torch.optim.lr_scheduler, + cfg['scheduler']['type']) + scheduler = _scheduler(optimizer=optimizer, + **cfg['scheduler']['params']) + else: + scheduler = None + + if cfg.get('logger'): + logger = LOGGER.build(dict(**cfg['logger'], + model=pose_model)) + else: + logger = None + + solver = SINGLE_ANIMAL_SOLVER.build(dict(**cfg['solver'], + model=pose_model, + criterion=criterion, + optimizer=optimizer, + cfg=pose_cfg, + device=cfg['device'], + scheduler=scheduler, + logger=logger)) + return solver diff --git a/deeplabcut/pose_estimation_pytorch/data/__init__.py b/deeplabcut/pose_estimation_pytorch/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py new file mode 100644 index 0000000000..345ca13439 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +class Base(ABC): + + @abstractmethod + def create_from_config(self, config): + raise NotImplementedError + + diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py new file mode 100644 index 0000000000..c3cce24274 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -0,0 +1,120 @@ +import cv2 +import numpy as np +import torch +from torch.utils.data import Dataset + +from deeplabcut.pose_estimation_pytorch.data.base import Base +from deeplabcut.pose_estimation_pytorch.utils import df2generic + + +class PoseDataset(Dataset, Base): + + def __init__(self, + project, + transform=None, + image_id_offset=0, + mode='train'): + """ + + Parameters + ---------- + project: see class Project (wrapper for DLC original project class) + transform: transformation function: + + def transform(image, keypoints): + return image, keypoints + + image_id_offset: TODO + mode: 'train' or 'test' + this parameter specify which dataframe parse from the Project (df_tran or df_test) + + """ + super().__init__() + try: + self.dataframe = getattr(project, f'df_{mode}') + except: + raise AttributeError(f"PoseDataset doesn't have df_{mode} attr. Do project.train_test_split() first!") + + data = df2generic(project.proj_root, self.dataframe, image_id_offset) + + self.images = data['images'] + self.keypoints = data['annotations'] + self.transform = transform + self.cfg = project.cfg + assert len(self.images) == len(self.keypoints) + + def create_from_config(self, config): + # TODO + pass + + @staticmethod + def _annotation2key(annotation): + """ + TODO + This function was copied from modelzoo project (transformation to coco format) + Parameters + ---------- + annotation: dict of annotations + + Returns + ------- + keypoints: list + paired keypoints + undef_ids: list + mask + """ + x = annotation['keypoints'][::3] + y = annotation['keypoints'][1::3] + vis = annotation['keypoints'][2::3] + undef_ids = list(np.where(x == -1)[0]) + keypoints = [] + + for pair in np.stack([x, y]).T: + if pair[0] != -1: + keypoints.append((pair[0], pair[1])) + else: + keypoints.append((0, 0)) + return keypoints, undef_ids + + def __len__(self): + return len(self.images) + + def __getitem__(self, + index: int): + """ + + Parameters + ---------- + index: int + ordered number of the item in the dataset + Returns + ------- + image: torch.FloatTensor \in [0, 255] + Tensor for the image from the dataset + keypoints: list of keypoints + + train_dataset = PoseDataset(project, transform=transform) + im, keipoints = train_dataset[0] + + """ + image_file = self.images[index]['file_name'] + image = cv2.imread(image_file) + + annotation = self.keypoints[index] + keypoints, undef_ids = self._annotation2key(annotation) + if self.transform: + + transformed = self.transform(image=image, keypoints=keypoints) + transformed['keipoints'] = [(-1, -1) if i in undef_ids else keypoint + for i, keypoint in enumerate(transformed['keypoints'])] + else: + transformed = {} + transformed['keipoints'] = keypoints + transformed['image'] = image + + image = torch.FloatTensor(transformed['image']).permute(2, 0, 1) # channels first + + assert len(transformed['keipoints']) == len(keypoints) + keypoints = np.array(transformed['keipoints']) + + return image, keypoints diff --git a/deeplabcut/pose_estimation_pytorch/data/project.py b/deeplabcut/pose_estimation_pytorch/data/project.py new file mode 100644 index 0000000000..04d6c3f850 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/project.py @@ -0,0 +1,36 @@ +import os +import pandas as pd +import deeplabcut +import pickle +import numpy as np + +class Project: + def __init__(self, proj_root, + shuffle = 1): + self.proj_root = proj_root + self.shuffle = shuffle + + config_file = os.path.join(self.proj_root, 'config.yaml') + self.cfg = deeplabcut.auxiliaryfunctions.read_config(config_file) + self.task = self.cfg['Task'] + self.scorer = self.cfg['scorer'] + self.datasets_folder = os.path.join( + self.proj_root, deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(self.cfg), + ) + tr_frac = int(self.cfg['TrainingFraction'][0]*100) + self.path_dlc_data = os.path.join(self.datasets_folder,f'CollectedData_{self.scorer}.h5') + self.path_dlc_doc = os.path.join(self.datasets_folder,f'Documentation_data-{self.task}_{tr_frac}shuffle{self.shuffle}.pickle') + self.dlc_df = pd.read_hdf(self.path_dlc_data) + + def train_test_split(self): + with open(self.path_dlc_doc, 'rb') as f: + meta = pickle.load(f) + + train_ids = meta[1] + test_ids = meta[2] + + train_images = self.dlc_df.index[train_ids] + test_images = self.dlc_df.index[test_ids] + self.dlc_images = np.hstack([train_images,test_images]) + self.df_train = self.dlc_df.loc[train_images] + self.df_test = self.dlc_df.loc[test_images] \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/__init__.py b/deeplabcut/pose_estimation_pytorch/models/__init__.py new file mode 100644 index 0000000000..22d7dd957c --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/__init__.py @@ -0,0 +1,5 @@ +from deeplabcut.pose_estimation_pytorch.models.utils import _generate_heatmaps, gaussian_scmap +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES +from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS +from deeplabcut.pose_estimation_pytorch.models.criterion import LOSSES \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py new file mode 100644 index 0000000000..fd33b2a889 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py @@ -0,0 +1 @@ +from .resnet import ResNet \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py new file mode 100644 index 0000000000..c26e4bea6a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod +import validators +import torch.nn as nn +import torch +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + + +BACKBONES = Registry('backbones', build_func=build_from_cfg) + +class BaseBackbone(ABC, nn.Module): + + def __init__(self): + super().__init__() + + @abstractmethod + def forward(self, x): + pass + + def _init_weights(self, pretrained: str = None): + """ + + Parameters + ---------- + pretrained + + Returns + ------- + + """ + if not pretrained: + pass + elif validators.url(pretrained): + state_dict = torch.hub.load_state_dict_from_url(pretrained) + self.model.load_state_dict(state_dict, strict=False) + else: + self.model.load_state_dict(torch.load(pretrained), strict=False) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py new file mode 100644 index 0000000000..b72d0d69da --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -0,0 +1,24 @@ +import torch.nn as nn +import torchvision + +from deeplabcut.pose_estimation_pytorch.models.backbones.base import BaseBackbone, BACKBONES + +@BACKBONES.register_module +class ResNet(BaseBackbone): + + def __init__(self, model_name: str = 'resnet50', + pretrained: str = None) -> nn.Module: + """ + Parameters + ---------- + model_name + """ + super().__init__() + _backbone = torchvision.models.get_model(model_name) + _backbone._modules.pop('fc') + _backbone._modules.pop('avgpool') + self.model = nn.Sequential(_backbone._modules) + self._init_weights(pretrained) + + def forward(self, x): + return self.model(x) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py new file mode 100644 index 0000000000..de682a2d91 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -0,0 +1,69 @@ +import torch +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry + +LOSSES = Registry('losses', build_func=build_from_cfg) + + +class WeightedMSELoss(nn.MSELoss): + + def __init__(self): + super(WeightedMSELoss, self).__init__() + self.mse_loss = nn.MSELoss(reduction='none') + + def __call__(self, prediction, target, weights): + loss_item = self.mse_loss(prediction, target) + loss_item_weighted = loss_item * weights + return torch.mean(loss_item_weighted) + +@LOSSES.register_module +class PoseLoss(nn.Module): + def __init__(self, + loss_weight_locref: float = 0.1, + locref_huber_loss: bool = False): + """ + + Parameters + ---------- + loss_weight_locref: float + Weight for loss_locref part + (parsed from the pose_cfg.yaml from the dlc_models folder) + locref_huber_loss: bool + If `True` uses torch.nn.HuberLoss for locref + (default is False) + + """ + super(PoseLoss, self).__init__() + if locref_huber_loss: + self.locref_criterion = nn.HuberLoss() + else: + self.locref_criterion = WeightedMSELoss() + self.loss_weight_locref = loss_weight_locref + self.heatmap_criterion = nn.BCEWithLogitsLoss() + + def forward(self, prediction, target): + """ + + Parameters + ---------- + prediction: tuple of Tensors `(heatmaps, locref)` of size `(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)` + Predicted heatmap and locref + target: dict = { + 'heatmaps': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'locref_maps': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'locref_masks': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'weights': torch.Tensor (optional, default is None) + } + Returns + ------- + loss: sum + """ + heatmaps, locref = prediction + heatmap_loss = self.heatmap_criterion(heatmaps, + target['heatmaps']) + locref_loss = self.loss_weight_locref * self.locref_criterion(locref, + target['locref_maps'], + target['locref_masks']) + total_loss = locref_loss + heatmap_loss + return total_loss, heatmap_loss, locref_loss diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py new file mode 100644 index 0000000000..6036574267 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py @@ -0,0 +1,2 @@ +from .base import HEADS +from .simple_head import SimpleHead \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py new file mode 100644 index 0000000000..46ac6bc11e --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod + +import torch +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + +HEADS = Registry('heads', build_func=build_from_cfg) + +class BaseHead(ABC, nn.Module): + + def __init__(self): + super().__init__() + + @abstractmethod + def forward(self, x): + pass + + def _init_weights(self, pretrained): + if not pretrained: + pass + else: + self.model.load_state_dict(torch.load(pretrained)) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py new file mode 100644 index 0000000000..d9c5bd801a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -0,0 +1,87 @@ +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS +from .base import BaseHead + + +@HEADS.register_module +class SimpleHead(BaseHead): + + def __init__(self, channels: list, + kernel_size: list, + strides: list, + pretrained: str = None): + super().__init__() + self.kernel_size = kernel_size + self.strides = strides + + if len(kernel_size) == 1: + self.model = self._make_layer(channels[0], + channels[1], + kernel_size[0], + strides[0]) + else: + layers = [] + for i in range(len(channels) - 1): + up_layer = self._make_layer(channels[i], + channels[i + 1], + kernel_size[i], + strides[i] + ) + layers.append(up_layer) + if i < len(channels) - 2: + layers.append(nn.ReLU()) + self.model = nn.Sequential(*layers) + + self._init_weights(pretrained) + + def _make_layer(self, + input_channels, + output_channels, + kernel_size, + stride): + upsample_layer = nn.ConvTranspose2d(input_channels, output_channels, + kernel_size, stride=stride) + return upsample_layer + + def forward(self, x): + out = self.model(x) + + return out + +# class TransformerHead(BaseHead): +# +# def __init__(self, dim, hidden_heatmap_dim, +# heatmap_dim, apply_multi, +# heatmap_size, +# apply_init): +# super().__init__() +# self.mlp_head = nn.Sequential( +# nn.LayerNorm(dim * 3), +# nn.Linear(dim * 3, hidden_heatmap_dim), +# nn.LayerNorm(hidden_heatmap_dim), +# nn.Linear(hidden_heatmap_dim, heatmap_dim) +# ) if (dim*3 <= hidden_heatmap_dim*0.5 and apply_multi) else nn.Sequential( +# nn.LayerNorm(dim*3), +# nn.Linear(dim*3, heatmap_dim) +# ) +# self.heatmap_size = heatmap_size +# # trunc_normal_(self.keypoint_token, std=.02) +# if apply_init: +# self.apply(self._init_weights) +# +# def _init_weights(self, m): +# if isinstance(m, nn.Linear): +# trunc_normal_(m.weight, std=.02) +# if isinstance(m, nn.Linear) and m.bias is not None: +# nn.init.constant_(m.bias, 0) +# elif isinstance(m, nn.LayerNorm): +# nn.init.constant_(m.bias, 0) +# nn.init.constant_(m.weight, 1.0) +# +# def forward(self, x): +# x = self.mlp_head(x) +# x = rearrange(x,'b c (p1 p2) -> b c p1 p2', +# p1=self.heatmap_size[0], p2=self.heatmap_size[1]) +# +# return x diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py new file mode 100644 index 0000000000..e7fc376783 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -0,0 +1,116 @@ +import numpy as np +import torch +from torch import nn + +from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps +from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict + + +class PoseModel(nn.Module): + + def __init__(self, + cfg: dict, + backbone: torch.nn.Module, + head_heatmaps: torch.nn.Module, + head_locref: torch.nn.Module, + neck: torch.nn.Module = None, + stride: int = 8, + heatmap_type: str = 'gaussian'): + + super().__init__() + self.backbone = backbone + self.head_heatmaps = head_heatmaps + self.head_locref = head_locref + self.neck = neck + self.stride = stride + self.cfg = cfg + self.heatmap_type = heatmap_type + self.sigmoid = nn.Sigmoid() + + def forward(self, x): + """ + TODO + Parameters + ---------- + x + + Returns + ------- + + """ + features = self.backbone(x) + if self.neck: + features = self.neck(features) + heat_maps = self.head_heatmaps(features) + loc_ref = self.head_locref(features) + + return heat_maps, loc_ref + + @torch.no_grad() + def load_model(self, checkpoint): + self.model.load_state_dict(torch.load(checkpoint), strict=True) + + def get_target(self, + keypoints_batch, + heatmap_size): + + heatmaps_target = [] + locref_target = [] + weights = [] + locref_masks = [] + for keypoints in keypoints_batch: + # TODO: make faster + heatmap, weight, locref_map, locref_mask = generate_heatmaps(self.cfg, + keypoints, + heatmap_size=heatmap_size) + locref_target.append(locref_map) + heatmaps_target.append(heatmap) + locref_masks.append(locref_mask) + + heatmaps = torch.stack(heatmaps_target).permute(0, 3, 1, 2) + locref_maps = torch.stack(locref_target).permute(0, 3, 1, 2) + locref_masks = torch.stack(locref_masks).permute(0, 3, 1, 2) + + if weight is not None: + weights = torch.stack(weights) + else: + weights = None + + target = { + 'heatmaps': heatmaps, + 'locref_maps': locref_maps, + 'locref_masks': locref_masks, + 'weights': weights + } + return target + + + @torch.no_grad() + def predict(self, x): + """ + + Parameters + ---------- + x: input image tensor + + Returns + ------- + pose: predicted keypoints coordinates + """ + + self.eval() + poses = [] + if x.dim() == 3: + x = x[None, :] + heatmaps, locref = self.forward(x) + heatmaps = self.sigmoid(heatmaps) + heatmaps = heatmaps.permute(0, 2, 3, 1).detach().cpu().numpy() + locref = locref.permute(0, 2, 3, 1).detach().cpu().numpy() + for i in range(x.shape[0]): + shape = locref[i].shape + locref_i = np.reshape(locref, (shape[0], shape[1], -1, 2)) + if self.cfg['location_refinement']: + locref_i = locref_i * self.cfg['locref_stdev'] + pose = multi_pose_predict(heatmaps[i], locref_i, self.stride, 1) + poses.append(pose) + return np.stack(poses, axis=0) diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py new file mode 100644 index 0000000000..38e7c51826 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py @@ -0,0 +1,102 @@ +import torch +import torch.nn.functional as F +from einops import rearrange, repeat +from torch import nn + +class Residual(nn.Module): + def __init__(self, fn): + super().__init__() + self.fn = fn + def forward(self, x, **kwargs): + return self.fn(x, **kwargs) + x + +class PreNorm(nn.Module): + def __init__(self, dim, fn,fusion_factor=1): + super().__init__() + self.norm = nn.LayerNorm(dim*fusion_factor) + self.fn = fn + def forward(self, x, **kwargs): + return self.fn(self.norm(x), **kwargs) + +class FeedForward(nn.Module): + def __init__(self, dim, hidden_dim, dropout = 0.): + super().__init__() + self.net = nn.Sequential( + nn.Linear(dim, hidden_dim), + nn.GELU(), + nn.Dropout(dropout), + nn.Linear(hidden_dim, dim), + nn.Dropout(dropout) + ) + def forward(self, x): + return self.net(x) + +class Attention(nn.Module): + def __init__(self, dim, heads = 8, + dropout = 0., + num_keypoints=None, + scale_with_head=False): + + super().__init__() + self.heads = heads + self.scale = (dim//heads) ** -0.5 if scale_with_head else dim ** -0.5 + + self.to_qkv = nn.Linear(dim, dim * 3, bias = False) + self.to_out = nn.Sequential( + nn.Linear(dim, dim), + nn.Dropout(dropout) + ) + self.num_keypoints = num_keypoints + + def forward(self, x, mask = None): + b, n, _, h = *x.shape, self.heads + qkv = self.to_qkv(x).chunk(3, dim = -1) + q, k, v = map(lambda t: rearrange(t, 'b n (h d) -> b h n d', h = h), qkv) + + dots = torch.einsum('bhid,bhjd->bhij', q, k) * self.scale + mask_value = -torch.finfo(dots.dtype).max + + if mask is not None: + mask = F.pad(mask.flatten(1), (1, 0), value = True) + assert mask.shape[-1] == dots.shape[-1], 'mask has incorrect dimensions' + mask = mask[:, None, :] * mask[:, :, None] + dots.masked_fill_(~mask, mask_value) + del mask + + attn = dots.softmax(dim=-1) + + out = torch.einsum('bhij,bhjd->bhid', attn, v) + + out = rearrange(out, 'b h n d -> b n (h d)') + out = self.to_out(out) + return out + +class TransformerLayer(nn.Module): + def __init__(self, dim, + depth, + heads, + mlp_dim, + dropout, + num_keypoints=None, + all_attn=False, + scale_with_head=False): + + super().__init__() + self.layers = nn.ModuleList([]) + self.all_attn = all_attn + self.num_keypoints = num_keypoints + for _ in range(depth): + self.layers.append(nn.ModuleList([ + Residual(PreNorm(dim, Attention(dim, heads = heads, + dropout = dropout, + num_keypoints = num_keypoints, + scale_with_head = scale_with_head))), + Residual(PreNorm(dim, FeedForward(dim, mlp_dim, dropout = dropout))) + ])) + def forward(self, x, mask = None,pos=None): + for idx,(attn, ff) in enumerate(self.layers): + if idx>0 and self.all_attn: + x[:,self.num_keypoints:] += pos + x = attn(x, mask = mask) + x = ff(x) + return x \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py new file mode 100644 index 0000000000..a532f5d57a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py @@ -0,0 +1,134 @@ +import torch +from einops import rearrange, repeat +from torch import nn +from timm.models.layers.weight_init import trunc_normal_ +from .layers import TransformerLayer +from .utils import make_sine_position_embedding + +MIN_NUM_PATCHES = 16 +BN_MOMENTUM = 0.1 + +class Transformer(nn.Module): + def __init__(self, *, feature_size, + patch_size, num_keypoints, + dim, depth, heads, + mlp_dim=3, apply_init=False, + heatmap_size=[64,64], + channels = 32, + dropout = 0., + emb_dropout = 0., + pos_embedding_type="sine-full"): + + super().__init__() + + num_patches = (feature_size[0] // (patch_size[0])) * (feature_size[1] // (patch_size[1])) + patch_dim = channels * patch_size[0] * patch_size[1] + + self.inplanes = 64 + self.patch_size = patch_size + self.heatmap_size = heatmap_size + self.num_keypoints = num_keypoints + self.num_patches = num_patches + self.pos_embedding_type = pos_embedding_type + self.all_attn = (self.pos_embedding_type == "sine-full") + + self.keypoint_token = nn.Parameter(torch.zeros(1, self.num_keypoints, dim)) + h,w = feature_size[0] // (self.patch_size[0]), feature_size[1] // ( self.patch_size[1]) + + self._make_position_embedding(w, h, dim, pos_embedding_type) + + + self.patch_to_embedding = nn.Linear(patch_dim, dim) + self.dropout = nn.Dropout(emb_dropout) + + self.transformer1 = TransformerLayer(dim, depth, heads, + mlp_dim, dropout, + num_keypoints=num_keypoints, + scale_with_head=True) + self.transformer2 = TransformerLayer(dim, depth, heads, + mlp_dim, dropout, + num_keypoints=num_keypoints, + all_attn=self.all_attn, + scale_with_head=True ) + self.transformer3 = TransformerLayer(dim, depth, heads, + mlp_dim, dropout, + num_keypoints=num_keypoints, + all_attn=self.all_attn, + scale_with_head=True) + + self.to_keypoint_token = nn.Identity() + + if apply_init: + self.apply(self._init_weights) + + def _make_position_embedding(self, w, h, d_model, pe_type='learnable'): + ''' + d_model: embedding size in transformer encoder + ''' + with torch.no_grad(): + self.pe_h = h + self.pe_w = w + length = h * w + if pe_type != 'learnable': + self.pos_embedding = nn.Parameter(make_sine_position_embedding(h, w, d_model), + requires_grad=False) + else: + self.pos_embedding = nn.Parameter(torch.zeros(1, self.num_patches + + self.num_keypoints, + d_model)) + + def _make_layer(self, block, planes, blocks, stride=1): + downsample = None + if stride != 1 or self.inplanes != planes * block.expansion: + downsample = nn.Sequential( + nn.Conv2d(self.inplanes, planes * block.expansion, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(planes * block.expansion, momentum=BN_MOMENTUM), + ) + + layers = [] + layers.append(block(self.inplanes, planes, stride, downsample)) + self.inplanes = planes * block.expansion + for i in range(1, blocks): + layers.append(block(self.inplanes, planes)) + + return nn.Sequential(*layers) + + def _init_weights(self, m): + print("Initialization...") + if isinstance(m, nn.Linear): + trunc_normal_(m.weight, std=.02) + if isinstance(m, nn.Linear) and m.bias is not None: + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.LayerNorm): + nn.init.constant_(m.bias, 0) + nn.init.constant_(m.weight, 1.0) + + def forward(self, feature, mask = None): + p = self.patch_size + + x = rearrange(feature, 'b c (h p1) (w p2) -> b (h w) (p1 p2 c)', p1 = p[0], p2 = p[1]) + x = self.patch_to_embedding(x) + + b, n, _ = x.shape + + keypoint_tokens = repeat(self.keypoint_token, '() n d -> b n d', b = b) + if self.pos_embedding_type in ["sine","sine-full"] : + x += self.pos_embedding[:, :n] + x = torch.cat((keypoint_tokens, x), dim=1) + else: + x = torch.cat((keypoint_tokens, x), dim=1) + x += self.pos_embedding[:, :(n + self.num_keypoints)] + x = self.dropout(x) + + x1 = self.transformer1(x, mask,self.pos_embedding) + x2 = self.transformer2(x1, mask,self.pos_embedding) + x3 = self.transformer3(x2, mask,self.pos_embedding) + + x1_out = self.to_keypoint_token(x1[:, 0:self.num_keypoints]) + x2_out = self.to_keypoint_token(x2[:, 0:self.num_keypoints]) + x3_out = self.to_keypoint_token(x3[:, 0:self.num_keypoints]) + + x = torch.cat((x1_out, x2_out, x3_out), dim=2) + + return x \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/utils.py b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py new file mode 100644 index 0000000000..10835af80f --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py @@ -0,0 +1,26 @@ +import torch +import math + +def make_sine_position_embedding(h, w, d_model, temperature=10000, + scale=2 * math.pi): + area = torch.ones(1, h, w) + y_embed = area.cumsum(1, dtype=torch.float32) + x_embed = area.cumsum(2, dtype=torch.float32) + one_direction_feats = d_model // 2 + eps = 1e-6 + y_embed = y_embed / (y_embed[:, -1:, :] + eps) * scale + x_embed = x_embed / (x_embed[:, :, -1:] + eps) * scale + + dim_t = torch.arange(one_direction_feats, dtype=torch.float32) + dim_t = temperature ** (2 * (dim_t // 2) / one_direction_feats) + + pos_x = x_embed[:, :, :, None] / dim_t + pos_y = y_embed[:, :, :, None] / dim_t + pos_x = torch.stack( + (pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4).flatten(3) + pos_y = torch.stack( + (pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4).flatten(3) + pos = torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2) + pos = pos.flatten(2).permute(0, 2, 1) + + return pos \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/utils.py b/deeplabcut/pose_estimation_pytorch/models/utils.py new file mode 100644 index 0000000000..a301d9ace3 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/utils.py @@ -0,0 +1,121 @@ +import numpy as np +import torch + + +def generate_heatmaps(cfg: dict, + coords: np.array, + heatmap_size: tuple = (64, 64), + heatmap_type: str = 'gaussian'): + if heatmap_type == 'gaussian': + scmap, weights, locref_map, locref_mask = gaussian_scmap(cfg, + coords, + heatmap_size) + else: + raise ValueError('Only gaussian heatmap is supported!') + scmap = torch.FloatTensor(scmap) + if weights: + weights = torch.FloatTensor(weights) + locref_map = torch.FloatTensor(locref_map) + locref_mask = torch.FloatTensor(locref_mask) + + return scmap, weights, locref_map, locref_mask + + +# Copy from dlc +def gaussian_scmap(cfg, coords, heatmap_size): + """ + + Parameters + ---------- + cfg: dlc config + Standart dlc config in the dlc project folder + joint_id: + coords: list/np.array of coordinates + data_item + heatmap_size + scale + + Returns + ------- + + """ + locref_scale = 1.0 / cfg["locref_stdev"] + num_joints = cfg["num_joints"] + # stride = cfg['stride'] # Apparently, there is no stride in the cfg + stride = 8 # TODO just test + scmap = np.zeros(( + heatmap_size[0], + heatmap_size[1], num_joints), dtype=np.float32) + + locref_map = np.zeros(( + heatmap_size[0], + heatmap_size[1], num_joints * 2), dtype=np.float32) + locref_mask = np.zeros_like(locref_map) + + width = heatmap_size[1] + height = heatmap_size[0] + dist_thresh = float((width + height) / 6) + dist_thresh_sq = dist_thresh ** 2 + + std = dist_thresh / 4 + grid = np.mgrid[:height, :width].transpose((1, 2, 0)) + grid = grid * stride + stride / 2 + for i, coord in enumerate(coords): + coord = np.array(coord)[::-1] + dist = np.linalg.norm(grid - coord, axis=2) ** 2 + scmap_j = np.exp(-dist / (2 * std ** 2)) + scmap[:, :, i] = scmap_j + locref_mask[dist <= dist_thresh_sq, i * 2 + 0] = 1 + locref_mask[dist <= dist_thresh_sq, i * 2 + 1] = 1 + dx = coord[1] - grid.copy()[:, :, 1] + dy = coord[0] - grid.copy()[:, :, 0] + locref_map[:, :, i * 2 + 0] = dx * locref_scale + locref_map[:, :, i * 2 + 1] = dy * locref_scale + weights = None + # weights = self.compute_scmap_weights(scmap.shape, joint_id, data_item) + return scmap, weights, locref_map, locref_mask + + +# TODO: check this function and rewrite above +def _generate_heatmaps(keypoints, + heatmap_size, + image_size=(256, 256), + sigma=5): + """ + TODO: MAKE FASTER + Parameters + ---------- + keypoints + heatmap_size + image_size + sigma + + Returns + ------- + + """ + target = torch.zeros((keypoints.shape[0], + heatmap_size[1], + heatmap_size[0]), dtype=torch.float32) + scale_x = heatmap_size[0] / image_size[0] + scale_y = heatmap_size[1] / image_size[1] + for joint_id in range(keypoints.shape[0]): + mu_x = keypoints[joint_id, 0] * scale_x + mu_y = keypoints[joint_id, 1] * scale_y + if mu_x == -1: + continue + + x = torch.arange(0, heatmap_size[0], 1, dtype=torch.float32) + y = torch.arange(0, heatmap_size[1], 1, dtype=torch.float32) + y = y[:, None] + + if mu_x > 0: + target[joint_id] = torch.exp(- ((x - mu_x) ** 2 + (y - mu_y) ** 2) / (2 * sigma ** 2)) + + return target + + +def sigmoid(tx: np.ndarray): + + exp_x = np.exp(tx) + return exp_x / (1 + exp_x) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/registry.py b/deeplabcut/pose_estimation_pytorch/registry.py new file mode 100644 index 0000000000..583eff0d4b --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/registry.py @@ -0,0 +1,226 @@ +import inspect +from functools import partial +from typing import Any, Dict, Optional + + +def build_from_cfg(cfg: Dict, + registry: 'Registry', + default_args: Optional[Dict] = None) -> Any: + """Build a module from config dict when it is a class configuration, or + call a function from config dict when it is a function configuration. + Args: + cfg (dict): Config dict. It should at least contain the key "type". + registry (:obj:`Registry`): The registry to search the type from. + default_args (dict, optional): Default initialization arguments. + Returns: + object: The constructed object. + """ + args = cfg.copy() + + if default_args is not None: + for name, value in default_args.items(): + args.setdefault(name, value) + + obj_type = args.pop('type') + if isinstance(obj_type, str): + obj_cls = registry.get(obj_type) + if obj_cls is None: + raise KeyError( + f'{obj_type} is not in the {registry.name} registry') + elif inspect.isclass(obj_type) or inspect.isfunction(obj_type): + obj_cls = obj_type + else: + raise TypeError( + f'type must be a str or valid type, but got {type(obj_type)}') + try: + return obj_cls(**args) + except Exception as e: + # Normal TypeError does not print class name. + raise type(e)(f'{obj_cls.__name__}: {e}') + + +class Registry: + """A registry to map strings to classes or functions. + Registered object could be built from registry. Meanwhile, registered + functions could be called from registry. + + Args: + name (str): Registry name. + build_func(func, optional): Build function to construct instance from + Registry, func:`build_from_cfg` is used if neither ``parent`` or + ``build_func`` is specified. If ``parent`` is specified and + ``build_func`` is not given, ``build_func`` will be inherited + from ``parent``. Default: None. + parent (Registry, optional): Parent registry. The class registered in + children registry could be built from parent. Default: None. + scope (str, optional): The scope of registry. It is the key to search + for children registry. If not specified, scope will be the name of + the package where class is defined, e.g. mmdet, mmcls, mmseg. + Default: None. + """ + + def __init__(self, name, build_func=None, parent=None, scope=None): + self._name = name + self._module_dict = dict() + self._children = dict() + self._scope = '.' + + if build_func is None: + if parent is not None: + self.build_func = parent.build_func + else: + self.build_func = build_from_cfg + else: + self.build_func = build_func + if parent is not None: + assert isinstance(parent, Registry) + parent._add_children(self) + self.parent = parent + else: + self.parent = None + + def __len__(self): + return len(self._module_dict) + + def __contains__(self, key): + return self.get(key) is not None + + def __repr__(self): + format_str = self.__class__.__name__ + \ + f'(name={self._name}, ' \ + f'items={self._module_dict})' + return format_str + + + @staticmethod + def split_scope_key(key): + """Split scope and key. + The first scope will be split from key. + Examples: + >>> Registry.split_scope_key('mmdet.ResNet') + 'mmdet', 'ResNet' + >>> Registry.split_scope_key('ResNet') + None, 'ResNet' + Return: + tuple[str | None, str]: The former element is the first scope of + the key, which can be ``None``. The latter is the remaining key. + """ + split_index = key.find('.') + if split_index != -1: + return key[:split_index], key[split_index + 1:] + else: + return None, key + + @property + def name(self): + return self._name + + @property + def scope(self): + return self._scope + + @property + def module_dict(self): + return self._module_dict + + @property + def children(self): + return self._children + + def get(self, key): + """Get the registry record. + Args: + key (str): The class name in string format. + Returns: + class: The corresponding class. + """ + scope, real_key = self.split_scope_key(key) + if scope is None or scope == self._scope: + # get from self + if real_key in self._module_dict: + return self._module_dict[real_key] + else: + # get from self._children + if scope in self._children: + return self._children[scope].get(real_key) + else: + # goto root + parent = self.parent + while parent.parent is not None: + parent = parent.parent + return parent.get(key) + + def build(self, *args, **kwargs): + return self.build_func(*args, **kwargs, registry=self) + + def _add_children(self, registry): + """Add children for a registry. + The ``registry`` will be added as children based on its scope. + The parent registry could build objects from children registry. + Example: + >>> models = Registry('models') + >>> mmdet_models = Registry('models', parent=models) + >>> @mmdet_models.register_module() + >>> class ResNet: + >>> pass + >>> resnet = models.build(dict(type='mmdet.ResNet')) + """ + + assert isinstance(registry, Registry) + assert registry.scope is not None + assert registry.scope not in self.children, \ + f'scope {registry.scope} exists in {self.name} registry' + self.children[registry.scope] = registry + + def _register_module(self, module, module_name=None, force=False): + if not inspect.isclass(module) and not inspect.isfunction(module): + raise TypeError('module must be a class or a function, ' + f'but got {type(module)}') + + if module_name is None: + module_name = module.__name__ + if isinstance(module_name, str): + module_name = [module_name] + for name in module_name: + if not force and name in self._module_dict: + raise KeyError(f'{name} is already registered ' + f'in {self.name}') + self._module_dict[name] = module + + def deprecated_register_module(self, cls=None, force=False): + + if cls is None: + return partial(self.deprecated_register_module, force=force) + self._register_module(cls, force=force) + return cls + + def register_module(self, name=None, force=False, module=None): + """Register a module. + A record will be added to `self._module_dict`, whose key is the class + name or the specified name, and value is the class itself. + It can be used as a decorator or a normal function. + Args: + name (str | None): The module name to be registered. If not + specified, the class name will be used. + force (bool, optional): Whether to override an existing class with + the same name. Default: False. + module (type): Module class or function to be registered. + """ + if not isinstance(force, bool): + raise TypeError(f'force must be a boolean, but got {type(force)}') + # NOTE: This is a walkaround to be compatible with the old api, + # while it may introduce unexpected bugs. + if isinstance(name, type): + return self.deprecated_register_module(name, force=force) + + # use it as a normal method: x.register_module(module=SomeClass) + if module is not None: + self._register_module(module=module, module_name=name, force=force) + return module + + # use it as a decorator: @x.register_module() + def _register(module): + self._register_module(module=module, module_name=name, force=force) + return module + + return diff --git a/deeplabcut/pose_estimation_pytorch/solvers/__init__.py b/deeplabcut/pose_estimation_pytorch/solvers/__init__.py new file mode 100644 index 0000000000..1aa5bf3680 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/solvers/__init__.py @@ -0,0 +1,3 @@ +from deeplabcut.pose_estimation_pytorch.solvers.logger import LOGGER +from deeplabcut.pose_estimation_pytorch.solvers.single_animal import SINGLE_ANIMAL_SOLVER + diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py new file mode 100644 index 0000000000..e8557af079 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -0,0 +1,247 @@ +from abc import ABC, abstractmethod +from typing import Optional +from typing import Tuple, Dict + +import pandas as pd +import numpy as np +import torch +from tqdm import tqdm + +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset +from .utils import * + + +class Solver(ABC): + + def __init__(self, + model: PoseModel, + criterion: torch.nn, + optimizer: torch.optim.Optimizer, + cfg: Dict, + device: str = 'cpu', + scheduler: Optional = None, + logger: Optional = None): + + """Solver base class. + + A solvers contains helper methods for bundling a model, criterion and optimizer. + + Parameters + ---------- + model: The neural network for solving pose estimation task. + criterion: The criterion computed from the difference between the prediction + and the target. + optimizer: A PyTorch optimizer for updating model parameters. + cfg: DeepLabCut pose_cfg for training. + See https://github.com/DeepLabCut/DeepLabCut/blob/main/deeplabcut/pose_cfg.yaml for more details. + scheduler: Optional. Scheduler for adjusting the lr of the optimizer. + """ + if cfg is None: + raise ValueError('') + self.model = model + self.device = device + self.cfg = cfg + self.optimizer = optimizer + self.scheduler = scheduler + self.criterion = criterion + self.history = {'train_loss': [], + 'eval_loss': []} + self.logger = logger + if self.logger: + logger.log_config(cfg) + self.model.to(device) + + def fit( + self, + train_loader: torch.utils.data.DataLoader, + valid_loader: torch.utils.data.DataLoader, + train_fraction: float = 0.95, + shuffle: int = 0, + model_prefix: str = '', + *, + epochs: int = 10000) -> None: + """ + Train model for the specified number of steps. + + Parameters + ---------- + train_loader: Data loader, which is an iterator over train instances. + Each batch contains image tensor and heat maps tensor input samples. + valid_loader: Data loader used for validation of the model. + train_fraction: TODO discuss (mb better specify with config) + shuffle: TODO discuss (mb better specify with config) + model_prefix: TODO discuss (mb better specify with config) + epochs: The number of training iterations. + """ + model_folder = get_model_folder(train_fraction, + shuffle, + model_prefix, + train_loader.dataset.cfg) + for i in tqdm(range(epochs)): + train_loss = self.epoch(train_loader, mode='train') + if self.scheduler: + self.scheduler.step() + valid_loss = self.epoch(valid_loader, mode='eval') + save_path = f'{model_folder}/train/snapshot-{i}.pt' + torch.save(self.model.state_dict(), save_path) + print(f'Epoch {i + 1}/{epochs}, ' + f'train loss {train_loss}, ' + f'valid loss {valid_loss}') + + def get_scores(self, + prediction: pd.DataFrame, + target: pd.DataFrame, + bodyparts: List = None): + if self.cfg.get( 'pcutoff'): + pcutoff = self.cfg['pcutoff'] + rmse, rmse_p = get_rmse(prediction, target, pcutoff, + bodyparts = bodyparts) + else: + rmse, rmse_p = get_rmse(prediction, target, + bodyparts = bodyparts) + + return np.nanmean(rmse), np.nanmean(rmse_p) + + def epoch(self, + loader: torch.utils.data.DataLoader, + mode: str = 'train') -> np.array: + """ + + Parameters + ---------- + loader: Data loader, which is an iterator over instances. + Each batch contains image tensor and heat maps tensor input samples. + mode: + Returns + ------- + epoch_loss: Average of the loss over the batches. + """ + if mode not in ['train', 'eval']: + raise ValueError(f'Solver must be in train or eval mode, but {mode} was found.') + to_mode = getattr(self.model, mode) + to_mode() + epoch_loss = [] + for batch in loader: + loss = self.step(batch, mode) + epoch_loss.append(loss) + epoch_loss = np.mean(epoch_loss) + self.history[f'{mode}_loss'].append(epoch_loss) + + return epoch_loss + + @abstractmethod + def step(self, + batch: Tuple[torch.Tensor, torch.Tensor], + *args) -> Optional: + raise NotImplementedError + + @torch.no_grad() + def inference(self, + dataset: PoseDataset) -> np.array: + # todo add scale + predicted_poses = [] + for item in dataset: + if isinstance(item, tuple) or isinstance(item, list): + item = item[0] + else: + item = item + item = item.to(self.device) + pose = self.model.predict(item) + predicted_poses.append(pose) + predicted_poses = np.concatenate(predicted_poses) + return predicted_poses + + @staticmethod + def _get_paths(train_fraction: float = 0.95, + shuffle: int = 0, + model_prefix: str = "", + cfg: dict = None, + train_iterations: int = 9): + + dlc_scorer, dlc_scorer_legacy = get_dlc_scorer(train_fraction, + shuffle, + model_prefix, + cfg, + train_iterations) + evaluation_folder = get_evaluation_folder(train_fraction, + shuffle, + model_prefix, + cfg) + + model_folder = get_model_folder(train_fraction, + shuffle, + model_prefix, + cfg) + + model_path = get_model_path(model_folder, train_iterations) + + return { + 'dlc_scorer': dlc_scorer, + 'dlc_scorer_legacy': dlc_scorer_legacy, + 'evaluation_folder': evaluation_folder, + 'model_folder': model_folder, + 'model_path': model_path + } + + @staticmethod + def _get_results_filename(evaluation_folder, + dlc_scorer, + dlc_scorer_legacy, + model_path): + + results_filename = get_result_filename(evaluation_folder, + dlc_scorer, + dlc_scorer_legacy, + model_path) + + return results_filename + + +class BottomUpSolver(Solver): + """ + Base solvers for bottom up pose estimation. + """ + + def step(self, + batch: Tuple[torch.Tensor, torch.Tensor], + mode: str = 'train') -> np.array: + """Perform a single epoch gradient update or validation step. + + Parameters + ---------- + batch: Tuple of input image(s) and target(s) for train or valid single step. + mode: `train` or `eval` + + Returns + ------- + batch loss + """ + if mode not in ['train', 'eval']: + raise ValueError(f'Solver must be in train or eval mode, but {mode} was found.') + if mode == 'train': + self.optimizer.zero_grad() + image, keypoints = batch + image = image.to(self.device) + prediction = self.model(image) + target = self.model.get_target(keypoints, prediction[0].shape[2:]) # (batch_size, channels, h, w) + + for key in target: + if target[key] is not None: + target[key] = target[key].to(self.device) + + total_loss, heatmap_loss, locref_loss = self.criterion(prediction, target) + if self.logger: + self.logger.log(f'{mode} total loss', total_loss) + self.logger.log(f'{mode} heatmap loss', heatmap_loss) + self.logger.log(f'{mode} locref loss', locref_loss) + if mode == 'train': + total_loss.backward() + self.optimizer.step() + + return total_loss.detach().cpu().numpy() + + +class TopDownSolver(Solver): + # TODO + pass diff --git a/deeplabcut/pose_estimation_pytorch/solvers/logger.py b/deeplabcut/pose_estimation_pytorch/solvers/logger.py new file mode 100644 index 0000000000..f42b81e5e0 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/solvers/logger.py @@ -0,0 +1,62 @@ +import wandb as wb + +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + +LOGGER = Registry('single_animal_solver', + build_func=build_from_cfg) + +@LOGGER.register_module +class WandbLogger: + """ + Wandb logger to track experiments and log data. + (https://docs.wandb.ai/guides) + """ + + def __init__(self, + project_name: str = 'deeplabcut', + run_name: str = 'tmp', + model: PoseModel = None) -> None: + """ + Initialization of wandb logger. + + Parameters + ---------- + project_name: the name of the wandb project + run_name: the name of the wandb run + model: model to log + """ + self.run = wb.init(project=project_name, + name=run_name) + if model is None: + raise ValueError('Specify the model to track!') + self.run.watch(model) + + def log(self, + key: str = None, + value: str = None) -> None: + """ + Use this method to log data from runs, such as scalars, images, video, histograms, plots, and tables. + + Parameters + ---------- + key: name of the logged value + value: data to log + """ + if key is None or value is None: + raise ValueError(f'Nothing to log. Key: {key} and value: {value} expected to be scalar, table or image.') + self.run.log({key: value}) + + def save(self): + self.run.save(self.run.run.dir) + + def log_config(self, + config: dict = None) -> None: + """ + Use this method to save + + Parameters + ---------- + config: experiment config + """ + self.run.config.update(config) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py b/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py new file mode 100644 index 0000000000..70eb6009cc --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py @@ -0,0 +1,83 @@ +import os + +import pandas as pd +import torch + +from .base import BottomUpSolver +from ..registry import Registry, build_from_cfg +from ...pose_estimation_tensorflow import Plotting +from ...utils import auxiliaryfunctions + +SINGLE_ANIMAL_SOLVER = Registry('single_animal_solver', + build_func=build_from_cfg) + + +@SINGLE_ANIMAL_SOLVER.register_module +class BottomUpSingleAnimalSolver(BottomUpSolver): + + def evaluate(self, + dataset, + model_prefix: str = '', + train_fraction: int = 0.95, + train_iterations: int = 49, + shuffle: int = 0, + plotting: bool = False): + target_df = dataset.dataframe + self.names = self._get_paths(train_fraction=train_fraction, + model_prefix=model_prefix, + shuffle=shuffle, + cfg=dataset.cfg, + train_iterations=train_iterations) + + results_filename = self._get_results_filename(self.names['evaluation_folder'], + self.names['dlc_scorer'], + self.names['dlc_scorer_legacy'], + self.names['model_path'][:-3]) + self.model.load_state_dict(torch.load(self.names['model_path'])) + + predicted_poses = self.inference(dataset) + predicted_df = self.save_predictions(target_df.index, + predicted_poses.reshape(target_df.index.shape[0], -1), + results_filename) + if plotting: + foldername = f'{self.names["evaluation_folder"]}/LabeledImages_{self.names["dlc_scorer"]}-{train_iterations}' + auxiliaryfunctions.attempttomakefolder(foldername) + combined_df = predicted_df.merge(target_df, + left_index=True, + right_index=True) + Plotting(dataset.cfg, + dataset.cfg['bodyparts'], + self.names['dlc_scorer'], + predicted_df.index, + combined_df, + foldername) + + rmse, rmes_p = self.get_scores(predicted_df, target_df) + print(f'RMSE: {rmse}, RMSE pcutoff: {rmes_p}') + + def save_predictions(self, + data_index, + predicted_poses, + results_filename): + if not os.path.exists(self.names['evaluation_folder']): + os.makedirs(self.names['evaluation_folder']) + + results_path = f'{results_filename}' + index = pd.MultiIndex.from_product( + [ + [self.names['dlc_scorer']], + self.cfg["all_joints_names"], + ["x", "y", "likelihood"], + ], + names=["scorer", "bodyparts", "coords"], + ) + + predicted_data = pd.DataFrame( + predicted_poses, columns=index, index=data_index + ) + + predicted_data.to_hdf( + results_path, "df_with_missing", format="table", mode="w" + ) + + return predicted_data diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py new file mode 100644 index 0000000000..3680c2129a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -0,0 +1,96 @@ +import glob +import os +import pandas as pd +from typing import List + +import numpy as np + +from deeplabcut import auxiliaryfunctions +from ..utils import create_folder + + +def get_dlc_scorer(train_fraction, shuffle, model_prefix, test_cfg, train_iterations): + dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.GetScorerName( + test_cfg, + shuffle, + train_fraction, + train_iterations, + modelprefix=model_prefix, + ) + + return dlc_scorer, dlc_scorer_legacy + + +def get_evaluation_folder(train_fraction, shuffle, model_prefix, test_cfg): + evaluation_folder = os.path.join( + test_cfg["project_path"], + str( + auxiliaryfunctions.GetEvaluationFolder( + train_fraction, + shuffle, + test_cfg, + modelprefix=model_prefix + ) + ), + ) + create_folder(evaluation_folder) + return evaluation_folder + + +def get_model_folder(train_fraction, shuffle, model_prefix, test_cfg): + model_folder = os.path.join( + test_cfg["project_path"], + str( + auxiliaryfunctions.GetModelFolder( + train_fraction, + shuffle, + test_cfg, + modelprefix=model_prefix + ) + ), + ) + create_folder(model_folder) + return model_folder + + +def get_result_filename(evaluation_folder, + dlc_scorer, + dlc_scorerlegacy, + model_path): + _, results_filename, _ = auxiliaryfunctions.CheckifNotEvaluated(evaluation_folder, + dlc_scorer, + dlc_scorerlegacy, + os.path.basename(model_path)) + + return results_filename + + +def get_model_path(model_folder: str, + load_epoch: int = -1): + model_paths = glob.glob(f'{model_folder}/train/snapshot*') + sorted_paths = sort_paths(model_paths) + model_path = sorted_paths[load_epoch] + return model_path + + +def sort_paths(paths: list): + sorted_paths = sorted(paths, key=lambda i: int(os.path.basename(i).split('-')[-1][:-3])) + return sorted_paths + + +def get_rmse(prediction, + target: pd.DataFrame, + pcutoff: int=-1, + bodyparts: List[str] =None): + scorer_pred = prediction.columns[0][0] + scorer_target = target.columns[0][0] + mask = prediction[scorer_pred].xs("likelihood", level=1, axis=1) >= pcutoff + if bodyparts: + diff = (target[scorer_target][bodyparts] - prediction[scorer_pred][bodyparts]) ** 2 + else: + diff = (target[scorer_target] - prediction[scorer_pred]) ** 2 + mse = diff.xs("x", level=1, axis=1) + diff.xs("y", level=1, axis=1) + rmse = np.sqrt(mse) + rmse_p = np.sqrt(mse[mask]) + + return rmse, rmse_p diff --git a/deeplabcut/pose_estimation_pytorch/tests/__init__.py b/deeplabcut/pose_estimation_pytorch/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py b/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py new file mode 100644 index 0000000000..582c7b4629 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py @@ -0,0 +1,45 @@ +import albumentations as A +import numpy as np +import pytest + +import deeplabcut.pose_estimation_pytorch as dlc + +def _get_dataset(path, transform): + dlc_project = dlc.Project(path) + dlc_project.train_test_split() + dataset = dlc.PoseDataset(dlc_project, + transform=transform) + return dataset + + +@pytest.mark.parametrize('path', ['/mnt/md0/shaokai/DLC-ModelZoo/data/all_topview/openfield-Pranav-2018-08-20']) +def test_check_train_test(path): + dlc_project = dlc.Project(path) + dlc_project.train_test_split() + assert getattr(dlc_project, 'df_train', None) is not None + assert getattr(dlc_project, 'df_test', None) is not None + + +@pytest.mark.parametrize('path', ['/mnt/md0/shaokai/DLC-ModelZoo/data/all_topview/openfield-Pranav-2018-08-20']) +def test_resize_transform(path): + transform = A.Compose([ + A.Resize(width=256, height=256), ], + keypoint_params=A.KeypointParams(format='xy')) + + dlc_project = dlc.Project(path) + dlc_project.train_test_split() + dataset = dlc.PoseDataset(dlc_project, transform=None) + dataset_resized = dlc.PoseDataset(dlc_project, transform=transform) + image_tensor_resized, keypoints_resized = dataset_resized[0] + image_tensor, keypoints = dataset[0] + + assert image_tensor_resized.shape == (3, transform.transforms[0].height, transform.transforms[0].width) + + x_scale = image_tensor.shape[2] / image_tensor_resized.shape[2] + y_scale = image_tensor.shape[1] / image_tensor_resized.shape[1] + + x_scale_keypoints = keypoints[:, 0] / keypoints_resized[:, 0] + y_scale_keypoints = keypoints[:, 1] / keypoints_resized[:, 1] + + assert np.allclose(x_scale_keypoints, x_scale, atol=1e-4) + assert np.allclose(y_scale_keypoints, y_scale, atol=1e-4) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_helper.py b/deeplabcut/pose_estimation_pytorch/tests/test_helper.py new file mode 100644 index 0000000000..fdc650f190 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_helper.py @@ -0,0 +1,11 @@ +import torch + +def test_train_valid_call(): + + tmp_model = torch.nn.Linear(3, 10) + to_train_mode = getattr(tmp_model, 'train') + to_train_mode() + assert tmp_model.training == True + to_valid_mode = getattr(tmp_model, 'eval') + to_valid_mode() + assert tmp_model.training == False \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py new file mode 100644 index 0000000000..c61d58865f --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py @@ -0,0 +1,13 @@ +import pytest +import deeplabcut.pose_estimation_pytorch.models as dlc_models + + +def test_backbone(): + pass + +def test_head(): + pass + + +def test_pose_model(): + pass \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py new file mode 100644 index 0000000000..d4adb601a4 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py @@ -0,0 +1,23 @@ +"""TODO""" +import torch + +import deeplabcut.pose_estimation_pytorch.models as dlc_models + +def _get_keypoints(number_of_joints: int = 4, + axis: int = 2): + keypoints_torch = torch.Tensor(number_of_joints, axis) + + return keypoints_torch, number_of_joints + + +def test_generate_heatmaps(): + + keypoints_torch, number_of_joints = _get_keypoints() + image_size = (256, 256) + sigma = 5 + heatmap_size = (64, 64) + heatmaps = dlc_models._generate_heatmaps(keypoints_torch, + heatmap_size, + image_size, + sigma=sigma) + assert heatmaps.shape == (number_of_joints, heatmap_size[0], heatmap_size[1]) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py new file mode 100644 index 0000000000..ebaed8c7ba --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -0,0 +1,154 @@ +import os + +import abc +import numpy as np +import pandas as pd +import torch + +from deeplabcut.generate_training_dataset.trainingsetmanipulation import read_image_shape_fast +from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import calc_bboxes_from_keypoints + + +# Shaokai's function +def df2generic(proj_root, df, image_id_offset=0): + bpts = df.columns.get_level_values('bodyparts').unique().tolist() + + coco_categories = [] + + # single animal only has individual0 + + category = { + "name": 'individual0', + "id": 0, + "supercategory": "animal", + + } + + category['keypoints'] = bpts + + coco_categories.append(category) + + coco_images = [] + coco_annotations = [] + + annotation_id = 0 + image_id = -1 + + for _, file_name in enumerate(df.index): + data = df.loc[file_name] + + # skipping all nan + + if np.isnan(data.to_numpy()).all(): + continue + + image_id +=1 + category_id = 0 + kpts = data.to_numpy().reshape(-1,2) + keypoints = np.zeros((len(kpts),3)) + + keypoints[:,:2] = kpts + + is_visible = ~pd.isnull(kpts).all(axis=1) + + keypoints[:, 2] = np.where(is_visible, 2, 0) + + num_keypoints = is_visible.sum() + + bbox_margin = 20 + + xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( + [keypoints], slack=bbox_margin, + # clip=True, + )[0][:4] + + w = xmax - xmin + h = ymax - ymin + area = w * h + bbox = np.nan_to_num([xmin, ymin, w, h]) + keypoints = np.nan_to_num(keypoints.flatten()) + + + annotation_id += 1 + annotation = { + "image_id": image_id + image_id_offset, + "num_keypoints": num_keypoints, + "keypoints": keypoints, + "id": annotation_id, + "category_id": category_id, + "area": area, + "bbox": bbox, + "iscrowd": 0, + } + if np.sum(keypoints)!=0: + + coco_annotations.append(annotation) + + + # I think width and height are important + + if isinstance(file_name, tuple): + image_path = os.path.join(proj_root, *list(file_name)) + else: + image_path = os.path.join(proj_root, file_name) + + + _, height, width = read_image_shape_fast(image_path) + + + image = {'file_name' : image_path, + "width": width, + "height": height, + 'id': image_id + image_id_offset + } + coco_images.append(image) + + ret_obj = {'images': coco_images, + 'annotations': coco_annotations, + 'categories': coco_categories, + } + return ret_obj + + +def create_folder(path_to_folder): + """Creates all folders contained in the path. + Parameters + ---------- + path_to_folder : str + Path to the folder that should be created + """ + if not os.path.exists(path_to_folder): + os.makedirs(path_to_folder) + + +def fix_seeds(seed: int): + """ + Fixes seed for all random functions + @param seed: int + Seed to be fixed + """ + torch.manual_seed(seed) + np.random.seed(seed) + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + +def is_seq_of(seq, expected_type, seq_type=None): + """Check whether it is a sequence of some type. + Args: + seq (Sequence): The sequence to be checked. + expected_type (type): Expected type of sequence items. + seq_type (type, optional): Expected sequence type. + Returns: + bool: Whether the sequence is valid. + """ + if seq_type is None: + exp_seq_type = abc.Sequence + else: + assert isinstance(seq_type, type) + exp_seq_type = seq_type + if not isinstance(seq, exp_seq_type): + return False + for item in seq: + if not isinstance(item, expected_type): + return False + return True \ No newline at end of file diff --git a/docs/pytorch_dlc.md b/docs/pytorch_dlc.md new file mode 100644 index 0000000000..5cb32c61bd --- /dev/null +++ b/docs/pytorch_dlc.md @@ -0,0 +1,9 @@ +# Pytorch DLC API +- [data](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/project.py#L7): +The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. + - `Project` class provides train and test splitting and converts dataset to required format. For intance, to [COCO]() format. + - `PoseTrainDataset` class is a [torch.utils.Dataset](https://pytorch.org/docs/stable/data.html) class, which converts raw images and keypoints to a tensor dataset for training and evaluation. +- [models](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): +The `deeplabcut.pose_estimations_pytorch.models` package contains all related to building a model with `backbone`, `neck` (optional) and `head`. +- [train_module](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. + From 694770f25dd3369fae9d44fbe1352184fcf0e37a Mon Sep 17 00:00:00 2001 From: Anastasiia Filippova <41966024+nastya236@users.noreply.github.com> Date: Tue, 3 Jan 2023 14:49:05 +0100 Subject: [PATCH 006/293] Started README file (cherry picked from commit 32235fd4e653337dfc5b8c7479f0ea137902e466) --- deeplabcut/pose_estimation_pytorch/README.md | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/deeplabcut/pose_estimation_pytorch/README.md b/deeplabcut/pose_estimation_pytorch/README.md index e69de29bb2..d4541257b8 100644 --- a/deeplabcut/pose_estimation_pytorch/README.md +++ b/deeplabcut/pose_estimation_pytorch/README.md @@ -0,0 +1,64 @@ +# Pytorch DLC API + +##### The structure of the repo: +[Data](#data) +[Models](#models) +[Solvers](#solvers) +[Apis](#apis) + +## Data + +- [data](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/project.py#L7): +The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. + - `Project` class provides train and test splitting and converts dataset to required format. For intance, to [COCO]() format. + + Example: + + ```python3 + import deeplabcut.pose_estimation_pytorch as dlc + + project = dlc.Project(proj_root=config['project_root']) + project.train_test_split() + ``` + - `PoseDataset` class is an instance of [torch.utils.Dataset](https://pytorch.org/docs/stable/data.html), which converts raw images and keypoints to a tensor dataset for training and evaluation. + + Example: + + ```python3 + transform = None + train_dataset = dlc.PoseDataset(project, + transform=transform, + mode='train') + valid_dataset = dlc.PoseDataset(project, + transform=transform, + mode='test') + ``` + + > **Note** + > `transform` is a `List` of transformations to be applied to images and keypoints sequentialy, `None` by default. + + Example: + + ```python3 + import albumentations as A + + transform = A.Compose([ + A.Resize(width=256, height=256), + ], keypoint_params=A.KeypointParams(format='xy')) + + ``` + + > **Warning** + > By now supports only [albumentations](https://albumentations.ai), will be extended in the future. +## Models +- [models](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): +The `deeplabcut.pose_estimations_pytorch.models` package contains all related to building a model with `backbone`, `neck` (optional) and `head`. + +## Solvers +- [solvers](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. + +## Apis +- [apis] + +## Registry + From 43400534fef83452190df039b0c093bc493b0763 Mon Sep 17 00:00:00 2001 From: LucZot Date: Fri, 13 Jan 2023 10:39:41 +0100 Subject: [PATCH 007/293] readme fixes --- deeplabcut/pose_estimation_pytorch/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/README.md b/deeplabcut/pose_estimation_pytorch/README.md index d4541257b8..6b2012d677 100644 --- a/deeplabcut/pose_estimation_pytorch/README.md +++ b/deeplabcut/pose_estimation_pytorch/README.md @@ -7,8 +7,7 @@ [Apis](#apis) ## Data - -- [data](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/project.py#L7): +- [data](data/project.py#L7): The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. - `Project` class provides train and test splitting and converts dataset to required format. For intance, to [COCO]() format. @@ -51,14 +50,14 @@ The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pyt > **Warning** > By now supports only [albumentations](https://albumentations.ai), will be extended in the future. ## Models -- [models](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): +- [models](models): The `deeplabcut.pose_estimations_pytorch.models` package contains all related to building a model with `backbone`, `neck` (optional) and `head`. ## Solvers -- [solvers](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. +- [solvers](solvers): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. ## Apis -- [apis] +- [apis](apis): The `deeplabcut.pose_estimations_pytorch.apis` contains functionalities for training and testing as well as the corresponding configuration file [config.yaml](apis/config.yaml). ## Registry From a3678b2dd140f6ccf7eca3ff9df6505531c9e6b8 Mon Sep 17 00:00:00 2001 From: zhoumu53 Date: Fri, 13 Jan 2023 15:25:36 +0100 Subject: [PATCH 008/293] fix: function name in auxiliaryfunctions (cherry picked from commit ebddc3c4025acbe2a69dea89e14d4816728e2430) --- deeplabcut/utils/auxiliaryfunctions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index e71f8a8114..fa8cd9f7e7 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -848,3 +848,5 @@ def find_next_unlabeled_folder(config_path, verbose=False): CheckifPostProcessing = check_if_post_processing CheckifNotAnalyzed = check_if_not_analyzed CheckifNotEvaluated = check_if_not_evaluated +GetEvaluationFolder = get_evaluation_folder +GetModelFolder = get_model_folder \ No newline at end of file From 008bcce345119ac5c1287eddafb35cd540979b84 Mon Sep 17 00:00:00 2001 From: nastya236 Date: Fri, 13 Jan 2023 15:47:36 +0100 Subject: [PATCH 009/293] bug fixes and improvements to architecture --- .../pose_estimation_pytorch/apis/test.py | 3 +- .../pose_estimation_pytorch/apis/train.py | 3 +- .../pose_estimation_pytorch/data/base.py | 20 +++- .../pose_estimation_pytorch/data/dataset.py | 81 ++++--------- .../data/dlcproject.py | 113 ++++++++++++++++++ .../pose_estimation_pytorch/data/project.py | 36 ------ 6 files changed, 153 insertions(+), 103 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/data/dlcproject.py delete mode 100644 deeplabcut/pose_estimation_pytorch/data/project.py diff --git a/deeplabcut/pose_estimation_pytorch/apis/test.py b/deeplabcut/pose_estimation_pytorch/apis/test.py index e82dfa295f..094fd3120f 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/test.py +++ b/deeplabcut/pose_estimation_pytorch/apis/test.py @@ -8,8 +8,7 @@ transform = None dlc.fix_seeds(config['seed']) -project = dlc.Project(proj_root=config['project_root']) -project.train_test_split() +project = dlc.DLCProject(proj_root=config['project_root']) solver = build_solver(config) test_dataset = dlc.PoseDataset(project, diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 2d7e81bcc5..9835ff4680 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -11,8 +11,7 @@ transform = None dlc.fix_seeds(config['seed']) -project = dlc.Project(proj_root=config['project_root']) -project.train_test_split() +project = dlc.DLCProject(proj_root=config['project_root']) train_dataset = dlc.PoseDataset(project, transform=transform, diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 345ca13439..299cafe0f5 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -1,9 +1,25 @@ from abc import ABC, abstractmethod -class Base(ABC): + +class BaseProject(ABC): + """ + TODO + """ + + def __init__(self): + pass @abstractmethod - def create_from_config(self, config): + def convert2dict(self): raise NotImplementedError + @staticmethod + def annotation2key(annotation): + return annotation + +class BaseDataset(ABC): + """ + TODO + """ + pass diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index c3cce24274..36fad96989 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -3,17 +3,15 @@ import torch from torch.utils.data import Dataset -from deeplabcut.pose_estimation_pytorch.data.base import Base -from deeplabcut.pose_estimation_pytorch.utils import df2generic +from .base import BaseDataset -class PoseDataset(Dataset, Base): +class PoseDataset(Dataset, BaseDataset): def __init__(self, project, - transform=None, - image_id_offset=0, - mode='train'): + transform: object = None, + mode: str = 'train'): """ Parameters @@ -24,60 +22,19 @@ def __init__(self, def transform(image, keypoints): return image, keypoints - image_id_offset: TODO mode: 'train' or 'test' - this parameter specify which dataframe parse from the Project (df_tran or df_test) + this parameter which dataframe parse from the Project (df_tran or df_test) """ super().__init__() - try: - self.dataframe = getattr(project, f'df_{mode}') - except: - raise AttributeError(f"PoseDataset doesn't have df_{mode} attr. Do project.train_test_split() first!") - - data = df2generic(project.proj_root, self.dataframe, image_id_offset) - - self.images = data['images'] - self.keypoints = data['annotations'] self.transform = transform - self.cfg = project.cfg - assert len(self.images) == len(self.keypoints) - - def create_from_config(self, config): - # TODO - pass - - @staticmethod - def _annotation2key(annotation): - """ - TODO - This function was copied from modelzoo project (transformation to coco format) - Parameters - ---------- - annotation: dict of annotations - - Returns - ------- - keypoints: list - paired keypoints - undef_ids: list - mask - """ - x = annotation['keypoints'][::3] - y = annotation['keypoints'][1::3] - vis = annotation['keypoints'][2::3] - undef_ids = list(np.where(x == -1)[0]) - keypoints = [] - - for pair in np.stack([x, y]).T: - if pair[0] != -1: - keypoints.append((pair[0], pair[1])) - else: - keypoints.append((0, 0)) - return keypoints, undef_ids + self.project = project + self.cfg = self.project.cfg + self.shuffle = self.project.shuffle + self.project.convert2dict(mode) def __len__(self): - return len(self.images) + return len(self.project.images) def __getitem__(self, index: int): @@ -97,24 +54,26 @@ def __getitem__(self, im, keipoints = train_dataset[0] """ - image_file = self.images[index]['file_name'] + # load images + image_file = self.project.images[index]['file_name'] image = cv2.imread(image_file) - annotation = self.keypoints[index] - keypoints, undef_ids = self._annotation2key(annotation) - if self.transform: + # load annotation + annotation = self.project.annotations[index] + keypoints, undef_ids = self.project.annotation2keypoints(annotation) + if self.transform: transformed = self.transform(image=image, keypoints=keypoints) - transformed['keipoints'] = [(-1, -1) if i in undef_ids else keypoint + transformed['keypoints'] = [(-1, -1) if i in undef_ids else keypoint for i, keypoint in enumerate(transformed['keypoints'])] else: transformed = {} - transformed['keipoints'] = keypoints + transformed['keypoints'] = keypoints transformed['image'] = image image = torch.FloatTensor(transformed['image']).permute(2, 0, 1) # channels first - assert len(transformed['keipoints']) == len(keypoints) - keypoints = np.array(transformed['keipoints']) + assert len(transformed['keypoints']) == len(keypoints) + keypoints = np.array(transformed['keypoints']) return image, keypoints diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py new file mode 100644 index 0000000000..5eb8f3e0ab --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py @@ -0,0 +1,113 @@ +import os +import pickle +from typing import List + +import numpy as np +import pandas as pd + +import deeplabcut +from .base import BaseProject +from ..utils import df2generic + + +class DLCProject(BaseProject): + """ + TODO + """ + + def __init__(self, proj_root, + shuffle: int = 0, + image_id_offset: int = 0, + keys_to_load: List[str] = ['images', 'annotations']): + super().__init__() + self.proj_root = proj_root + self.shuffle = shuffle + self.keys_to_load = keys_to_load + self.image_id_offset = image_id_offset + config_file = os.path.join(self.proj_root, 'config.yaml') + self.cfg = deeplabcut.auxiliaryfunctions.read_config(config_file) + self.task = self.cfg['Task'] + self.scorer = self.cfg['scorer'] + self.datasets_folder = os.path.join( + self.proj_root, deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(self.cfg), + ) + tr_frac = int(self.cfg['TrainingFraction'][0] * 100) + self.path_dlc_data = os.path.join(self.datasets_folder, f'CollectedData_{self.scorer}.h5') + self.path_dlc_doc = os.path.join(self.datasets_folder, + f'Documentation_data-{self.task}_{tr_frac}shuffle{self.shuffle}.pickle') + self.dlc_df = pd.read_hdf(self.path_dlc_data) + self.load_split() + + def convert2dict(self, + mode: str = 'train'): + """ + + Parameters + ---------- + mode + + Returns + ------- + + """ + try: + self.dataframe = getattr(self, f'df_{mode}') + except: + raise AttributeError(f"PoseDataset doesn't have df_{mode} attr. Do project.train_test_split() first!") + + data = df2generic(self.proj_root, + self.dataframe, + self.image_id_offset) + + for key in self.keys_to_load: + setattr(self, key, data[key]) + print('The data has been loaded!') + + def load_split(self): + """ + + Returns + ------- + + """ + with open(self.path_dlc_doc, 'rb') as f: + meta = pickle.load(f) + + train_ids = meta[1] + test_ids = meta[2] + + train_images = self.dlc_df.index[train_ids] + if len(test_ids) != 0: + test_images = self.dlc_df.index[test_ids] + self.dlc_images = np.hstack([train_images, test_images]) + self.df_test = self.dlc_df.loc[test_images] + self.df_train = self.dlc_df.loc[train_images] + + @staticmethod + def annotation2keypoints(annotation): + """ + TODO + This function was copied from modelzoo project (transformation to coco format) + Parameters + ---------- + annotation: dict of annotations + + Returns + ------- + keypoints: list + paired keypoints + undef_ids: list + mask + """ + x = annotation['keypoints'][::3] + y = annotation['keypoints'][1::3] + vis = annotation['keypoints'][2::3] + undef_ids = list(np.where(x == -1)[0]) + keypoints = [] + + for pair in np.stack([x, y]).T: + if pair[0] != -1: + keypoints.append((pair[0], pair[1])) + else: + keypoints.append((0, 0)) + return keypoints, undef_ids diff --git a/deeplabcut/pose_estimation_pytorch/data/project.py b/deeplabcut/pose_estimation_pytorch/data/project.py deleted file mode 100644 index 04d6c3f850..0000000000 --- a/deeplabcut/pose_estimation_pytorch/data/project.py +++ /dev/null @@ -1,36 +0,0 @@ -import os -import pandas as pd -import deeplabcut -import pickle -import numpy as np - -class Project: - def __init__(self, proj_root, - shuffle = 1): - self.proj_root = proj_root - self.shuffle = shuffle - - config_file = os.path.join(self.proj_root, 'config.yaml') - self.cfg = deeplabcut.auxiliaryfunctions.read_config(config_file) - self.task = self.cfg['Task'] - self.scorer = self.cfg['scorer'] - self.datasets_folder = os.path.join( - self.proj_root, deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(self.cfg), - ) - tr_frac = int(self.cfg['TrainingFraction'][0]*100) - self.path_dlc_data = os.path.join(self.datasets_folder,f'CollectedData_{self.scorer}.h5') - self.path_dlc_doc = os.path.join(self.datasets_folder,f'Documentation_data-{self.task}_{tr_frac}shuffle{self.shuffle}.pickle') - self.dlc_df = pd.read_hdf(self.path_dlc_data) - - def train_test_split(self): - with open(self.path_dlc_doc, 'rb') as f: - meta = pickle.load(f) - - train_ids = meta[1] - test_ids = meta[2] - - train_images = self.dlc_df.index[train_ids] - test_images = self.dlc_df.index[test_ids] - self.dlc_images = np.hstack([train_images,test_images]) - self.df_train = self.dlc_df.loc[train_images] - self.df_test = self.dlc_df.loc[test_images] \ No newline at end of file From 7f1c27cb68b83ac605af4b66eba49c83bf2a104e Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Fri, 13 Jan 2023 16:34:30 +0100 Subject: [PATCH 010/293] improvements to training/evaluation code and minor fixes --- .../trainingsetmanipulation.py | 15 ++++ .../pose_estimation_pytorch/__init__.py | 2 +- .../pose_estimation_pytorch/apis/config.yaml | 6 +- .../pose_estimation_pytorch/apis/train.py | 86 +++++++++++++------ .../models/backbones/base.py | 3 +- requirements.txt | 3 + 6 files changed, 84 insertions(+), 31 deletions(-) diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 6d237c27b3..1551609286 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -1119,6 +1119,21 @@ def create_training_dataset( "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!" ) + # Populate the pytorch config yaml file + pytorch_config_path = os.path.join( + dlcparent_path, + "pose_estimation_pytorch", + "apis", + "pytorch_config.yaml", + ) + pytorch_cfg = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + pytorch_cfg["project_root"] = os.path.dirname(config) + pytorch_cfg["pose_cfg_path"] = path_train_config + pytorch_cfg["cfg_path"] = config + auxiliaryfunctions.write_plainconfig( + path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml"), + pytorch_cfg, + ) return splits diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index 12bfb2412b..486ebefc23 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -1,3 +1,3 @@ -from deeplabcut.pose_estimation_pytorch.data.project import Project +from deeplabcut.pose_estimation_pytorch.data.dlcproject import DLCProject from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset from deeplabcut.pose_estimation_pytorch.utils import fix_seeds diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml index 6a27d2702e..3e29a6a7d7 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -1,6 +1,6 @@ -project_root: '/mnt/md0/anastasiia/ModelZoo/data/all_topview/openfield-Pranav-2018-08-20' -pose_cfg_path: '/mnt/md0/anastasiia/ModelZoo/data/all_topview/openfield-Pranav-2018-08-20/dlc-models/iteration-0/openfieldAug20-trainset95shuffle0/train/pose_cfg.yaml' -cfg_path: '/mnt/md0/anastasiia/ModelZoo/data/all_topview/openfield-Pranav-2018-08-20/config.yaml' +project_root: willbeautomaticallyupdatedbycreate_training_datasetcode +pose_cfg_path: willbeautomaticallyupdatedbycreate_training_datasetcode +cfg_path: willbeautomaticallyupdatedbycreate_training_datasetcode seed: 42 device: 'cuda:0' diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 9835ff4680..ef5eac774c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -1,37 +1,73 @@ +import argparse +import os from torch.utils.data import DataLoader import deeplabcut.pose_estimation_pytorch as dlc from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver +from typing import Union -config = auxiliaryfunctions.read_config('config.yaml') -batch_size = config['batch_size'] -device = config['device'] -epochs = config['epochs'] -transform = None -dlc.fix_seeds(config['seed']) -project = dlc.DLCProject(proj_root=config['project_root']) +def train_network( + config_path: str, + shuffle: int = 1, + training_set_index: Union[int, str] = 0, + model_prefix: str = "", +): + cfg = auxiliaryfunctions.read_config(config_path) + if training_set_index == "all": + train_fraction = cfg["TrainingFraction"] + else: + train_fraction = [cfg["TrainingFraction"][training_set_index]] + modelfolder = os.path.join( + cfg["project_path"], + auxiliaryfunctions.get_model_folder( + train_fraction[0], shuffle, cfg, modelprefix=model_prefix, + ), + ) + pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") + config = auxiliaryfunctions.read_config(pytorch_config_path) + batch_size = config['batch_size'] + epochs = config['epochs'] + transform = None + dlc.fix_seeds(config['seed']) + project = dlc.DLCProject(proj_root=config['project_root'], shuffle=shuffle) + train_dataset = dlc.PoseDataset(project, + transform=transform, + mode='train') + valid_dataset = dlc.PoseDataset(project, + transform=transform, + mode='test') -train_dataset = dlc.PoseDataset(project, - transform=transform, - mode='train') -valid_dataset = dlc.PoseDataset(project, - transform=transform, - mode='test') + train_dataloader = DataLoader(train_dataset, + batch_size=batch_size, + shuffle=True) -train_dataloader = DataLoader(train_dataset, - batch_size=batch_size, - shuffle=True) + valid_dataloader = DataLoader(valid_dataset, + batch_size=batch_size, + shuffle=False) -valid_dataloader = DataLoader(valid_dataset, - batch_size=batch_size, - shuffle=False) + solver = build_solver(config) + solver.fit( + train_dataloader, + valid_dataloader, + epochs=epochs, + shuffle=shuffle, + model_prefix=model_prefix, + ) + return solver -solver = build_solver(config) -solver.fit(train_dataloader, valid_dataloader, epochs=epochs) - -solver.evaluate(valid_dataset, - plotting=False, - train_iterations=epochs - 1) +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("--config-path", type=str) + parser.add_argument("--shuffle", type=int, default=1) + parser.add_argument("--train-ind", type=int, default=0) + parser.add_argument("--modelprefix", type=str, default="") + args = parser.parse_args() + solver = train_network( + config_path = args.config_path, + shuffle=args.shuffle, + training_set_index=args.train_ind, + model_prefix=args.modelprefix, + ) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index c26e4bea6a..ad8d211c58 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -import validators import torch.nn as nn import torch from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg @@ -29,7 +28,7 @@ def _init_weights(self, pretrained: str = None): """ if not pretrained: pass - elif validators.url(pretrained): + elif pretrained.startswith("http") or pretrained.startswith("ftp"): state_dict = torch.hub.load_state_dict_from_url(pretrained) self.model.load_state_dict(state_dict, strict=False) else: diff --git a/requirements.txt b/requirements.txt index 57aec6e2b9..ed7629b5ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ +# novel for pytorch DLC: +albumentations +# old: dlclibrary ipython filterpy From 05618e564b3024c33201e1814aaab34cb4cf4ea2 Mon Sep 17 00:00:00 2001 From: shaokaiye Date: Fri, 13 Jan 2023 16:29:59 +0000 Subject: [PATCH 011/293] added coco project parser (cherry picked from commit f2da1e23d1a9ddda38b00ee3da59afa41feb5cda) --- .../data/cocoproject.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 deeplabcut/pose_estimation_pytorch/data/cocoproject.py diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoproject.py b/deeplabcut/pose_estimation_pytorch/data/cocoproject.py new file mode 100644 index 0000000000..914f1eefb7 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/cocoproject.py @@ -0,0 +1,53 @@ +import os +import pickle +from typing import List +import numpy as np +import pandas as pd +import json +from .base import BaseProject + +class COCOProject(BaseProject): + """ + TODO + """ + + def __init__(self, + proj_root, + shuffle: int = 0, + image_id_offset: int = 0, + keys_to_load: List[str] = ['images', 'annotations']): + super().__init__() + self.proj_root = proj_root + self.keys_to_load = keys_to_load + + self.train_json_obj = self._load_json('train.json') if shuffle is None else self._load_json(f'train_shuffle{shuffle}.json') + self.test_json_obj = self._load_json('test.json') if shuffle is None else self._load_json(f'test_shuffle{shuffle}.json') + + def _load_json(self, json_fn): + path = os.path.join(self.proj_root, 'annotations', json_fn) + with open(path, 'r') as f: + json_obj = json.load(f) + return json_obj + + def load_split(self): + ''' + We expected that coco project has train test split in train test json already + ''' + pass + + def convert2dict(self, + mode: str = 'train'): + + json_obj = getattr(self, f'{mode}_json_obj') + + + for image in self.images + image_path = image['file_name'] + #if os.sep not in image_path: + # assuming the file_name is mmpose style, i.e. only the image name is stored + # so we need to add back absolute path + image['file_name'] = os.path.join(self.proj_root, 'images', image_path) + + + for key in self.keys_to_load: + setattr(self, key, json_obj[key]) From c16a3e9952c22ea64a41b5d5fa47b40a4fa0e21a Mon Sep 17 00:00:00 2001 From: LucZot Date: Fri, 13 Jan 2023 14:02:57 +0100 Subject: [PATCH 012/293] hrnet backbone --- .../models/backbones/__init__.py | 3 ++- .../models/backbones/hrnet.py | 24 +++++++++++++++++++ requirements.txt | 1 + 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py index fd33b2a889..fcdb8e759d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py @@ -1 +1,2 @@ -from .resnet import ResNet \ No newline at end of file +from .resnet import ResNet +from .hrnet import HRNet \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py new file mode 100644 index 0000000000..824ac1371d --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -0,0 +1,24 @@ +import timm +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.models.backbones.base import BaseBackbone, BACKBONES + +@BACKBONES.register_module +class HRNet(BaseBackbone): + + def __init__(self, model_name: str = 'hrnet_w32') -> nn.Module: + """ + Constructs an ImageNet pre-trained HRNet from timm + (https://github.com/rwightman/pytorch-image-models/blob/main/timm/models/hrnet.py) + + Parameters + ---------- + model_name: str + type of HRNet (e.g. 'hrnet_w32, 'hrnet_w48') + """ + super().__init__() + _backbone = timm.create_model(model_name, pretrained=True) + self.model = _backbone + + def forward(self, x): + return self.model.forward_features(x) diff --git a/requirements.txt b/requirements.txt index ed7629b5ea..4c8932b653 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # novel for pytorch DLC: albumentations +timm # old: dlclibrary ipython From 1111296681fba41c06c0e760f582a0cd356a0f41 Mon Sep 17 00:00:00 2001 From: zhoumu53 Date: Tue, 17 Jan 2023 19:55:58 +0100 Subject: [PATCH 013/293] add inference script --- .../pose_estimation_pytorch/apis/inference.py | 35 +++++++++++ .../pose_estimation_pytorch/models/model.py | 60 ++++++++++--------- .../pose_estimation_pytorch/solvers/base.py | 5 +- .../solvers/inference.py | 29 +++++++++ 4 files changed, 99 insertions(+), 30 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/apis/inference.py create mode 100644 deeplabcut/pose_estimation_pytorch/solvers/inference.py diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py new file mode 100644 index 0000000000..4bd7a32b50 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -0,0 +1,35 @@ +import deeplabcut.pose_estimation_pytorch as dlc +from deeplabcut import auxiliaryfunctions +from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction +from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model + +import torch +from torch.utils.data import DataLoader + + +config = auxiliaryfunctions.read_config('pytorch_config.yaml') +batch_size = config['batch_size'] +device = config['device'] + +transform = None +dlc.fix_seeds(config['seed']) + +### Load data +project = dlc.Project(proj_root=config['project_root']) +project.train_test_split() +valid_dataset = dlc.PoseDataset(project, + transform=transform, + mode='test') +valid_dataloader = DataLoader(valid_dataset, + batch_size=batch_size, + shuffle=1) + +### TODO: anothor option: user can load ood data + +checkpoint_path="../../../examples/openfield-Pranav-2018-10-30/dlc-models/iteration-0/openfieldOct30-trainset95shuffle0/train/snapshot-1.pt" +pose_cfg = auxiliaryfunctions.read_config(config['pose_cfg_path']) +model = build_pose_model(config['model'], pose_cfg) +model.load_state_dict(torch.load(checkpoint_path), strict=False) + +output = model(valid_dataloader) +predictions = get_prediction(config, output, stride) diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index e7fc376783..af75d4665a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -38,6 +38,8 @@ def forward(self, x): ------- """ + if x.dim() == 3: + x = x[None, :] features = self.backbone(x) if self.neck: features = self.neck(features) @@ -85,32 +87,32 @@ def get_target(self, return target - @torch.no_grad() - def predict(self, x): - """ - - Parameters - ---------- - x: input image tensor - - Returns - ------- - pose: predicted keypoints coordinates - """ - - self.eval() - poses = [] - if x.dim() == 3: - x = x[None, :] - heatmaps, locref = self.forward(x) - heatmaps = self.sigmoid(heatmaps) - heatmaps = heatmaps.permute(0, 2, 3, 1).detach().cpu().numpy() - locref = locref.permute(0, 2, 3, 1).detach().cpu().numpy() - for i in range(x.shape[0]): - shape = locref[i].shape - locref_i = np.reshape(locref, (shape[0], shape[1], -1, 2)) - if self.cfg['location_refinement']: - locref_i = locref_i * self.cfg['locref_stdev'] - pose = multi_pose_predict(heatmaps[i], locref_i, self.stride, 1) - poses.append(pose) - return np.stack(poses, axis=0) + # @torch.no_grad() + # def predict(self, x): + # """ + + # Parameters + # ---------- + # x: input image tensor + + # Returns + # ------- + # pose: predicted keypoints coordinates + # """ + + # self.eval() + # poses = [] + # if x.dim() == 3: + # x = x[None, :] + # heatmaps, locref = self.forward(x) + # heatmaps = self.sigmoid(heatmaps) + # heatmaps = heatmaps.permute(0, 2, 3, 1).detach().cpu().numpy() + # locref = locref.permute(0, 2, 3, 1).detach().cpu().numpy() + # for i in range(x.shape[0]): + # shape = locref[i].shape + # locref_i = np.reshape(locref, (shape[0], shape[1], -1, 2)) + # if self.cfg['location_refinement']: + # locref_i = locref_i * self.cfg['locref_stdev'] + # pose = multi_pose_predict(heatmaps[i], locref_i, self.stride, 1) + # poses.append(pose) + # return np.stack(poses, axis=0) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index e8557af079..697a9b5627 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -10,6 +10,7 @@ from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset from .utils import * +from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction class Solver(ABC): @@ -51,6 +52,7 @@ def __init__(self, if self.logger: logger.log_config(cfg) self.model.to(device) + self.stride = 8 # TODO: stride from config? def fit( self, @@ -147,7 +149,8 @@ def inference(self, else: item = item item = item.to(self.device) - pose = self.model.predict(item) + output = self.model(item) + pose = get_prediction(self.cfg, output, self.stride) predicted_poses.append(pose) predicted_poses = np.concatenate(predicted_poses) return predicted_poses diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py new file mode 100644 index 0000000000..a5ca3afc0e --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -0,0 +1,29 @@ +import numpy as np +import torch +from torch import nn + +from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps +from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict, argmax_pose_predict + + +def get_prediction(cfg, output, stride=8): + ''' + get predictions from model output + output = heatmaps, locref + heatmaps: numpy.ndarray([batch_size, num_joints, height, width]) + locref: numpy.ndarray([batch_size, num_joints, height, width]) + ''' + + poses = [] + heatmaps, locref = output + heatmaps = nn.Sigmoid()(heatmaps) + heatmaps = heatmaps.permute(0, 2, 3, 1).detach().cpu().numpy() + locref = locref.permute(0, 2, 3, 1).detach().cpu().numpy() + for i in range(heatmaps.shape[0]): + shape = locref[i].shape + locref_i = np.reshape(locref, (shape[0], shape[1], -1, 2)) + if cfg['location_refinement']: + locref_i = locref_i * cfg['locref_stdev'] + pose = multi_pose_predict(heatmaps[i], locref_i, stride, 1) + poses.append(pose) + return np.stack(poses, axis=0) From a980102c84e09e86e9f64c151f646a44e86d431f Mon Sep 17 00:00:00 2001 From: nastya236 Date: Fri, 3 Feb 2023 17:26:50 +0100 Subject: [PATCH 014/293] Modify inference and delete red. in model, refactored the code for inference+eval --- .../pose_estimation_pytorch/apis/__init__.py | 10 ++ .../pose_estimation_pytorch/apis/inference.py | 123 ++++++++++++++---- .../pose_estimation_pytorch/apis/test.py | 19 --- .../pose_estimation_pytorch/apis/train.py | 14 +- .../pose_estimation_pytorch/data/dataset.py | 1 + .../pose_estimation_pytorch/models/model.py | 38 +----- .../pose_estimation_pytorch/solvers/base.py | 59 --------- .../solvers/inference.py | 38 +++++- .../solvers/single_animal.py | 73 +---------- .../pose_estimation_pytorch/solvers/utils.py | 91 ++++++++++--- 10 files changed, 229 insertions(+), 237 deletions(-) delete mode 100644 deeplabcut/pose_estimation_pytorch/apis/test.py diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py index e69de29bb2..6da7ce54f9 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py @@ -0,0 +1,10 @@ + +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 4bd7a32b50..1f82ee0b26 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -1,35 +1,110 @@ +import argparse import deeplabcut.pose_estimation_pytorch as dlc +import numpy as np +import os +import torch from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model +from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction, get_scores +from deeplabcut.pose_estimation_pytorch.solvers.utils import get_paths, get_results_filename, save_predictions +from deeplabcut.pose_estimation_tensorflow import Plotting +from typing import Union -import torch -from torch.utils.data import DataLoader +def inference_network( + config_path: str, + shuffle: int = 0, + model_prefix: str = "", + load_epoch: int = 49, + stride: int = 8, + transform: object = None, + plot: bool = False, + evaluate: bool = True): + # reading pytorch config + cfg = auxiliaryfunctions.read_config(config_path) + train_fraction = cfg["TrainingFraction"] + modelfolder = os.path.join( + cfg["project_path"], + auxiliaryfunctions.get_model_folder( + train_fraction[0], shuffle, cfg, modelprefix=model_prefix, + ), + ) + pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") + config = auxiliaryfunctions.read_config(pytorch_config_path) + + batch_size = config['batch_size'] + project = dlc.DLCProject(shuffle=shuffle, + proj_root=config['project_root']) + + valid_dataset = dlc.PoseDataset(project, + transform=transform, + mode='test') + valid_dataloader = torch.utils.data.DataLoader(valid_dataset, + batch_size=batch_size, + shuffle=False) + names = get_paths(train_fraction=train_fraction[0], + model_prefix=model_prefix, + shuffle=shuffle, + cfg=valid_dataset.cfg, + train_iterations=load_epoch) -config = auxiliaryfunctions.read_config('pytorch_config.yaml') -batch_size = config['batch_size'] -device = config['device'] + results_filename = get_results_filename(names['evaluation_folder'], + names['dlc_scorer'], + names['dlc_scorer_legacy'], + names['model_path'][:-3]) -transform = None -dlc.fix_seeds(config['seed']) + pose_cfg = auxiliaryfunctions.read_config(config['pose_cfg_path']) + model = build_pose_model(config['model'], pose_cfg) + model.load_state_dict(torch.load(names['model_path'])) -### Load data -project = dlc.Project(proj_root=config['project_root']) -project.train_test_split() -valid_dataset = dlc.PoseDataset(project, - transform=transform, - mode='test') -valid_dataloader = DataLoader(valid_dataset, - batch_size=batch_size, - shuffle=1) + target_df = valid_dataset.dataframe + predicted_poses = [] + model.eval() + with torch.no_grad(): + for item in valid_dataloader: + if isinstance(item, tuple) or (isinstance, list): + item = item[0] + output = model(item) + predictions = get_prediction(pose_cfg, output, stride) + predicted_poses.append(predictions) + predicted_poses = np.array(predicted_poses) -### TODO: anothor option: user can load ood data + predicted_df = save_predictions(names, + pose_cfg, + target_df.index, + predicted_poses.reshape(target_df.index.shape[0], -1), + results_filename) + if plot: + foldername = f'{names["evaluation_folder"]}/LabeledImages_{names["dlc_scorer"]}-{load_epoch}' + auxiliaryfunctions.attempttomakefolder(foldername) + combined_df = predicted_df.merge(target_df, + left_index=True, + right_index=True) + Plotting(valid_dataset.cfg, + valid_dataset.cfg['bodyparts'], + names['dlc_scorer'], + predicted_df.index, + combined_df, + foldername) + if evaluate: + rmse, rmes_p = get_scores(pose_cfg, + predicted_df, + target_df) + print(f'RMSE: {rmse}, RMSE pcutoff: {rmes_p}') -checkpoint_path="../../../examples/openfield-Pranav-2018-10-30/dlc-models/iteration-0/openfieldOct30-trainset95shuffle0/train/snapshot-1.pt" -pose_cfg = auxiliaryfunctions.read_config(config['pose_cfg_path']) -model = build_pose_model(config['model'], pose_cfg) -model.load_state_dict(torch.load(checkpoint_path), strict=False) -output = model(valid_dataloader) -predictions = get_prediction(config, output, stride) +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("--config_path", type=str) + parser.add_argument("--shuffle", type=int, default=0) + parser.add_argument("--modelprefix", type=str, default="") + parser.add_argument("--load_epoch", type=int, default=49) + parser.add_argument("--plot", type=bool, default=False) + parser.add_argument("--evaluate", type=bool, default=True) + args = parser.parse_args() + inference_network(config_path=args.config_path, + shuffle=args.shuffle, + model_prefix=args.modelprefix, + load_epoch=args.load_epoch, + plot=args.plot, + evaluate=args.evaluate) diff --git a/deeplabcut/pose_estimation_pytorch/apis/test.py b/deeplabcut/pose_estimation_pytorch/apis/test.py deleted file mode 100644 index 094fd3120f..0000000000 --- a/deeplabcut/pose_estimation_pytorch/apis/test.py +++ /dev/null @@ -1,19 +0,0 @@ -import deeplabcut.pose_estimation_pytorch as dlc -from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver - -config = auxiliaryfunctions.read_config('config.yaml') -batch_size = config['batch_size'] -device = config['device'] - -transform = None -dlc.fix_seeds(config['seed']) -project = dlc.DLCProject(proj_root=config['project_root']) -solver = build_solver(config) - -test_dataset = dlc.PoseDataset(project, - mode='test') - -solver.evaluate(test_dataset, - train_iterations=49, - plotting=True) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index ef5eac774c..572713d4c1 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -1,19 +1,17 @@ import argparse -import os -from torch.utils.data import DataLoader - import deeplabcut.pose_estimation_pytorch as dlc +import os from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver +from torch.utils.data import DataLoader from typing import Union def train_network( - config_path: str, - shuffle: int = 1, - training_set_index: Union[int, str] = 0, - model_prefix: str = "", -): + config_path: str, + shuffle: int = 1, + training_set_index: Union[int, str] = 0, + model_prefix: str = ""): cfg = auxiliaryfunctions.read_config(config_path) if training_set_index == "all": train_fraction = cfg["TrainingFraction"] diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 36fad96989..60bb105509 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -32,6 +32,7 @@ def transform(image, keypoints): self.cfg = self.project.cfg self.shuffle = self.project.shuffle self.project.convert2dict(mode) + self.dataframe = self.project.dataframe def __len__(self): return len(self.project.images) diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index af75d4665a..2b966e0f60 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -1,9 +1,8 @@ import numpy as np import torch -from torch import nn - from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict +from torch import nn class PoseModel(nn.Module): @@ -48,10 +47,6 @@ def forward(self, x): return heat_maps, loc_ref - @torch.no_grad() - def load_model(self, checkpoint): - self.model.load_state_dict(torch.load(checkpoint), strict=True) - def get_target(self, keypoints_batch, heatmap_size): @@ -85,34 +80,3 @@ def get_target(self, 'weights': weights } return target - - - # @torch.no_grad() - # def predict(self, x): - # """ - - # Parameters - # ---------- - # x: input image tensor - - # Returns - # ------- - # pose: predicted keypoints coordinates - # """ - - # self.eval() - # poses = [] - # if x.dim() == 3: - # x = x[None, :] - # heatmaps, locref = self.forward(x) - # heatmaps = self.sigmoid(heatmaps) - # heatmaps = heatmaps.permute(0, 2, 3, 1).detach().cpu().numpy() - # locref = locref.permute(0, 2, 3, 1).detach().cpu().numpy() - # for i in range(x.shape[0]): - # shape = locref[i].shape - # locref_i = np.reshape(locref, (shape[0], shape[1], -1, 2)) - # if self.cfg['location_refinement']: - # locref_i = locref_i * self.cfg['locref_stdev'] - # pose = multi_pose_predict(heatmaps[i], locref_i, self.stride, 1) - # poses.append(pose) - # return np.stack(poses, axis=0) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 697a9b5627..936d9b4405 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -91,20 +91,6 @@ def fit( f'train loss {train_loss}, ' f'valid loss {valid_loss}') - def get_scores(self, - prediction: pd.DataFrame, - target: pd.DataFrame, - bodyparts: List = None): - if self.cfg.get( 'pcutoff'): - pcutoff = self.cfg['pcutoff'] - rmse, rmse_p = get_rmse(prediction, target, pcutoff, - bodyparts = bodyparts) - else: - rmse, rmse_p = get_rmse(prediction, target, - bodyparts = bodyparts) - - return np.nanmean(rmse), np.nanmean(rmse_p) - def epoch(self, loader: torch.utils.data.DataLoader, mode: str = 'train') -> np.array: @@ -155,51 +141,6 @@ def inference(self, predicted_poses = np.concatenate(predicted_poses) return predicted_poses - @staticmethod - def _get_paths(train_fraction: float = 0.95, - shuffle: int = 0, - model_prefix: str = "", - cfg: dict = None, - train_iterations: int = 9): - - dlc_scorer, dlc_scorer_legacy = get_dlc_scorer(train_fraction, - shuffle, - model_prefix, - cfg, - train_iterations) - evaluation_folder = get_evaluation_folder(train_fraction, - shuffle, - model_prefix, - cfg) - - model_folder = get_model_folder(train_fraction, - shuffle, - model_prefix, - cfg) - - model_path = get_model_path(model_folder, train_iterations) - - return { - 'dlc_scorer': dlc_scorer, - 'dlc_scorer_legacy': dlc_scorer_legacy, - 'evaluation_folder': evaluation_folder, - 'model_folder': model_folder, - 'model_path': model_path - } - - @staticmethod - def _get_results_filename(evaluation_folder, - dlc_scorer, - dlc_scorer_legacy, - model_path): - - results_filename = get_result_filename(evaluation_folder, - dlc_scorer, - dlc_scorer_legacy, - model_path) - - return results_filename - class BottomUpSolver(Solver): """ diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index a5ca3afc0e..434f1bee0f 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -1,9 +1,10 @@ import numpy as np +import pandas as pd import torch -from torch import nn - from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict, argmax_pose_predict +from torch import nn +from typing import List def get_prediction(cfg, output, stride=8): @@ -27,3 +28,36 @@ def get_prediction(cfg, output, stride=8): pose = multi_pose_predict(heatmaps[i], locref_i, stride, 1) poses.append(pose) return np.stack(poses, axis=0) + + +def get_scores(cfg, + prediction: pd.DataFrame, + target: pd.DataFrame, + bodyparts: List = None): + if cfg.get('pcutoff'): + pcutoff = cfg['pcutoff'] + rmse, rmse_p = get_rmse(prediction, target, pcutoff, + bodyparts = bodyparts) + else: + rmse, rmse_p = get_rmse(prediction, target, + bodyparts = bodyparts) + + return np.nanmean(rmse), np.nanmean(rmse_p) + + +def get_rmse(prediction, + target: pd.DataFrame, + pcutoff: int=-1, + bodyparts: List[str] =None): + scorer_pred = prediction.columns[0][0] + scorer_target = target.columns[0][0] + mask = prediction[scorer_pred].xs("likelihood", level=1, axis=1) >= pcutoff + if bodyparts: + diff = (target[scorer_target][bodyparts] - prediction[scorer_pred][bodyparts]) ** 2 + else: + diff = (target[scorer_target] - prediction[scorer_pred]) ** 2 + mse = diff.xs("x", level=1, axis=1) + diff.xs("y", level=1, axis=1) + rmse = np.sqrt(mse) + rmse_p = np.sqrt(mse[mask]) + + return rmse, rmse_p \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py b/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py index 70eb6009cc..04ca095cc9 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py @@ -1,10 +1,10 @@ import os - import pandas as pd import torch from .base import BottomUpSolver from ..registry import Registry, build_from_cfg +from ..utils import * from ...pose_estimation_tensorflow import Plotting from ...utils import auxiliaryfunctions @@ -14,70 +14,7 @@ @SINGLE_ANIMAL_SOLVER.register_module class BottomUpSingleAnimalSolver(BottomUpSolver): - - def evaluate(self, - dataset, - model_prefix: str = '', - train_fraction: int = 0.95, - train_iterations: int = 49, - shuffle: int = 0, - plotting: bool = False): - target_df = dataset.dataframe - self.names = self._get_paths(train_fraction=train_fraction, - model_prefix=model_prefix, - shuffle=shuffle, - cfg=dataset.cfg, - train_iterations=train_iterations) - - results_filename = self._get_results_filename(self.names['evaluation_folder'], - self.names['dlc_scorer'], - self.names['dlc_scorer_legacy'], - self.names['model_path'][:-3]) - self.model.load_state_dict(torch.load(self.names['model_path'])) - - predicted_poses = self.inference(dataset) - predicted_df = self.save_predictions(target_df.index, - predicted_poses.reshape(target_df.index.shape[0], -1), - results_filename) - if plotting: - foldername = f'{self.names["evaluation_folder"]}/LabeledImages_{self.names["dlc_scorer"]}-{train_iterations}' - auxiliaryfunctions.attempttomakefolder(foldername) - combined_df = predicted_df.merge(target_df, - left_index=True, - right_index=True) - Plotting(dataset.cfg, - dataset.cfg['bodyparts'], - self.names['dlc_scorer'], - predicted_df.index, - combined_df, - foldername) - - rmse, rmes_p = self.get_scores(predicted_df, target_df) - print(f'RMSE: {rmse}, RMSE pcutoff: {rmes_p}') - - def save_predictions(self, - data_index, - predicted_poses, - results_filename): - if not os.path.exists(self.names['evaluation_folder']): - os.makedirs(self.names['evaluation_folder']) - - results_path = f'{results_filename}' - index = pd.MultiIndex.from_product( - [ - [self.names['dlc_scorer']], - self.cfg["all_joints_names"], - ["x", "y", "likelihood"], - ], - names=["scorer", "bodyparts", "coords"], - ) - - predicted_data = pd.DataFrame( - predicted_poses, columns=index, index=data_index - ) - - predicted_data.to_hdf( - results_path, "df_with_missing", format="table", mode="w" - ) - - return predicted_data + """ + To be extended if needed + """ + pass \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index 3680c2129a..2895df53d1 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -1,11 +1,10 @@ import glob +import numpy as np import os import pandas as pd +from deeplabcut import auxiliaryfunctions from typing import List -import numpy as np - -from deeplabcut import auxiliaryfunctions from ..utils import create_folder @@ -66,7 +65,7 @@ def get_result_filename(evaluation_folder, def get_model_path(model_folder: str, - load_epoch: int = -1): + load_epoch: int): model_paths = glob.glob(f'{model_folder}/train/snapshot*') sorted_paths = sort_paths(model_paths) model_path = sorted_paths[load_epoch] @@ -78,19 +77,71 @@ def sort_paths(paths: list): return sorted_paths -def get_rmse(prediction, - target: pd.DataFrame, - pcutoff: int=-1, - bodyparts: List[str] =None): - scorer_pred = prediction.columns[0][0] - scorer_target = target.columns[0][0] - mask = prediction[scorer_pred].xs("likelihood", level=1, axis=1) >= pcutoff - if bodyparts: - diff = (target[scorer_target][bodyparts] - prediction[scorer_pred][bodyparts]) ** 2 - else: - diff = (target[scorer_target] - prediction[scorer_pred]) ** 2 - mse = diff.xs("x", level=1, axis=1) + diff.xs("y", level=1, axis=1) - rmse = np.sqrt(mse) - rmse_p = np.sqrt(mse[mask]) - - return rmse, rmse_p +def save_predictions(names, cfg, data_index, + predicted_poses, + results_filename): + if not os.path.exists(names['evaluation_folder']): + os.makedirs(names['evaluation_folder']) + + results_path = f'{results_filename}' + index = pd.MultiIndex.from_product( + [ + [names['dlc_scorer']], + cfg["all_joints_names"], + ["x", "y", "likelihood"], + ], + names=["scorer", "bodyparts", "coords"], + ) + + predicted_data = pd.DataFrame( + predicted_poses, columns=index, index=data_index + ) + + predicted_data.to_hdf( + results_path, "df_with_missing", format="table", mode="w" + ) + + return predicted_data + + +def get_paths(train_fraction: float = 0.95, + shuffle: int = 0, + model_prefix: str = "", + cfg: dict = None, + train_iterations: int = 99): + dlc_scorer, dlc_scorer_legacy = get_dlc_scorer(train_fraction, + shuffle, + model_prefix, + cfg, + train_iterations) + evaluation_folder = get_evaluation_folder(train_fraction, + shuffle, + model_prefix, + cfg) + + model_folder = get_model_folder(train_fraction, + shuffle, + model_prefix, + cfg) + + model_path = get_model_path(model_folder, train_iterations) + + return { + 'dlc_scorer': dlc_scorer, + 'dlc_scorer_legacy': dlc_scorer_legacy, + 'evaluation_folder': evaluation_folder, + 'model_folder': model_folder, + 'model_path': model_path + } + + +def get_results_filename(evaluation_folder, + dlc_scorer, + dlc_scorer_legacy, + model_path): + results_filename = get_result_filename(evaluation_folder, + dlc_scorer, + dlc_scorer_legacy, + model_path) + + return results_filename From 7426f0fd38da0ab368c507b1234f6dec81fccd4f Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Thu, 16 Mar 2023 17:10:56 +0100 Subject: [PATCH 015/293] fix pytorch single animal models --- .../pose_estimation_pytorch/__init__.py | 1 + .../pose_estimation_pytorch/apis/__init__.py | 3 + .../pose_estimation_pytorch/apis/inference.py | 9 +- .../pose_estimation_pytorch/apis/train.py | 11 +- .../pose_estimation_pytorch/apis/utils.py | 9 +- .../pose_estimation_pytorch/data/dataset.py | 25 ++- .../data/dlcproject.py | 12 +- .../models/backbones/base.py | 21 +++ .../models/criterion.py | 20 ++- .../pose_estimation_pytorch/models/model.py | 10 +- .../pose_estimation_pytorch/models/utils.py | 70 +++++++- .../pose_estimation_pytorch/solvers/base.py | 20 ++- .../solvers/inference.py | 43 ++++- .../solvers/schedulers.py | 14 ++ .../pose_estimation_pytorch/solvers/utils.py | 4 +- .../tests/test_configs/config.yaml | 106 +++++++++++ .../tests/test_configs/pose_cfg.yaml | 115 ++++++++++++ .../tests/test_configs/pytorch_config.yaml | 45 +++++ .../tests/test_single_animal.py | 167 ++++++++++++++++++ deeplabcut/pose_estimation_pytorch/utils.py | 24 ++- deeplabcut/utils/auxiliaryfunctions.py | 5 +- deeplabcut/utils/visualization.py | 5 +- 22 files changed, 685 insertions(+), 54 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/solvers/schedulers.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_configs/config.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_configs/pose_cfg.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_configs/pytorch_config.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index 486ebefc23..9d17ba5fe7 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -1,3 +1,4 @@ from deeplabcut.pose_estimation_pytorch.data.dlcproject import DLCProject from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset from deeplabcut.pose_estimation_pytorch.utils import fix_seeds +from deeplabcut.pose_estimation_pytorch.apis import train_network, inference_network diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py index 6da7ce54f9..135ae638df 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py @@ -8,3 +8,6 @@ # # Licensed under GNU Lesser General Public License v3.0 # + +from deeplabcut.pose_estimation_pytorch.apis.train import train_network +from deeplabcut.pose_estimation_pytorch.apis.inference import inference_network \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 1f82ee0b26..590fa32a71 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -15,7 +15,7 @@ def inference_network( config_path: str, shuffle: int = 0, model_prefix: str = "", - load_epoch: int = 49, + load_epoch: Union[int, str] = 49, stride: int = 8, transform: object = None, plot: bool = False, @@ -57,7 +57,9 @@ def inference_network( model = build_pose_model(config['model'], pose_cfg) model.load_state_dict(torch.load(names['model_path'])) - target_df = valid_dataset.dataframe + # You need to dropna() here because on some frames no keypoint is annotated + # Thus the target_df (contains NaNs) may not match the valid_dataloader (has dropped them) + target_df = valid_dataset.dataframe.dropna(axis = 0, how = "all") predicted_poses = [] model.eval() with torch.no_grad(): @@ -65,7 +67,8 @@ def inference_network( if isinstance(item, tuple) or (isinstance, list): item = item[0] output = model(item) - predictions = get_prediction(pose_cfg, output, stride) + scale_factor = (item.shape[2]/output[0].shape[2] , item.shape[3]/output[0].shape[3]) + predictions = get_prediction(pose_cfg, output, scale_factor) predicted_poses.append(predictions) predicted_poses = np.array(predicted_poses) diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 572713d4c1..6b14014a46 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -11,6 +11,7 @@ def train_network( config_path: str, shuffle: int = 1, training_set_index: Union[int, str] = 0, + transform = None, model_prefix: str = ""): cfg = auxiliaryfunctions.read_config(config_path) if training_set_index == "all": @@ -27,13 +28,14 @@ def train_network( config = auxiliaryfunctions.read_config(pytorch_config_path) batch_size = config['batch_size'] epochs = config['epochs'] - transform = None + dlc.fix_seeds(config['seed']) - project = dlc.DLCProject(proj_root=config['project_root'], shuffle=shuffle) - train_dataset = dlc.PoseDataset(project, + project_train = dlc.DLCProject(proj_root=config['project_root'], shuffle=shuffle) + project_valid = dlc.DLCProject(proj_root=config['project_root'], shuffle=shuffle) + train_dataset = dlc.PoseDataset(project_train, transform=transform, mode='train') - valid_dataset = dlc.PoseDataset(project, + valid_dataset = dlc.PoseDataset(project_valid, transform=transform, mode='test') @@ -49,6 +51,7 @@ def train_network( solver.fit( train_dataloader, valid_dataloader, + train_fraction=train_fraction[0], epochs=epochs, shuffle=shuffle, model_prefix=model_prefix, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 5cd200cfba..389ead8de6 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -4,6 +4,8 @@ from deeplabcut.pose_estimation_pytorch.models import PoseModel, BACKBONES, HEADS, LOSSES from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SINGLE_ANIMAL_SOLVER +from deeplabcut.pose_estimation_pytorch.solvers.schedulers import LRListScheduler +from deeplabcut.pose_estimation_pytorch.solvers.base import Solver from deeplabcut.utils import auxiliaryfunctions @@ -26,7 +28,7 @@ def build_pose_model(cfg: Dict, return pose_model -def build_solver(cfg: Dict): +def build_solver(cfg: Dict) -> Solver: pose_cfg = auxiliaryfunctions.read_config(cfg['pose_cfg_path']) pose_model = build_pose_model(cfg['model'], pose_cfg) @@ -36,7 +38,10 @@ def build_solver(cfg: Dict): criterion = LOSSES.build(cfg['criterion']) if cfg.get('scheduler'): - _scheduler = getattr(torch.optim.lr_scheduler, + if cfg['scheduler']['type'] == "LRListScheduler": + _scheduler = LRListScheduler + else: + _scheduler = getattr(torch.optim.lr_scheduler, cfg['scheduler']['type']) scheduler = _scheduler(optimizer=optimizer, **cfg['scheduler']['params']) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 60bb105509..6d32f17e1d 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -1,15 +1,17 @@ import cv2 +import os import numpy as np import torch from torch.utils.data import Dataset from .base import BaseDataset +from .dlcproject import DLCProject class PoseDataset(Dataset, BaseDataset): def __init__(self, - project, + project: DLCProject, transform: object = None, mode: str = 'train'): """ @@ -34,8 +36,12 @@ def transform(image, keypoints): self.project.convert2dict(mode) self.dataframe = self.project.dataframe + # We must dropna because self.project.images doesn't contain imgaes with no labels so it can produce an indexnotfound error + # length is stored here to avoid repeating the computation + self.length = self.dataframe.dropna(axis=0, how="all").shape[0] + def __len__(self): - return len(self.project.images) + return self.length def __getitem__(self, index: int): @@ -52,15 +58,24 @@ def __getitem__(self, keypoints: list of keypoints train_dataset = PoseDataset(project, transform=transform) - im, keipoints = train_dataset[0] + im, keypoints = train_dataset[0] """ # load images - image_file = self.project.images[index]['file_name'] + try: + image_file = self.dataframe.index[index] + if isinstance(image_file, tuple): + image_file = os.path.join(self.cfg["project_path"], *image_file) + else: + image_file = os.path.join(self.cfg["project_path"], image_file) + except: + print(len(self.project.images)) + print(index) image = cv2.imread(image_file) # load annotation - annotation = self.project.annotations[index] + annotation_index = self.project.image_path2index[image_file] + annotation = self.project.annotations[annotation_index] keypoints, undef_ids = self.project.annotation2keypoints(annotation) if self.transform: diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py index 5eb8f3e0ab..f90a39a5c4 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py @@ -37,6 +37,10 @@ def __init__(self, proj_root, f'Documentation_data-{self.task}_{tr_frac}shuffle{self.shuffle}.pickle') self.dlc_df = pd.read_hdf(self.path_dlc_data) self.load_split() + self.dlc_df = self.dlc_df[~self.dlc_df.index.duplicated(keep = 'first')] + self.df_train = self.df_train[~self.df_train.index.duplicated(keep = 'first')] + if hasattr(self, "df_test"): + self.df_test = self.df_test[~self.df_test.index.duplicated(keep = 'first')] def convert2dict(self, mode: str = 'train'): @@ -58,6 +62,11 @@ def convert2dict(self, data = df2generic(self.proj_root, self.dataframe, self.image_id_offset) + + self.image_path2index = {} + for i, image in enumerate(data["images"]): + image_path = image["file_name"] + self.image_path2index[image_path] = i for key in self.keys_to_load: setattr(self, key, data[key]) @@ -102,7 +111,8 @@ def annotation2keypoints(annotation): x = annotation['keypoints'][::3] y = annotation['keypoints'][1::3] vis = annotation['keypoints'][2::3] - undef_ids = list(np.where(x == -1)[0]) + eps = 1e-6 + undef_ids = list(np.where(x <= eps)[0]) keypoints = [] for pair in np.stack([x, y]).T: diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index ad8d211c58..c99f76b0ad 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -10,6 +10,7 @@ class BaseBackbone(ABC, nn.Module): def __init__(self): super().__init__() + self.batch_norm_on = False @abstractmethod def forward(self, x): @@ -33,3 +34,23 @@ def _init_weights(self, pretrained: str = None): self.model.load_state_dict(state_dict, strict=False) else: self.model.load_state_dict(torch.load(pretrained), strict=False) + + def activate_batch_norm(self, activation: bool=False): + """Turns on or off batch norm layers updating their weights while training + + Prameters + --------- + activation: should batch_norm be activated or not for training""" + self.batch_norm_on = activation + + def train(self, mode = True): + super(BaseBackbone, self).train(mode) + + if not self.batch_norm_on: + for m in self.modules(): + if isinstance(m, nn.BatchNorm2d): + m.eval() + m.weight.requires_grad = False + m.bias.requires_grad = False + + return diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index de682a2d91..27049fb052 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -14,7 +14,22 @@ def __init__(self): def __call__(self, prediction, target, weights): loss_item = self.mse_loss(prediction, target) - loss_item_weighted = loss_item * weights + loss_item_weighted = loss_item[weights] + if loss_item_weighted.nelement() == 0: + return torch.tensor(0.) + return torch.mean(loss_item_weighted) + +class WeightedHuberLoss(nn.HuberLoss): + + def __init__(self): + super(WeightedHuberLoss, self).__init__() + self.huber_loss = nn.HuberLoss(reduction='none') + + def __call__(self, prediction, target, weights): + loss_item = self.huber_loss(prediction, target) + loss_item_weighted = loss_item[weights] + if loss_item_weighted.nelement() == 0: + return torch.tensor(0.) return torch.mean(loss_item_weighted) @LOSSES.register_module @@ -36,7 +51,7 @@ def __init__(self, """ super(PoseLoss, self).__init__() if locref_huber_loss: - self.locref_criterion = nn.HuberLoss() + self.locref_criterion = WeightedHuberLoss() else: self.locref_criterion = WeightedMSELoss() self.loss_weight_locref = loss_weight_locref @@ -62,6 +77,7 @@ def forward(self, prediction, target): heatmaps, locref = prediction heatmap_loss = self.heatmap_criterion(heatmaps, target['heatmaps']) + locref_loss = self.loss_weight_locref * self.locref_criterion(locref, target['locref_maps'], target['locref_masks']) diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 2b966e0f60..d0e7ddf6bb 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -18,6 +18,8 @@ def __init__(self, super().__init__() self.backbone = backbone + self.backbone.activate_batch_norm(cfg['batch_size'] >= 8) # We don't want batch norm to update for small batch sizes + self.head_heatmaps = head_heatmaps self.head_locref = head_locref self.neck = neck @@ -49,7 +51,8 @@ def forward(self, x): def get_target(self, keypoints_batch, - heatmap_size): + heatmap_size, + scale_factor): heatmaps_target = [] locref_target = [] @@ -59,7 +62,10 @@ def get_target(self, # TODO: make faster heatmap, weight, locref_map, locref_mask = generate_heatmaps(self.cfg, keypoints, - heatmap_size=heatmap_size) + scale_factor, + heatmap_size=heatmap_size, + heatmap_type=self.cfg['scmap_type'], + ) locref_target.append(locref_map) heatmaps_target.append(heatmap) locref_masks.append(locref_mask) diff --git a/deeplabcut/pose_estimation_pytorch/models/utils.py b/deeplabcut/pose_estimation_pytorch/models/utils.py index a301d9ace3..88bc885ebb 100644 --- a/deeplabcut/pose_estimation_pytorch/models/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/utils.py @@ -1,14 +1,23 @@ import numpy as np import torch +from typing import Tuple def generate_heatmaps(cfg: dict, coords: np.array, + scale_factor, heatmap_size: tuple = (64, 64), heatmap_type: str = 'gaussian'): + # print(heatmap_type) if heatmap_type == 'gaussian': scmap, weights, locref_map, locref_mask = gaussian_scmap(cfg, coords, + scale_factor, + heatmap_size) + elif heatmap_type == 'plateau': + scmap, weights, locref_map, locref_mask = plateau_scmap(cfg, + coords, + scale_factor, heatmap_size) else: raise ValueError('Only gaussian heatmap is supported!') @@ -16,13 +25,13 @@ def generate_heatmaps(cfg: dict, if weights: weights = torch.FloatTensor(weights) locref_map = torch.FloatTensor(locref_map) - locref_mask = torch.FloatTensor(locref_mask) + locref_mask = torch.BoolTensor(locref_mask) return scmap, weights, locref_map, locref_mask # Copy from dlc -def gaussian_scmap(cfg, coords, heatmap_size): +def gaussian_scmap(cfg, coords, scale_factors, heatmap_size): """ Parameters @@ -42,7 +51,8 @@ def gaussian_scmap(cfg, coords, heatmap_size): locref_scale = 1.0 / cfg["locref_stdev"] num_joints = cfg["num_joints"] # stride = cfg['stride'] # Apparently, there is no stride in the cfg - stride = 8 # TODO just test + # stride = scale_factors # TODO just test + stride_y, stride_x = scale_factors scmap = np.zeros(( heatmap_size[0], heatmap_size[1], num_joints), dtype=np.float32) @@ -50,32 +60,74 @@ def gaussian_scmap(cfg, coords, heatmap_size): locref_map = np.zeros(( heatmap_size[0], heatmap_size[1], num_joints * 2), dtype=np.float32) - locref_mask = np.zeros_like(locref_map) + locref_mask = np.zeros_like(locref_map, dtype=int) width = heatmap_size[1] height = heatmap_size[0] - dist_thresh = float((width + height) / 6) + dist_thresh = float(cfg['pos_dist_thresh']) #TODO Should depend on config dist_thresh_sq = dist_thresh ** 2 std = dist_thresh / 4 grid = np.mgrid[:height, :width].transpose((1, 2, 0)) - grid = grid * stride + stride / 2 + grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 + grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 for i, coord in enumerate(coords): coord = np.array(coord)[::-1] + if np.any(coord <= 0.): + continue dist = np.linalg.norm(grid - coord, axis=2) ** 2 scmap_j = np.exp(-dist / (2 * std ** 2)) scmap[:, :, i] = scmap_j - locref_mask[dist <= dist_thresh_sq, i * 2 + 0] = 1 - locref_mask[dist <= dist_thresh_sq, i * 2 + 1] = 1 + locref_mask[dist <= dist_thresh_sq, i * 2:i*2+2] = 1 dx = coord[1] - grid.copy()[:, :, 1] dy = coord[0] - grid.copy()[:, :, 0] locref_map[:, :, i * 2 + 0] = dx * locref_scale locref_map[:, :, i * 2 + 1] = dy * locref_scale weights = None - # weights = self.compute_scmap_weights(scmap.shape, joint_id, data_item) return scmap, weights, locref_map, locref_mask +def plateau_scmap(cfg, coords, scale_factors, heatmap_size): + """Computes target objectives with plateau function rather than gaussian""" + + locref_scale = 1.0 / cfg["locref_stdev"] + num_joints = cfg["num_joints"] + stride_y, stride_x = scale_factors + scmap = np.zeros(( + heatmap_size[0], + heatmap_size[1], num_joints), dtype=np.float32) + + locref_map = np.zeros(( + heatmap_size[0], + heatmap_size[1], num_joints * 2), dtype=np.float32) + locref_mask = np.zeros_like(locref_map, dtype=int) + + width = heatmap_size[1] + height = heatmap_size[0] + dist_thresh = float(cfg['pos_dist_thresh']) #TODO Should depend on config + dist_thresh_sq = dist_thresh ** 2 + + std = dist_thresh / 4 + grid = np.mgrid[:height, :width].transpose((1, 2, 0)) + + grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 + grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 + + for i, coord in enumerate(coords): + coord = np.array(coord)[::-1] + if np.any(coord <= 0.): + continue + dist = np.linalg.norm(grid - coord, axis=2) ** 2 + mask = (dist <= dist_thresh_sq) + scmap[(dist <= dist_thresh_sq), i] = 1 + locref_mask[dist <= dist_thresh_sq, i * 2:i*2+2] = 1 + dx = coord[1] - grid.copy()[:, :, 1] + dy = coord[0] - grid.copy()[:, :, 0] + locref_map[mask, i * 2 + 0] = (dx * locref_scale)[mask] + locref_map[mask, i * 2 + 1] = (dy * locref_scale)[mask] + weights = None + return scmap, weights, locref_map, locref_mask, + # TODO: check this function and rewrite above def _generate_heatmaps(keypoints, heatmap_size, diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 936d9b4405..10fa4246ad 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -2,7 +2,6 @@ from typing import Optional from typing import Tuple, Dict -import pandas as pd import numpy as np import torch from tqdm import tqdm @@ -12,7 +11,6 @@ from .utils import * from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction - class Solver(ABC): def __init__(self, @@ -80,13 +78,16 @@ def fit( shuffle, model_prefix, train_loader.dataset.cfg) - for i in tqdm(range(epochs)): + # for i in tqdm(range(epochs)): + for i in range(epochs): train_loss = self.epoch(train_loader, mode='train') if self.scheduler: self.scheduler.step() + print(f'Training for epoch {i+1} is done, started evaluating on validation data') valid_loss = self.epoch(valid_loader, mode='eval') - save_path = f'{model_folder}/train/snapshot-{i}.pt' - torch.save(self.model.state_dict(), save_path) + save_path = f'{model_folder}/train/snapshot-{i+1}.pt' + if (i+1)%10 == 0: + torch.save(self.model.state_dict(), save_path) print(f'Epoch {i + 1}/{epochs}, ' f'train loss {train_loss}, ' f'valid loss {valid_loss}') @@ -110,9 +111,12 @@ def epoch(self, to_mode = getattr(self.model, mode) to_mode() epoch_loss = [] - for batch in loader: + for i, batch in enumerate(loader): loss = self.step(batch, mode) epoch_loss.append(loss) + + if (i+1)%self.cfg['display_iters'] == 0: + print(f"Number of iterations : {i+1}, loss : {loss}, lr : {self.optimizer.param_groups[0]['lr']}") epoch_loss = np.mean(epoch_loss) self.history[f'{mode}_loss'].append(epoch_loss) @@ -168,7 +172,9 @@ def step(self, image, keypoints = batch image = image.to(self.device) prediction = self.model(image) - target = self.model.get_target(keypoints, prediction[0].shape[2:]) # (batch_size, channels, h, w) + + scale_factor = (image.shape[2]/prediction[0].shape[2] , image.shape[3]/prediction[0].shape[3]) + target = self.model.get_target(keypoints, prediction[0].shape[2:], scale_factor) # (batch_size, channels, h, w) for key in target: if target[key] is not None: diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index 434f1bee0f..504fb753f0 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -2,7 +2,6 @@ import pandas as pd import torch from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps -from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict, argmax_pose_predict from torch import nn from typing import List @@ -30,6 +29,46 @@ def get_prediction(cfg, output, stride=8): return np.stack(poses, axis=0) +def get_top_values(scmap, n_top=5): + batchsize, ny, nx, num_joints = scmap.shape + scmap_flat = scmap.reshape(batchsize, nx * ny, num_joints) + if n_top == 1: + scmap_top = np.argmax(scmap_flat, axis=1)[None] + else: + scmap_top = np.argpartition(scmap_flat, -n_top, axis=1)[:, -n_top:] + for ix in range(batchsize): + vals = scmap_flat[ix, scmap_top[ix], np.arange(num_joints)] + arg = np.argsort(-vals, axis=0) + scmap_top[ix] = scmap_top[ix, arg, np.arange(num_joints)] + scmap_top = scmap_top.swapaxes(0, 1) + + Y, X = np.unravel_index(scmap_top, (ny, nx)) + return Y, X + + +def multi_pose_predict(scmap, locref, stride, num_outputs): + Y, X = get_top_values(scmap[None], num_outputs) + Y, X = Y[:, 0], X[:, 0] + num_joints = scmap.shape[2] + + DZ = np.zeros((num_outputs, num_joints, 3)) + indices = np.indices((num_outputs, num_joints)) + x = X[indices[0], indices[1]] + y = Y[indices[0], indices[1]] + DZ[:, :, :2] = locref[y, x, indices[1], :] + DZ[:, :, 2] = scmap[y, x, indices[1]] + + X = X.astype("float32") * stride[1] + 0.5 * stride[1] + DZ[:, :, 0] + Y = Y.astype("float32") * stride[0] + 0.5 * stride[0] + DZ[:, :, 1] + P = DZ[:, :, 2] + + pose = np.empty((num_joints, num_outputs * 3), dtype="float32") + pose[:, 0::3] = X.T + pose[:, 1::3] = Y.T + pose[:, 2::3] = P.T + + return pose + def get_scores(cfg, prediction: pd.DataFrame, target: pd.DataFrame, @@ -47,7 +86,7 @@ def get_scores(cfg, def get_rmse(prediction, target: pd.DataFrame, - pcutoff: int=-1, + pcutoff: float=-1, bodyparts: List[str] =None): scorer_pred = prediction.columns[0][0] scorer_target = target.columns[0][0] diff --git a/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py b/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py new file mode 100644 index 0000000000..4da0b8e95c --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py @@ -0,0 +1,14 @@ +from torch.optim.lr_scheduler import LRScheduler + +class LRListScheduler(LRScheduler): + + def __init__(self, optimizer, last_epoch=-1, verbose=False, milestones=[10], lr_list=[0.001]): + self.milestones = milestones + self.lr_list = lr_list + super().__init__(optimizer, last_epoch, verbose) + + def get_lr(self): + if self.last_epoch not in self.milestones: + return [group['lr'] for group in self.optimizer.param_groups] + return [lr for lr in self.lr_list[self.milestones.index(self.last_epoch)]] + \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index 2895df53d1..65f35e04c4 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -3,7 +3,7 @@ import os import pandas as pd from deeplabcut import auxiliaryfunctions -from typing import List +from typing import List, Union from ..utils import create_folder @@ -98,7 +98,7 @@ def save_predictions(names, cfg, data_index, ) predicted_data.to_hdf( - results_path, "df_with_missing", format="table", mode="w" + results_path, "df_with_missing" ) return predicted_data diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_configs/config.yaml b/deeplabcut/pose_estimation_pytorch/tests/test_configs/config.yaml new file mode 100644 index 0000000000..15ad6f4678 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_configs/config.yaml @@ -0,0 +1,106 @@ + # Project definitions (do not edit) +Task: openfield +scorer: Pranav +date: Aug20 +multianimalproject: false +identity: + + # Project path (change when moving around) +project_path: /home/quentin/datasets/Openfield_pytorch + + # Annotation data set configuration (and individual video cropping parameters) +video_sets: + /Data/openfield-Pranav-2018-08-20/videos/m1s1.mp4: + crop: 0, 640, 0, 480 + /Data/openfield-Pranav-2018-08-20/videos/m1s2.mp4: + crop: 0, 640, 0, 480 + /Data/openfield-Pranav-2018-08-20/videos/m2s1.mp4: + crop: 0, 640, 0, 480 + /Data/openfield-Pranav-2018-08-20/videos/m3s1.mp4: + crop: 0, 640, 0, 480 + /Data/openfield-Pranav-2018-08-20/videos/m3s2.mp4: + crop: 0, 640, 0, 480 + /Data/openfield-Pranav-2018-08-20/videos/m4s1.mp4: + crop: 0, 640, 0, 480 + /Data/openfield-Pranav-2018-08-20/videos/m5s1.mp4: + crop: 0, 800, 0, 800 + /Data/openfield-Pranav-2018-08-20/videos/m6s1.mp4: + crop: 0, 800, 0, 800 + /Data/openfield-Pranav-2018-08-20/videos/m6s2.mp4: + crop: 0, 800, 0, 800 + /Data/openfield-Pranav-2018-08-20/videos/m7s1.mp4: + crop: 0, 800, 0, 800 + /Data/openfield-Pranav-2018-08-20/videos/m7s2.mp4: + crop: 0, 800, 0, 800 + /Data/openfield-Pranav-2018-08-20/videos/m7s3.mp4: + crop: 0, 800, 0, 800 + /Data/openfield-Pranav-2018-08-20/videos/m8s1.mp4: + crop: 0, 800, 0, 800 + + /Users/mwmathis/Downloads/ARCricket1.avi: + crop: 0, 720, 0, 540 +bodyparts: +- snout +- leftear +- rightear +- tailbase + +flipped_keypoints: +- 0 +- 2 +- 1 +- 3 + + + # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement +start: 0 +stop: 1 +numframes2pick: 20 + + # Plotting configuration +skeleton: [] +skeleton_color: black +pcutoff: 0.4 +dotsize: 8 +alphavalue: 0.7 +colormap: jet + + # Training,Evaluation and Analysis configuration +TrainingFraction: +- 0.95 +iteration: 1 +default_net_type: resnet_50 +default_augmenter: default +snapshotindex: -1 +batch_size: 1 + + # Cropping Parameters (for analysis and outlier frame detection) +cropping: false + #if cropping is true for analysis, then set the values here: +x1: 0 +x2: 640 +y1: 277 +y2: 624 + + # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) +corner2move2: +- 50 +- 50 +move2corner: true +croppedtraining: diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_configs/pose_cfg.yaml b/deeplabcut/pose_estimation_pytorch/tests/test_configs/pose_cfg.yaml new file mode 100644 index 0000000000..ec41492bd4 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_configs/pose_cfg.yaml @@ -0,0 +1,115 @@ + # Project definitions (do not edit) +Task: +scorer: +date: +multianimalproject: +identity: + + # Project path (change when moving around) +project_path: /home/quentin/datasets/Openfield_pytorch/dlc-models/iteration-1/openfieldAug20-trainset95shuffle1/train + + # Annotation data set configuration (and individual video cropping parameters) +video_sets: +bodyparts: + + # Fraction of video to start/stop when extracting frames for labeling/refinement +start: +stop: +numframes2pick: + + # Plotting configuration +skeleton: [] +skeleton_color: black +pcutoff: +dotsize: +alphavalue: +colormap: + + # Training,Evaluation and Analysis configuration +TrainingFraction: +iteration: +default_net_type: +default_augmenter: +snapshotindex: +batch_size: 1 + + # Cropping Parameters (for analysis and outlier frame detection) +cropping: + #if cropping is true for analysis, then set the values here: +x1: +x2: +y1: +y2: + + # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) +corner2move2: +move2corner: +all_joints: +- - 0 +- - 1 +- - 2 +- - 3 +all_joints_names: +- snout +- leftear +- rightear +- tailbase +alpha_r: 0.02 +apply_prob: 0.5 +contrast: + clahe: true + claheratio: 0.1 + histeq: true + histeqratio: 0.1 +convolution: + edge: false + emboss: + alpha: + - 0.0 + - 1.0 + strength: + - 0.5 + - 1.5 + embossratio: 0.1 + sharpen: false + sharpenratio: 0.3 +cropratio: 0.4 +dataset: training-datasets/iteration-1/UnaugmentedDataSet_openfieldAug20/openfield_Pranav95shuffle1.mat +dataset_type: default +decay_steps: 30000 +display_iters: 1000 +global_scale: 0.8 +init_weights: /home/quentin/miniconda/envs/DEEPLABCUT/lib/python3.8/site-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained/resnet_v1_50.ckpt +intermediate_supervision: false +intermediate_supervision_layer: 12 +location_refinement: true +locref_huber_loss: true +locref_loss_weight: 0.05 +locref_stdev: 7.2801 +lr_init: 0.0005 +max_input_size: 1500 +metadataset: training-datasets/iteration-1/UnaugmentedDataSet_openfieldAug20/Documentation_data-openfield_95shuffle1.pickle +min_input_size: 64 +mirror: false +multi_stage: false +multi_step: +- - 0.005 + - 10000 +- - 0.02 + - 430000 +- - 0.002 + - 730000 +- - 0.001 + - 1030000 +net_type: resnet_50 +num_joints: 4 +pairwise_huber_loss: false +pairwise_predict: false +partaffinityfield_predict: false +pos_dist_thresh: 17 +rotation: 25 +rotratio: 0.4 +save_iters: 50000 +scale_jitter_lo: 0.5 +scale_jitter_up: 1.25 +scmap_type: plateau diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_configs/pytorch_config.yaml b/deeplabcut/pose_estimation_pytorch/tests/test_configs/pytorch_config.yaml new file mode 100644 index 0000000000..0be2ca0ed8 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_configs/pytorch_config.yaml @@ -0,0 +1,45 @@ +project_root: /home/quentin/datasets/Openfield_pytorch +pose_cfg_path: /home/quentin/datasets/Openfield_pytorch/dlc-models/iteration-1/openfieldAug20-trainset95shuffle1/train/pose_cfg.yaml +cfg_path: /home/quentin/datasets/Openfield_pytorch/config.yaml + +seed: 42 +device: 'cuda:2' #needs to be updated dynamically; some users might have CPUs +model: + backbone: + type: 'ResNet' + pretrained: 'https://download.pytorch.org/models/resnet50-19c8e357.pth' + heatmap_head: + type: 'SimpleHead' + channels: [ 2048, 1024, 4 ] + kernel_size: [ 2, 2 ] + strides: [ 2, 2 ] + locref_head: + type: 'SimpleHead' + channels: [ 2048, 1024, 8 ] + kernel_size: [ 2, 2 ] + strides: [ 2, 2 ] + pose_model: + stride: 8 + heatmap_type: 'plateau' +optimizer: + type: 'SGD' + params: + lr: 0.005 +scheduler: + type: "LRListScheduler" + params: + milestones : [10, 430] + lr_list : [[0.02], [0.002]] +criterion: + type: 'PoseLoss' + loss_weight_locref: 0.1 + locref_huber_loss: True +#logger: +# type: 'WandbLogger' +# project_name: 'deeplabcut' +# run_name: 'tmp' +solver: + type: 'BottomUpSingleAnimalSolver' +pos_dist_thresh : 17 +batch_size: 1 +epochs: 600 diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py b/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py new file mode 100644 index 0000000000..534ab45473 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py @@ -0,0 +1,167 @@ +import yaml +import os +import pickle +import time + +import albumentations as A +import torch +import torch.nn as nn +import numpy as np +import deeplabcut +from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model +from deeplabcut.pose_estimation_pytorch.solvers.utils import get_paths, get_results_filename +from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction +from deeplabcut.pose_estimation_pytorch.models.criterion import PoseLoss + +def read_yaml(path): + try : + with open(path, "r") as stream: + try: + return yaml.safe_load(stream) + except yaml.YAMLError as exc: + print(exc) + except : + raise FileNotFoundError("An eero occured whilereading the file") + +def get_training_set_length(cfg, train_fraction, shuffle): + + training_folder = os.path.join(cfg["project_path"], deeplabcut.auxiliaryfunctions.get_training_set_folder(cfg)) + train_idx_path = os.path.join(training_folder, f'Documentation_data-{cfg["Task"]}_{int(train_fraction*100)}shuffle{shuffle}.pickle') + + with open(train_idx_path, "rb") as file: + meta = pickle.load(file) + + print(f'length of the training set {len(meta[1])}, length of the test set {len(meta[2])}') + return len(meta[1]) + +def load_model(cfg, pytorch_config, shuffle, model_prefix="", train_iteration=-1): + names = get_paths(train_fraction=cfg['TrainingFraction'][0], + model_prefix=model_prefix, + shuffle=shuffle, + cfg=cfg, + train_iterations=train_iteration) + print(names['model_path']) + + results_filename = get_results_filename(names['evaluation_folder'], + names['dlc_scorer'], + names['dlc_scorer_legacy'], + names['model_path'][:-3]) + + pose_cfg = deeplabcut.auxiliaryfunctions.read_config(pytorch_config['pose_cfg_path']) + model = build_pose_model(pytorch_config['model'], pose_cfg) + model.load_state_dict(torch.load(names['model_path'])) + + return model + +def evaluate_network_custom(config_path, shuffle, model_prefix="", transform=None, train_iteration=-1): + cfg = read_yaml(config_path) + train_fraction = cfg["TrainingFraction"][0] + model_folder = os.path.join( + cfg["project_path"], + deeplabcut.auxiliaryfunctions.get_model_folder( + train_fraction, shuffle, cfg, modelprefix=model_prefix, + ), + ) + pytorch_config_path = os.path.join(model_folder, "train", "pytorch_config.yaml") + pytorch_config = read_yaml(pytorch_config_path) + pose_cfg = deeplabcut.auxiliaryfunctions.read_config(pytorch_config['pose_cfg_path']) + + batch_size = pytorch_config['batch_size'] + project = deeplabcut.pose_estimation_pytorch.DLCProject(shuffle=shuffle, + proj_root=pytorch_config['project_root']) + + valid_dataset = deeplabcut.pose_estimation_pytorch.PoseDataset(project, + transform=transform, + mode='train') + valid_dataloader = torch.utils.data.DataLoader(valid_dataset, + batch_size=batch_size, + shuffle=True) + + model = load_model(cfg, pytorch_config, shuffle, model_prefix, train_iteration) + model.to("cuda") + model.eval() + criterion = PoseLoss(locref_huber_loss=True) + + with torch.no_grad(): + losses = [] + rmses = [] + for i, item in enumerate(valid_dataloader): + _, keypoints = item + if isinstance(item, tuple) or (isinstance, list): + item = item[0].to("cuda") + output = model(item) + + scale_factor = (item.shape[2]/output[0].shape[2] , item.shape[3]/output[0].shape[3]) + + gt = model.get_target(keypoints, output[0].shape[2:], scale_factor) + for key in gt: + if gt[key] is not None: + gt[key] = gt[key].to("cuda") + + predictions= get_prediction(pose_cfg, output, scale_factor) + + rmse = keypoints.numpy() - predictions[:, :, :2] + rmse*=rmse + rmse = np.sqrt(rmse.sum(axis = 2)) + rmses.append(np.nanmean(rmse)) + + losses.append(criterion(output, gt)[0].cpu().numpy()) + + print(np.mean(losses), np.nanmean(rmses)) + return np.mean(losses), np.nanmean(rmses) + +def runBenchmark(path_dataset, train_fraction, shuffle, transform = None): + """Trains the model and evaluates it on a given dataset""" + config_path = os.path.join(path_dataset, "config.yaml") + + # Training the network + print("Training started") + start_time = time.time() + deeplabcut.pose_estimation_pytorch.apis.train.train_network(config_path, shuffle=shuffle, transform=transform) + delta_time = time.time() - start_time + print("Training ended") + + # #evaluate the nework + print("Starting evaluation of the last saved model") + evaluate_network_custom(config_path, shuffle, transform = transform) + + +class CustomHorizontalFlip(A.HorizontalFlip): + + def __init__(self, flipped_keypoints, always_apply = False, p = 0.5): + """ + flipped_keypoints : list of the new order of keypoints + """ + super().__init__(always_apply=always_apply, p=p) + self.flipped_keypoints = flipped_keypoints + + def apply_to_keypoints(self, keypoints, **params): + keypoints = list(super().apply_to_keypoints(keypoints, **params)) + + return [keypoints[i] for i in self.flipped_keypoints] + + + +if __name__ == "__main__": + path_dataset = "/home/quentin/datasets/Openfield_pytorch" + config_path = os.path.join(path_dataset, "config.yaml") + + cfg = read_yaml(config_path) + if cfg.get('flipped_keypoints'): + flip_transform = CustomHorizontalFlip(cfg['flipped_keypoints']) + else: + flip_transform = A.HorizontalFlip() + + + transform = A.Compose([ + flip_transform, + A.RandomScale(scale_limit=[-0.25, 0.25]), + A.RandomBrightnessContrast(p=0.5), + A.Rotate(limit=10), + A.MotionBlur(), + A.PixelDropout(), + A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]), + ], + keypoint_params=A.KeypointParams(format='xy', remove_invisible=False) + ) + runBenchmark(path_dataset, 0.95, 1, transform = transform) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index ebaed8c7ba..61326e8cad 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -39,9 +39,6 @@ def df2generic(proj_root, df, image_id_offset=0): # skipping all nan - if np.isnan(data.to_numpy()).all(): - continue - image_id +=1 category_id = 0 kpts = data.to_numpy().reshape(-1,2) @@ -70,7 +67,17 @@ def df2generic(proj_root, df, image_id_offset=0): annotation_id += 1 + + + # I think width and height are important + + if isinstance(file_name, tuple): + image_path = os.path.join(proj_root, *list(file_name)) + else: + image_path = os.path.join(proj_root, file_name) + annotation = { + "file_name" : image_path, "image_id": image_id + image_id_offset, "num_keypoints": num_keypoints, "keypoints": keypoints, @@ -80,17 +87,8 @@ def df2generic(proj_root, df, image_id_offset=0): "bbox": bbox, "iscrowd": 0, } - if np.sum(keypoints)!=0: - - coco_annotations.append(annotation) - - - # I think width and height are important - if isinstance(file_name, tuple): - image_path = os.path.join(proj_root, *list(file_name)) - else: - image_path = os.path.join(proj_root, file_name) + coco_annotations.append(annotation) _, height, width = read_image_shape_fast(image_path) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index fa8cd9f7e7..9f86ea6077 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -588,8 +588,11 @@ def get_scorer_name( str(get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)), "train", ) + # Snapshots = np.array( + # [fn.split(".")[0] for fn in os.listdir(modelfolder) if "index" in fn] + # ) Snapshots = np.array( - [fn.split(".")[0] for fn in os.listdir(modelfolder) if "index" in fn] + [fn.split(".")[0] for fn in os.listdir(modelfolder) if "snapshot" in fn] ) increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) Snapshots = Snapshots[increasing_indices] diff --git a/deeplabcut/utils/visualization.py b/deeplabcut/utils/visualization.py index 407ae65dd7..f42874bf9b 100644 --- a/deeplabcut/utils/visualization.py +++ b/deeplabcut/utils/visualization.py @@ -159,7 +159,10 @@ def plot_and_save_labeled_frame( ax, scaling=1, ): - image_path = os.path.join(cfg["project_path"], *DataCombined.index[ind]) + if isinstance(DataCombined.index[ind], tuple): + image_path = os.path.join(cfg["project_path"], *DataCombined.index[ind]) + else: + image_path = os.path.join(cfg["project_path"], DataCombined.index[ind]) frame = io.imread(image_path) if np.ndim(frame) > 2: # color image! h, w, numcolors = np.shape(frame) From 63e35f499f29fc34b83b05066301ff7642373fc9 Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Thu, 11 May 2023 17:48:07 +0200 Subject: [PATCH 016/293] implement DEKR for multi-animal pose estimation --- .gitignore | 3 + .../pose_estimation_pytorch/apis/train.py | 2 + .../pose_estimation_pytorch/apis/utils.py | 7 + .../pose_estimation_pytorch/data/dataset.py | 108 ++++++++- .../data/dlcproject.py | 27 ++- .../models/__init__.py | 3 +- .../models/backbones/hrnet.py | 11 +- .../models/criterion.py | 37 +++- .../models/heads/__init__.py | 3 +- .../models/heads/dekr_heads.py | 209 ++++++++++++++++++ .../pose_estimation_pytorch/models/model.py | 47 +--- .../models/modules/__init__.py | 13 ++ .../models/modules/conv_block.py | 161 ++++++++++++++ .../models/modules/conv_module.py | 160 ++++++++++++++ .../models/predictors/__init__.py | 3 + .../models/predictors/base.py | 18 ++ .../models/predictors/dekr_predictor.py | 172 ++++++++++++++ .../models/predictors/single_predictor.py | 75 +++++++ .../models/target_generators/__init__.py | 4 + .../models/target_generators/base.py | 19 ++ .../models/target_generators/dekr_targets.py | 127 +++++++++++ .../target_generators/gaussian_targets.py | 75 +++++++ .../target_generators/plateau_targets.py | 71 ++++++ .../pose_estimation_pytorch/models/utils.py | 8 +- .../pose_estimation_pytorch/solvers/base.py | 34 ++- deeplabcut/pose_estimation_pytorch/utils.py | 168 ++++++++------ 26 files changed, 1413 insertions(+), 152 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/modules/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py diff --git a/.gitignore b/.gitignore index 86a6943487..515862966e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ examples/.DS_Store *.ckpt snapshot-* +# Wandb files +wandb/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 6b14014a46..71802a01ea 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -3,6 +3,7 @@ import os from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver +from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS from torch.utils.data import DataLoader from typing import Union @@ -26,6 +27,7 @@ def train_network( ) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") config = auxiliaryfunctions.read_config(pytorch_config_path) + batch_size = config['batch_size'] epochs = config['epochs'] diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 389ead8de6..2004211f4e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -4,6 +4,8 @@ from deeplabcut.pose_estimation_pytorch.models import PoseModel, BACKBONES, HEADS, LOSSES from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SINGLE_ANIMAL_SOLVER +from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS +from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS from deeplabcut.pose_estimation_pytorch.solvers.schedulers import LRListScheduler from deeplabcut.pose_estimation_pytorch.solvers.base import Solver from deeplabcut.utils import auxiliaryfunctions @@ -14,6 +16,7 @@ def build_pose_model(cfg: Dict, backbone = BACKBONES.build(dict(cfg['backbone'])) head_heatmaps = HEADS.build(dict(cfg['heatmap_head'])) head_locref = HEADS.build(dict(cfg['locref_head'])) + target_generator = TARGET_GENERATORS.build(dict(cfg['target_generator'])) if cfg.get('neck'): neck = None else: @@ -22,6 +25,7 @@ def build_pose_model(cfg: Dict, backbone=backbone, head_heatmaps=head_heatmaps, head_locref=head_locref, + target_generator=target_generator, neck=neck, **cfg['pose_model']) @@ -37,6 +41,8 @@ def build_solver(cfg: Dict) -> Solver: criterion = LOSSES.build(cfg['criterion']) + predictor = PREDICTORS.build(dict(cfg['predictor'])) + if cfg.get('scheduler'): if cfg['scheduler']['type'] == "LRListScheduler": _scheduler = LRListScheduler @@ -58,6 +64,7 @@ def build_solver(cfg: Dict) -> Solver: model=pose_model, criterion=criterion, optimizer=optimizer, + predictor=predictor, cfg=pose_cfg, device=cfg['device'], scheduler=scheduler, diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 6d32f17e1d..e6eef05171 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -4,6 +4,9 @@ import torch from torch.utils.data import Dataset +from deeplabcut.utils.auxiliaryfunctions import read_config, get_model_folder +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator + from .base import BaseDataset from .dlcproject import DLCProject @@ -32,17 +35,50 @@ def transform(image, keypoints): self.transform = transform self.project = project self.cfg = self.project.cfg + self.num_joints = len(self.cfg['bodyparts']) self.shuffle = self.project.shuffle self.project.convert2dict(mode) self.dataframe = self.project.dataframe + modelfolder = os.path.join( + self.project.proj_root, + get_model_folder( + self.cfg['TrainingFraction'][0], + self.shuffle, + self.cfg, + '', + ) + ) + pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") + pytorch_cfg = read_config(pytorch_config_path) + self.with_center = pytorch_cfg.get('with_center', False) + self.max_num_animals = len(self.cfg.get('individuals', ['0'])) + # We must dropna because self.project.images doesn't contain imgaes with no labels so it can produce an indexnotfound error # length is stored here to avoid repeating the computation self.length = self.dataframe.dropna(axis=0, how="all").shape[0] def __len__(self): return self.length + + def _calc_area_from_keypoints(self, keypoints): + w = keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1) + h = keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1) + return w*h + + def _keypoint_in_boundary(self, keypoint, shape): + ''' + + Parameters + ---------- + keypoint: [x, y] + shape: (height, width) + Returns + ------- + bool : wether a keypoint lies inside the given shape''' + return (keypoint[0] > 0) and (keypoint[1] > 0) and (keypoint[0] < shape[1]) and (keypoint[1] < shape[0]) + def __getitem__(self, index: int): """ @@ -73,23 +109,75 @@ def __getitem__(self, print(index) image = cv2.imread(image_file) - # load annotation - annotation_index = self.project.image_path2index[image_file] - annotation = self.project.annotations[annotation_index] - keypoints, undef_ids = self.project.annotation2keypoints(annotation) + # load annotations + image_id = self.project.image_path2image_id[image_file] + n_annotations = len(self.project.id2annotations_idx[image_id]) + + if not self.with_center: + keypoints = np.zeros((self.max_num_animals, self.num_joints, 3)) + num_keypoints_returned = self.num_joints + else: + keypoints = np.zeros((self.max_num_animals, self.num_joints + 1, 3)) + num_keypoints_returned = self.num_joints + 1 + + for i, annotation_idx in enumerate(self.project.id2annotations_idx[image_id]): + _annotation = self.project.annotations[annotation_idx] + _keypoints, _undef_ids = self.project.annotation2keypoints(_annotation) + _keypoints = np.array(_keypoints) + + if self.with_center: + keypoints[i, :-1, :2] = _keypoints + keypoints[i, :-1, 2] = _undef_ids + + else: + keypoints[i, :, :2] = _keypoints + keypoints[i, :, 2] = _undef_ids + + + # Needs two be 2 dimensional for albumentations + keypoints = keypoints.reshape((-1, 3)) if self.transform: - transformed = self.transform(image=image, keypoints=keypoints) - transformed['keypoints'] = [(-1, -1) if i in undef_ids else keypoint + transformed = self.transform(image=image, keypoints=keypoints[:, :2]) + shape_transformed = transformed['image'].shape + transformed['keypoints'] = [(-1, -1) + if ((keypoints[i, 2] == 0) or (not self._keypoint_in_boundary(keypoint, shape_transformed))) + else keypoint for i, keypoint in enumerate(transformed['keypoints'])] else: transformed = {} - transformed['keypoints'] = keypoints + transformed['keypoints'] = keypoints[:, :2] transformed['image'] = image image = torch.FloatTensor(transformed['image']).permute(2, 0, 1) # channels first assert len(transformed['keypoints']) == len(keypoints) - keypoints = np.array(transformed['keypoints']) - - return image, keypoints + keypoints = np.array(transformed['keypoints']).reshape((n_annotations, num_keypoints_returned, 2)) + + #TODO Quite ugly + # + # Center keypoint needs to be computed after transformation because + # it should depend on visible keypoints only (which may change after augmentation) + if self.with_center: + try: + keypoints[:, -1, :] = keypoints[:, :-1, :][~np.any(keypoints[:, :-1, :] == -1, axis=2)].reshape(n_annotations, -1, 2).mean(axis = 1) + except ValueError: + # For at least one annotation every keypoint is out of the frame + for i in range(keypoints.shape[0]): + try: + keypoints[i, -1, :] = keypoints[i, :-1, :][~np.any(keypoints[i, :-1, :] == -1, axis=1)].mean(axis = 0) + except ValueError: + keypoints[i, -1, :] = np.array([-1, -1]) + + np.nan_to_num(keypoints, copy=False, nan=-1) + area = self._calc_area_from_keypoints(keypoints) + + + res = {} + res['image'] = image + res['annotations'] = {} + res['annotations']['keypoints'] = keypoints + res['annotations']['area'] = area + + + return res diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py index f90a39a5c4..1fe4eafa61 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py @@ -63,15 +63,31 @@ def convert2dict(self, self.dataframe, self.image_id_offset) - self.image_path2index = {} - for i, image in enumerate(data["images"]): - image_path = image["file_name"] - self.image_path2index[image_path] = i + self._init_annotation_image_correspondance(data) for key in self.keys_to_load: setattr(self, key, data[key]) print('The data has been loaded!') + def _init_annotation_image_correspondance(self, data): + + # Path to id correspondance + self.image_path2image_id = {} + for i, image in enumerate(data["images"]): + image_path = image["file_name"] + self.image_path2image_id[image_path] = image['id'] + + # id to annotations list + self.id2annotations_idx = {} + for i, annotation in enumerate(data['annotations']): + image_id = annotation['image_id'] + try: + self.id2annotations_idx[image_id].append(i) + except KeyError: + self.id2annotations_idx[image_id] = [i] + + return + def load_split(self): """ @@ -111,8 +127,7 @@ def annotation2keypoints(annotation): x = annotation['keypoints'][::3] y = annotation['keypoints'][1::3] vis = annotation['keypoints'][2::3] - eps = 1e-6 - undef_ids = list(np.where(x <= eps)[0]) + undef_ids = ((x > 0) & (y > 0)).astype(int) keypoints = [] for pair in np.stack([x, y]).T: diff --git a/deeplabcut/pose_estimation_pytorch/models/__init__.py b/deeplabcut/pose_estimation_pytorch/models/__init__.py index 22d7dd957c..cf3a471411 100644 --- a/deeplabcut/pose_estimation_pytorch/models/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/__init__.py @@ -2,4 +2,5 @@ from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS -from deeplabcut.pose_estimation_pytorch.models.criterion import LOSSES \ No newline at end of file +from deeplabcut.pose_estimation_pytorch.models.criterion import LOSSES +from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index 824ac1371d..aed5a32f0a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -1,5 +1,7 @@ import timm +import torch import torch.nn as nn +import torch.nn.functional as F from deeplabcut.pose_estimation_pytorch.models.backbones.base import BaseBackbone, BACKBONES @@ -18,7 +20,14 @@ def __init__(self, model_name: str = 'hrnet_w32') -> nn.Module: """ super().__init__() _backbone = timm.create_model(model_name, pretrained=True) + _backbone.incre_modules = None #Necesssary to get high resolution features, if not set to None _backbone.forward_features will return low_res images self.model = _backbone def forward(self, x): - return self.model.forward_features(x) + y_list = self.model.forward_features(x) + x0_h, x0_w = y_list[0].size(2), y_list[0].size(3) + x = torch.cat([y_list[0], \ + F.interpolate(y_list[1], size=(x0_h, x0_w), mode='bilinear'), \ + F.interpolate(y_list[2], size=(x0_h, x0_w), mode='bilinear'), \ + F.interpolate(y_list[3], size=(x0_h, x0_w), mode='bilinear')], 1) + return x diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index 27049fb052..d5e42d1b74 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -12,12 +12,14 @@ def __init__(self): super(WeightedMSELoss, self).__init__() self.mse_loss = nn.MSELoss(reduction='none') - def __call__(self, prediction, target, weights): + def __call__(self, prediction, target, weights = 1): loss_item = self.mse_loss(prediction, target) - loss_item_weighted = loss_item[weights] - if loss_item_weighted.nelement() == 0: + loss_item_weighted = loss_item * weights + + loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] + if loss_without_zeros.nelement() == 0: return torch.tensor(0.) - return torch.mean(loss_item_weighted) + return torch.mean(loss_without_zeros) class WeightedHuberLoss(nn.HuberLoss): @@ -25,18 +27,21 @@ def __init__(self): super(WeightedHuberLoss, self).__init__() self.huber_loss = nn.HuberLoss(reduction='none') - def __call__(self, prediction, target, weights): + def __call__(self, prediction, target, weights = 1): loss_item = self.huber_loss(prediction, target) - loss_item_weighted = loss_item[weights] - if loss_item_weighted.nelement() == 0: + loss_item_weighted = loss_item*weights + + loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] + if loss_without_zeros.nelement() == 0: return torch.tensor(0.) - return torch.mean(loss_item_weighted) + return torch.mean(loss_without_zeros) @LOSSES.register_module class PoseLoss(nn.Module): def __init__(self, loss_weight_locref: float = 0.1, - locref_huber_loss: bool = False): + locref_huber_loss: bool = False, + apply_sigmoid: bool= True): """ Parameters @@ -55,7 +60,9 @@ def __init__(self, else: self.locref_criterion = WeightedMSELoss() self.loss_weight_locref = loss_weight_locref - self.heatmap_criterion = nn.BCEWithLogitsLoss() + self.heatmap_criterion = WeightedMSELoss() + self.apply_sigmoid = apply_sigmoid + self.sigmoid = nn.Sigmoid() def forward(self, prediction, target): """ @@ -75,8 +82,14 @@ def forward(self, prediction, target): loss: sum """ heatmaps, locref = prediction - heatmap_loss = self.heatmap_criterion(heatmaps, - target['heatmaps']) + if self.apply_sigmoid: + heatmap_loss = self.heatmap_criterion(self.sigmoid(heatmaps), + target['heatmaps'], + target.get('heatmaps_ignored', 1)) + else: + heatmap_loss = self.heatmap_criterion(heatmaps, + target['heatmaps'], + target.get('heatmaps_ignored', 1)) locref_loss = self.loss_weight_locref * self.locref_criterion(locref, target['locref_maps'], diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py index 6036574267..d5dc94fb32 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py @@ -1,2 +1,3 @@ from .base import HEADS -from .simple_head import SimpleHead \ No newline at end of file +from .simple_head import SimpleHead +from .dekr_heads import HeatmapDEKRHead, OffsetDEKRHead \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py new file mode 100644 index 0000000000..5834510832 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py @@ -0,0 +1,209 @@ +import torch +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS +from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import BLOCKS +from deeplabcut.pose_estimation_pytorch.models.modules import BasicBlock, AdaptBlock +from .base import BaseHead + +@HEADS.register_module +class HeatmapDEKRHead(BaseHead): + + def __init__( + self, + channels, + num_blocks, + dilation_rate, + final_conv_kernel, + block = BasicBlock, + ): + super().__init__() + self.bn_momentum = 0.1 + self.inp_channels = channels[0] + self.num_joints_with_center = channels[2] #Should account for the center being a joint + self.final_conv_kernel = final_conv_kernel + + self.transition_heatmap = self._make_transition_for_head( + self.inp_channels, + channels[1], + ) + self.head_heatmap = self._make_heatmap_head( + block, + num_blocks, + channels[1], + dilation_rate, + ) + + + def _make_transition_for_head(self, inplanes, outplanes): + transition_layer = [ + nn.Conv2d(inplanes, outplanes, 1, 1, 0, bias=False), + nn.BatchNorm2d(outplanes), + nn.ReLU(True) + ] + return nn.Sequential(*transition_layer) + + def _make_heatmap_head(self, block, num_blocks, num_channels, dilation_rate): + heatmap_head_layers = [] + + feature_conv = self._make_layer( + block, + num_channels, + num_channels, + num_blocks, + dilation=dilation_rate + ) + heatmap_head_layers.append(feature_conv) + + heatmap_conv = nn.Conv2d( + in_channels=num_channels, + out_channels=self.num_joints_with_center, + kernel_size=self.final_conv_kernel, + stride=1, + padding=1 if self.final_conv_kernel == 3 else 0 + ) + heatmap_head_layers.append(heatmap_conv) + + return nn.ModuleList(heatmap_head_layers) + + def _make_layer( + self, block, inplanes, planes, blocks, stride=1, dilation=1): + downsample = None + if stride != 1 or inplanes != planes * block.expansion: + downsample = nn.Sequential( + nn.Conv2d(inplanes, planes * block.expansion, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(planes * block.expansion, momentum=self.bn_momentum), + ) + + layers = [] + layers.append(block(inplanes, planes, + stride, downsample, dilation=dilation)) + inplanes = planes * block.expansion + for _ in range(1, blocks): + layers.append(block(inplanes, planes, dilation=dilation)) + + return nn.Sequential(*layers) + + def forward(self, x): + + heatmap = self.head_heatmap[1]( + self.head_heatmap[0]( + self.transition_heatmap(x) + ) + ) + + return heatmap + + +@HEADS.register_module +class OffsetDEKRHead(BaseHead): + + def __init__( + self, + channels, + num_offset_per_kpt, + num_blocks, + dilation_rate, + final_conv_kernel, + block = AdaptBlock, + ): + super().__init__() + self.inp_channels = channels[0] + self.num_joints = channels[2] + self.num_joints_with_center = self.num_joints + 1 + + self.bn_momentum = 0.1 + self.offset_perkpt = num_offset_per_kpt + self.num_joints_without_center = self.num_joints + self.offset_channels = self.offset_perkpt*self.num_joints_without_center + assert(self.offset_channels == channels[1]) + + self.num_blocks=num_blocks + self.dilation_rate = dilation_rate + self.final_conv_kernel = final_conv_kernel + + self.transition_offset = self._make_transition_for_head( + self.inp_channels, + self.offset_channels, + ) + self.offset_feature_layers, self.offset_final_layer = \ + self._make_separete_regression_head( + block, + num_blocks=num_blocks, + num_channels_per_kpt=self.offset_perkpt, + dilation_rate=self.dilation_rate + ) + + + def _make_layer( + self, block, inplanes, planes, blocks, stride=1, dilation=1): + downsample = None + if stride != 1 or inplanes != planes * block.expansion: + downsample = nn.Sequential( + nn.Conv2d(inplanes, planes * block.expansion, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(planes * block.expansion, momentum=self.bn_momentum), + ) + + layers = [] + layers.append(block(inplanes, planes, + stride, downsample, dilation=dilation)) + inplanes = planes * block.expansion + for _ in range(1, blocks): + layers.append(block(inplanes, planes, dilation=dilation)) + + return nn.Sequential(*layers) + + def _make_transition_for_head(self, inplanes, outplanes): + transition_layer = [ + nn.Conv2d(inplanes, outplanes, 1, 1, 0, bias=False), + nn.BatchNorm2d(outplanes), + nn.ReLU(True) + ] + return nn.Sequential(*transition_layer) + + def _make_separete_regression_head( + self, + block, + num_blocks, + num_channels_per_kpt, + dilation_rate, + ): + offset_feature_layers = [] + offset_final_layer = [] + + for _ in range(self.num_joints): + feature_conv = self._make_layer( + block, + num_channels_per_kpt, + num_channels_per_kpt, + num_blocks, + dilation=dilation_rate + ) + offset_feature_layers.append(feature_conv) + + offset_conv = nn.Conv2d( + in_channels=num_channels_per_kpt, + out_channels=2, + kernel_size=self.final_conv_kernel, + stride=1, + padding=1 if self.final_conv_kernel == 3 else 0 + ) + offset_final_layer.append(offset_conv) + + return nn.ModuleList(offset_feature_layers), nn.ModuleList(offset_final_layer) + + def forward(self, x): + final_offset = [] + offset_feature = self.transition_offset(x) + + for j in range(self.num_joints): + final_offset.append( + self.offset_final_layer[j]( + self.offset_feature_layers[j]( + offset_feature[:,j*self.offset_perkpt:(j+1)*self.offset_perkpt]))) + + offset = torch.cat(final_offset, dim=1) + + return offset \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index d0e7ddf6bb..bbc655245a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -3,6 +3,7 @@ from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict from torch import nn +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator class PoseModel(nn.Module): @@ -12,9 +13,10 @@ def __init__(self, backbone: torch.nn.Module, head_heatmaps: torch.nn.Module, head_locref: torch.nn.Module, + target_generator: BaseGenerator, neck: torch.nn.Module = None, stride: int = 8, - heatmap_type: str = 'gaussian'): + ): super().__init__() self.backbone = backbone @@ -25,7 +27,7 @@ def __init__(self, self.neck = neck self.stride = stride self.cfg = cfg - self.heatmap_type = heatmap_type + self.target_generator = target_generator self.sigmoid = nn.Sigmoid() def forward(self, x): @@ -50,39 +52,8 @@ def forward(self, x): return heat_maps, loc_ref def get_target(self, - keypoints_batch, - heatmap_size, - scale_factor): - - heatmaps_target = [] - locref_target = [] - weights = [] - locref_masks = [] - for keypoints in keypoints_batch: - # TODO: make faster - heatmap, weight, locref_map, locref_mask = generate_heatmaps(self.cfg, - keypoints, - scale_factor, - heatmap_size=heatmap_size, - heatmap_type=self.cfg['scmap_type'], - ) - locref_target.append(locref_map) - heatmaps_target.append(heatmap) - locref_masks.append(locref_mask) - - heatmaps = torch.stack(heatmaps_target).permute(0, 3, 1, 2) - locref_maps = torch.stack(locref_target).permute(0, 3, 1, 2) - locref_masks = torch.stack(locref_masks).permute(0, 3, 1, 2) - - if weight is not None: - weights = torch.stack(weights) - else: - weights = None - - target = { - 'heatmaps': heatmaps, - 'locref_maps': locref_maps, - 'locref_masks': locref_masks, - 'weights': weights - } - return target + annotations, + prediction, + image_size): + + return self.target_generator(annotations, prediction, image_size) diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py new file mode 100644 index 0000000000..32b6c66152 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py @@ -0,0 +1,13 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) Microsoft +# Licensed under the MIT License. +# The code is based on HigherHRNet-Human-Pose-Estimation. +# (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) +# ------------------------------------------------------------------------------ + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +from .conv_block import BasicBlock, Bottleneck, AdaptBlock +from .conv_module import HighResolutionModule diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py new file mode 100644 index 0000000000..7cc79dcd6b --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py @@ -0,0 +1,161 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) Microsoft +# Licensed under the MIT License. +# The code is based on HigherHRNet-Human-Pose-Estimation. +# (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) +# Modified by Zigang Geng (zigang@mail.ustc.edu.cn). +# ------------------------------------------------------------------------------ + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import torch +import torch.nn as nn +import torchvision.ops as ops + +from abc import ABC, abstractmethod + +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + +BLOCKS = Registry('blocks', build_func=build_from_cfg) + + +class BaseBlock(ABC, nn.Module): + + def __init__(self): + super().__init__() + self.bn_momentum = 0.1 + + @abstractmethod + def forward(self, x): + pass + + def _init_weights(self, pretrained): + if not pretrained: + pass + else: + self.load_state_dict(torch.load(pretrained)) + +@BLOCKS.register_module +class BasicBlock(BaseBlock): + expansion = 1 + + def __init__(self, inplanes, planes, stride=1, + downsample=None, dilation=1): + super(BasicBlock, self).__init__() + self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=3, stride=stride, + padding=dilation, bias=False, dilation=dilation) + self.bn1 = nn.BatchNorm2d(planes, momentum=self.bn_momentum) + self.relu = nn.ReLU(inplace=True) + self.conv2 = nn.Conv2d(inplanes, planes, kernel_size=3, stride=stride, + padding=dilation, bias=False, dilation=dilation) + self.bn2 = nn.BatchNorm2d(planes, momentum=self.bn_momentum) + self.downsample = downsample + self.stride = stride + + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.bn1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.bn2(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + +@BLOCKS.register_module +class Bottleneck(BaseBlock): + expansion = 4 + + def __init__(self, inplanes, planes, stride=1, + downsample=None, dilation=1): + super(Bottleneck, self).__init__() + self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=1, bias=False) + self.bn1 = nn.BatchNorm2d(planes, momentum=self.bn_momentum) + self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=stride, + padding=dilation, bias=False, dilation=dilation) + self.bn2 = nn.BatchNorm2d(planes, momentum=self.bn_momentum) + self.conv3 = nn.Conv2d(planes, planes * self.expansion, kernel_size=1, + bias=False) + self.bn3 = nn.BatchNorm2d(planes * self.expansion, + momentum=self.bn_momentum) + self.relu = nn.ReLU(inplace=True) + self.downsample = downsample + self.stride = stride + + def forward(self, x): + residual = x + + out = self.conv1(x) + out = self.bn1(out) + out = self.relu(out) + + out = self.conv2(out) + out = self.bn2(out) + out = self.relu(out) + + out = self.conv3(out) + out = self.bn3(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + + +@BLOCKS.register_module +class AdaptBlock(BaseBlock): + expansion = 1 + + def __init__(self, inplanes, outplanes, stride=1, + downsample=None, dilation=1, deformable_groups=1): + super(AdaptBlock, self).__init__() + regular_matrix = torch.tensor([[-1, -1, -1, 0, 0, 0, 1, 1, 1],\ + [-1, 0, 1, -1 ,0 ,1 ,-1, 0, 1]]) + self.register_buffer('regular_matrix', regular_matrix.float()) + self.downsample = downsample + self.transform_matrix_conv = nn.Conv2d(inplanes, 4, 3, 1, 1, bias=True) + self.translation_conv = nn.Conv2d(inplanes, 2, 3, 1, 1, bias=True) + self.adapt_conv = ops.DeformConv2d(inplanes, outplanes, kernel_size=3, stride=stride, \ + padding=dilation, dilation=dilation, bias=False, groups=deformable_groups) + self.bn = nn.BatchNorm2d(outplanes, momentum=self.bn_momentum) + self.relu = nn.ReLU(inplace=True) + + def forward(self, x): + residual = x + + N, _, H, W = x.shape + transform_matrix = self.transform_matrix_conv(x) + transform_matrix = transform_matrix.permute(0,2,3,1).reshape((N*H*W,2,2)) + offset = torch.matmul(transform_matrix, self.regular_matrix) + offset = offset-self.regular_matrix + offset = offset.transpose(1,2).reshape((N,H,W,18)).permute(0,3,1,2) + + translation = self.translation_conv(x) + offset[:,0::2,:,:] += translation[:,0:1,:,:] + offset[:,1::2,:,:] += translation[:,1:2,:,:] + + out = self.adapt_conv(x, offset) + out = self.bn(out) + + if self.downsample is not None: + residual = self.downsample(x) + + out += residual + out = self.relu(out) + + return out + diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py new file mode 100644 index 0000000000..2772ddf3a5 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py @@ -0,0 +1,160 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) Microsoft +# Licensed under the MIT License. +# The code is based on HigherHRNet-Human-Pose-Estimation. +# (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) +# Modified by Zigang Geng (zigang@mail.ustc.edu.cn). +# ------------------------------------------------------------------------------ + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import os +import logging + +import torch +import torch.nn as nn + +BN_MOMENTUM = 0.1 +logger = logging.getLogger(__name__) + + +class HighResolutionModule(nn.Module): + def __init__(self, num_branches, blocks, num_blocks, num_inchannels, + num_channels, fuse_method, multi_scale_output=True): + super(HighResolutionModule, self).__init__() + self._check_branches( + num_branches, blocks, num_blocks, num_inchannels, num_channels) + + self.num_inchannels = num_inchannels + self.fuse_method = fuse_method + self.num_branches = num_branches + + self.multi_scale_output = multi_scale_output + + self.branches = self._make_branches( + num_branches, blocks, num_blocks, num_channels) + self.fuse_layers = self._make_fuse_layers() + self.relu = nn.ReLU(True) + + def _check_branches(self, num_branches, blocks, num_blocks, + num_inchannels, num_channels): + if num_branches != len(num_blocks): + error_msg = 'NUM_BRANCHES({}) <> NUM_BLOCKS({})'.format( + num_branches, len(num_blocks)) + logger.error(error_msg) + raise ValueError(error_msg) + + if num_branches != len(num_channels): + error_msg = 'NUM_BRANCHES({}) <> NUM_CHANNELS({})'.format( + num_branches, len(num_channels)) + logger.error(error_msg) + raise ValueError(error_msg) + + if num_branches != len(num_inchannels): + error_msg = 'NUM_BRANCHES({}) <> NUM_INCHANNELS({})'.format( + num_branches, len(num_inchannels)) + logger.error(error_msg) + raise ValueError(error_msg) + + def _make_one_branch(self, branch_index, block, num_blocks, num_channels, + stride=1): + downsample = None + if stride != 1 or \ + self.num_inchannels[branch_index] != num_channels[branch_index] * block.expansion: + downsample = nn.Sequential( + nn.Conv2d(self.num_inchannels[branch_index], + num_channels[branch_index] * block.expansion, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(num_channels[branch_index] * block.expansion, + momentum=BN_MOMENTUM), + ) + + layers = [] + layers.append(block(self.num_inchannels[branch_index], + num_channels[branch_index], stride, downsample)) + self.num_inchannels[branch_index] = \ + num_channels[branch_index] * block.expansion + for i in range(1, num_blocks[branch_index]): + layers.append(block(self.num_inchannels[branch_index], + num_channels[branch_index])) + + return nn.Sequential(*layers) + + def _make_branches(self, num_branches, block, num_blocks, num_channels): + branches = [] + + for i in range(num_branches): + branches.append( + self._make_one_branch(i, block, num_blocks, num_channels)) + + return nn.ModuleList(branches) + + def _make_fuse_layers(self): + if self.num_branches == 1: + return None + + num_branches = self.num_branches + num_inchannels = self.num_inchannels + fuse_layers = [] + for i in range(num_branches if self.multi_scale_output else 1): + fuse_layer = [] + for j in range(num_branches): + if j > i: + fuse_layer.append(nn.Sequential( + nn.Conv2d(num_inchannels[j], + num_inchannels[i], + 1, + 1, + 0, + bias=False), + nn.BatchNorm2d(num_inchannels[i]), + nn.Upsample(scale_factor=2**(j-i), mode='nearest'))) + elif j == i: + fuse_layer.append(None) + else: + conv3x3s = [] + for k in range(i-j): + if k == i - j - 1: + num_outchannels_conv3x3 = num_inchannels[i] + conv3x3s.append(nn.Sequential( + nn.Conv2d(num_inchannels[j], + num_outchannels_conv3x3, + 3, 2, 1, bias=False), + nn.BatchNorm2d(num_outchannels_conv3x3))) + else: + num_outchannels_conv3x3 = num_inchannels[j] + conv3x3s.append(nn.Sequential( + nn.Conv2d(num_inchannels[j], + num_outchannels_conv3x3, + 3, 2, 1, bias=False), + nn.BatchNorm2d(num_outchannels_conv3x3), + nn.ReLU(True))) + fuse_layer.append(nn.Sequential(*conv3x3s)) + fuse_layers.append(nn.ModuleList(fuse_layer)) + + return nn.ModuleList(fuse_layers) + + def get_num_inchannels(self): + return self.num_inchannels + + def forward(self, x): + if self.num_branches == 1: + return [self.branches[0](x[0])] + + for i in range(self.num_branches): + x[i] = self.branches[i](x[i]) + + x_fuse = [] + + for i in range(len(self.fuse_layers)): + y = x[0] if i == 0 else self.fuse_layers[i][0](x[0]) + for j in range(1, self.num_branches): + if i == j: + y = y + x[j] + else: + y = y + self.fuse_layers[i][j](x[j]) + x_fuse.append(self.relu(y)) + + return x_fuse diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py new file mode 100644 index 0000000000..66677afad4 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -0,0 +1,3 @@ +from .base import PREDICTORS, BasePredictor +from .single_predictor import SinglePredictor +from .dekr_predictor import DEKRPredictor \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py new file mode 100644 index 0000000000..9664891ed4 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -0,0 +1,18 @@ +import torch +import torch.nn as nn +from abc import ABC, abstractmethod + +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + +PREDICTORS = Registry('predictors', build_func=build_from_cfg) + +class BasePredictor(ABC, nn.Module): + + def __init__(self): + super().__init__() + + self.num_animals = None + + @abstractmethod + def forward(self, outputs): + pass \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py new file mode 100644 index 0000000000..362c4f447b --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -0,0 +1,172 @@ +import torch +import torch.nn as nn + +from typing import Tuple + +from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS, BasePredictor + +#TODO what about credits ? DIfferent code from DEKR repo but still largely inspired from it + +@PREDICTORS.register_module +class DEKRPredictor(BasePredictor): + + def __init__(self, num_animals: int, detection_threshold: float=0.01, apply_sigmoid: bool=True, use_heatmap = True): + super().__init__() + + self.num_animals = num_animals + self.detection_threshold = detection_threshold + self.apply_sigmoid = apply_sigmoid + self.use_heatmap = use_heatmap + + def forward(self, outputs, scale_factors: Tuple[float, float]): + + heatmaps, offsets = outputs + if self.apply_sigmoid: + heatmaps = nn.Sigmoid()(heatmaps) + posemap = self.offset_to_pose(offsets) + + batch_size, num_joints_with_center, h, w = heatmaps.shape + num_joints = num_joints_with_center - 1 + + center_heatmaps = heatmaps[:, -1] + pose_ind, scores = self.get_top_values(center_heatmaps) + + posemap = posemap.permute(0, 2, 3, 1).view(batch_size, h*w, -1, 2) + poses = torch.zeros(batch_size, pose_ind.shape[1], num_joints, 2).to(scores.device) + for i in range(batch_size): + pose = posemap[i, pose_ind[i]] + poses[i] = pose + + ctr_score = scores[:, :,None].expand(batch_size, -1, num_joints)[:,:,:,None] + + poses[:, :, :, 0] = poses[:, :, :, 0]*scale_factors[1] + 0.5*scale_factors[1] + poses[:, :, :, 1] = poses[:, :, :, 1]*scale_factors[0] + 0.5*scale_factors[0] + + poses_w_scores = torch.cat([poses, ctr_score], dim=3) + self.pose_nms(heatmaps, poses_w_scores) + + return poses_w_scores + + def get_locations(self, height: int, width: int, device: torch.device): + shifts_x = torch.arange( + 0, width, step=1, + dtype=torch.float32 + ).to(device) + shifts_y = torch.arange( + 0, height, step=1, + dtype=torch.float32 + ).to(device) + shift_y, shift_x = torch.meshgrid(shifts_y, shifts_x, indexing='ij') + shift_x = shift_x.reshape(-1) + shift_y = shift_y.reshape(-1) + locations = torch.stack((shift_x, shift_y), dim=1) + + return locations + + def get_reg_poses(self, offsets: torch.Tensor, num_joints: int): + ''' + offsets : (batch_size, num_joints*2, h, w) + ''' + batch_size, _, h, w = offsets.shape + offsets = offsets.permute(0, 2, 3, 1).reshape(batch_size, h*w, num_joints, 2) + locations = self.get_locations(h, w, offsets.device) + locations = locations[None, :, None, :].expand(batch_size, -1, num_joints, -1) + poses = locations - offsets + + return poses + + def offset_to_pose(self, offsets: torch.Tensor): + ''' + offsets : (batch_size, num_joints*2, h, w) + + RETURN + --------- + reg_poses : (batch_size, 2*num_joints, h, w) + ''' + batch_size, num_offset, h, w = offsets.shape + num_joints = int(num_offset/2) + reg_poses = self.get_reg_poses(offsets, num_joints) + + reg_poses = reg_poses.contiguous().view(batch_size, h*w, 2*num_joints).permute(0, 2, 1) + reg_poses = reg_poses.contiguous().view(batch_size,-1,h,w).contiguous() + + return reg_poses + + def max_pool(self, heatmap: torch.Tensor): + ''' + heatmap: (batch_size, h, w) + ''' + pool1 = torch.nn.MaxPool2d(3, 1, 1) + pool2 = torch.nn.MaxPool2d(5, 1, 2) + pool3 = torch.nn.MaxPool2d(7, 1, 3) + map_size = (heatmap.shape[1]+heatmap.shape[2])/2.0 + maxm = pool2(heatmap) # Here I think pool 2 is a good match for default 17 pos_dist_tresh + + return maxm + + def get_top_values(self, heatmap: torch.Tensor): + ''' + heatmap: (batch_size, h, w) + ''' + maximum = self.max_pool(heatmap) + maximum = torch.eq(maximum, heatmap) + heatmap *= maximum + + batchsize, ny, nx = heatmap.shape + heatmap_flat = heatmap.reshape(batchsize, nx * ny) + + scores, pos_ind = torch.topk(heatmap_flat, self.num_animals, dim=1) + + return pos_ind, scores + + ########## WIP to take heatmap into account for scoring ########## + def get_heat_value(self, pose_coords, heatmaps): + """ + pose_coords : (batch_size, num_people, num_joints, 2) + heatmaps : (batch_size, 1+num_joints, h, w) + """ + h, w = heatmaps.shape[2:] + heatmaps_nocenter = heatmaps[:, :-1].flatten(2, 3) # (batch_size, num_joints, h*w) + + # Predicted poses based on the offset can be outsied of the image + y = torch.clamp(torch.floor(pose_coords[:, :, :, 1]), 0, h-1).long() + x = torch.clamp(torch.floor(pose_coords[:, :, :, 0]), 0, w-1).long() + + heatvals = torch.gather(heatmaps_nocenter, 2, y*w + x) + + return heatvals + + def pose_nms(self, heatmaps, poses): + """ + NMS for the regressed poses results. + + Args: + heatmaps (Tensor): Avg of the heatmaps at all scales (batch_size, 1+num_joints, h, w) + poses (List): Gather of the pose proposals (batch_size, num_people, num_joints, 3) + """ + pose_scores = poses[:, :, :, 2] + pose_coords = poses[:, :, :, :2] + + if pose_coords.shape[1] == 0: + return [], [] + + batch_size, num_people, num_joints, _ = pose_coords.shape + heatvals = self.get_heat_value(pose_coords, heatmaps) + heat_score = (torch.sum(heatvals, dim=1)/num_joints)[:,0] + + # pose_score = pose_score*heatvals + # poses = torch.cat([pose_coord.cpu(), pose_score.cpu()], dim=2) + + # keep_pose_inds = nms_core(cfg, pose_coord, heat_score) + # poses = poses[keep_pose_inds] + # heat_score = heat_score[keep_pose_inds] + + # if len(keep_pose_inds) > cfg.DATASET.MAX_NUM_PEOPLE: + # heat_score, topk_inds = torch.topk(heat_score, + # cfg.DATASET.MAX_NUM_PEOPLE) + # poses = poses[topk_inds] + + # poses = [poses.numpy()] + # scores = [i[:, 2].mean() for i in poses[0]] + + # return poses, scores diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py new file mode 100644 index 0000000000..fde0f3a240 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -0,0 +1,75 @@ +import torch +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.models.predictors.base import PREDICTORS, BasePredictor + +@PREDICTORS.register_module +class SinglePredictor(BasePredictor): + """Predictor only intended for single animal pose estimation""" + + def __init__(self, num_animals, location_refinement, locref_stdev, apply_sigmoid: bool= True): + super().__init__() + #TODO add num_animals in pytorch_cfg automatically + self.num_animals = num_animals + assert(self.num_animals == 1, "SinglePredictor must only be used for single animal predictions") + self.location_refinement = location_refinement + self.locref_stdev = locref_stdev + self.apply_sigmoid = apply_sigmoid + self.sigmoid = nn.Sigmoid() + + def forward(self, output, scale_factors): + ''' + get predictions from model output + output = heatmaps, locref + heatmaps: torch.Tensor([batch_size, num_joints, height, width]) + locref: torch.Tensor([batch_size, num_joints, height, width]) + ''' + heatmaps, locrefs = output + if self.apply_sigmoid: + heatmaps = self.sigmoid(heatmaps) + heatmaps = heatmaps.permute(0, 2, 3, 1) + batch_size, height, width, num_joints = heatmaps.shape + + locrefs = locrefs.permute(0, 2, 3, 1).reshape(batch_size, height, width, num_joints, 2) + + poses = self.get_pose_prediction(heatmaps, locrefs*self.locref_stdev, scale_factors) + return poses + + def get_top_values(self, heatmap) -> torch.Tensor: + batchsize, ny, nx, num_joints = heatmap.shape + heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints) + + heatmap_top = torch.argmax(heatmap_flat, axis=1) + + Y, X = heatmap_top//nx, heatmap_top%nx + return Y, X + + def get_pose_prediction(self, heatmap, locref, scale_factors): + ''' + heatmap shape : (batch_size, height, width, num_joints) + locref shape : (batch_size, height, width, num_joints, 2) + + RETURN + ---------- + pose : (batch_size, num_people = 1, num_joints, 3)''' + Y, X = self.get_top_values(heatmap) + batch_size, num_joints = X.shape + + DZ = torch.zeros((batch_size, 1, num_joints, 3)).to(X.device) + for b in range(batch_size): + for j in range(num_joints): + DZ[b, 0, j, :2] = locref[b, Y[b, j], X[b, j], j, :] + DZ[b, 0, j, 2] = heatmap[b, Y[b, j], X[b, j], j] + + X, Y = torch.unsqueeze(X, 1), torch.unsqueeze(Y, 1) + + X = X * scale_factors[1] + 0.5 * scale_factors[1] + DZ[:, :, :, 0] + Y = Y * scale_factors[0] + 0.5 * scale_factors[0] + DZ[:, :, :, 1] + # P = DZ[:, :, 2] + + pose = torch.empty((batch_size, 1, num_joints, 3)) + pose[:, :, :, 0] = X + pose[:, :, :, 1] = Y + pose[:, :, :, 2] = DZ[:, :, :, 2] + + return pose \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py new file mode 100644 index 0000000000..7f9d5c7e67 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py @@ -0,0 +1,4 @@ +from .base import BaseGenerator, TARGET_GENERATORS +from .dekr_targets import DEKRGenerator +from .gaussian_targets import GaussianGenerator +from .plateau_targets import PlateauGenerator \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py new file mode 100644 index 0000000000..f0d7aef796 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod +import torch.nn as nn +import torch +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + + +TARGET_GENERATORS = Registry('target_generators', build_func=build_from_cfg) + +class BaseGenerator(ABC, nn.Module): + + def __init__(self): + super().__init__() + self.batch_norm_on = False + + @abstractmethod + def forward(self, x): + pass + + \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py new file mode 100644 index 0000000000..39547ba71a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -0,0 +1,127 @@ +import numpy as np + +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS + +@TARGET_GENERATORS.register_module +class DEKRGenerator(BaseGenerator): + + def __init__(self, num_joints, pos_dist_thresh, bg_weight = 0.1): + super().__init__() + + self.num_joints = num_joints + self.pos_dist_thresh = pos_dist_thresh + self.bg_weight = bg_weight + + self.num_joints_with_center = self.num_joints + 1 + + def get_heat_val(self, sigma, x, y, x0, y0): + + g = np.exp(- ((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma ** 2)) + + return g + + def forward(self, annotations, prediction, image_size): + + batch_size, _, output_h, output_w = prediction[0].shape + output_res = output_h, output_w + stride_y, stride_x = image_size[0]/output_h, image_size[1]/output_w + + num_joints_without_center = self.num_joints + num_joints_with_center = num_joints_without_center + 1 + + coords = annotations['keypoints'].cpu().numpy() + area = annotations['area'].cpu().numpy() + + assert self.num_joints + 1 == coords.shape[2], \ + f'the number of joints should be {coords.shape}' + + #TODO make it possible to differenciate between center sigma and other sigmas + scale = max(1/stride_x, 1/stride_y) + sgm, ct_sgm = (self.pos_dist_thresh/2)*scale, (self.pos_dist_thresh)*scale + radius = self.pos_dist_thresh*scale + + hms = np.zeros((batch_size, num_joints_with_center, output_h, output_w), + dtype=np.float32) + ignored_hms = 2*np.ones((batch_size, num_joints_with_center, output_h, output_w), + dtype=np.float32) + offset_map = np.zeros((batch_size, num_joints_without_center*2, output_h, output_w), + dtype=np.float32) + weight_map = np.zeros((batch_size, num_joints_without_center*2, output_h, output_w), + dtype=np.float32) + area_map = np.zeros((batch_size, output_h, output_w), + dtype=np.float32) + + hms_list = [hms, ignored_hms] + + for b in range(batch_size): + for person_id, p in enumerate(coords[b]): + idx_center = len(p) - 1 + ct_x = int(p[-1, 0]) + ct_y = int(p[-1, 1]) + + ct_x_sm = (ct_x - stride_x/2)/stride_x + ct_y_sm = (ct_y - stride_y/2)/stride_y + for idx, pt in enumerate(p): + if idx == idx_center: + sigma = ct_sgm + else: + sigma = sgm + if np.any(pt <= 0.): + continue + x, y = pt[0], pt[1] + x_sm, y_sm = (x - stride_x/2)/stride_x, (y - stride_y/2)/stride_y + + if x_sm < 0 or y_sm < 0 or \ + x_sm >= output_w or y_sm >= output_h: + continue + + # HEATMAP COMPUTATION + ul = int(np.floor(x_sm - 3 * sigma - 1) + ), int(np.floor(y_sm - 3 * sigma - 1)) + br = int(np.ceil(x_sm + 3 * sigma + 2) + ), int(np.ceil(y_sm + 3 * sigma + 2)) + + cc, dd = max(0, ul[0]), min(br[0], min(output_res)) + aa, bb = max(0, ul[1]), min(br[1], min(output_res)) + + joint_rg = np.zeros((bb-aa, dd-cc)) + for sy in range(aa, bb): + for sx in range(cc, dd): + joint_rg[sy-aa, sx - + cc] = self.get_heat_val(sigma, sx, sy, x_sm, y_sm) + + hms_list[0][b, idx, aa:bb, cc:dd] = np.maximum( + hms_list[0][b, idx, aa:bb, cc:dd], joint_rg) + hms_list[1][b, idx, aa:bb, cc:dd] = 1. + + # OFFSET COMPUTATION + if idx != idx_center: + start_x = max(int(ct_x_sm - radius), 0) + start_y = max(int(ct_y_sm - radius), 0) + end_x = min(int(ct_x_sm + radius), output_w) + end_y = min(int(ct_y_sm + radius), output_h) + + for pos_x in range(start_x, end_x): + for pos_y in range(start_y, end_y): + offset_x = pos_x - x_sm + offset_y = pos_y - y_sm + if offset_map[b, idx*2, pos_y, pos_x] != 0 \ + or offset_map[b, idx*2+1, pos_y, pos_x] != 0: + if area_map[b, pos_y, pos_x] < area[b, person_id]: + continue + offset_map[b, idx*2, pos_y, pos_x] = offset_x + offset_map[b, idx*2+1, pos_y, pos_x] = offset_y + #TODO find a decent constant make weights vary giving animal area + weight_map[b, idx*2, pos_y, pos_x] = 1.#/((scale**2)*np.sqrt(area[person_id])) + weight_map[b, idx*2+1, pos_y, pos_x] = 1.#/((scale**2)*np.sqrt(area[person_id])) + area_map[b, pos_y, pos_x] = area[b, person_id] + + hms_list[1][hms_list[1] == 2] = self.bg_weight + + targets = { + "heatmaps": hms_list[0], + "heatmaps_ignored": hms_list[1], + "locref_maps": offset_map, + "locref_masks": weight_map + } + return targets \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py new file mode 100644 index 0000000000..27e88cc871 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py @@ -0,0 +1,75 @@ +import numpy as np + +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS + +@TARGET_GENERATORS.register_module +class GaussianGenerator(BaseGenerator): + + def __init__(self, locref_stdev, num_joints, pos_dist_thresh): + super().__init__() + + self.locref_scale = 1.0/locref_stdev + self.num_joints = num_joints + self.dist_thresh = float(pos_dist_thresh) + self.dist_thresh_sq = self.dist_thresh ** 2 + self.std = 2*self.dist_thresh / 3 # We think of dist_thresh as a radius and std is a 'diameter' + + + def forward(self, annotations, prediction, image_size): + """ + + Parameters + ---------- + annotations: dict, each entry should begin with the shape batch_size + prediction: output of model, format could depend on the model, only used to compute output resolution + image_size: size of image (only one tuple since for batch training all images should have the same size) + + Returns + ------- + dict of the targets + + """ + # stride = cfg['stride'] # Apparently, there is no stride in the cfg + # stride = scale_factors # TODO just test + batch_size, _, height, width = prediction[0].shape + stride_y, stride_x = image_size[0]/height, image_size[1]/width + coords = annotations['keypoints'].cpu().numpy() + scmap = np.zeros(( + batch_size, + height, + width, self.num_joints), dtype=np.float32) + + locref_map = np.zeros(( + batch_size, + height, + width, self.num_joints * 2), dtype=np.float32) + locref_mask = np.zeros_like(locref_map, dtype=int) + + grid = np.mgrid[:height, :width].transpose((1, 2, 0)) + grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 + grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 + + for b in range(batch_size): + for idx_animal, kpts_animal in enumerate(coords[b]): + for i, coord in enumerate(kpts_animal): + coord = np.array(coord)[::-1] + if np.any(coord <= 0.): + continue + dist = np.linalg.norm(grid - coord, axis=2) ** 2 + scmap_j = np.exp(-dist / (2 * self.std ** 2)) + scmap[b, :, :, i] += scmap_j + locref_mask[b, dist <= self.dist_thresh_sq, i * 2:i*2+2] = 1 + dx = coord[1] - grid.copy()[:, :, 1] + dy = coord[0] - grid.copy()[:, :, 0] + locref_map[b, :, :, i * 2 + 0] += dx * self.locref_scale + locref_map[b, :, :, i * 2 + 1] += dy * self.locref_scale + scmap = scmap.transpose(0, 3, 1, 2) + locref_map = locref_map.transpose(0, 3, 1, 2) + locref_mask = locref_mask.transpose(0, 3, 1, 2) + targets = { + "heatmaps": scmap, + "locref_maps": locref_map, + "locref_masks": locref_mask, + } + + return targets \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py new file mode 100644 index 0000000000..1fb84e311b --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py @@ -0,0 +1,71 @@ +import numpy as np + +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS + +@TARGET_GENERATORS.register_module +class PlateauGenerator(BaseGenerator): + + def __init__(self, locref_stdev, num_joints, pos_dist_thresh): + super().__init__() + + self.locref_scale = 1.0/locref_stdev + self.num_joints = num_joints + self.dist_thresh = float(pos_dist_thresh) + self.dist_thresh_sq = self.dist_thresh ** 2 + + def forward(self, annotations, prediction, image_size): + """ + + Parameters + ---------- + annotations : dict of annoations which should all be tensors of first dimension batch_size + prediction: model's output + image_size : size of input images + + Returns + ------- + + """ + batch_size, _, height, width = prediction[0].shape + stride_y, stride_x = image_size[0]/height, image_size[1]/width + coords = annotations['keypoints'].cpu().numpy() + scmap = np.zeros(( + batch_size, + height, + width, self.num_joints), dtype=np.float32) + + locref_map = np.zeros(( + batch_size, + height, + width, self.num_joints * 2), dtype=np.float32) + locref_mask = np.zeros_like(locref_map, dtype=int) + + grid = np.mgrid[:height, :width].transpose((1, 2, 0)) + grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 + grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 + + for b in range(batch_size): + for idx_animal, kpts_animal in enumerate(coords[b]): + for i, coord in enumerate(kpts_animal): + coord = np.array(coord)[::-1] + if np.any(coord <= 0.): + continue + dist = np.linalg.norm(grid - coord, axis=2) ** 2 + mask = (dist <= self.dist_thresh_sq) + scmap[b, (dist <= self.dist_thresh_sq), i] += 1 + locref_mask[b, dist <= self.dist_thresh_sq, i * 2:i*2+2] = 1 + dx = coord[1] - grid.copy()[:, :, 1] + dy = coord[0] - grid.copy()[:, :, 0] + locref_map[b, mask, i * 2 + 0] += (dx * self.locref_scale)[mask] + locref_map[b, mask, i * 2 + 1] += (dy * self.locref_scale)[mask] + + scmap = scmap.transpose(0, 3, 1, 2) + locref_map = locref_map.transpose(0, 3, 1, 2) + locref_mask = locref_mask.transpose(0, 3, 1, 2) + targets = { + "heatmaps": scmap, + "locref_maps": locref_map, + "locref_masks": locref_mask, + } + + return targets \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/utils.py b/deeplabcut/pose_estimation_pytorch/models/utils.py index 88bc885ebb..79b46d90b2 100644 --- a/deeplabcut/pose_estimation_pytorch/models/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/utils.py @@ -2,13 +2,13 @@ import torch from typing import Tuple +''' FILE NOT USED ANYMORE ''' def generate_heatmaps(cfg: dict, coords: np.array, scale_factor, heatmap_size: tuple = (64, 64), heatmap_type: str = 'gaussian'): - # print(heatmap_type) if heatmap_type == 'gaussian': scmap, weights, locref_map, locref_mask = gaussian_scmap(cfg, coords, @@ -50,8 +50,6 @@ def gaussian_scmap(cfg, coords, scale_factors, heatmap_size): """ locref_scale = 1.0 / cfg["locref_stdev"] num_joints = cfg["num_joints"] - # stride = cfg['stride'] # Apparently, there is no stride in the cfg - # stride = scale_factors # TODO just test stride_y, stride_x = scale_factors scmap = np.zeros(( heatmap_size[0], @@ -64,7 +62,7 @@ def gaussian_scmap(cfg, coords, scale_factors, heatmap_size): width = heatmap_size[1] height = heatmap_size[0] - dist_thresh = float(cfg['pos_dist_thresh']) #TODO Should depend on config + dist_thresh = float(cfg['pos_dist_thresh']) dist_thresh_sq = dist_thresh ** 2 std = dist_thresh / 4 @@ -104,7 +102,7 @@ def plateau_scmap(cfg, coords, scale_factors, heatmap_size): width = heatmap_size[1] height = heatmap_size[0] - dist_thresh = float(cfg['pos_dist_thresh']) #TODO Should depend on config + dist_thresh = float(cfg['pos_dist_thresh']) dist_thresh_sq = dist_thresh ** 2 std = dist_thresh / 4 diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 10fa4246ad..de5b545779 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -10,6 +10,7 @@ from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset from .utils import * from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor class Solver(ABC): @@ -17,6 +18,7 @@ def __init__(self, model: PoseModel, criterion: torch.nn, optimizer: torch.optim.Optimizer, + predictor: BasePredictor, cfg: Dict, device: str = 'cpu', scheduler: Optional = None, @@ -44,6 +46,7 @@ def __init__(self, self.optimizer = optimizer self.scheduler = scheduler self.criterion = criterion + self.predictor = predictor self.history = {'train_loss': [], 'eval_loss': []} self.logger = logger @@ -86,7 +89,7 @@ def fit( print(f'Training for epoch {i+1} is done, started evaluating on validation data') valid_loss = self.epoch(valid_loader, mode='eval') save_path = f'{model_folder}/train/snapshot-{i+1}.pt' - if (i+1)%10 == 0: + if (i+1)%30 == 0: torch.save(self.model.state_dict(), save_path) print(f'Epoch {i + 1}/{epochs}, ' f'train loss {train_loss}, ' @@ -111,15 +114,28 @@ def epoch(self, to_mode = getattr(self.model, mode) to_mode() epoch_loss = [] + metrics={ + 'total_loss': [], + 'heatmap_loss': [], + 'locref_loss': [], + } for i, batch in enumerate(loader): - loss = self.step(batch, mode) + loss, htmp_loss, locref_loss = self.step(batch, mode) epoch_loss.append(loss) + metrics['total_loss'].append(loss) + metrics['heatmap_loss'].append(htmp_loss) + metrics['locref_loss'].append(locref_loss) + if (i+1)%self.cfg['display_iters'] == 0: print(f"Number of iterations : {i+1}, loss : {loss}, lr : {self.optimizer.param_groups[0]['lr']}") epoch_loss = np.mean(epoch_loss) self.history[f'{mode}_loss'].append(epoch_loss) + if self.logger: + for key in metrics.keys(): + self.logger.log(f'{mode} {key}', np.nanmean(metrics[key])) + return epoch_loss @abstractmethod @@ -169,27 +185,21 @@ def step(self, raise ValueError(f'Solver must be in train or eval mode, but {mode} was found.') if mode == 'train': self.optimizer.zero_grad() - image, keypoints = batch + image = batch['image'] image = image.to(self.device) prediction = self.model(image) - scale_factor = (image.shape[2]/prediction[0].shape[2] , image.shape[3]/prediction[0].shape[3]) - target = self.model.get_target(keypoints, prediction[0].shape[2:], scale_factor) # (batch_size, channels, h, w) - + target = self.model.get_target(batch['annotations'], prediction, image.shape[2:]) # (batch_size, channels, h, w) for key in target: if target[key] is not None: - target[key] = target[key].to(self.device) + target[key] = torch.Tensor(target[key]).to(self.device) total_loss, heatmap_loss, locref_loss = self.criterion(prediction, target) - if self.logger: - self.logger.log(f'{mode} total loss', total_loss) - self.logger.log(f'{mode} heatmap loss', heatmap_loss) - self.logger.log(f'{mode} locref loss', locref_loss) if mode == 'train': total_loss.backward() self.optimizer.step() - return total_loss.detach().cpu().numpy() + return total_loss.detach().cpu().numpy(), heatmap_loss.detach().cpu().numpy(), locref_loss.detach().cpu().numpy(), #rmse #, rmse_pcutoff class TopDownSolver(Solver): diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index 61326e8cad..c5928fea79 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -10,64 +10,114 @@ # Shaokai's function -def df2generic(proj_root, df, image_id_offset=0): - bpts = df.columns.get_level_values('bodyparts').unique().tolist() - +def df2generic(proj_root, df, image_id_offset = 0): + + try: + individuals = df.columns.get_level_values('individuals').unique().tolist() + except KeyError: + new_cols = pd.MultiIndex.from_tuples( + [(col[0], 'single', col[1], col[2]) for col in df.columns], + names=['scorer', 'individuals', 'bodyparts', 'coords'] + ) + df.columns = new_cols + + individuals = df.columns.get_level_values('individuals').unique().tolist() + + unique_bpts = [] + + if 'single' in individuals: + unique_bpts.extend( + df + .xs('single', level='individuals', axis=1) + .columns.get_level_values('bodyparts').unique() + ) + multi_bpts = ( + df + .xs(individuals[0], level='individuals', axis=1) + .columns.get_level_values('bodyparts').unique().tolist() + ) + coco_categories = [] - # single animal only has individual0 + # assuming all individuals have the same name and same category id + individual = individuals[0] + category = { - "name": 'individual0', + "name": individual, "id": 0, "supercategory": "animal", - + } - category['keypoints'] = bpts - + if individual == 'single': + category['keypoints'] = unique_bpts + else: + category['keypoints'] = multi_bpts + coco_categories.append(category) - + coco_images = [] - coco_annotations = [] + coco_annotations = [] annotation_id = 0 image_id = -1 - for _, file_name in enumerate(df.index): - data = df.loc[file_name] + data = df.loc[file_name] # skipping all nan - - image_id +=1 - category_id = 0 - kpts = data.to_numpy().reshape(-1,2) - keypoints = np.zeros((len(kpts),3)) - - keypoints[:,:2] = kpts - - is_visible = ~pd.isnull(kpts).all(axis=1) - - keypoints[:, 2] = np.where(is_visible, 2, 0) - - num_keypoints = is_visible.sum() - - bbox_margin = 20 - - xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( - [keypoints], slack=bbox_margin, - # clip=True, - )[0][:4] - - w = xmax - xmin - h = ymax - ymin - area = w * h - bbox = np.nan_to_num([xmin, ymin, w, h]) - keypoints = np.nan_to_num(keypoints.flatten()) - - - annotation_id += 1 - + if np.isnan(data.to_numpy()).all(): + continue + + image_id+=1 + + for individual_id, individual in enumerate(individuals): + category_id = 0 + try: + kpts = data.xs(individual, level='individuals').to_numpy().reshape((-1, 2)) + except: + # somehow there are duplicates. So only use the first occurence + data = data.iloc[0] + kpts = data.xs(individual, level='individuals').to_numpy().reshape((-1, 2)) + + keypoints = np.zeros((len(kpts),3)) + + keypoints[:,:2] = kpts + + is_visible = ~pd.isnull(kpts).all(axis=1) + + keypoints[:, 2] = np.where(is_visible, 2, 0) + + num_keypoints = is_visible.sum() + + bbox_margin = 20 + + xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( + [keypoints], slack=bbox_margin, + )[0][:4] + + w = xmax - xmin + h = ymax - ymin + area = w * h + bbox = np.nan_to_num([xmin, ymin, w, h]) + keypoints = np.nan_to_num(keypoints.flatten()) + + annotation_id += 1 + annotation = { + "image_id": image_id + image_id_offset, + "num_keypoints": num_keypoints, + "keypoints": keypoints, + "id": annotation_id, + "category_id": category_id, + "area": area, + "bbox": bbox, + "iscrowd": 0, + } + + # adds an annotaion even if no keypoint is annotated for the current individual + # This is not standard for COCO but is useful because each image will then have + # the same number of annotations (i.e possible to train with batches without overcomplicating the code) + coco_annotations.append(annotation) # I think width and height are important @@ -76,35 +126,21 @@ def df2generic(proj_root, df, image_id_offset=0): else: image_path = os.path.join(proj_root, file_name) - annotation = { - "file_name" : image_path, - "image_id": image_id + image_id_offset, - "num_keypoints": num_keypoints, - "keypoints": keypoints, - "id": annotation_id, - "category_id": category_id, - "area": area, - "bbox": bbox, - "iscrowd": 0, - } - - coco_annotations.append(annotation) - - + _, height, width = read_image_shape_fast(image_path) - - + + image = {'file_name' : image_path, - "width": width, - "height": height, - 'id': image_id + image_id_offset - } + "width": width, + "height": height, + 'id': image_id + image_id_offset + } coco_images.append(image) ret_obj = {'images': coco_images, - 'annotations': coco_annotations, - 'categories': coco_categories, - } + 'annotations': coco_annotations, + 'categories': coco_categories, + } return ret_obj From 9cdfca7bc4fb1ac3b214833cd8cd7207e6559026 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Mon, 5 Jun 2023 11:21:55 +0200 Subject: [PATCH 017/293] fix issue if step is none and improved logging --- .../pose_estimation_pytorch/solvers/base.py | 53 +++++++++++++------ .../pose_estimation_pytorch/solvers/logger.py | 8 ++- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index de5b545779..2b9c81c653 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -4,13 +4,13 @@ import numpy as np import torch -from tqdm import tqdm from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset -from .utils import * from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from .utils import * + class Solver(ABC): @@ -81,36 +81,53 @@ def fit( shuffle, model_prefix, train_loader.dataset.cfg) - # for i in tqdm(range(epochs)): + + save_epochs = 30 # TODO: read this value from config file for i in range(epochs): - train_loss = self.epoch(train_loader, mode='train') + train_loss = self.epoch(train_loader, mode='train', step=i + 1) if self.scheduler: self.scheduler.step() - print(f'Training for epoch {i+1} is done, started evaluating on validation data') - valid_loss = self.epoch(valid_loader, mode='eval') - save_path = f'{model_folder}/train/snapshot-{i+1}.pt' - if (i+1)%30 == 0: - torch.save(self.model.state_dict(), save_path) - print(f'Epoch {i + 1}/{epochs}, ' - f'train loss {train_loss}, ' - f'valid loss {valid_loss}') + print(f'Training for epoch {i + 1} done, starting eval on validation data') + valid_loss = self.epoch(valid_loader, mode='eval', step=i + 1) + + if (i + 1) % save_epochs == 0: + print(f"Finished epoch {i + 1}; saving model") + torch.save( + self.model.state_dict(), + f"{model_folder}/train/snapshot-{i + 1}.pt", + ) + + print( + f'Epoch {i + 1}/{epochs}, ' + f'train loss {train_loss}, ' + f'valid loss {valid_loss}' + ) + + if epochs % save_epochs != 0: + print(f"Finished epoch {epochs}; saving model") + torch.save( + self.model.state_dict(), + f"{model_folder}/train/snapshot-{epochs}.pt", + ) def epoch(self, loader: torch.utils.data.DataLoader, - mode: str = 'train') -> np.array: + mode: str = 'train', + step: Optional[int] = None) -> np.array: """ Parameters ---------- loader: Data loader, which is an iterator over instances. Each batch contains image tensor and heat maps tensor input samples. - mode: + mode: "train" or "eval" + step: the global step in processing, used to log metrics. Returns ------- epoch_loss: Average of the loss over the batches. """ if mode not in ['train', 'eval']: - raise ValueError(f'Solver must be in train or eval mode, but {mode} was found.') + raise ValueError(f'Solver mode must be train or eval, found mode={mode}.') to_mode = getattr(self.model, mode) to_mode() epoch_loss = [] @@ -134,7 +151,11 @@ def epoch(self, if self.logger: for key in metrics.keys(): - self.logger.log(f'{mode} {key}', np.nanmean(metrics[key])) + self.logger.log( + f'{mode} {key}', + np.nanmean(metrics[key]), + step=step, + ) return epoch_loss diff --git a/deeplabcut/pose_estimation_pytorch/solvers/logger.py b/deeplabcut/pose_estimation_pytorch/solvers/logger.py index f42b81e5e0..dde8083afd 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/logger.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/logger.py @@ -1,3 +1,5 @@ +from typing import Optional + import wandb as wb from deeplabcut.pose_estimation_pytorch.models.model import PoseModel @@ -34,7 +36,8 @@ def __init__(self, def log(self, key: str = None, - value: str = None) -> None: + value: str = None, + step: Optional[int] = None) -> None: """ Use this method to log data from runs, such as scalars, images, video, histograms, plots, and tables. @@ -42,10 +45,11 @@ def log(self, ---------- key: name of the logged value value: data to log + step: the global step in processing """ if key is None or value is None: raise ValueError(f'Nothing to log. Key: {key} and value: {value} expected to be scalar, table or image.') - self.run.log({key: value}) + self.run.log({key: value}, step=step) def save(self): self.run.save(self.run.run.dir) From c8b247b60f40d3d7b345ea0329aaab73e9cc68f0 Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Tue, 6 Jun 2023 10:48:54 +0200 Subject: [PATCH 018/293] implement inference and evaluation --- .../pose_estimation_pytorch/apis/inference.py | 59 ++++++++++--- .../pose_estimation_pytorch/data/dataset.py | 2 + .../models/criterion.py | 20 ++++- .../post_processing/__init__.py | 1 + .../match_predictions_to_gt.py | 87 +++++++++++++++++++ .../solvers/inference.py | 86 +++++++++++++++++- .../pose_estimation_pytorch/solvers/utils.py | 30 +++++-- 7 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/post_processing/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 590fa32a71..d51af8b6bf 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -1,13 +1,17 @@ import argparse import deeplabcut.pose_estimation_pytorch as dlc import numpy as np +import pandas as pd import os import torch from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model +from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction, get_scores from deeplabcut.pose_estimation_pytorch.solvers.utils import get_paths, get_results_filename, save_predictions from deeplabcut.pose_estimation_tensorflow import Plotting +from deeplabcut.pose_estimation_pytorch.post_processing import rmse_match_prediction_to_gt, oks_match_prediction_to_gt +from deeplabcut.utils.visualization import make_labeled_images_from_dataframe from typing import Union @@ -15,7 +19,7 @@ def inference_network( config_path: str, shuffle: int = 0, model_prefix: str = "", - load_epoch: Union[int, str] = 49, + load_epoch: Union[int, str] = -1, stride: int = 8, transform: object = None, plot: bool = False, @@ -29,8 +33,9 @@ def inference_network( train_fraction[0], shuffle, cfg, modelprefix=model_prefix, ), ) + individuals = cfg.get('individuals', ['single']) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - config = auxiliaryfunctions.read_config(pytorch_config_path) + config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) batch_size = config['batch_size'] project = dlc.DLCProject(shuffle=shuffle, @@ -42,6 +47,7 @@ def inference_network( valid_dataloader = torch.utils.data.DataLoader(valid_dataset, batch_size=batch_size, shuffle=False) + names = get_paths(train_fraction=train_fraction[0], model_prefix=model_prefix, shuffle=shuffle, @@ -53,30 +59,61 @@ def inference_network( names['dlc_scorer_legacy'], names['model_path'][:-3]) - pose_cfg = auxiliaryfunctions.read_config(config['pose_cfg_path']) + pose_cfg = auxiliaryfunctions.read_plainconfig(config['pose_cfg_path']) model = build_pose_model(config['model'], pose_cfg) model.load_state_dict(torch.load(names['model_path'])) + predictor = PREDICTORS.build(dict(config['predictor'])) + # You need to dropna() here because on some frames no keypoint is annotated # Thus the target_df (contains NaNs) may not match the valid_dataloader (has dropped them) target_df = valid_dataset.dataframe.dropna(axis = 0, how = "all") predicted_poses = [] model.eval() + model.to('cuda:3') with torch.no_grad(): for item in valid_dataloader: - if isinstance(item, tuple) or (isinstance, list): - item = item[0] - output = model(item) - scale_factor = (item.shape[2]/output[0].shape[2] , item.shape[3]/output[0].shape[3]) - predictions = get_prediction(pose_cfg, output, scale_factor) + item['image'] = item['image'].to('cuda:3') + output = model(item['image']) + shape_image = item['image'].shape + scale_factor = (shape_image[2]/output[0].shape[2] , shape_image[3]/output[0].shape[3]) + predictions = predictor(output, scale_factor).cpu().numpy() + + # Matching predictions to ground truth individuals in order to compute rmse and save as dataframe + if len(individuals) > 1: + for b in range(predictions.shape[0]): + match_individuals = oks_match_prediction_to_gt( + predictions[b], + item['annotations']['keypoints'][b].cpu().numpy(), + individuals + ) + predictions[b] = predictions[b][match_individuals] + + #converts back to original image size if image was resized during the augmentation pipeline + for b in range(predictions.shape[0]): + resizing_factor = (item['original_size'][0]/shape_image[2]).item(), (item['original_size'][1]/shape_image[3]).item() + predictions[b, :, :, 0] = predictions[b, :, :, 0]*resizing_factor[1] + resizing_factor[1]/2 + predictions[b, :, :, 1] = predictions[b, :, :, 1]*resizing_factor[0] + resizing_factor[0]/2 predicted_poses.append(predictions) + predicted_poses = np.array(predicted_poses) predicted_df = save_predictions(names, - pose_cfg, + cfg, target_df.index, predicted_poses.reshape(target_df.index.shape[0], -1), results_filename) + + # Convert dataframe to 'multianimal' format in any case, allows for similar post_processing + try: + predicted_df.columns.get_level_values('individuals').unique().tolist() + except KeyError: + new_cols = pd.MultiIndex.from_tuples( + [(col[0], 'single', col[1], col[2]) for col in predicted_df.columns], + names=['scorer', 'individuals', 'bodyparts', 'coords'] + ) + predicted_df.columns = new_cols + if plot: foldername = f'{names["evaluation_folder"]}/LabeledImages_{names["dlc_scorer"]}-{load_epoch}' auxiliaryfunctions.attempttomakefolder(foldername) @@ -90,10 +127,10 @@ def inference_network( combined_df, foldername) if evaluate: - rmse, rmes_p = get_scores(pose_cfg, + scores = get_scores(pose_cfg, predicted_df, target_df) - print(f'RMSE: {rmse}, RMSE pcutoff: {rmes_p}') + print(scores) if __name__ == '__main__': diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index e6eef05171..e0bc06ff67 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -108,6 +108,7 @@ def __getitem__(self, print(len(self.project.images)) print(index) image = cv2.imread(image_file) + original_size = image.shape # load annotations image_id = self.project.image_path2image_id[image_file] @@ -175,6 +176,7 @@ def __getitem__(self, res = {} res['image'] = image + res['original_size'] = original_size # In order to convert back the keypoints to their original space res['annotations'] = {} res['annotations']['keypoints'] = keypoints res['annotations']['area'] = area diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index d5e42d1b74..4f90dea3c4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -36,12 +36,27 @@ def __call__(self, prediction, target, weights = 1): return torch.tensor(0.) return torch.mean(loss_without_zeros) +class WeightedBCELoss(nn.BCEWithLogitsLoss): + + def __init__(self): + super(WeightedBCELoss, self).__init__() + self.BCELoss = nn.BCEWithLogitsLoss(reduction='none') + + def __call__(self, prediction, target, weights=1): + loss_item = self.BCELoss(prediction, target) + loss_item_weighted = loss_item*weights + + loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] + if loss_without_zeros.nelement() == 0: + return torch.tensor(0.) + return torch.mean(loss_without_zeros) + @LOSSES.register_module class PoseLoss(nn.Module): def __init__(self, loss_weight_locref: float = 0.1, locref_huber_loss: bool = False, - apply_sigmoid: bool= True): + apply_sigmoid: bool= False): """ Parameters @@ -52,6 +67,7 @@ def __init__(self, locref_huber_loss: bool If `True` uses torch.nn.HuberLoss for locref (default is False) + apply_sigmoid : wether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) """ super(PoseLoss, self).__init__() @@ -60,7 +76,7 @@ def __init__(self, else: self.locref_criterion = WeightedMSELoss() self.loss_weight_locref = loss_weight_locref - self.heatmap_criterion = WeightedMSELoss() + self.heatmap_criterion = WeightedBCELoss() self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py new file mode 100644 index 0000000000..dfceeefec3 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py @@ -0,0 +1 @@ +from deeplabcut.pose_estimation_pytorch.post_processing.match_predictions_to_gt import rmse_match_prediction_to_gt, oks_match_prediction_to_gt \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py new file mode 100644 index 0000000000..55a0d0faeb --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -0,0 +1,87 @@ +import numpy as np +from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import calc_object_keypoint_similarity +from scipy.optimize import linear_sum_assignment + +def rmse_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individual_names: list): + ''' + Hungarian algorithm predicted individuals to ground truth ones, using rmse + + Arguments + --------- + pred_kpts: (num_animals, num_keypoints, 3) + gt_kpts: (num_animals, num_keypoints(+1 if with center), 2) + individual_names: names of individuals + + Output + ------ + row_ind: array of the individuals indexes for prediction + ''' + + num_animals, num_keypoints, _ = pred_kpts.shape + if num_keypoints + 1 == gt_kpts.shape[1]: + gt_kpts_without_ctr = gt_kpts[:, :-1, :].copy() + elif num_keypoints == gt_kpts.shape[1]: + gt_kpts_without_ctr = gt_kpts.copy() + else: + raise ValueError('Shape mismatch between ground truth and predictions') + + # Computation of the number of annotated animals in the ground truth + num_animals_gt = num_animals + for animal_index in range(num_animals): + if (gt_kpts_without_ctr[animal_index] < 0).all(): + num_animals_gt -= 1 + + distance_matrix = np.zeros((num_animals_gt, num_animals)) + for g in range(num_animals_gt): + for p in range(num_animals): + distance_matrix[g, p] = np.linalg.norm(gt_kpts_without_ctr[g] - pred_kpts[p, :, :2]) + + row_ind, col_ind = linear_sum_assignment(distance_matrix) + + return col_ind + + +def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individual_names: list): + ''' + Hungarian algorithm predicted individuals to ground truth ones, using oks + + Arguments + --------- + pred_kpts: (num_animals, num_keypoints, 3) + gt_kpts: (num_animals, num_keypoints(+1 if with center), 2) + individual_names: names of individuals + + Output + ------ + row_ind: array of the individuals indexes for prediction + ''' + + num_animals, num_keypoints, _ = pred_kpts.shape + if num_keypoints + 1 == gt_kpts.shape[1]: + gt_kpts_without_ctr = gt_kpts[:, :-1, :].copy() + elif num_keypoints == gt_kpts.shape[1]: + gt_kpts_without_ctr = gt_kpts.copy() + else: + raise ValueError('Shape mismatch between ground truth and predictions') + + # Computation of the number of annotated animals in the ground truth + num_animals_gt = num_animals + for animal_index in range(num_animals): + if (gt_kpts_without_ctr[animal_index] < 0).all(): + num_animals_gt -= 1 + + oks_matrix = np.zeros((num_animals_gt, num_animals)) + gt_kpts_without_ctr[gt_kpts_without_ctr < 0] = np.nan # non visible keypoints should be nan to use calc_oks + for g in range(num_animals_gt): + for p in range(num_animals): + oks_matrix[g, p] = calc_object_keypoint_similarity( + pred_kpts[p, :, :2], + gt_kpts_without_ctr[g], + 0.1, + margin=0, + symmetric_kpts=None #TODO take into account symmetric keypoints + ) + + row_ind, col_ind = linear_sum_assignment(oks_matrix, maximize=True) + + return col_ind \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index 504fb753f0..7551235061 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -2,6 +2,7 @@ import pandas as pd import torch from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps +from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly, evaluate_assembly from torch import nn from typing import List @@ -77,11 +78,21 @@ def get_scores(cfg, pcutoff = cfg['pcutoff'] rmse, rmse_p = get_rmse(prediction, target, pcutoff, bodyparts = bodyparts) + oks, oks_pcutoff = get_oks(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) else: rmse, rmse_p = get_rmse(prediction, target, bodyparts = bodyparts) + scores = get_oks(prediction, target, bodyparts=bodyparts) - return np.nanmean(rmse), np.nanmean(rmse_p) + scores = {} + scores['rmse'] = np.nanmean(rmse) + scores['rmse_pcutoff'] = np.nanmean(rmse_p) + scores['mAP'] = oks['mAP'] + scores['mAR'] = oks['mAR'] + scores['mAP_pcutoff'] = oks_pcutoff['mAP'] + scores['mAR_pcutoff'] = oks_pcutoff['mAR'] + + return scores def get_rmse(prediction, @@ -90,13 +101,80 @@ def get_rmse(prediction, bodyparts: List[str] =None): scorer_pred = prediction.columns[0][0] scorer_target = target.columns[0][0] - mask = prediction[scorer_pred].xs("likelihood", level=1, axis=1) >= pcutoff + mask = prediction[scorer_pred].xs("likelihood", level=2, axis=1) >= pcutoff if bodyparts: diff = (target[scorer_target][bodyparts] - prediction[scorer_pred][bodyparts]) ** 2 else: diff = (target[scorer_target] - prediction[scorer_pred]) ** 2 - mse = diff.xs("x", level=1, axis=1) + diff.xs("y", level=1, axis=1) + mse = diff.xs("x", level=2, axis=1) + diff.xs("y", level=2, axis=1) rmse = np.sqrt(mse) rmse_p = np.sqrt(mse[mask]) - return rmse, rmse_p \ No newline at end of file + return rmse, rmse_p + +def get_oks(prediction: pd.DataFrame, + target: pd.DataFrame, + oks_sigma=0.1, + margin=0, + symmetric_kpts=None, + pcutoff: float=-1, + bodyparts: List[str] =None): + + scorer_pred = prediction.columns[0][0] + scorer_target = target.columns[0][0] + + if bodyparts != None: + idx_slice = pd.IndexSlice[:, :, bodyparts, :] + prediction = prediction.loc[:, idx_slice] + target = target.loc[:, idx_slice] + mask = prediction[scorer_pred].xs("likelihood", level=2, axis=1) >= pcutoff + + # Convert predicitons to DLC assemblies + assemblies_pred_raw = conv_df_to_assemblies(prediction[scorer_pred]) + assemblies_gt_raw = conv_df_to_assemblies(target[scorer_target]) + + assemblies_pred_masked = conv_df_to_assemblies(prediction[scorer_pred][mask]) + assemblies_gt_masked = conv_df_to_assemblies(target[scorer_target][mask]) + + oks_raw = evaluate_assembly( + assemblies_pred_raw, + assemblies_gt_raw, + oks_sigma, + margin=margin, + symmetric_kpts=symmetric_kpts + ) + + oks_pcutoff = evaluate_assembly( + assemblies_pred_masked, + assemblies_gt_masked, + oks_sigma, + margin=margin, + symmetric_kpts=symmetric_kpts + ) + + return oks_raw, oks_pcutoff + + +def conv_df_to_assemblies(df: pd.DataFrame): + ''' + Convert a dataframe to an assemblies dictionnary + + Arguments : + df : dataframe of coordinates/predictions, df is expected to have a multi_index of shape (num_animals, num_keypoints, 2 or 3) + ''' + assemblies = {} + + num_animals=len(df.columns.get_level_values(0).unique()) + num_kpts=len(df.columns.get_level_values(1).unique()) + for image_path in df.index: + row = df.loc[image_path].to_numpy() + row = row.reshape(num_animals, num_kpts, -1) + + kpt_lst = [] + for i in range(num_animals): + ass = Assembly.from_array(row[i]) + if len(ass): + kpt_lst.append(ass) + + assemblies[image_path] = kpt_lst + return assemblies \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index 65f35e04c4..130a926ba7 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -84,14 +84,28 @@ def save_predictions(names, cfg, data_index, os.makedirs(names['evaluation_folder']) results_path = f'{results_filename}' - index = pd.MultiIndex.from_product( - [ - [names['dlc_scorer']], - cfg["all_joints_names"], - ["x", "y", "likelihood"], - ], - names=["scorer", "bodyparts", "coords"], - ) + num_animals = len(cfg.get('individuals', ['single'])) + if num_animals == 1: + # Single animal prediction deataframe + index = pd.MultiIndex.from_product( + [ + [names['dlc_scorer']], + cfg["bodyparts"], + ["x", "y", "likelihood"], + ], + names=["scorer", "bodyparts", "coords"], + ) + else: + # Multi animal prediction dataframe + index = pd.MultiIndex.from_product( + [ + [names['dlc_scorer']], + cfg['individuals'], + cfg["bodyparts"], + ["x", "y", "likelihood"], + ], + names=["scorer", "individuals", "bodyparts", "coords"], + ) predicted_data = pd.DataFrame( predicted_poses, columns=index, index=data_index From 249cd99555c9e92809d22e672a976e6254eb4693 Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Fri, 9 Jun 2023 13:15:02 +0200 Subject: [PATCH 019/293] Merge branch 'quentin_pytorch_config_merge' into quentin_dekr --- .../make_pytorch_config.py | 150 ++++++++++++++++++ ...ple_individuals_trainingsetmanipulation.py | 20 ++- .../trainingsetmanipulation.py | 6 +- .../pose_estimation_pytorch/apis/config.yaml | 101 ++++++++---- .../pose_estimation_pytorch/apis/train.py | 19 ++- .../pose_estimation_pytorch/apis/utils.py | 136 +++++++++++++--- .../pose_estimation_pytorch/data/dataset.py | 4 +- .../pose_estimation_pytorch/default_config.py | 89 +++++++++++ .../models/predictors/dekr_predictor.py | 5 + .../models/predictors/single_predictor.py | 6 + .../models/target_generators/dekr_targets.py | 4 +- .../pose_estimation_pytorch/solvers/base.py | 2 +- 12 files changed, 473 insertions(+), 69 deletions(-) create mode 100644 deeplabcut/generate_training_dataset/make_pytorch_config.py create mode 100644 deeplabcut/pose_estimation_pytorch/default_config.py diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py new file mode 100644 index 0000000000..fe30fbab98 --- /dev/null +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -0,0 +1,150 @@ +import yaml + +BACKBONE_OUT_CHANNELS = { + 'resnet-50': 2048, + 'resnet-50': 2048, + 'resnet-50': 2048, + 'mobilenet_v2_1.0': 1280, + 'mobilenet_v2_0.75': 1280, + 'mobilenet_v2_0.5': 1280, + 'mobilenet_v2_0.35': 1280, + 'efficientnet-b0': 1280, + 'efficientnet-b1': 1280, + 'efficientnet-b2': 1408, + 'efficientnet-b3': 1536, + 'efficientnet-b4': 1792, + 'efficientnet-b5': 2048, + 'efficientnet-b6': 2304, + 'efficientnet-b7': 2560, + 'efficientnet-b8': 2816, + 'hrnet_w18': 270, + 'hrnet_w32': 480, + 'hrnet_w48': 720, +} + + +def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str = 'default', config_template: dict=None): + ''' + Currently supported net types : + Single Animal : + - resnet-50 + - mobilenet_v2_1.0 + - mobilenet_v2_0.75 + - mobilenet_v2_0.5 + - mobilenet_v2_0.35 + - efficientnet-b0 + - efficientnet-b1 + - efficientnet-b2 + - efficientnet-b3 + - efficientnet-b4 + - efficientnet-b5 + - efficientnet-b6 + - efficientnet-b7 + - efficientnet-b8 + - hrnet_w18 + - hrnet_w32 + - hrnet_w48 + + Multi Animal: + - dekr_w18 + - dekr_w32 + - dekr_w48 + + ''' + + single_animal_nets = ['resnet_50' + , 'mobilenet_v2_1.0' + , 'mobilenet_v2_0.75' + , 'mobilenet_v2_0.5' + , 'mobilenet_v2_0.35' + , 'efficientnet-b0' + , 'efficientnet-b1' + , 'efficientnet-b2' + , 'efficientnet-b3' + , 'efficientnet-b4' + , 'efficientnet-b5' + , 'efficientnet-b6' + , 'efficientnet-b7' + , 'efficientnet-b8' + , 'hrnet_w18' + , 'hrnet_w32' + , 'hrnet_w48'] + + multi_animal_nets = ['dekr_w18' + , 'dekr_w32' + , 'dekr_w48'] + + pytorch_config = config_template + if net_type in single_animal_nets: + num_joints = len(project_config['bodyparts']) + pytorch_config['model']['heatmap_head']['channels'][-1] = num_joints + pytorch_config['model']['locref_head']['channels'][-1] = 2*num_joints + pytorch_config['model']['target_generator']['num_joints'] = num_joints + pytorch_config['predictor']['num_animals'] = 1 + + if 'efficientnet' in net_type: + raise NotImplementedError('efficientnet config not yet implemented') + elif 'mobilenetv2' in net_type: + raise NotImplementedError('mobilenet config not yet implemented') + elif 'hrnet' in net_type: + raise NotImplementedError('hrnet config not yet implemented') + + + elif net_type in multi_animal_nets: + num_joints = len(project_config['bodyparts']) + num_animals = len(project_config.get('individuals', [0])) + if 'dekr' in net_type: + version = net_type.split('_')[-1] + backbone_type = 'hrnet_' + version + num_offset_per_kpt = 15 + pytorch_config['model']['backbone'] = { + 'type': 'HRNet', + 'model_name': 'hrnet_' + version + } + pytorch_config['model']['heatmap_head']= { + 'type': 'HeatmapDEKRHead', + 'channels': [ + BACKBONE_OUT_CHANNELS[backbone_type], + 64, + num_joints + 1 + ], # +1 since we need center + 'num_blocks': 1, + 'dilation_rate': 1, + 'final_conv_kernel': 1, + } + pytorch_config['model']['locref_head']= { + 'type': 'OffsetDEKRHead', + 'channels': [ + BACKBONE_OUT_CHANNELS[backbone_type], + num_offset_per_kpt*num_joints, + num_joints + ], + 'num_offset_per_kpt' : num_offset_per_kpt, + 'num_blocks': 1, + 'dilation_rate': 1, + 'final_conv_kernel': 1 + } + pytorch_config['model']['target_generator']= { + 'type': 'DEKRGenerator', + 'num_joints': num_joints, + 'pos_dist_thresh': 17, + } + + pytorch_config['predictor']= { + 'type': 'DEKRPredictor', + 'num_animals': num_animals, + } + + pytorch_config['with_center'] = True + else: + raise NotImplementedError('Currently no other model than dekr are implemented') + + else: + raise ValueError('This net type is not supported by pytorch verison') + + if augmenter_type == None: + pytorch_config['data'] = {} + elif augmenter_type != 'default' and augmenter_type != None: + raise NotImplementedError('Other augmentations than default are not implemented') + + return pytorch_config \ No newline at end of file diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index 196eb216d9..238652e645 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -27,6 +27,7 @@ MakeTest_pose_yaml, MakeInference_yaml, pad_train_test_indices, + make_pytorch_config, ) from deeplabcut.utils import ( auxiliaryfunctions, @@ -209,7 +210,7 @@ def create_multianimaltraining_dataset( if net_type is None: # loading & linking pretrained models net_type = cfg.get("default_net_type", "dlcrnet_ms5") - elif not any(net in net_type for net in ("resnet", "eff", "dlc", "mob")): + elif not any(net in net_type for net in ("resnet", "eff", "dlc", "mob", 'dekr')): raise ValueError(f"Unsupported network {net_type}.") multi_stage = False @@ -478,6 +479,23 @@ def create_multianimaltraining_dataset( items2change, path_inference_config, defaultinference_configfile ) + # Populate the pytorch config yaml file + pytorch_config_path = os.path.join( + dlcparent_path, + "pose_estimation_pytorch", + "apis", + "pytorch_config.yaml", + ) + pytorch_cfg_template = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + pytorch_cfg = make_pytorch_config(cfg, net_type, config_template=pytorch_cfg_template) + pytorch_cfg["project_path"] = os.path.dirname(config) + pytorch_cfg["pose_cfg_path"] = path_train_config + pytorch_cfg["cfg_path"] = config + auxiliaryfunctions.write_plainconfig( + path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml"), + pytorch_cfg, + ) + print( "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!" ) diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 1551609286..3b555b797f 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -24,6 +24,7 @@ import yaml from deeplabcut.pose_estimation_tensorflow import training +from deeplabcut.generate_training_dataset.make_pytorch_config import make_pytorch_config from deeplabcut.utils import ( auxiliaryfunctions, conversioncode, @@ -1126,8 +1127,9 @@ def create_training_dataset( "apis", "pytorch_config.yaml", ) - pytorch_cfg = auxiliaryfunctions.read_plainconfig(pytorch_config_path) - pytorch_cfg["project_root"] = os.path.dirname(config) + pytorch_cfg_template = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + pytorch_cfg = make_pytorch_config(cfg, net_type, config_template=pytorch_cfg_template) + pytorch_cfg["project_path"] = os.path.dirname(config) pytorch_cfg["pose_cfg_path"] = path_train_config pytorch_cfg["cfg_path"] = config auxiliaryfunctions.write_plainconfig( diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml index 3e29a6a7d7..b29463e293 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -1,44 +1,79 @@ -project_root: willbeautomaticallyupdatedbycreate_training_datasetcode -pose_cfg_path: willbeautomaticallyupdatedbycreate_training_datasetcode -cfg_path: willbeautomaticallyupdatedbycreate_training_datasetcode - -seed: 42 -device: 'cuda:0' +batch_size: 1 +cfg_path: /data/quentin/datasets/daniel3mouse/config.yaml +criterion: + locref_huber_loss: true + loss_weight_locref: 0.02 + type: PoseLoss +data: + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true + rotation: 30 + scale_jitter: + - 0.5 + - 1.25 + translation: 40 +device: cuda:0 +display_iters: 1000 +epochs: 1000 model: backbone: - type: 'ResNet' - pretrained: 'https://download.pytorch.org/models/resnet50-19c8e357.pth' + pretrained: https://download.pytorch.org/models/resnet50-19c8e357.pth + type: ResNet heatmap_head: - type: 'SimpleHead' - channels: [ 2048, 1024, 4 ] - kernel_size: [ 2, 2 ] - strides: [ 2, 2 ] + channels: + - 2048 + - 1024 + - -1 + kernel_size: + - 2 + - 2 + strides: + - 2 + - 2 + type: SimpleHead locref_head: - type: 'SimpleHead' - channels: [ 2048, 1024, 8 ] - kernel_size: [ 2, 2 ] - strides: [ 2, 2 ] + channels: + - 2048 + - 1024 + - -1 + kernel_size: + - 2 + - 2 + strides: + - 2 + - 2 + type: SimpleHead pose_model: stride: 8 - heatmap_type: 'gaussian' + target_generator: + locref_stdev: 7.2801 + num_joints: -1 + pos_dist_thresh: 17 + type: PlateauGenerator optimizer: - type: 'Adam' params: - lr: 0.0001 + lr: 0.01 + type: SGD +pos_dist_thresh: 17 +predictor: + location_refinement: true + locref_stdev: 7.2801 + num_animals: -1 + type: SinglePredictor +save_epochs: 50 scheduler: - type: "StepLR" params: - step_size: 30 - gamma: 0.1 -criterion: - type: 'PoseLoss' - loss_weight_locref: 0.1 - locref_huber_loss: False -#logger: -# type: 'WandbLogger' -# project_name: 'deeplabcut' -# run_name: 'tmp' + lr_list: + - - 0.05 + - - 0.005 + milestones: + - 10 + - 430 + type: LRListScheduler +seed: 42 solver: - type: 'BottomUpSingleAnimalSolver' -batch_size: 1 -epochs: 1 + type: BottomUpSingleAnimalSolver +with_center: false diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 71802a01ea..cf5573821b 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -2,9 +2,10 @@ import deeplabcut.pose_estimation_pytorch as dlc import os from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver +from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver, build_transforms from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS from torch.utils.data import DataLoader +import albumentations as A from typing import Union @@ -26,14 +27,16 @@ def train_network( ), ) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - config = auxiliaryfunctions.read_config(pytorch_config_path) + pytorch_config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) - batch_size = config['batch_size'] - epochs = config['epochs'] - dlc.fix_seeds(config['seed']) - project_train = dlc.DLCProject(proj_root=config['project_root'], shuffle=shuffle) - project_valid = dlc.DLCProject(proj_root=config['project_root'], shuffle=shuffle) + transform = build_transforms(dict(pytorch_config['data'])) + batch_size = pytorch_config['batch_size'] + epochs = pytorch_config['epochs'] + + dlc.fix_seeds(pytorch_config['seed']) + project_train = dlc.DLCProject(proj_root=pytorch_config['project_path'], shuffle=shuffle) + project_valid = dlc.DLCProject(proj_root=pytorch_config['project_path'], shuffle=shuffle) train_dataset = dlc.PoseDataset(project_train, transform=transform, mode='train') @@ -49,7 +52,7 @@ def train_network( batch_size=batch_size, shuffle=False) - solver = build_solver(config) + solver = build_solver(pytorch_config) solver.fit( train_dataloader, valid_dataloader, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 2004211f4e..5865a33c71 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -1,6 +1,9 @@ from typing import Dict +import os +import yaml import torch +import albumentations as A from deeplabcut.pose_estimation_pytorch.models import PoseModel, BACKBONES, HEADS, LOSSES from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SINGLE_ANIMAL_SOLVER @@ -8,11 +11,11 @@ from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS from deeplabcut.pose_estimation_pytorch.solvers.schedulers import LRListScheduler from deeplabcut.pose_estimation_pytorch.solvers.base import Solver -from deeplabcut.utils import auxiliaryfunctions +# from deeplabcut.pose_estimation_pytorch.default_config import pytorch_cfg_template +# from deeplabcut.utils import auxiliaryfunctions -def build_pose_model(cfg: Dict, - pose_cfg: Dict): +def build_pose_model(cfg: Dict, pytorch_cfg): backbone = BACKBONES.build(dict(cfg['backbone'])) head_heatmaps = HEADS.build(dict(cfg['heatmap_head'])) head_locref = HEADS.build(dict(cfg['locref_head'])) @@ -21,7 +24,7 @@ def build_pose_model(cfg: Dict, neck = None else: neck = None - pose_model = PoseModel(cfg=pose_cfg, + pose_model = PoseModel(cfg=pytorch_cfg, backbone=backbone, head_heatmaps=head_heatmaps, head_locref=head_locref, @@ -32,41 +35,134 @@ def build_pose_model(cfg: Dict, return pose_model -def build_solver(cfg: Dict) -> Solver: - pose_cfg = auxiliaryfunctions.read_config(cfg['pose_cfg_path']) - pose_model = build_pose_model(cfg['model'], pose_cfg) +def build_solver(pytorch_cfg: Dict) -> Solver: + # pose_cfg = auxiliaryfunctions.read_plainconfig(cfg['pose_cfg_path']) + pose_model = build_pose_model(pytorch_cfg['model'], pytorch_cfg) - get_optimizer = getattr(torch.optim, cfg['optimizer']['type']) - optimizer = get_optimizer(params=pose_model.parameters(), **cfg['optimizer']['params']) + get_optimizer = getattr(torch.optim, pytorch_cfg['optimizer']['type']) + optimizer = get_optimizer(params=pose_model.parameters(), **pytorch_cfg['optimizer']['params']) - criterion = LOSSES.build(cfg['criterion']) + criterion = LOSSES.build(pytorch_cfg['criterion']) - predictor = PREDICTORS.build(dict(cfg['predictor'])) + predictor = PREDICTORS.build(dict(pytorch_cfg['predictor'])) - if cfg.get('scheduler'): - if cfg['scheduler']['type'] == "LRListScheduler": + if pytorch_cfg.get('scheduler'): + if pytorch_cfg['scheduler']['type'] == "LRListScheduler": _scheduler = LRListScheduler else: _scheduler = getattr(torch.optim.lr_scheduler, - cfg['scheduler']['type']) + pytorch_cfg['scheduler']['type']) scheduler = _scheduler(optimizer=optimizer, - **cfg['scheduler']['params']) + **pytorch_cfg['scheduler']['params']) else: scheduler = None - if cfg.get('logger'): - logger = LOGGER.build(dict(**cfg['logger'], + if pytorch_cfg.get('logger'): + logger = LOGGER.build(dict(**pytorch_cfg['logger'], model=pose_model)) else: logger = None - solver = SINGLE_ANIMAL_SOLVER.build(dict(**cfg['solver'], + solver = SINGLE_ANIMAL_SOLVER.build(dict(**pytorch_cfg['solver'], model=pose_model, criterion=criterion, optimizer=optimizer, predictor=predictor, - cfg=pose_cfg, - device=cfg['device'], + cfg=pytorch_cfg, + device=pytorch_cfg['device'], scheduler=scheduler, logger=logger)) return solver + +def build_transforms(aug_cfg): + transforms = [] + + if aug_cfg.get('resize', False): + input_size = aug_cfg.get('resize', False) + transforms.append( + A.Resize(input_size[0], input_size[1]) + ) + + # TODO code again this augmentation to match the symmetric_pair syntax in orignal dlc + # if aug_cfg.get('flipr', False) and aug_cfg.get('symmetric_pair', False): + # opt = aug_cfg.get("fliplr", False) + # if type(opt) == int: + # p = opt + # else: + # p = 0.5 + # transforms.append( + # CustomHorizontalFlip( + + # symmetric_pairs = aug_cfg['symmetric_pairs'], + # p=p + # ) + # ) + scale_jitter_lo, scale_jitter_up = aug_cfg.get('scale_jitter', (1, 1)) + rotation = aug_cfg.get('rotation', 0) + translation = aug_cfg.get('translation', 0) + transforms.append( + A.Affine( + scale=(scale_jitter_lo, scale_jitter_up), + rotate=(-rotation, rotation), + translate_px=(-translation, translation), + p=0.5, + ) + ) + if aug_cfg.get('hist_eq', False): + transforms.append( + A.Equalize( + p=0.5 + ) + ) + if aug_cfg.get('motion_blur', False): + transforms.append(A.MotionBlur(p=0.5)) + #TODO Coarse dropout can mask a keypoint which messes up the training, implement new augmentation + # if aug_cfg.get('covering', False): + # transforms.append( + # A.CoarseDropout( + # max_holes=10, + # max_height=0.05, + # min_height=0.01, + # max_width=0.05, + # min_width=0.01, + # p=0.5 + # ) + # ) + #TODO implement elastic transform apply_to_keypoints in albumentations + # if aug_cfg.get('elastic_transform', False): + # transforms.append(A.ElasticTransform(sigma=5, p=0.5)) + #TODO implement iia grayscale augmentation with albumentation + # if aug_cfg.get('grayscale', False): + if aug_cfg.get('gaussian_noise', False): + opt = aug_cfg.get('gaussian_noise', False) #std + # TODO inherit custom gaussian transform to support per_channel = 0.5 + if type(opt) == int or type(opt) == float: + transforms.append( + A.GaussNoise( + var_limit= (0, opt**2), + mean= 0, + per_channel= True, # Albumentations doesn't support per_cahnnel = 0.5 + p=0.5, + ) + ) + else: + transforms.append( + A.GaussNoise( + var_limit= (0, (0.05 * 255)**2), + mean= 0, + per_channel= True, + p=0.5, + ) + ) + + if aug_cfg.get('normalize_images'): + transforms.append(A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225])) + return A.Compose( + transforms, + keypoint_params=A.KeypointParams('xy', remove_invisible=False) + ) + +def read_yaml(path): + with open(path) as f: + file = yaml.safe_load(f) + return file diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index e0bc06ff67..dbdaafb538 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -4,7 +4,7 @@ import torch from torch.utils.data import Dataset -from deeplabcut.utils.auxiliaryfunctions import read_config, get_model_folder +from deeplabcut.utils.auxiliaryfunctions import read_plainconfig, get_model_folder from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator from .base import BaseDataset @@ -50,7 +50,7 @@ def transform(image, keypoints): ) ) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - pytorch_cfg = read_config(pytorch_config_path) + pytorch_cfg = read_plainconfig(pytorch_config_path) self.with_center = pytorch_cfg.get('with_center', False) self.max_num_animals = len(self.cfg.get('individuals', ['0'])) diff --git a/deeplabcut/pose_estimation_pytorch/default_config.py b/deeplabcut/pose_estimation_pytorch/default_config.py new file mode 100644 index 0000000000..d7b94d8372 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/default_config.py @@ -0,0 +1,89 @@ +pytorch_cfg_template = {} + +pytorch_cfg_template['cfg_path']='/data/quentin/datasets/daniel3mouse/config.yaml' +pytorch_cfg_template['seed']=42 +pytorch_cfg_template['device']='cuda:0' +pytorch_cfg_template['display_iters']=1000 +pytorch_cfg_template['save_epochs']=50 #not iterations, epochs + +pytorch_cfg_template['data'] = { + 'scale_jitter' : [0.5, 1.25], + 'rotation' : 30, + 'translation' : 40, + 'hist_eq' : True, + 'motion_blur': True, + 'covering': True, + 'gaussian_noise': 0.05*255, + 'normalize_images': True +} + +pytorch_cfg_template['model']= { + 'backbone': { + 'type': 'ResNet', + 'pretrained': 'https://download.pytorch.org/models/resnet50-19c8e357.pth', + }, + 'heatmap_head': { + 'type': 'SimpleHead', + 'channels': [2048, 1024, -1], #-1 acts as undefined here + 'kernel_size': [2, 2], + 'strides': [2, 2], + }, + 'locref_head': { + 'type': 'SimpleHead', + 'channels': [2048, 1024, -1], #-1 acts as undefined here + 'kernel_size': [2, 2], + 'strides': [2, 2], + }, + 'target_generator': { + 'type' : 'PlateauGenerator', + 'locref_stdev': 7.2801, + 'num_joints': -1, + 'pos_dist_thresh': 17, + }, + 'pose_model': { + 'stride': 8, + }, +} + +pytorch_cfg_template['optimizer'] = { + 'type' : 'SGD', + 'params': { + 'lr': 0.01, + } +} + +pytorch_cfg_template['scheduler'] = { + 'type': 'LRListScheduler', + 'params': { + 'milestones': [10, 430], + 'lr_list': [[0.05], [0.005]] + } +} + +pytorch_cfg_template['predictor'] = { + 'type': 'SinglePredictor', + 'num_animals': -1, + 'location_refinement': True, + 'locref_stdev': 7.2801, +} + +pytorch_cfg_template['criterion'] = { + 'type' : 'PoseLoss', + 'loss_weight_locref': 0.02, + 'locref_huber_loss': True, +} + +pytorch_cfg_template['solver'] = { + 'type' : 'BottomUpSingleAnimalSolver' +} + +pytorch_cfg_template['pos_dist_thresh'] = 17 +pytorch_cfg_template['with_center'] = False +pytorch_cfg_template['batch_size'] = 1 +pytorch_cfg_template['epochs'] = 1000 + +if __name__ == '__main__': + import yaml + + with open('pytorch_config.yaml', 'w') as f: + yaml.safe_dump(pytorch_cfg_template, f) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 362c4f447b..989ef8244a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -10,6 +10,11 @@ @PREDICTORS.register_module class DEKRPredictor(BasePredictor): + default_init = { + 'apply_sigmoid' : True, + 'detection_threshold' : 0.01 + } + def __init__(self, num_animals: int, detection_threshold: float=0.01, apply_sigmoid: bool=True, use_heatmap = True): super().__init__() diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index fde0f3a240..6792de696f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -7,6 +7,12 @@ class SinglePredictor(BasePredictor): """Predictor only intended for single animal pose estimation""" + default_init = { + 'location_refinement': True, + 'locref_stdev': 7.2801, + 'apply_sigmoid' : True + } + def __init__(self, num_animals, location_refinement, locref_stdev, apply_sigmoid: bool= True): super().__init__() #TODO add num_animals in pytorch_cfg automatically diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index 39547ba71a..82158dd2b0 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -81,8 +81,8 @@ def forward(self, annotations, prediction, image_size): br = int(np.ceil(x_sm + 3 * sigma + 2) ), int(np.ceil(y_sm + 3 * sigma + 2)) - cc, dd = max(0, ul[0]), min(br[0], min(output_res)) - aa, bb = max(0, ul[1]), min(br[1], min(output_res)) + cc, dd = max(0, ul[0]), min(br[0], output_res[1]) + aa, bb = max(0, ul[1]), min(br[1], output_res[0]) joint_rg = np.zeros((bb-aa, dd-cc)) for sy in range(aa, bb): diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 2b9c81c653..23e1999707 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -90,7 +90,7 @@ def fit( print(f'Training for epoch {i + 1} done, starting eval on validation data') valid_loss = self.epoch(valid_loader, mode='eval', step=i + 1) - if (i + 1) % save_epochs == 0: + if (i + 1) % self.cfg['save_epochs'] == 0: print(f"Finished epoch {i + 1}; saving model") torch.save( self.model.state_dict(), From 2cb4dfc61ec0f76be053fc784ef252a1d1ee1dcf Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Mon, 12 Jun 2023 13:08:00 +0200 Subject: [PATCH 020/293] bug fixes: inference and dataset --- .../trainingsetmanipulation.py | 1 + .../pose_estimation_pytorch/apis/inference.py | 22 +++++++++++-------- .../pose_estimation_pytorch/data/dataset.py | 3 ++- .../match_predictions_to_gt.py | 21 +++++++++++++++--- .../solvers/inference.py | 8 +++---- deeplabcut/pose_estimation_pytorch/utils.py | 4 ++-- deeplabcut/utils/auxiliaryfunctions.py | 2 ++ 7 files changed, 42 insertions(+), 19 deletions(-) diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 3b555b797f..9530fa05d8 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -908,6 +908,7 @@ def create_training_dataset( or "mobilenet" in net_type or "efficientnet" in net_type or "dlcrnet" in net_type + or "dekr" in net_type ): pass else: diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index d51af8b6bf..e3bc26a9fe 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -36,10 +36,11 @@ def inference_network( individuals = cfg.get('individuals', ['single']) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + device = config['device'] batch_size = config['batch_size'] project = dlc.DLCProject(shuffle=shuffle, - proj_root=config['project_root']) + proj_root=config['project_path']) valid_dataset = dlc.PoseDataset(project, transform=transform, @@ -65,15 +66,16 @@ def inference_network( predictor = PREDICTORS.build(dict(config['predictor'])) - # You need to dropna() here because on some frames no keypoint is annotated - # Thus the target_df (contains NaNs) may not match the valid_dataloader (has dropped them) - target_df = valid_dataset.dataframe.dropna(axis = 0, how = "all") + # # You need to dropna() here because on some frames no keypoint is annotated + # # Thus the target_df (contains NaNs) may not match the valid_dataloader (has dropped them) + # target_df = valid_dataset.dataframe.dropna(axis = 0, how = "all") + target_df = valid_dataset.dataframe predicted_poses = [] model.eval() - model.to('cuda:3') + model.to(device) with torch.no_grad(): for item in valid_dataloader: - item['image'] = item['image'].to('cuda:3') + item['image'] = item['image'].to(device) output = model(item['image']) shape_image = item['image'].shape scale_factor = (shape_image[2]/output[0].shape[2] , shape_image[3]/output[0].shape[3]) @@ -82,7 +84,9 @@ def inference_network( # Matching predictions to ground truth individuals in order to compute rmse and save as dataframe if len(individuals) > 1: for b in range(predictions.shape[0]): - match_individuals = oks_match_prediction_to_gt( + # rmse is more practical than oks + # since oks needs at least 2 annotated keypoints per animal (to compute area) + match_individuals = rmse_match_prediction_to_gt( predictions[b], item['annotations']['keypoints'][b].cpu().numpy(), individuals @@ -91,10 +95,10 @@ def inference_network( #converts back to original image size if image was resized during the augmentation pipeline for b in range(predictions.shape[0]): - resizing_factor = (item['original_size'][0]/shape_image[2]).item(), (item['original_size'][1]/shape_image[3]).item() + resizing_factor = (item['original_size'][0][b]/shape_image[2]).item(), (item['original_size'][1][b]/shape_image[3]).item() predictions[b, :, :, 0] = predictions[b, :, :, 0]*resizing_factor[1] + resizing_factor[1]/2 predictions[b, :, :, 1] = predictions[b, :, :, 1]*resizing_factor[0] + resizing_factor[0]/2 - predicted_poses.append(predictions) + predicted_poses.append(predictions) predicted_poses = np.array(predicted_poses) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index dbdaafb538..e48b74dddd 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -56,7 +56,8 @@ def transform(image, keypoints): # We must dropna because self.project.images doesn't contain imgaes with no labels so it can produce an indexnotfound error # length is stored here to avoid repeating the computation - self.length = self.dataframe.dropna(axis=0, how="all").shape[0] + self.length = self.dataframe.shape[0] + assert self.length == len(self.project.image_path2image_id.keys()) def __len__(self): return self.length diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 55a0d0faeb..88fa1ba7bd 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -37,6 +37,8 @@ def rmse_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individu distance_matrix[g, p] = np.linalg.norm(gt_kpts_without_ctr[g] - pred_kpts[p, :, :2]) row_ind, col_ind = linear_sum_assignment(distance_matrix) + # if animals are missing in the frame, the predictions corresponding to nothing are not shuffled + col_ind = extend_col_ind(col_ind, num_animals) return col_ind @@ -72,9 +74,14 @@ def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individua oks_matrix = np.zeros((num_animals_gt, num_animals)) gt_kpts_without_ctr[gt_kpts_without_ctr < 0] = np.nan # non visible keypoints should be nan to use calc_oks - for g in range(num_animals_gt): + idx_gt = -1 + for g in range(num_animals): + if np.isnan(gt_kpts_without_ctr[g]).all(): + continue + else: + idx_gt += 1 for p in range(num_animals): - oks_matrix[g, p] = calc_object_keypoint_similarity( + oks_matrix[idx_gt, p] = calc_object_keypoint_similarity( pred_kpts[p, :, :2], gt_kpts_without_ctr[g], 0.1, @@ -83,5 +90,13 @@ def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individua ) row_ind, col_ind = linear_sum_assignment(oks_matrix, maximize=True) + # if animals are missing in the frame, the predictions corresponding to nothing are not shuffled + col_ind = extend_col_ind(col_ind, num_animals) - return col_ind \ No newline at end of file + return col_ind + +def extend_col_ind(col_ind, num_animals): + existing_cols = set(col_ind) # Convert the array to a set for faster lookup + missing_cols = [num for num in range(num_animals) if num not in existing_cols] + extended_array = np.concatenate((col_ind, missing_cols)).astype(int) + return extended_array \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index 7551235061..0f2f955afb 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -78,19 +78,19 @@ def get_scores(cfg, pcutoff = cfg['pcutoff'] rmse, rmse_p = get_rmse(prediction, target, pcutoff, bodyparts = bodyparts) - oks, oks_pcutoff = get_oks(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) + oks, oks_p = get_oks(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) else: rmse, rmse_p = get_rmse(prediction, target, bodyparts = bodyparts) - scores = get_oks(prediction, target, bodyparts=bodyparts) + oks, oks_p = get_oks(prediction, target, bodyparts=bodyparts) scores = {} scores['rmse'] = np.nanmean(rmse) scores['rmse_pcutoff'] = np.nanmean(rmse_p) scores['mAP'] = oks['mAP'] scores['mAR'] = oks['mAR'] - scores['mAP_pcutoff'] = oks_pcutoff['mAP'] - scores['mAR_pcutoff'] = oks_pcutoff['mAR'] + scores['mAP_pcutoff'] = oks_p['mAP'] + scores['mAR_pcutoff'] = oks_p['mAR'] return scores diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index c5928fea79..b8519a6cdd 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -66,8 +66,8 @@ def df2generic(proj_root, df, image_id_offset = 0): data = df.loc[file_name] # skipping all nan - if np.isnan(data.to_numpy()).all(): - continue + # if np.isnan(data.to_numpy()).all(): + # continue image_id+=1 diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 9f86ea6077..87cfa7a44e 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -617,6 +617,8 @@ def get_scorer_name( netname = "mobnet_" + str(int(float(dlc_cfg["net_type"].split("_")[-1]) * 100)) elif "efficientnet" in dlc_cfg["net_type"]: netname = "effnet_" + dlc_cfg["net_type"].split("-")[1] + elif "dekr" in dlc_cfg["net_type"]: + netname = "dekr_" + dlc_cfg["net_type"].split("_")[-1] scorer = ( "DLC_" From b64eb9b77ae75f034704e7fed031f6e7ee992c88 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Tue, 6 Jun 2023 08:59:28 +0200 Subject: [PATCH 021/293] implement alpha video analysis. bug fixes, style improvements --- .../make_pytorch_config.py | 9 +- ...ple_individuals_trainingsetmanipulation.py | 1 + .../trainingsetmanipulation.py | 2 +- .../pose_estimation_pytorch/__init__.py | 7 +- .../pose_estimation_pytorch/apis/__init__.py | 6 +- .../apis/analyze_videos.py | 376 ++++++++++++++++++ .../apis/convert_detections_to_tracklets.py | 299 ++++++++++++++ .../pose_estimation_pytorch/apis/inference.py | 2 +- .../pose_estimation_pytorch/apis/train.py | 4 +- .../pose_estimation_pytorch/apis/utils.py | 54 ++- .../pose_estimation_pytorch/data/dataset.py | 44 +- .../models/predictors/base.py | 19 +- .../pose_estimation_pytorch/solvers/base.py | 7 +- .../pose_estimation_pytorch/solvers/utils.py | 2 +- deeplabcut/utils/auxfun_models.py | 3 + deeplabcut/utils/auxiliaryfunctions.py | 20 +- 16 files changed, 822 insertions(+), 33 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py create mode 100644 deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index fe30fbab98..63948d0e01 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -1,4 +1,4 @@ -import yaml +from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions BACKBONE_OUT_CHANNELS = { 'resnet-50': 2048, @@ -74,9 +74,10 @@ def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str , 'dekr_w32' , 'dekr_w48'] + bodyparts = auxiliaryfunctions.get_bodyparts(project_config) + num_joints = len(bodyparts) pytorch_config = config_template if net_type in single_animal_nets: - num_joints = len(project_config['bodyparts']) pytorch_config['model']['heatmap_head']['channels'][-1] = num_joints pytorch_config['model']['locref_head']['channels'][-1] = 2*num_joints pytorch_config['model']['target_generator']['num_joints'] = num_joints @@ -88,10 +89,8 @@ def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str raise NotImplementedError('mobilenet config not yet implemented') elif 'hrnet' in net_type: raise NotImplementedError('hrnet config not yet implemented') - elif net_type in multi_animal_nets: - num_joints = len(project_config['bodyparts']) num_animals = len(project_config.get('individuals', [0])) if 'dekr' in net_type: version = net_type.split('_')[-1] @@ -147,4 +146,4 @@ def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str elif augmenter_type != 'default' and augmenter_type != None: raise NotImplementedError('Other augmentations than default are not implemented') - return pytorch_config \ No newline at end of file + return pytorch_config diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index 238652e645..7437227688 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -210,6 +210,7 @@ def create_multianimaltraining_dataset( if net_type is None: # loading & linking pretrained models net_type = cfg.get("default_net_type", "dlcrnet_ms5") + elif not any(net in net_type for net in ("resnet", "eff", "dlc", "mob", 'dekr')): raise ValueError(f"Unsupported network {net_type}.") diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 9530fa05d8..7bf0d18d18 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -990,7 +990,7 @@ def create_training_dataset( (trainFraction, Shuffles[shuffle], (train_inds, test_inds)) ) - bodyparts = cfg["bodyparts"] + bodyparts = auxiliaryfunctions.get_bodyparts(cfg) nbodyparts = len(bodyparts) for trainFraction, shuffle, (trainIndices, testIndices) in splits: if len(trainIndices) > 0: diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index 9d17ba5fe7..72be8306f5 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -1,4 +1,9 @@ from deeplabcut.pose_estimation_pytorch.data.dlcproject import DLCProject from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset from deeplabcut.pose_estimation_pytorch.utils import fix_seeds -from deeplabcut.pose_estimation_pytorch.apis import train_network, inference_network +from deeplabcut.pose_estimation_pytorch.apis import ( + analyze_videos, + convert_detections2tracklets, + inference_network, + train_network, +) diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py index 135ae638df..271394e54e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py @@ -9,5 +9,9 @@ # Licensed under GNU Lesser General Public License v3.0 # +from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import analyze_videos +from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( + convert_detections2tracklets +) +from deeplabcut.pose_estimation_pytorch.apis.inference import inference_network from deeplabcut.pose_estimation_pytorch.apis.train import train_network -from deeplabcut.pose_estimation_pytorch.apis.inference import inference_network \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py new file mode 100644 index 0000000000..4aba7e2a9f --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -0,0 +1,376 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import sys +import time +from pathlib import Path +from typing import List, Tuple, Optional, Union + +import albumentations as A +import numpy as np +import pandas as pd +import torch +from tqdm import tqdm +from skimage.util import img_as_ubyte + +import deeplabcut.pose_estimation_pytorch as dlc +from deeplabcut import auxiliaryfunctions +from deeplabcut.utils import auxfun_multianimal +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS, BasePredictor +from deeplabcut.utils import VideoReader +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_pose_model, read_yaml, get_model_snapshots, videos_in_folder, +) + + +def video_inference( + model: PoseModel, + predictor: BasePredictor, + video_path: Path, + batch_size: int = 1, + device: Optional[str] = None, + transform: Optional[A.Compose] = None, +) -> List[np.ndarray]: + """ + Runs inference on all frames of a video + + Args: + model: the model with which to run inference + predictor: the predictor to use alongside the model + video_path: the path to the video onto which inference should be run + batch_size: the batch size with which to run inference + device: the torch device to use to run inference. Dynamic selection if None + transform: the image augmentation transform to use on the video frames, if any + + Returns: + for each frame in the video, a numpy array containing the output of the + predictor for the frame + """ + if device is None: + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + + # Set the model to eval mode and put it on the device + model.eval() + model.to(device) + + print(f"Loading {video_path}") + video_reader = VideoReader(str(video_path)) + n_frames = video_reader.get_n_frames() + vid_w, vid_h = video_reader.dimensions + print( + f"Video metadata: \n" + f" n_frames: {n_frames}\n" + f" fps: {video_reader.fps}\n" + f" resolution: w={vid_w}, h={vid_h}\n" + ) + + batch_ind = 0 # Index of the current img in batch + batch_frames = np.empty((batch_size, vid_h, vid_w, 3)) + + pbar = tqdm(total=n_frames, file=sys.stdout) + predictions = [] + frame = video_reader.read_frame() + with torch.no_grad(): + while frame is not None: + if frame.dtype != np.uint8: + frame = img_as_ubyte(frame) + + batch_frames[batch_ind] = frame + if batch_ind == batch_size - 1: + if transform: + batch_frames = transform(image=batch_frames)["image"] + + batch = torch.FloatTensor(batch_frames, device=device).permute(0, 3, 1, 2) + output = model(batch) + scale_factor = ( + batch.shape[2] / output[0].shape[2], + batch.shape[3] / output[0].shape[3] + ) + for frame_pred in predictor(output, scale_factor).cpu().numpy(): + predictions.append(frame_pred) + + frame = video_reader.read_frame() + batch_ind += 1 + batch_ind = batch_ind % batch_size + pbar.update(1) + + return predictions + + +def analyze_videos( + config_path: str, + data_path: Union[str, List[str]], + output_folder: Optional[str] = None, + video_type: Optional[str] = None, + dataset_index: int = 0, + shuffle: int = 1, + snapshot_index: Optional[int] = None, + model_prefix: str = "", + batch_size: Optional[int] = None, + device: Optional[str] = None, + transform: Optional[A.Compose] = None, + inv_transform: Optional[A.Compose] = None, + overwrite: bool = False +) -> List[Tuple[str, pd.DataFrame]]: + """ + Makes pose estimation predictions based on a trained model + TODO: finish doc + + Args: + config_path: + data_path: + output_folder: + video_type: + dataset_index: + shuffle: + snapshot_index: + model_prefix: + batch_size: + device: + transform: + inv_transform: + overwrite: + + Returns: + + """ + # Create the output folder + _create_output_folder(output_folder) + + # Load the project configuration + project = dlc.DLCProject( + shuffle=shuffle, + proj_root=str(Path(config_path).parent), + ) + project.convert2dict(mode="test") + project_path = Path(project.cfg["project_path"]) + train_fraction = project.cfg["TrainingFraction"][dataset_index] + model_folder = project_path / auxiliaryfunctions.get_model_folder( + train_fraction, shuffle, project.cfg, modelprefix=model_prefix, + ) + model_path = _get_model_path(model_folder, snapshot_index, project.cfg) + model_epochs = int(model_path.stem.split("-")[-1]) + dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( + project.cfg, + shuffle, + train_fraction, + trainingsiterations=model_epochs, + modelprefix=model_prefix, + ) + + # Read the inference configuration, load the model + pytorch_config_path = model_folder / "train" / "pytorch_config.yaml" + pytorch_config = read_yaml(pytorch_config_path) + pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" + pose_cfg = auxiliaryfunctions.read_config(pose_cfg_path) + + # Get model parameters + # TODO: Should we get the batch size from the inference pose_cfg? Or have an + # inference pytorch_cfg? + if batch_size is None: + batch_size = pytorch_config.get("batch_size", 1) + pose_cfg["batch_size"] = batch_size + individuals = project.cfg.get('individuals', ['single']) + + # Load model, predictor + model = build_pose_model(pytorch_config['model'], pose_cfg) + model.load_state_dict(torch.load(model_path)) + predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config['predictor'])) + + # Reading video and init variables + videos = videos_in_folder(data_path, video_type) + results = [] + for video in videos: + if output_folder is None: + output_path = video.parent + else: + output_path = Path(output_folder) + + output_prefix = video.stem + dlc_scorer + output_h5 = output_path / f"{output_prefix}.h5" + output_pkl = output_path / f"{output_prefix}_full.pickle" + if not overwrite and output_pkl.exists(): + print(f"Video already analyzed at {output_pkl}!") + else: + runtime = [time.time()] + predictions = video_inference( + model, + predictor, + video, + batch_size=batch_size, + device=device, + transform=transform, + ) + runtime.append(time.time()) + + print(f"Inference is done for {video}! Saving results...") + metadata = _generate_metadata( + config=project.cfg, + pose_config=pose_cfg, + pytorch_pose_config=pytorch_config, + dlc_scorer=dlc_scorer, + train_fraction=train_fraction, + batch_size=batch_size, + runtime=(runtime[0], runtime[1]), + video=VideoReader(str(video)), + ) + + if len(individuals) > 1: + print("Extracting ", len(individuals), "instances per bodypart") + xyz_labs_orig = ["x", "y", "likelihood"] + suffix = [str(s + 1) for s in range(len(individuals))] + suffix[0] = "" # first has empty suffix for backwards compatibility + xyz_labs = [x + s for s in suffix for x in xyz_labs_orig] + else: + xyz_labs = ["x", "y", "likelihood"] + + results_df_index = pd.MultiIndex.from_product( + [[dlc_scorer], pose_cfg["all_joints_names"], xyz_labs], + names=["scorer", "bodyparts", "coords"], + ) + df = pd.DataFrame( + np.array(predictions).reshape((len(predictions), -1)), + columns=results_df_index, + index=range(len(predictions)), + ) + df.to_hdf( + str(output_h5), + "df_with_missing", + format="table", + mode="w", + ) + results.append((str(video), df)) + output_data = _generate_output_data(pose_cfg, predictions) + _ = auxfun_multianimal.SaveFullMultiAnimalData( + output_data, metadata, str(output_h5) + ) + + return results + + +def _create_output_folder(output_folder: Optional[Path]) -> None: + if output_folder is not None: + output_folder = Path(output_folder) + if not output_folder.exists(): + print(f"Creating the output folder {output_folder}") + output_folder.mkdir(parents=True) + + assert ( + Path(output_folder).is_dir() + ), f"Output folder must be a directory: you passed '{output_folder}'" + + +def _generate_metadata( + config: dict, + pose_config: dict, + pytorch_pose_config: dict, + dlc_scorer: str, + train_fraction: int, + batch_size: int, + runtime: Tuple[float, float], + video: VideoReader, +) -> dict: + w, h = video.dimensions + cropping = config.get("cropping", False) + if cropping: + cropping_parameters = [ + config["x1"], config["x2"], config["y1"], config["y2"], + ] + else: + cropping_parameters = [0, w, 0, h] + + metadata = { + "start": runtime[0], + "stop": runtime[1], + "run_duration": runtime[1] - runtime[0], + "Scorer": dlc_scorer, + "DLC-model-config file": pose_config, + "DLC-model-pytorch-config file": pytorch_pose_config, + "fps": video.fps, + "batch_size": batch_size, + "frame_dimensions": (w, h), + "nframes": video.get_n_frames(), + "iteration (active-learning)": config["iteration"], + "training set fraction": train_fraction, + "cropping": cropping, + "cropping_parameters": cropping_parameters, + } + return {"data": metadata} + + +def _get_model_path(model_folder: Path, snapshot_index: int, config: dict) -> Path: + trained_models = get_model_snapshots(model_folder / "train") + + if snapshot_index is None: + snapshot_index = config["snapshotindex"] + + if snapshot_index == "all": + print( + "snapshotindex is set to 'all' in the config.yaml file. Running video " + "analysis with all snapshots is very costly! Use the function " + "'evaluate_network' to choose the best the snapshot. For now, changing " + "snapshot index to -1. To evaluate another snapshot, you can change the " + "value in the config file or call `analyze_videos` with your desired " + "snapshot index." + ) + snapshot_index = -1 + + assert isinstance(snapshot_index, int), ( + f"snapshotindex must be an integer but was '{snapshot_index}'" + ) + return trained_models[snapshot_index] + + +def _generate_output_data( + pose_config: dict, + predictions: List[np.ndarray], +) -> dict: + output = { + "metadata": { + "nms radius": pose_config.get("nmsradius"), + "minimal confidence": pose_config.get("minconfidence"), + "sigma": pose_config.get("sigma", 1), + "PAFgraph": pose_config.get("partaffinityfield_graph"), + "PAFinds": pose_config.get( + "paf_best", np.arange(len(pose_config.get("partaffinityfield_graph", []))) + ), + "all_joints": [[i] for i in range(len(pose_config["all_joints"]))], + "all_joints_names": [ + pose_config["all_joints_names"][i] for i in range(len(pose_config["all_joints"])) + ], + "nframes": len(predictions), + } + } + + str_width = int(np.ceil(np.log10(len(predictions)))) + for frame_num, frame_predictions in enumerate(predictions): + key = "frame" + str(frame_num).zfill(str_width) + output[key] = frame_predictions.squeeze() + + # TODO: Do we want to keep the same format as in the TensorFlow version? + # On the one hand, it's "more" backwards compatible. + # On the other, might as well simplify the code. These files should only be loaded + # by the PyTorch version, and only predictions made by PyTorch models should be + # loaded using them + # p_bodypart_indv = np.transpose(frame_predictions.squeeze(), axes=[1, 0, 2]) + # coords = [ + # bodypart_predictions[:, :2] for bodypart_predictions in p_bodypart_indv + # ] + # scores = [ + # bodypart_predictions[:, 2:] for bodypart_predictions in p_bodypart_indv + # ] + # output[key] = { + # "coordinates": (coords,), + # "confidence": scores, + # "costs": None, + # } + + return output diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py new file mode 100644 index 0000000000..5de7eb560c --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -0,0 +1,299 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import pickle +import re +import os +import warnings +from pathlib import Path +from typing import Dict, List, Optional, Union + +import numpy as np +import pandas as pd +from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly +from scipy.optimize import linear_sum_assignment + +from tqdm import tqdm + +from deeplabcut import auxiliaryfunctions +from deeplabcut.pose_estimation_tensorflow import load_config +from deeplabcut.pose_estimation_tensorflow.lib import trackingutils +from deeplabcut.utils import auxfun_multianimal +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + get_model_snapshots, videos_in_folder, +) + + +def convert_detections2tracklets( + config_path: str, + data_path: Union[str, List[str]], + output_folder: Optional[str] = None, + video_type: Optional[str] = None, + shuffle: int = 1, + dataset_index: int = 0, + overwrite: bool = False, + ignore_bodyparts=None, + inference_cfg: Optional[str] = None, + modelprefix="", + greedy=False, + calibrate=False, + window_size=0, + identity_only=False, + track_method="", +): + """ TODO: Documentation, clean & remove code duplication (with analyze video) """ + cfg = auxiliaryfunctions.read_config(config_path) + track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method) + + if len(cfg["multianimalbodyparts"]) == 1 and track_method != "box": + warnings.warn("Switching to `box` tracker for single point tracking...") + track_method = "box" + cfg["default_track_method"] = track_method + auxiliaryfunctions.write_config(config_path, cfg) + + train_fraction = cfg["TrainingFraction"][dataset_index] + start_path = os.getcwd() # record cwd to return to this directory in the end + + # TODO: add cropping as in video analysis! + # if cropping is not None: + # cfg['cropping']=True + # cfg['x1'],cfg['x2'],cfg['y1'],cfg['y2']=cropping + # print("Overwriting cropping parameters:", cropping) + # print("These are used for all videos, but won't be save to the cfg file.") + + rel_model_dir = auxiliaryfunctions.get_model_folder( + train_fraction, shuffle, cfg, modelprefix=modelprefix + ) + model_dir = Path(cfg["project_path"]) / rel_model_dir + path_test_config = model_dir / "test" / "pose_cfg.yaml" + dlc_cfg = load_config(str(path_test_config)) + + if "multi-animal" not in dlc_cfg["dataset_type"]: + raise ValueError("This function is only required for multianimal projects!") + + if inference_cfg is None: + path_inference_config = model_dir / "test" / "inference_cfg.yaml" + inference_cfg = auxfun_multianimal.read_inferencecfg(path_inference_config, cfg) + else: + auxfun_multianimal.check_inferencecfg_sanity(cfg, inference_cfg) + + if len(cfg["multianimalbodyparts"]) == 1 and track_method != "box": + warnings.warn("Switching to `box` tracker for single point tracking...") + track_method = "box" + # Also ensure `boundingboxslack` is greater than zero, otherwise overlap + # between trackers cannot be evaluated, resulting in empty tracklets. + inference_cfg["boundingboxslack"] = max(inference_cfg["boundingboxslack"], 40) + + # Check which snapshots are available and sort them by # iterations + snapshots = get_model_snapshots(model_dir / "train") + assert len(snapshots) > 0, ( + f"No snapshots were found in the model directory {model_dir / 'train'}" + ) + snapshot_index = cfg["snapshotindex"] + if snapshot_index == "all": + print( + "snapshotindex is set to 'all' in the config.yaml file. Running video " + "analysis with all snapshots is very costly! Use the function " + "'evaluate_network' to choose the best the snapshot. For now, changing " + "snapshot index to -1. To evaluate another snapshot, you can change the " + "value in the config file or call `analyze_videos` with your desired " + "snapshot index." + ) + snapshot_index = -1 + + snapshot = snapshots[snapshot_index] + print(f"Using snapshot {snapshot} for model {model_dir}") + dlc_cfg["init_weights"] = str(snapshot) + num_epochs = int(snapshot.stem.split("-")[-1]) + dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( + cfg, + shuffle, + train_fraction, + trainingsiterations=num_epochs, + modelprefix=modelprefix, + ) + + # TODO: deal with lists of strings + videos = videos_in_folder(data_path, video_type) + if len(videos) == 0: + print(f"No videos were found in {data_path}") + return + + for video in videos: + print("Processing... ", video) + if output_folder is None: + output_path = video.parent + else: + output_path = Path(output_folder) + output_path.mkdir(exist_ok=True, parents=True) + + video_name = video.stem + + data_prefix = video_name + dlc_scorer + data_filename = output_path / (data_prefix + ".h5") + print(f"Loading From {data_filename}") + data, metadata = auxfun_multianimal.LoadFullMultiAnimalData(str(data_filename)) + if track_method == "ellipse": + method = "el" + elif track_method == "box": + method = "bx" + else: + method = "sk" + + track_filename = output_path / (data_prefix + f"_{method}.pickle") + if not overwrite and track_filename.exists(): + # TODO: check if metadata are identical (same parameters!) + print(f"Tracklets already computed at {track_filename}") + print("Set overwrite = True to overwrite.") + else: + dlc_scorer = metadata["data"]["Scorer"] + joints = data["metadata"]["all_joints_names"] + n_joints = len(joints) + + # TODO: adjust this for multi + unique bodyparts! + # this is only for multianimal parts and uniquebodyparts as one (not one + # uniquebodyparts guy tracked etc.) + bodypartlabels = [ + bpt for i, bpt in enumerate(joints) for _ in range(3) + ] + scorers = len(bodypartlabels) * [dlc_scorer] + xyl_value = int(len(bodypartlabels) / 3) * ["x", "y", "likelihood"] + df_index = pd.MultiIndex.from_arrays( + np.vstack([scorers, bodypartlabels, xyl_value]), + names=["scorer", "bodyparts", "coords"], + ) + image_names = [fn for fn in data if fn != "metadata"] + + if track_method == "box": + mot_tracker = trackingutils.SORTBox( + inference_cfg["max_age"], + inference_cfg["min_hits"], + inference_cfg.get("oks_threshold", 0.3), + ) + elif track_method == "skeleton": + mot_tracker = trackingutils.SORTSkeleton( + n_joints, + inference_cfg["max_age"], + inference_cfg["min_hits"], + inference_cfg.get("oks_threshold", 0.5), + ) + else: + mot_tracker = trackingutils.SORTEllipse( + inference_cfg.get("max_age", 1), + inference_cfg.get("min_hits", 1), + inference_cfg.get("iou_threshold", 0.6), + ) + + tracklets = {} + multi_bpts = cfg["multianimalbodyparts"] + + ass_unique = {} + ass_assemblies = _conv_predictions_to_assemblies(image_names, data) + ass_data = dict() + for ind, assemblies in ass_assemblies.items(): + ass_data[ind] = [ass.data for ass in assemblies] + if ass_unique: + ass_data["single"] = ass_unique + with open(data_filename.parent / (data_filename.stem + "_assemblies.pickle"), "wb") as file: + pickle.dump(ass_data, file, pickle.HIGHEST_PROTOCOL) + + # Initialize storage of the 'single' individual track + if cfg["uniquebodyparts"]: + tracklets["single"] = {} + _single = {} + for index, image_name in enumerate(image_names): + single_detection = ass_unique.get(index) + if single_detection is None: + continue + imindex = int(re.findall(r"\d+", image_name)[0]) + _single[imindex] = single_detection + tracklets["single"].update(_single) + + if inference_cfg["topktoretain"] == 1: + tracklets[0] = {} + for index, image_name in tqdm(enumerate(image_names)): + assemblies = ass_assemblies.get(index) + if assemblies is None: + continue + tracklets[0][image_name] = assemblies[0].data + else: + keep = set(multi_bpts).difference(ignore_bodyparts or []) + keep_inds = sorted(multi_bpts.index(bpt) for bpt in keep) + for index, image_name in tqdm(enumerate(image_names)): + assemblies = ass_assemblies.get(index) + if assemblies is None: + continue + + animals = np.stack([ass.data for ass in assemblies]) + if not identity_only: + if track_method == "box": + xy = trackingutils.calc_bboxes_from_keypoints( + animals[:, keep_inds], + inference_cfg["boundingboxslack"], + ) # TODO: get cropping parameters and utilize! + else: + xy = animals[:, keep_inds, :2] + trackers = mot_tracker.track(xy) + else: + # Optimal identity assignment based on soft voting + mat = np.zeros( + (len(assemblies), inference_cfg["topktoretain"]) + ) + for nrow, assembly in enumerate(assemblies): + for k, v in assembly.soft_identity.items(): + mat[nrow, k] = v + inds = linear_sum_assignment(mat, maximize=True) + trackers = np.c_[inds][:, ::-1] + trackingutils.fill_tracklets( + tracklets, trackers, animals, image_name + ) + + tracklets["header"] = df_index + with open(track_filename, "wb") as f: + pickle.dump(tracklets, f, pickle.HIGHEST_PROTOCOL) + + os.chdir(str(start_path)) + + print( + "The tracklets were created (i.e., under the hood " + "deeplabcut.convert_detections2tracklets was run). Now you can " + "'refine_tracklets' in the GUI, or run 'deeplabcut.stitch_tracklets'." + ) + + +def _conv_predictions_to_assemblies( + image_names: List[str], + predictions: Dict[str, np.ndarray], +) -> Dict[int, List[Assembly]]: + """ + Converts predictions to an assemblies dictionary + predictions shape (num_animals, num_keypoints, 2 or 3) + """ + assemblies = {} + if len(predictions) == 0: + return assemblies + + for image_index, image_name in enumerate(image_names): + frame_predictions = predictions.get(image_name) + if frame_predictions is not None: + num_kpts, num_animals, pred_shape = frame_predictions.shape + kpt_lst = [] + for i in range(num_animals): + animal_prediction = frame_predictions[:, i, :] + ass_prediction = np.ones((num_kpts, 4), dtype=frame_predictions.dtype) + ass_prediction[:, 3] = -ass_prediction[:, 3] + ass_prediction[:, :pred_shape] = animal_prediction.copy() + ass = Assembly.from_array(ass_prediction) + if len(ass) > 0: + kpt_lst.append(ass) + + assemblies[image_index] = kpt_lst + + return assemblies diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index e3bc26a9fe..2d894289c1 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -93,7 +93,7 @@ def inference_network( ) predictions[b] = predictions[b][match_individuals] - #converts back to original image size if image was resized during the augmentation pipeline + # converts back to original image size if image was resized during the augmentation pipeline for b in range(predictions.shape[0]): resizing_factor = (item['original_size'][0][b]/shape_image[2]).item(), (item['original_size'][1][b]/shape_image[3]).item() predictions[b, :, :, 0] = predictions[b, :, :, 0]*resizing_factor[1] + resizing_factor[1]/2 diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index cf5573821b..9caf1e3291 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -29,8 +29,10 @@ def train_network( pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") pytorch_config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + if transform is None: + print("No transform specified... using default") + transform = build_transforms(dict(pytorch_config['data'])) - transform = build_transforms(dict(pytorch_config['data'])) batch_size = pytorch_config['batch_size'] epochs = pytorch_config['epochs'] diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 5865a33c71..2a544aba4b 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -1,9 +1,10 @@ -from typing import Dict +from pathlib import Path +from typing import Dict, List, Optional, Union -import os -import yaml -import torch import albumentations as A +import torch +import yaml +from deeplabcut.utils import auxfun_videos from deeplabcut.pose_estimation_pytorch.models import PoseModel, BACKBONES, HEADS, LOSSES from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SINGLE_ANIMAL_SOLVER @@ -11,8 +12,6 @@ from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS from deeplabcut.pose_estimation_pytorch.solvers.schedulers import LRListScheduler from deeplabcut.pose_estimation_pytorch.solvers.base import Solver -# from deeplabcut.pose_estimation_pytorch.default_config import pytorch_cfg_template -# from deeplabcut.utils import auxiliaryfunctions def build_pose_model(cfg: Dict, pytorch_cfg): @@ -74,6 +73,7 @@ def build_solver(pytorch_cfg: Dict) -> Solver: logger=logger)) return solver + def build_transforms(aug_cfg): transforms = [] @@ -162,7 +162,49 @@ def build_transforms(aug_cfg): keypoint_params=A.KeypointParams('xy', remove_invisible=False) ) + def read_yaml(path): with open(path) as f: file = yaml.safe_load(f) return file + + +def get_model_snapshots(model_folder: Path) -> List[Path]: + """ + Assumes that all snapshots are named using the pattern "snapshot-{idx}.pt" + + Args: + model_folder: the path to the folder containing the snapshots + + Returns: + the paths of snapshots in the folder, sorted by index in ascending order + """ + return sorted( + [file for file in model_folder.iterdir() if file.suffix == ".pt"], + key=lambda p: int(p.stem.split("-")[-1]) + ) + + +def videos_in_folder( + data_path: Union[str, List[str]], + video_type: Optional[str], +) -> List[Path]: + """ + TODO + """ + video_path = Path(data_path) + if video_path.is_dir(): + if video_type is None: + video_suffixes = auxfun_videos.SUPPORTED_VIDEOS + else: + video_suffixes = [video_type] + + return [ + file for file in video_path.iterdir() + if video_path.stem in video_suffixes + ] + + assert video_path.exists(), ( + f"Could not find the video: {video_path}. Check access rights." + ) + return [video_path] diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index e48b74dddd..d411dab48f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -4,9 +4,8 @@ import torch from torch.utils.data import Dataset +from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions from deeplabcut.utils.auxiliaryfunctions import read_plainconfig, get_model_folder -from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator - from .base import BaseDataset from .dlcproject import DLCProject @@ -35,7 +34,10 @@ def transform(image, keypoints): self.transform = transform self.project = project self.cfg = self.project.cfg - self.num_joints = len(self.cfg['bodyparts']) + + self.bodyparts = auxiliaryfunctions.get_bodyparts(self.cfg) + self.num_joints = len(self.bodyparts) + self.shuffle = self.project.shuffle self.project.convert2dict(mode) self.dataframe = self.project.dataframe @@ -108,6 +110,7 @@ def __getitem__(self, except: print(len(self.project.images)) print(index) + image = cv2.imread(image_file) original_size = image.shape @@ -115,12 +118,14 @@ def __getitem__(self, image_id = self.project.image_path2image_id[image_file] n_annotations = len(self.project.id2annotations_idx[image_id]) + bodyparts = [bpt for bpt in self.bodyparts] if not self.with_center: keypoints = np.zeros((self.max_num_animals, self.num_joints, 3)) num_keypoints_returned = self.num_joints else: keypoints = np.zeros((self.max_num_animals, self.num_joints + 1, 3)) num_keypoints_returned = self.num_joints + 1 + bodyparts += ["_center_"] for i, annotation_idx in enumerate(self.project.id2annotations_idx[image_id]): _annotation = self.project.annotations[annotation_idx] @@ -130,7 +135,7 @@ def __getitem__(self, if self.with_center: keypoints[i, :-1, :2] = _keypoints keypoints[i, :-1, 2] = _undef_ids - + else: keypoints[i, :, :2] = _keypoints keypoints[i, :, 2] = _undef_ids @@ -140,12 +145,33 @@ def __getitem__(self, keypoints = keypoints.reshape((-1, 3)) if self.transform: - transformed = self.transform(image=image, keypoints=keypoints[:, :2]) + class_labels = [ + f"individual{i}_{bpt}" + for i in range(self.max_num_animals) + for bpt in bodyparts + ] + transformed = self.transform( + image=image, + keypoints=keypoints[:, :2], + class_labels=class_labels, + ) + + # Discard keypoints that aren't in the frame anymore shape_transformed = transformed['image'].shape - transformed['keypoints'] = [(-1, -1) - if ((keypoints[i, 2] == 0) or (not self._keypoint_in_boundary(keypoint, shape_transformed))) - else keypoint - for i, keypoint in enumerate(transformed['keypoints'])] + transformed['keypoints'] = [ + keypoint if self._keypoint_in_boundary(keypoint, shape_transformed) + else (-1, -1) + for keypoint in transformed['keypoints'] + ] + + # Discard keypoints that are undefined + undef_class_labels = [ + class_labels[i] for i, kpt in enumerate(keypoints) if kpt[2] == 0 + ] + for label in undef_class_labels: + new_index = transformed["class_labels"].index(label) + transformed['keypoints'][new_index] = (-1, -1) + else: transformed = {} transformed['keypoints'] = keypoints[:, :2] diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index 9664891ed4..9eb954e424 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -1,12 +1,25 @@ -import torch -import torch.nn as nn +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from abc import ABC, abstractmethod +import torch.nn as nn + from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + PREDICTORS = Registry('predictors', build_func=build_from_cfg) + class BasePredictor(ABC, nn.Module): + """ A base predictor """ def __init__(self): super().__init__() @@ -15,4 +28,4 @@ def __init__(self): @abstractmethod def forward(self, outputs): - pass \ No newline at end of file + pass diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 23e1999707..6fa5ca4406 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -99,10 +99,11 @@ def fit( print( f'Epoch {i + 1}/{epochs}, ' - f'train loss {train_loss}, ' - f'valid loss {valid_loss}' + f'train loss {float(train_loss):.5f}, ' + f'valid loss {float(valid_loss):.5f}, ' + f'lr {self.optimizer.param_groups[0]["lr"]}' ) - + if epochs % save_epochs != 0: print(f"Finished epoch {epochs}; saving model") torch.save( diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index 130a926ba7..c38acfc895 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -101,7 +101,7 @@ def save_predictions(names, cfg, data_index, [ [names['dlc_scorer']], cfg['individuals'], - cfg["bodyparts"], + cfg["multianimalbodyparts"], ["x", "y", "likelihood"], ], names=["scorer", "individuals", "bodyparts", "coords"], diff --git a/deeplabcut/utils/auxfun_models.py b/deeplabcut/utils/auxfun_models.py index db7bcbfe63..efaf09c5dc 100644 --- a/deeplabcut/utils/auxfun_models.py +++ b/deeplabcut/utils/auxfun_models.py @@ -46,6 +46,9 @@ def check_for_weights(modeltype, parent_path): """gets local path to network weights and checks if they are present. If not, downloads them from tensorflow.org""" + # TODO: Adapt code for all PyTorch models + if any([torch_fam in modeltype for torch_fam in ["dekr"]]): + return str(parent_path), num_shuffles if modeltype not in MODELTYPE_FILEPATH_MAP.keys(): print( diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 87cfa7a44e..9692cd5142 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -29,7 +29,7 @@ import yaml from ruamel.yaml import YAML from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import TRACK_METHODS -from deeplabcut.utils import auxfun_videos +from deeplabcut.utils import auxfun_videos, auxfun_multianimal def create_config_template(multianimal=False): @@ -270,6 +270,24 @@ def edit_config(configname, edits, output_name=""): return cfg +def get_bodyparts(cfg: dict) -> typing.List[str]: + """ + Args: + cfg: a project configuration file + + Returns: all bodyparts listed in the project + """ + if cfg.get("multianimalproject", False): + ( + _, + unique_bodyparts, + multianimal_bodyparts, + ) = auxfun_multianimal.extractindividualsandbodyparts(cfg) + return multianimal_bodyparts + unique_bodyparts + + return cfg["bodyparts"] + + def write_config_3d(configname, cfg): """ Write structured 3D config file. From 3aed7166fc4645e9df5367434f791ae7be9f229a Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Wed, 21 Jun 2023 10:58:22 +0200 Subject: [PATCH 022/293] bug fixes, docstrings and colormode for dataset --- .../make_pytorch_config.py | 2 + .../apis/analyze_videos.py | 7 +++ .../pose_estimation_pytorch/apis/config.yaml | 1 + .../pose_estimation_pytorch/apis/inference.py | 33 +++++++++- .../pose_estimation_pytorch/apis/train.py | 36 +++++++++-- .../pose_estimation_pytorch/apis/utils.py | 46 +++++++++++++- .../pose_estimation_pytorch/data/dataset.py | 31 ++++++---- .../data/dlcproject.py | 23 +++++-- .../models/backbones/base.py | 3 + .../models/backbones/hrnet.py | 6 ++ .../models/backbones/resnet.py | 3 + .../models/criterion.py | 8 +-- .../models/heads/base.py | 3 + .../models/heads/dekr_heads.py | 20 +++++++ .../models/heads/simple_head.py | 3 + .../pose_estimation_pytorch/models/model.py | 29 ++++++--- .../models/predictors/dekr_predictor.py | 14 ++++- .../models/predictors/single_predictor.py | 6 +- .../models/target_generators/dekr_targets.py | 35 ++++++++++- .../target_generators/gaussian_targets.py | 19 +++++- .../target_generators/plateau_targets.py | 19 +++++- .../pose_estimation_pytorch/solvers/base.py | 3 +- .../solvers/inference.py | 60 ++++++++++++++++--- .../solvers/schedulers.py | 4 +- 24 files changed, 350 insertions(+), 64 deletions(-) diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 63948d0e01..0e827ab9e8 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -1,3 +1,4 @@ +import torch from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions BACKBONE_OUT_CHANNELS = { @@ -77,6 +78,7 @@ def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str bodyparts = auxiliaryfunctions.get_bodyparts(project_config) num_joints = len(bodyparts) pytorch_config = config_template + pytorch_config['device'] = 'cuda' if torch.cuda.is_available() else 'cpu' if net_type in single_animal_nets: pytorch_config['model']['heatmap_head']['channels'][-1] = num_joints pytorch_config['model']['locref_head']['channels'][-1] = 2*num_joints diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 4aba7e2a9f..815c8b0f2c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -19,6 +19,7 @@ import torch from tqdm import tqdm from skimage.util import img_as_ubyte +import cv2 import deeplabcut.pose_estimation_pytorch as dlc from deeplabcut import auxiliaryfunctions @@ -38,6 +39,7 @@ def video_inference( batch_size: int = 1, device: Optional[str] = None, transform: Optional[A.Compose] = None, + colormode: Optional[str]= 'RGB' ) -> List[np.ndarray]: """ Runs inference on all frames of a video @@ -49,6 +51,7 @@ def video_inference( batch_size: the batch size with which to run inference device: the torch device to use to run inference. Dynamic selection if None transform: the image augmentation transform to use on the video frames, if any + colormode: RGB or BGR Returns: for each frame in the video, a numpy array containing the output of the @@ -83,6 +86,9 @@ def video_inference( if frame.dtype != np.uint8: frame = img_as_ubyte(frame) + if colormode =='BGR': + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + batch_frames[batch_ind] = frame if batch_ind == batch_size - 1: if transform: @@ -208,6 +214,7 @@ def analyze_videos( batch_size=batch_size, device=device, transform=transform, + colormode=pytorch_config.get('colormode', 'RGB') ) runtime.append(time.time()) diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml index b29463e293..499ecb16b4 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -1,5 +1,6 @@ batch_size: 1 cfg_path: /data/quentin/datasets/daniel3mouse/config.yaml +colormode: 'RGB' criterion: locref_huber_loss: true loss_weight_locref: 0.02 diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 2d894289c1..a48a7b7c85 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -11,7 +11,7 @@ from deeplabcut.pose_estimation_pytorch.solvers.utils import get_paths, get_results_filename, save_predictions from deeplabcut.pose_estimation_tensorflow import Plotting from deeplabcut.pose_estimation_pytorch.post_processing import rmse_match_prediction_to_gt, oks_match_prediction_to_gt -from deeplabcut.utils.visualization import make_labeled_images_from_dataframe +import albumentations as A from typing import Union @@ -21,9 +21,36 @@ def inference_network( model_prefix: str = "", load_epoch: Union[int, str] = -1, stride: int = 8, - transform: object = None, + transform: Union[A.BasicTransform, A.Compose] = None, plot: bool = False, - evaluate: bool = True): + evaluate: bool = True) -> None: + """ + Performs inference on the validation dataset and save the results as a dataframe + + Args: + - config_path : path to the project's config file + - shuffle : shuffle index + - model_prefix: model prefix + - load_epoch: + index (starting at 0) of the snapshot we want to load, + if -1 loads the last one automatically + for example if we have 3 models saved + -snapshot-0.pt + -snapshot-50.pt + -snapshot-100.pt + and we want to load the second one, load epoch should be 1 + - stride : unused #TODO We clearly should remove this + - transform : + transformation pipeline for evaluation + ** Should normalise the data the same way it was normalised during training ** + - plot: whether to plot the predicted data or not + #TODO Currently does not work for multinaimal, should be False for multianimal project + otherwise it breaks + - evaluate: whether to compare predictions and ground truth + + Returns: + None + """ # reading pytorch config cfg = auxiliaryfunctions.read_config(config_path) train_fraction = cfg["TrainingFraction"] diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 9caf1e3291..381b33d060 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -2,8 +2,8 @@ import deeplabcut.pose_estimation_pytorch as dlc import os from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver, build_transforms -from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS +from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver, build_transforms, update_config_parameters +from deeplabcut.pose_estimation_pytorch.solvers.base import Solver from torch.utils.data import DataLoader import albumentations as A from typing import Union @@ -13,8 +13,34 @@ def train_network( config_path: str, shuffle: int = 1, training_set_index: Union[int, str] = 0, - transform = None, - model_prefix: str = ""): + transform: Union[A.BaseCompose, A.BasicTransform] = None, + model_prefix: str = "", + **kwargs) -> Solver: + + """ + Trains a network for a project + + Args: + - config_path : path to the yaml config file of the project + - shuffle : index of the shuffle we want to train on + - training_set_index : training set index + + - transform: if None, the augmentation pipeline is built from config files + Advice if you want ot use custom transformations: + Keep in mind that in order for transfer leanring to be efficient, your + data statistical distribution should resemble the one used to pretrain your backbone + + In most cases (e.g bacbone was pretrained on ImageNet), that means it should be Normalized with + A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) + + - model_prefix: model prefix + - **kwargs : could be any entry of the pytorch_config dictionnary + to see the full list see the pytorch_cfg.yaml file in your project folder + + Returns: + solver: solver used for training, stores data about losses during training + """ + cfg = auxiliaryfunctions.read_config(config_path) if training_set_index == "all": train_fraction = cfg["TrainingFraction"] @@ -28,7 +54,7 @@ def train_network( ) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") pytorch_config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) - + update_config_parameters(pytorch_config=pytorch_config, **kwargs) if transform is None: print("No transform specified... using default") transform = build_transforms(dict(pytorch_config['data'])) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 2a544aba4b..3ec730fe1c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -14,7 +14,15 @@ from deeplabcut.pose_estimation_pytorch.solvers.base import Solver -def build_pose_model(cfg: Dict, pytorch_cfg): +def build_pose_model(cfg: Dict, pytorch_cfg: Dict) -> PoseModel: + """ + Returns a pytorch pose model based on pytorch config + + Args: + cfg : sub dict of the pytorch config that contains all information about the model + pytorch_cfg : entire pytorch config""" + + #TODO not sure why exactly we would need those two dicts as entries backbone = BACKBONES.build(dict(cfg['backbone'])) head_heatmaps = HEADS.build(dict(cfg['heatmap_head'])) head_locref = HEADS.build(dict(cfg['locref_head'])) @@ -35,7 +43,14 @@ def build_pose_model(cfg: Dict, pytorch_cfg): def build_solver(pytorch_cfg: Dict) -> Solver: - # pose_cfg = auxiliaryfunctions.read_plainconfig(cfg['pose_cfg_path']) + """ + Build the solver object to run training + + Args: + pytorch_cfg: config dictionnary to build the solver + Returns: + solver : solver to train the model + """ pose_model = build_pose_model(pytorch_cfg['model'], pytorch_cfg) get_optimizer = getattr(torch.optim, pytorch_cfg['optimizer']['type']) @@ -74,7 +89,15 @@ def build_solver(pytorch_cfg: Dict) -> Solver: return solver -def build_transforms(aug_cfg): +def build_transforms(aug_cfg:dict) -> Union[A.BasicTransform, A.BaseCompose]: + """ + Returns the transformation pipeline based on config + + Args: + aug_cfg : dict containing all transforms information + Returns: + transform: callable element that can augment images, keypoints and bboxes + """ transforms = [] if aug_cfg.get('resize', False): @@ -208,3 +231,20 @@ def videos_in_folder( f"Could not find the video: {video_path}. Check access rights." ) return [video_path] + + +def update_config_parameters(pytorch_config: dict, **kwargs) -> None: + """ + Overwrites the pytorch config dictionnary to correspond to the command line input keys + + Args: + pytorch_config + **kwargs : any arguments that can be found as entry for the pytorch config + + Return: + None + """ + for key in kwargs.keys(): + pytorch_config[key] = kwargs[key] + + return \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index d411dab48f..005ddc0795 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -11,6 +11,9 @@ class PoseDataset(Dataset, BaseDataset): + """ + Dataset for pose estimation + """ def __init__(self, project: DLCProject, @@ -21,10 +24,7 @@ def __init__(self, Parameters ---------- project: see class Project (wrapper for DLC original project class) - transform: transformation function: - - def transform(image, keypoints): - return image, keypoints + transform: augmentation/normalization pipeline mode: 'train' or 'test' this parameter which dataframe parse from the Project (df_tran or df_test) @@ -55,9 +55,8 @@ def transform(image, keypoints): pytorch_cfg = read_plainconfig(pytorch_config_path) self.with_center = pytorch_cfg.get('with_center', False) self.max_num_animals = len(self.cfg.get('individuals', ['0'])) + self.color_mode = pytorch_cfg.get('colormode', 'RGB') - # We must dropna because self.project.images doesn't contain imgaes with no labels so it can produce an indexnotfound error - # length is stored here to avoid repeating the computation self.length = self.dataframe.shape[0] assert self.length == len(self.project.image_path2image_id.keys()) @@ -83,7 +82,7 @@ def _keypoint_in_boundary(self, keypoint, shape): return (keypoint[0] > 0) and (keypoint[1] > 0) and (keypoint[0] < shape[1]) and (keypoint[1] < shape[0]) def __getitem__(self, - index: int): + index: int) -> dict: """ Parameters @@ -92,12 +91,18 @@ def __getitem__(self, ordered number of the item in the dataset Returns ------- - image: torch.FloatTensor \in [0, 255] - Tensor for the image from the dataset - keypoints: list of keypoints + dictionnary corresponding to the image, annotations... + keys: + -'image' : image + -'annotations': + -'keypoints' : array of keypoints, invisible keypoints appear as (-1, -1) + -'area': array of animals area in this image + -'original_size' : original size of the image before applying transforms + useful to convert the predictions/ground truth back to + the input space train_dataset = PoseDataset(project, transform=transform) - im, keypoints = train_dataset[0] + pose_dict = train_dataset[0] """ # load images @@ -112,6 +117,8 @@ def __getitem__(self, print(index) image = cv2.imread(image_file) + if self.color_mode == 'RGB': + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) original_size = image.shape # load annotations @@ -180,7 +187,7 @@ def __getitem__(self, image = torch.FloatTensor(transformed['image']).permute(2, 0, 1) # channels first assert len(transformed['keypoints']) == len(keypoints) - keypoints = np.array(transformed['keypoints']).reshape((n_annotations, num_keypoints_returned, 2)) + keypoints = np.array(transformed['keypoints']).reshape((n_annotations, num_keypoints_returned, 2)).astype(float) #TODO Quite ugly # diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py index 1fe4eafa61..89a8d1a48e 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py @@ -12,10 +12,21 @@ class DLCProject(BaseProject): """ - TODO + Wrapper around the project containing information about the data, + the actual annotations and the configs + + Methods: + - convert2dict : convert the annotations dataframe into a coco format dict of annotations + - _init_annotation_image_correspondance: binds the image paths to corresponding annotations + ensures there is no indexing offsets between images and annotations when + going through the dataset + - load_split : split the annotation dataframe into train and test dataframes + based on project's split + - annotation2keypoints : convert the coco annotations into array of keypoints + also returns the array of the keypoints' visibility """ - def __init__(self, proj_root, + def __init__(self, proj_root:str, shuffle: int = 0, image_id_offset: int = 0, keys_to_load: List[str] = ['images', 'annotations']): @@ -69,7 +80,8 @@ def convert2dict(self, setattr(self, key, data[key]) print('The data has been loaded!') - def _init_annotation_image_correspondance(self, data): + def _init_annotation_image_correspondance(self, data:dict): + """data should be a COCO like dictionnary of the pose dataset""" # Path to id correspondance self.image_path2image_id = {} @@ -121,12 +133,11 @@ def annotation2keypoints(annotation): ------- keypoints: list paired keypoints - undef_ids: list - mask + undef_ids: array + 0 means this keypoints is undefined, 1 means it is """ x = annotation['keypoints'][::3] y = annotation['keypoints'][1::3] - vis = annotation['keypoints'][2::3] undef_ids = ((x > 0) & (y > 0)).astype(int) keypoints = [] diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index c99f76b0ad..8e393c9f3c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -7,6 +7,8 @@ BACKBONES = Registry('backbones', build_func=build_from_cfg) class BaseBackbone(ABC, nn.Module): + """ + Backbone for pose estimation""" def __init__(self): super().__init__() @@ -44,6 +46,7 @@ def activate_batch_norm(self, activation: bool=False): self.batch_norm_on = activation def train(self, mode = True): + # Bacth Norm should not be on for small batch sizes super(BaseBackbone, self).train(mode) if not self.batch_norm_on: diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index aed5a32f0a..bfc6ddb6ee 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -7,6 +7,12 @@ @BACKBONES.register_module class HRNet(BaseBackbone): + """ + HRNet backbone, this version returns high resolution feature maps of size + 1/4 * original_image_size + This is obtained using bilinear interpolation and concatenation of all the outputs of the + HRNet stages + """ def __init__(self, model_name: str = 'hrnet_w32') -> nn.Module: """ diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index b72d0d69da..635734230f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -5,6 +5,9 @@ @BACKBONES.register_module class ResNet(BaseBackbone): + """ + Typical ResNet backbone + """ def __init__(self, model_name: str = 'resnet50', pretrained: str = None) -> nn.Module: diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index 4f90dea3c4..0c57a66a0a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -107,8 +107,8 @@ def forward(self, prediction, target): target['heatmaps'], target.get('heatmaps_ignored', 1)) - locref_loss = self.loss_weight_locref * self.locref_criterion(locref, - target['locref_maps'], - target['locref_masks']) - total_loss = locref_loss + heatmap_loss + locref_loss = self.locref_criterion(locref, + target['locref_maps'], + target['locref_masks']) + total_loss = locref_loss*self.loss_weight_locref + heatmap_loss return total_loss, heatmap_loss, locref_loss diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index 46ac6bc11e..a9bce43d79 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -8,6 +8,9 @@ HEADS = Registry('heads', build_func=build_from_cfg) class BaseHead(ABC, nn.Module): + """ + Head for pose estimation models + """ def __init__(self): super().__init__() diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py index 5834510832..07efab4122 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py @@ -8,6 +8,16 @@ @HEADS.register_module class HeatmapDEKRHead(BaseHead): + """ + DEKR head to compute the heatmaps corresponding to keypoints + based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang + CVPR + 2021 + Code based on: + https://github.com/HRNet/DEKR + """ def __init__( self, @@ -98,6 +108,16 @@ def forward(self, x): @HEADS.register_module class OffsetDEKRHead(BaseHead): + """ + DEKR head to compute the offset from the center corresponding to each keypoints + based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang + CVPR + 2021 + Code based on: + https://github.com/HRNet/DEKR + """ def __init__( self, diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index d9c5bd801a..06a8cc7da2 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -6,6 +6,9 @@ @HEADS.register_module class SimpleHead(BaseHead): + """ + Deconvolutional head to predict maps from the extracted features + """ def __init__(self, channels: list, kernel_size: list, diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index bbc655245a..4d7d696d00 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -1,5 +1,6 @@ import numpy as np import torch +from typing import Tuple from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict from torch import nn @@ -7,7 +8,9 @@ class PoseModel(nn.Module): - + """ + Complete model architecture + """ def __init__(self, cfg: dict, backbone: torch.nn.Module, @@ -30,16 +33,17 @@ def __init__(self, self.target_generator = target_generator self.sigmoid = nn.Sigmoid() - def forward(self, x): + def forward(self, x:torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """ TODO Parameters ---------- - x + x: input images Returns ------- - + heat_maps : heatmaps + loc_ref : locref maps """ if x.dim() == 3: x = x[None, :] @@ -52,8 +56,19 @@ def forward(self, x): return heat_maps, loc_ref def get_target(self, - annotations, - prediction, - image_size): + annotations:dict, + prediction:Tuple[torch.Tensor, torch.Tensor], + image_size:Tuple[int, int]): + """_summary_ + + Args: + annotations (dict): dict of annotations + prediction (Tuple[torch.Tensor, torch.Tensor]): output of the model + (used here to compute the scaling factor of the model) + image_size (Tuple[int, int]): image_size, used here to compute the scaling factor of the model + + Returns: + targets : dict of the targets needed for model training + """ return self.target_generator(annotations, prediction, image_size) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 989ef8244a..fea2a9e678 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -5,10 +5,18 @@ from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS, BasePredictor -#TODO what about credits ? DIfferent code from DEKR repo but still largely inspired from it - @PREDICTORS.register_module class DEKRPredictor(BasePredictor): + """ + Regresses keypoints and assembles them (if multianimal project) from DEKR output + Based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang + CVPR + 2021 + Code based on: + https://github.com/HRNet/DEKR + """ default_init = { 'apply_sigmoid' : True, @@ -24,7 +32,7 @@ def __init__(self, num_animals: int, detection_threshold: float=0.01, apply_sigm self.use_heatmap = use_heatmap def forward(self, outputs, scale_factors: Tuple[float, float]): - + #TODO implement confidence scores for each keypoints heatmaps, offsets = outputs if self.apply_sigmoid: heatmaps = nn.Sigmoid()(heatmaps) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index 6792de696f..58d8d94c79 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -5,7 +5,11 @@ @PREDICTORS.register_module class SinglePredictor(BasePredictor): - """Predictor only intended for single animal pose estimation""" + """ + Predictor only intended for single animal pose estimation + + Regresses keypoints from heatmaps and locref_maps of baseline DLC model (ResNet + Deconv) + """ default_init = { 'location_refinement': True, diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index 82158dd2b0..be6c6eeeca 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -1,11 +1,22 @@ import numpy as np +from typing import Tuple +import torch from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS @TARGET_GENERATORS.register_module class DEKRGenerator(BaseGenerator): + """ + Generate ground truth target for DEKR model training + based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang + CVPR + 2021 + Code based on: + https://github.com/HRNet/DEKR""" - def __init__(self, num_joints, pos_dist_thresh, bg_weight = 0.1): + def __init__(self, num_joints:int, pos_dist_thresh:int, bg_weight:float = 0.1): super().__init__() self.num_joints = num_joints @@ -14,14 +25,32 @@ def __init__(self, num_joints, pos_dist_thresh, bg_weight = 0.1): self.num_joints_with_center = self.num_joints + 1 - def get_heat_val(self, sigma, x, y, x0, y0): + def get_heat_val(self, sigma:float, x:float, y:float, x0:float, y0:float): g = np.exp(- ((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma ** 2)) return g - def forward(self, annotations, prediction, image_size): + def forward(self, annotations:dict, prediction:Tuple[torch.Tensor, torch.Tensor], image_size:Tuple[int, int]): + """ + Parameters + ---------- + annotations: dict, each entry should begin with the shape batch_size + prediction: output of model, format could depend on the model, only used to compute output resolution + image_size: size of image (only one tuple since for batch training all images should have the same size) + + Returns + ------- + #TODO locref is a bad name here and should be 'offset to center', but for code's simplicity it + is easier to use the same keys as for the SingleAnimal target generators + targets : dict of the taregts, keys: + 'heatmaps' : heatmaps + 'heatmaps_ignored': weights to apply to the heatmaps for loss computation + 'locref_maps' : offset maps + 'locref_masks' : weights to apply to the offet maps for loss computation + + """ batch_size, _, output_h, output_w = prediction[0].shape output_res = output_h, output_w stride_y, stride_x = image_size[0]/output_h, image_size[1]/output_w diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py index 27e88cc871..fcf6fefa32 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py @@ -1,11 +1,17 @@ import numpy as np +from typing import Tuple +import torch from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS @TARGET_GENERATORS.register_module class GaussianGenerator(BaseGenerator): + """ + Generate gaussian heatmaps and locref targets from ground truth keypoints in order + to train baseline deeplabcut model (ResNet + Deconv) + """ - def __init__(self, locref_stdev, num_joints, pos_dist_thresh): + def __init__(self, locref_stdev:float, num_joints:int, pos_dist_thresh:int): super().__init__() self.locref_scale = 1.0/locref_stdev @@ -15,7 +21,11 @@ def __init__(self, locref_stdev, num_joints, pos_dist_thresh): self.std = 2*self.dist_thresh / 3 # We think of dist_thresh as a radius and std is a 'diameter' - def forward(self, annotations, prediction, image_size): + def forward(self, + annotations:dict, + prediction:Tuple[torch.Tensor, torch.Tensor], + image_size:Tuple[int, int] + ): """ Parameters @@ -26,7 +36,10 @@ def forward(self, annotations, prediction, image_size): Returns ------- - dict of the targets + targets : dict of the taregts, keys: + 'heatmaps' : heatmaps + 'locref_maps' : locref maps + 'locref_masks' : weights to apply to the locref maps for loss computation """ # stride = cfg['stride'] # Apparently, there is no stride in the cfg diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py index 1fb84e311b..64ae93a53a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py @@ -1,11 +1,17 @@ import numpy as np +import torch +from typing import Tuple from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS @TARGET_GENERATORS.register_module class PlateauGenerator(BaseGenerator): + """ + Generate gaussian heatmaps and locref targets from ground truth keypoints in order + to train baseline deeplabcut model (ResNet + Deconv) + """ - def __init__(self, locref_stdev, num_joints, pos_dist_thresh): + def __init__(self, locref_stdev:float, num_joints:int, pos_dist_thresh:int): super().__init__() self.locref_scale = 1.0/locref_stdev @@ -13,7 +19,11 @@ def __init__(self, locref_stdev, num_joints, pos_dist_thresh): self.dist_thresh = float(pos_dist_thresh) self.dist_thresh_sq = self.dist_thresh ** 2 - def forward(self, annotations, prediction, image_size): + def forward(self, + annotations:dict, + prediction:Tuple[torch.Tensor, torch.Tensor], + image_size:Tuple[int, int] + ): """ Parameters @@ -24,7 +34,10 @@ def forward(self, annotations, prediction, image_size): Returns ------- - + targets : dict of the taregts, keys: + 'heatmaps' : heatmaps + 'locref_maps' : locref maps + 'locref_masks' : weights to apply to the locref maps for loss computation """ batch_size, _, height, width = prediction[0].shape stride_y, stride_x = image_size[0]/height, image_size[1]/width diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 6fa5ca4406..a5ef6bd72c 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -37,6 +37,7 @@ def __init__(self, cfg: DeepLabCut pose_cfg for training. See https://github.com/DeepLabCut/DeepLabCut/blob/main/deeplabcut/pose_cfg.yaml for more details. scheduler: Optional. Scheduler for adjusting the lr of the optimizer. + logger: logger to monitor training (e.g WandB logger) """ if cfg is None: raise ValueError('') @@ -201,7 +202,7 @@ def step(self, Returns ------- - batch loss + batch loss, heatmap_loss, locref_loss """ if mode not in ['train', 'eval']: raise ValueError(f'Solver must be in train or eval mode, but {mode} was found.') diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index 0f2f955afb..b63f5e84d0 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -4,7 +4,7 @@ from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly, evaluate_assembly from torch import nn -from typing import List +from typing import List, Tuple, Dict def get_prediction(cfg, output, stride=8): @@ -29,7 +29,7 @@ def get_prediction(cfg, output, stride=8): poses.append(pose) return np.stack(poses, axis=0) - +#DEPRECATED def get_top_values(scmap, n_top=5): batchsize, ny, nx, num_joints = scmap.shape scmap_flat = scmap.reshape(batchsize, nx * ny, num_joints) @@ -46,7 +46,7 @@ def get_top_values(scmap, n_top=5): Y, X = np.unravel_index(scmap_top, (ny, nx)) return Y, X - +#DEPRECATED def multi_pose_predict(scmap, locref, stride, num_outputs): Y, X = get_top_values(scmap[None], num_outputs) Y, X = Y[:, 0], X[:, 0] @@ -70,10 +70,23 @@ def multi_pose_predict(scmap, locref, stride, num_outputs): return pose -def get_scores(cfg, +def get_scores(cfg: Dict, prediction: pd.DataFrame, target: pd.DataFrame, - bodyparts: List = None): + bodyparts: List[str] = None) -> Dict: + """_summary_ + + Args: + cfg (Dict): config dictionnary + prediction (pd.DataFrame): prediction df, should already be matched to ground truth using + hungarian algorithm + target (pd.DataFrame): ground truth dataframe + bodyparts (List[str], optional): names of the bodyparts. Defaults to None. + + Returns: + Dict: scores dict, keys are : + ['rmse', 'rmse_pcutoff', 'mAP', 'mAR', 'mAP_pcutoff', 'mAR_pcutoff'] + """ if cfg.get('pcutoff'): pcutoff = cfg['pcutoff'] rmse, rmse_p = get_rmse(prediction, target, pcutoff, @@ -95,10 +108,25 @@ def get_scores(cfg, return scores -def get_rmse(prediction, +def get_rmse(prediction: pd.DataFrame, target: pd.DataFrame, pcutoff: float=-1, - bodyparts: List[str] =None): + bodyparts: List[str] =None) -> Tuple[float, float]: + """Computes rmse for predictions + Assumes hungarian algorithm matching has already be applied to match predicted animals + and ground truth ones. + + Args: + prediction (pd.DataFrame): prediction dataframe + target (pd.DataFrame): target dataframe + pcutoff (float, optional): Confidence lower bound for a keypoint to be considred as detected. + Defaults to -1. + bodyparts (List[str], optional): list of the bodyparts names. Defaults to None. + + Returns: + rmse: rmse without cutoff + rmse_p : rmse with cutoff + """ scorer_pred = prediction.columns[0][0] scorer_target = target.columns[0][0] mask = prediction[scorer_pred].xs("likelihood", level=2, axis=1) >= pcutoff @@ -118,7 +146,23 @@ def get_oks(prediction: pd.DataFrame, margin=0, symmetric_kpts=None, pcutoff: float=-1, - bodyparts: List[str] =None): + bodyparts: List[str] =None) -> Tuple[Dict, Dict]: + """Computes oks related scores for predictions + + Args: + prediction (pd.DataFrame): prediction dataframe + target (pd.DataFrame): target dataframe + oks_sigma (float, optional): Sigma for oks conputation. Defaults to 0.1. + margin (int, optional): margin used for bbox computation. Defaults to 0. + symmetric_kpts (_type_, optional): Not supported yet. Defaults to None. + pcutoff (float, optional): Confidence lower bound for a keypoint to be considred as detected. + Defaults to -1. + bodyparts (List[str], optional): list of the bodyparts names. Defaults to None. + + Returns: + oks_raw (Dict): oks scores without p_cutoff + oks_pcutoff (Dict): oks scores with pcutoff + """ scorer_pred = prediction.columns[0][0] scorer_target = target.columns[0][0] diff --git a/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py b/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py index 4da0b8e95c..867ac6bb01 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py @@ -1,6 +1,6 @@ -from torch.optim.lr_scheduler import LRScheduler +from torch.optim.lr_scheduler import _LRScheduler -class LRListScheduler(LRScheduler): +class LRListScheduler(_LRScheduler): def __init__(self, optimizer, last_epoch=-1, verbose=False, milestones=[10], lr_list=[0.001]): self.milestones = milestones From 1fba354a4c9e8afed4ef1466cd37d3ff498dd8bc Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Tue, 27 Jun 2023 14:56:43 +0200 Subject: [PATCH 023/293] add auto_padding for images --- .../make_pytorch_config.py | 6 ++++++ .../apis/analyze_videos.py | 13 +++++++++++-- .../pose_estimation_pytorch/apis/inference.py | 15 ++++++++++----- deeplabcut/pose_estimation_pytorch/apis/utils.py | 11 +++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 0e827ab9e8..424314f2d8 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -98,6 +98,12 @@ def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str version = net_type.split('_')[-1] backbone_type = 'hrnet_' + version num_offset_per_kpt = 15 + pytorch_config['data']['auto_padding'] = { + 'min_height': 64, + 'min_width': 64, + 'pad_width_divisor': 32, + 'pad_height_divisor': 32, + } pytorch_config['model']['backbone'] = { 'type': 'HRNet', 'model_name': 'hrnet_' + version diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 815c8b0f2c..d25989d166 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -39,7 +39,8 @@ def video_inference( batch_size: int = 1, device: Optional[str] = None, transform: Optional[A.Compose] = None, - colormode: Optional[str]= 'RGB' + colormode: Optional[str]= 'RGB', + frames_resized: Optional[bool]= False, ) -> List[np.ndarray]: """ Runs inference on all frames of a video @@ -52,6 +53,7 @@ def video_inference( device: the torch device to use to run inference. Dynamic selection if None transform: the image augmentation transform to use on the video frames, if any colormode: RGB or BGR + frames_resized: Wether the frame are resized for inference or not Returns: for each frame in the video, a numpy array containing the output of the @@ -101,6 +103,7 @@ def video_inference( batch.shape[3] / output[0].shape[3] ) for frame_pred in predictor(output, scale_factor).cpu().numpy(): + #TODO if images are resized should be taken into account for inference predictions.append(frame_pred) frame = video_reader.read_frame() @@ -186,6 +189,11 @@ def analyze_videos( pose_cfg["batch_size"] = batch_size individuals = project.cfg.get('individuals', ['single']) + # Get data processing parameters + # if images are resized for inference, + # need to take that into account to go back to original space + frames_resized_with_transform = pytorch_config['data'].get('resize', False) + # Load model, predictor model = build_pose_model(pytorch_config['model'], pose_cfg) model.load_state_dict(torch.load(model_path)) @@ -214,7 +222,8 @@ def analyze_videos( batch_size=batch_size, device=device, transform=transform, - colormode=pytorch_config.get('colormode', 'RGB') + colormode=pytorch_config.get('colormode', 'RGB'), + frames_resized=frames_resized_with_transform ) runtime.append(time.time()) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index a48a7b7c85..cd8ce70e57 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -76,6 +76,10 @@ def inference_network( batch_size=batch_size, shuffle=False) + # if images are resized for inference, + # need to take that into account to go back to original space + images_resized_with_transform = config['data'].get('resize', False) + names = get_paths(train_fraction=train_fraction[0], model_prefix=model_prefix, shuffle=shuffle, @@ -121,11 +125,12 @@ def inference_network( predictions[b] = predictions[b][match_individuals] # converts back to original image size if image was resized during the augmentation pipeline - for b in range(predictions.shape[0]): - resizing_factor = (item['original_size'][0][b]/shape_image[2]).item(), (item['original_size'][1][b]/shape_image[3]).item() - predictions[b, :, :, 0] = predictions[b, :, :, 0]*resizing_factor[1] + resizing_factor[1]/2 - predictions[b, :, :, 1] = predictions[b, :, :, 1]*resizing_factor[0] + resizing_factor[0]/2 - predicted_poses.append(predictions) + if images_resized_with_transform: + for b in range(predictions.shape[0]): + resizing_factor = (item['original_size'][0][b]/shape_image[2]).item(), (item['original_size'][1][b]/shape_image[3]).item() + predictions[b, :, :, 0] = predictions[b, :, :, 0]*resizing_factor[1] + resizing_factor[1]/2 + predictions[b, :, :, 1] = predictions[b, :, :, 1]*resizing_factor[0] + resizing_factor[0]/2 + predicted_poses.append(predictions) predicted_poses = np.array(predicted_poses) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 3ec730fe1c..84c56c238d 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -178,6 +178,17 @@ def build_transforms(aug_cfg:dict) -> Union[A.BasicTransform, A.BaseCompose]: ) ) + if aug_cfg.get('auto_padding'): + params = aug_cfg.get('auto_padding') + pad_height_divisor = params.get('pad_height_divisor', 1) + pad_width_divisor = params.get('pad_width_divisor', 1) + transforms.append(A.PadIfNeeded( + min_height= None, + min_width= None, + pad_height_divisor=pad_height_divisor, + pad_width_divisor=pad_width_divisor) + ) + if aug_cfg.get('normalize_images'): transforms.append(A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225])) return A.Compose( From ad75508454319ee502bb60a28c973ae8ed91f1fc Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Tue, 4 Jul 2023 15:27:45 +0200 Subject: [PATCH 024/293] implement top down + token pose --- .../make_pytorch_config.py | 330 +++++++++++----- ...ple_individuals_trainingsetmanipulation.py | 12 +- .../trainingsetmanipulation.py | 9 +- deeplabcut/pose_estimation_pytorch/README.md | 4 +- .../pose_estimation_pytorch/__init__.py | 2 +- .../pose_estimation_pytorch/apis/__init__.py | 3 +- .../apis/analyze_videos.py | 168 ++++++-- .../pose_estimation_pytorch/apis/config.yaml | 35 +- .../apis/convert_detections_to_tracklets.py | 23 +- .../pose_estimation_pytorch/apis/inference.py | 250 +++++++----- .../apis/inference_utils.py | 106 +++++ .../pose_estimation_pytorch/apis/train.py | 143 ++++--- .../pose_estimation_pytorch/apis/utils.py | 351 ++++++++++++----- .../pose_estimation_pytorch/data/base.py | 1 + .../data/cocoproject.py | 53 +-- .../pose_estimation_pytorch/data/dataset.py | 361 +++++++++++++++--- .../data/dlcproject.py | 79 ++-- .../pose_estimation_pytorch/default_config.py | 123 +++--- .../models/__init__.py | 12 +- .../models/backbones/__init__.py | 2 +- .../models/backbones/base.py | 11 +- .../models/backbones/hrnet.py | 56 ++- .../models/backbones/resnet.py | 19 +- .../models/criterion.py | 119 ++++-- .../models/detectors/__init__.py | 2 + .../models/detectors/base.py | 25 ++ .../models/detectors/fasterRCNN.py | 50 +++ .../models/heads/__init__.py | 2 +- .../models/heads/base.py | 3 +- .../models/heads/dekr_heads.py | 175 ++++----- .../models/heads/simple_head.py | 122 +++--- .../pose_estimation_pytorch/models/model.py | 54 +-- .../models/necks/__init__.py | 2 + .../models/necks/base.py | 19 + .../models/necks/layers.py | 112 +++--- .../models/necks/transformer.py | 141 ++++--- .../models/necks/utils.py | 12 +- .../models/predictors/__init__.py | 3 +- .../models/predictors/base.py | 4 +- .../models/predictors/dekr_predictor.py | 123 +++--- .../models/predictors/single_predictor.py | 128 ++++++- .../models/predictors/top_down_prediction.py | 68 ++++ .../models/target_generators/__init__.py | 2 +- .../models/target_generators/base.py | 6 +- .../models/target_generators/dekr_targets.py | 166 ++++---- .../target_generators/gaussian_targets.py | 104 +++-- .../target_generators/plateau_targets.py | 101 +++-- .../pose_estimation_pytorch/models/utils.py | 109 +++--- .../post_processing/__init__.py | 5 +- .../match_predictions_to_gt.py | 46 ++- .../pose_estimation_pytorch/registry.py | 46 ++- .../solvers/__init__.py | 7 +- .../pose_estimation_pytorch/solvers/base.py | 152 ++++---- .../solvers/inference.py | 117 +++--- .../pose_estimation_pytorch/solvers/logger.py | 33 +- .../solvers/schedulers.py | 9 +- .../solvers/single_animal.py | 14 +- .../solvers/top_down.py | 249 ++++++++++++ .../pose_estimation_pytorch/solvers/utils.py | 134 +++---- deeplabcut/pose_estimation_pytorch/utils.py | 130 ++++--- deeplabcut/utils/auxfun_models.py | 2 +- deeplabcut/utils/auxiliaryfunctions.py | 2 + 62 files changed, 3239 insertions(+), 1512 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/apis/inference_utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/detectors/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/necks/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py create mode 100644 deeplabcut/pose_estimation_pytorch/solvers/top_down.py diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 424314f2d8..4d566bd9cc 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -2,30 +2,35 @@ from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions BACKBONE_OUT_CHANNELS = { - 'resnet-50': 2048, - 'resnet-50': 2048, - 'resnet-50': 2048, - 'mobilenet_v2_1.0': 1280, - 'mobilenet_v2_0.75': 1280, - 'mobilenet_v2_0.5': 1280, - 'mobilenet_v2_0.35': 1280, - 'efficientnet-b0': 1280, - 'efficientnet-b1': 1280, - 'efficientnet-b2': 1408, - 'efficientnet-b3': 1536, - 'efficientnet-b4': 1792, - 'efficientnet-b5': 2048, - 'efficientnet-b6': 2304, - 'efficientnet-b7': 2560, - 'efficientnet-b8': 2816, - 'hrnet_w18': 270, - 'hrnet_w32': 480, - 'hrnet_w48': 720, + "resnet-50": 2048, + "resnet-50": 2048, + "resnet-50": 2048, + "mobilenet_v2_1.0": 1280, + "mobilenet_v2_0.75": 1280, + "mobilenet_v2_0.5": 1280, + "mobilenet_v2_0.35": 1280, + "efficientnet-b0": 1280, + "efficientnet-b1": 1280, + "efficientnet-b2": 1408, + "efficientnet-b3": 1536, + "efficientnet-b4": 1792, + "efficientnet-b5": 2048, + "efficientnet-b6": 2304, + "efficientnet-b7": 2560, + "efficientnet-b8": 2816, + "hrnet_w18": 270, + "hrnet_w32": 480, + "hrnet_w48": 720, } -def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str = 'default', config_template: dict=None): - ''' +def make_pytorch_config( + project_config: dict, + net_type: str, + augmenter_type: str = "default", + config_template: dict = None, +): + """ Currently supported net types : Single Animal : - resnet-50 @@ -51,52 +56,59 @@ def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str - dekr_w32 - dekr_w48 - ''' - - single_animal_nets = ['resnet_50' - , 'mobilenet_v2_1.0' - , 'mobilenet_v2_0.75' - , 'mobilenet_v2_0.5' - , 'mobilenet_v2_0.35' - , 'efficientnet-b0' - , 'efficientnet-b1' - , 'efficientnet-b2' - , 'efficientnet-b3' - , 'efficientnet-b4' - , 'efficientnet-b5' - , 'efficientnet-b6' - , 'efficientnet-b7' - , 'efficientnet-b8' - , 'hrnet_w18' - , 'hrnet_w32' - , 'hrnet_w48'] - - multi_animal_nets = ['dekr_w18' - , 'dekr_w32' - , 'dekr_w48'] + """ + + single_animal_nets = [ + "resnet_50", + "mobilenet_v2_1.0", + "mobilenet_v2_0.75", + "mobilenet_v2_0.5", + "mobilenet_v2_0.35", + "efficientnet-b0", + "efficientnet-b1", + "efficientnet-b2", + "efficientnet-b3", + "efficientnet-b4", + "efficientnet-b5", + "efficientnet-b6", + "efficientnet-b7", + "efficientnet-b8", + "hrnet_w18", + "hrnet_w32", + "hrnet_w48", + ] + + multi_animal_nets = [ + "dekr_w18", + "dekr_w32", + "dekr_w48", + "token_pose_w18", + "token_pose_w32", + "token_pose_w48", + ] bodyparts = auxiliaryfunctions.get_bodyparts(project_config) num_joints = len(bodyparts) pytorch_config = config_template - pytorch_config['device'] = 'cuda' if torch.cuda.is_available() else 'cpu' + pytorch_config["device"] = "cuda" if torch.cuda.is_available() else "cpu" + pytorch_config["method"] = "bu" if net_type in single_animal_nets: - pytorch_config['model']['heatmap_head']['channels'][-1] = num_joints - pytorch_config['model']['locref_head']['channels'][-1] = 2*num_joints - pytorch_config['model']['target_generator']['num_joints'] = num_joints - pytorch_config['predictor']['num_animals'] = 1 - - if 'efficientnet' in net_type: - raise NotImplementedError('efficientnet config not yet implemented') - elif 'mobilenetv2' in net_type: - raise NotImplementedError('mobilenet config not yet implemented') - elif 'hrnet' in net_type: - raise NotImplementedError('hrnet config not yet implemented') + pytorch_config["model"]["heads"] = make_single_head_cfg(num_joints, net_type) + pytorch_config["model"]["target_generator"]["num_joints"] = num_joints + pytorch_config["predictor"]["num_animals"] = 1 + + if "efficientnet" in net_type: + raise NotImplementedError("efficientnet config not yet implemented") + elif "mobilenetv2" in net_type: + raise NotImplementedError("mobilenet config not yet implemented") + elif "hrnet" in net_type: + raise NotImplementedError("hrnet config not yet implemented") elif net_type in multi_animal_nets: - num_animals = len(project_config.get('individuals', [0])) - if 'dekr' in net_type: - version = net_type.split('_')[-1] - backbone_type = 'hrnet_' + version + num_animals = len(project_config.get("individuals", [0])) + if "dekr" in net_type: + version = net_type.split("_")[-1] + backbone_type = "hrnet_" + version num_offset_per_kpt = 15 pytorch_config['data']['auto_padding'] = { 'min_height': 64, @@ -108,50 +120,170 @@ def make_pytorch_config(project_config: dict, net_type: str, augmenter_type: str 'type': 'HRNet', 'model_name': 'hrnet_' + version } - pytorch_config['model']['heatmap_head']= { - 'type': 'HeatmapDEKRHead', - 'channels': [ - BACKBONE_OUT_CHANNELS[backbone_type], - 64, - num_joints + 1 - ], # +1 since we need center - 'num_blocks': 1, - 'dilation_rate': 1, - 'final_conv_kernel': 1, - } - pytorch_config['model']['locref_head']= { - 'type': 'OffsetDEKRHead', - 'channels': [ - BACKBONE_OUT_CHANNELS[backbone_type], - num_offset_per_kpt*num_joints, - num_joints - ], - 'num_offset_per_kpt' : num_offset_per_kpt, - 'num_blocks': 1, - 'dilation_rate': 1, - 'final_conv_kernel': 1 - } - pytorch_config['model']['target_generator']= { - 'type': 'DEKRGenerator', - 'num_joints': num_joints, - 'pos_dist_thresh': 17, + pytorch_config["model"]["heads"] = make_dekr_head_cfg( + num_joints, backbone_type, num_offset_per_kpt + ) + pytorch_config["model"]["target_generator"] = { + "type": "DEKRGenerator", + "num_joints": num_joints, + "pos_dist_thresh": 17, } - pytorch_config['predictor']= { - 'type': 'DEKRPredictor', - 'num_animals': num_animals, + pytorch_config["predictor"] = { + "type": "DEKRPredictor", + "num_animals": num_animals, } - pytorch_config['with_center'] = True + pytorch_config["with_center"] = True + elif "token_pose" in net_type: + pytorch_config["method"] = "td" + version = net_type.split("_")[-1] + backbone_type = "hrnet_" + version + pytorch_config["detector"] = make_detector_cfg() + pytorch_config["model"] = make_token_pose_model_cfg( + num_joints, backbone_type + ) + pytorch_config["predictor"] = { + "type": "HeatmapOnlyPredictor", + "num_animals": 1, + } + pytorch_config["criterion"] = {"type": "HeatmapOnlyLoss"} + pytorch_config["solver"] = { + "type": "TopDownSolver", + } + pytorch_config["with_center"] = False else: - raise NotImplementedError('Currently no other model than dekr are implemented') - + raise NotImplementedError( + "Currently no other model than dekr and token_pose are implemented" + ) + else: - raise ValueError('This net type is not supported by pytorch verison') - + raise ValueError("This net type is not supported by pytorch verison") + if augmenter_type == None: - pytorch_config['data'] = {} - elif augmenter_type != 'default' and augmenter_type != None: - raise NotImplementedError('Other augmentations than default are not implemented') - + pytorch_config["data"] = {} + elif augmenter_type != "default" and augmenter_type != None: + raise NotImplementedError( + "Other augmentations than default are not implemented" + ) + return pytorch_config + + +def make_single_head_cfg(num_joints: int, net_type: str): + head_configs = [] + heatmap_heag_cfg, locref_head_cfg = {}, {} + + if "resnet" in net_type: + heatmap_heag_cfg = { + "type": "SimpleHead", + "channels": [2048, 1024, num_joints], + "kernel_size": [2, 2], + "strides": [2, 2], + } + head_configs.append(heatmap_heag_cfg) + + locref_head_cfg = { + "type": "SimpleHead", + "channels": [2048, 1024, 2 * num_joints], + "kernel_size": [2, 2], + "strides": [2, 2], + } + head_configs.append(locref_head_cfg) + + return head_configs + + +def make_dekr_head_cfg(num_joints: int, backbone_type: str, num_offset_per_kpt: int): + head_configs = [] + heatmap_heag_cfg, offset_head_cfg = {}, {} + + heatmap_heag_cfg = { + "type": "HeatmapDEKRHead", + "channels": [ + BACKBONE_OUT_CHANNELS[backbone_type], + 64, + num_joints + 1, + ], # +1 since we need center + "num_blocks": 1, + "dilation_rate": 1, + "final_conv_kernel": 1, + } + head_configs.append(heatmap_heag_cfg) + + offset_head_cfg = { + "type": "OffsetDEKRHead", + "channels": [ + BACKBONE_OUT_CHANNELS[backbone_type], + num_offset_per_kpt * num_joints, + num_joints, + ], + "num_offset_per_kpt": num_offset_per_kpt, + "num_blocks": 1, + "dilation_rate": 1, + "final_conv_kernel": 1, + } + head_configs.append(offset_head_cfg) + + return head_configs + + +def make_token_pose_model_cfg(num_joints, backbone_type): + model_cfg = {} + model_cfg["backbone"] = { + "type": "HRNetTopDown", + "model_name": backbone_type, + } + + model_cfg["neck"] = { + "type": "Transformer", + "feature_size": [64, 64], + "patch_size": [4, 4], + "num_keypoints": num_joints, + "channels": 32, + "dim": 192, + "heads": 8, + "depth": 6, + } + + model_cfg["heads"] = [] + model_cfg["heads"].append( + { + "type": "TransformerHead", + "dim": 192, + "hidden_heatmap_dim": 384, + "heatmap_dim": 4096, + "apply_multi": True, + "heatmap_size": [64, 64], + "apply_init": False, + } + ) + + model_cfg["target_generator"] = { + "type": "PlateauWithoutLocref", + "num_joints": num_joints, + "pos_dist_thresh": 17, + } + + model_cfg["pose_model"] = {"stride": 4} + return model_cfg + + +def make_detector_cfg(): + detector_cfg = {} + + detector_cfg["detector_model"] = { + "type": "FasterRCNN", + } + + detector_cfg["detector_optimizer"] = { + "type": "SGD", + "params": {"lr": 0.01}, + } + + detector_cfg["detector_scheduler"] = { + "type": "LRListScheduler", + "params": {"milestones": [10, 430], "lr_list": [[0.05], [0.005]]}, + } + + return detector_cfg diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index 7437227688..6564f17c88 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -211,7 +211,9 @@ def create_multianimaltraining_dataset( if net_type is None: # loading & linking pretrained models net_type = cfg.get("default_net_type", "dlcrnet_ms5") - elif not any(net in net_type for net in ("resnet", "eff", "dlc", "mob", 'dekr')): + elif not any( + net in net_type for net in ("resnet", "eff", "dlc", "mob", "dekr", "token_pose") + ): raise ValueError(f"Unsupported network {net_type}.") multi_stage = False @@ -487,8 +489,12 @@ def create_multianimaltraining_dataset( "apis", "pytorch_config.yaml", ) - pytorch_cfg_template = auxiliaryfunctions.read_plainconfig(pytorch_config_path) - pytorch_cfg = make_pytorch_config(cfg, net_type, config_template=pytorch_cfg_template) + pytorch_cfg_template = auxiliaryfunctions.read_plainconfig( + pytorch_config_path + ) + pytorch_cfg = make_pytorch_config( + cfg, net_type, config_template=pytorch_cfg_template + ) pytorch_cfg["project_path"] = os.path.dirname(config) pytorch_cfg["pose_cfg_path"] = path_train_config pytorch_cfg["cfg_path"] = config diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 7bf0d18d18..e7e3d6f625 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -909,6 +909,7 @@ def create_training_dataset( or "efficientnet" in net_type or "dlcrnet" in net_type or "dekr" in net_type + or "token_pose" in net_type ): pass else: @@ -1128,8 +1129,12 @@ def create_training_dataset( "apis", "pytorch_config.yaml", ) - pytorch_cfg_template = auxiliaryfunctions.read_plainconfig(pytorch_config_path) - pytorch_cfg = make_pytorch_config(cfg, net_type, config_template=pytorch_cfg_template) + pytorch_cfg_template = auxiliaryfunctions.read_plainconfig( + pytorch_config_path + ) + pytorch_cfg = make_pytorch_config( + cfg, net_type, config_template=pytorch_cfg_template + ) pytorch_cfg["project_path"] = os.path.dirname(config) pytorch_cfg["pose_cfg_path"] = path_train_config pytorch_cfg["cfg_path"] = config diff --git a/deeplabcut/pose_estimation_pytorch/README.md b/deeplabcut/pose_estimation_pytorch/README.md index 6b2012d677..3e613312a5 100644 --- a/deeplabcut/pose_estimation_pytorch/README.md +++ b/deeplabcut/pose_estimation_pytorch/README.md @@ -9,7 +9,7 @@ ## Data - [data](data/project.py#L7): The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. - - `Project` class provides train and test splitting and converts dataset to required format. For intance, to [COCO]() format. + - `Project` class provides train and test splitting and converts dataset to required format. For instance, to [COCO]() format. Example: @@ -34,7 +34,7 @@ The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pyt ``` > **Note** - > `transform` is a `List` of transformations to be applied to images and keypoints sequentialy, `None` by default. + > `transform` is a `List` of transformations to be applied to images and keypoints sequentially, `None` by default. Example: diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index 72be8306f5..edb6d33aeb 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -1,5 +1,5 @@ from deeplabcut.pose_estimation_pytorch.data.dlcproject import DLCProject -from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset, CroppedDataset from deeplabcut.pose_estimation_pytorch.utils import fix_seeds from deeplabcut.pose_estimation_pytorch.apis import ( analyze_videos, diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py index 271394e54e..068923a387 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py @@ -1,4 +1,3 @@ - # DeepLabCut Toolbox (deeplabcut.org) # © A. & M.W. Mathis Labs # https://github.com/DeepLabCut/DeepLabCut @@ -11,7 +10,7 @@ from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import analyze_videos from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( - convert_detections2tracklets + convert_detections2tracklets, ) from deeplabcut.pose_estimation_pytorch.apis.inference import inference_network from deeplabcut.pose_estimation_pytorch.apis.train import train_network diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index d25989d166..37d6e73afa 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -22,13 +22,26 @@ import cv2 import deeplabcut.pose_estimation_pytorch as dlc -from deeplabcut import auxiliaryfunctions -from deeplabcut.utils import auxfun_multianimal +from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, VideoReader from deeplabcut.pose_estimation_pytorch.models.model import PoseModel -from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS, BasePredictor -from deeplabcut.utils import VideoReader +from deeplabcut.pose_estimation_pytorch.models.detectors import ( + DETECTORS, + BaseDetector, +) +from deeplabcut.pose_estimation_pytorch.models.predictors import ( + PREDICTORS, + BasePredictor, +) from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_pose_model, read_yaml, get_model_snapshots, videos_in_folder, + build_pose_model, + read_yaml, + get_model_snapshots, + get_detector_snapshots, + videos_in_folder, +) +from deeplabcut.pose_estimation_pytorch.apis.inference_utils import ( + get_predictions_bottom_up, + get_predictions_top_down, ) @@ -39,8 +52,13 @@ def video_inference( batch_size: int = 1, device: Optional[str] = None, transform: Optional[A.Compose] = None, - colormode: Optional[str]= 'RGB', - frames_resized: Optional[bool]= False, + colormode: Optional[str] = "RGB", + method: Optional[str] = "bu", + detector: Optional[BaseDetector] = None, + top_down_predictor: Optional[BasePredictor] = None, + max_num_animals: Optional[int] = 1, + num_keypoints: Optional[int] = 1, + frames_resized: Optional[bool] = False, ) -> List[np.ndarray]: """ Runs inference on all frames of a video @@ -53,7 +71,13 @@ def video_inference( device: the torch device to use to run inference. Dynamic selection if None transform: the image augmentation transform to use on the video frames, if any colormode: RGB or BGR - frames_resized: Wether the frame are resized for inference or not + method: 'td' (Top Down) or 'bu' (Bottom Up) + detector: Detector for top down approach + top_down_predictor: Makes predictions from the cropped keypoints coordinates and + the detected bbox + max_num_animals: max number of animals + num_keypoints: number of keypoints + frames_resized: Whether the frame are resized for inference or not Returns: for each frame in the video, a numpy array containing the output of the @@ -88,7 +112,7 @@ def video_inference( if frame.dtype != np.uint8: frame = img_as_ubyte(frame) - if colormode =='BGR': + if colormode == "BGR": frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) batch_frames[batch_ind] = frame @@ -96,14 +120,31 @@ def video_inference( if transform: batch_frames = transform(image=batch_frames)["image"] - batch = torch.FloatTensor(batch_frames, device=device).permute(0, 3, 1, 2) - output = model(batch) - scale_factor = ( - batch.shape[2] / output[0].shape[2], - batch.shape[3] / output[0].shape[3] - ) - for frame_pred in predictor(output, scale_factor).cpu().numpy(): - #TODO if images are resized should be taken into account for inference + batch = torch.tensor( + batch_frames, device=device, dtype=torch.float + ).permute(0, 3, 1, 2) + if method.lower() == "td": + batched_predictions = get_predictions_top_down( + detector=detector, + top_down_predictor=top_down_predictor, + model=model, + pose_predictor=predictor, + images=batch, + max_num_animals=max_num_animals, + num_keypoints=num_keypoints, + device=device, + ) + elif method.lower() == "bu": + batched_predictions = get_predictions_bottom_up( + model=model, + predictor=predictor, + images=batch, + ) + else: + raise ValueError( + "Method must be either 'bu' (Bottom Up) or 'td' (Top Down)." + ) + for frame_pred in batched_predictions: predictions.append(frame_pred) frame = video_reader.read_frame() @@ -127,7 +168,7 @@ def analyze_videos( device: Optional[str] = None, transform: Optional[A.Compose] = None, inv_transform: Optional[A.Compose] = None, - overwrite: bool = False + overwrite: bool = False, ) -> List[Tuple[str, pd.DataFrame]]: """ Makes pose estimation predictions based on a trained model @@ -163,7 +204,10 @@ def analyze_videos( project_path = Path(project.cfg["project_path"]) train_fraction = project.cfg["TrainingFraction"][dataset_index] model_folder = project_path / auxiliaryfunctions.get_model_folder( - train_fraction, shuffle, project.cfg, modelprefix=model_prefix, + train_fraction, + shuffle, + project.cfg, + modelprefix=model_prefix, ) model_path = _get_model_path(model_folder, snapshot_index, project.cfg) model_epochs = int(model_path.stem.split("-")[-1]) @@ -174,12 +218,16 @@ def analyze_videos( trainingsiterations=model_epochs, modelprefix=model_prefix, ) + # Get general project parameters + max_num_animals = len(project.cfg.get("individuals", ["single"])) + num_keypoints = len(auxiliaryfunctions.get_bodyparts(project.cfg)) # Read the inference configuration, load the model pytorch_config_path = model_folder / "train" / "pytorch_config.yaml" pytorch_config = read_yaml(pytorch_config_path) pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" pose_cfg = auxiliaryfunctions.read_config(pose_cfg_path) + method = pytorch_config.get("method", "bu") # Get model parameters # TODO: Should we get the batch size from the inference pose_cfg? Or have an @@ -187,18 +235,27 @@ def analyze_videos( if batch_size is None: batch_size = pytorch_config.get("batch_size", 1) pose_cfg["batch_size"] = batch_size - individuals = project.cfg.get('individuals', ['single']) + individuals = project.cfg.get("individuals", ["single"]) # Get data processing parameters - # if images are resized for inference, + # if images are resized for inference, # need to take that into account to go back to original space - frames_resized_with_transform = pytorch_config['data'].get('resize', False) + frames_resized_with_transform = pytorch_config["data"].get("resize", False) # Load model, predictor - model = build_pose_model(pytorch_config['model'], pose_cfg) + model = build_pose_model(pytorch_config["model"], pose_cfg) model.load_state_dict(torch.load(model_path)) - predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config['predictor'])) - + predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config["predictor"])) + detector: BaseDetector = None + top_down_predictor: BasePredictor = None + if method.lower() == "td": + detector_path = _get_detector_path(model_folder, snapshot_index, project.cfg) + detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) + detector.load_state_dict(torch.load(detector_path)) + + top_down_predictor = PREDICTORS.build( + {"type": "TopDownPredictor", "format_bbox": "xyxy"} + ) # Reading video and init variables videos = videos_in_folder(data_path, video_type) results = [] @@ -216,14 +273,19 @@ def analyze_videos( else: runtime = [time.time()] predictions = video_inference( - model, - predictor, - video, + model=model, + predictor=predictor, + video_path=video, batch_size=batch_size, device=device, transform=transform, - colormode=pytorch_config.get('colormode', 'RGB'), - frames_resized=frames_resized_with_transform + colormode=pytorch_config.get("colormode", "RGB"), + method=method, + detector=detector, + top_down_predictor=top_down_predictor, + max_num_animals=max_num_animals, + num_keypoints=num_keypoints, + frames_resized=frames_resized_with_transform, ) runtime.append(time.time()) @@ -279,9 +341,9 @@ def _create_output_folder(output_folder: Optional[Path]) -> None: print(f"Creating the output folder {output_folder}") output_folder.mkdir(parents=True) - assert ( - Path(output_folder).is_dir() - ), f"Output folder must be a directory: you passed '{output_folder}'" + assert Path( + output_folder + ).is_dir(), f"Output folder must be a directory: you passed '{output_folder}'" def _generate_metadata( @@ -298,7 +360,10 @@ def _generate_metadata( cropping = config.get("cropping", False) if cropping: cropping_parameters = [ - config["x1"], config["x2"], config["y1"], config["y2"], + config["x1"], + config["x2"], + config["y1"], + config["y2"], ] else: cropping_parameters = [0, w, 0, h] @@ -339,9 +404,32 @@ def _get_model_path(model_folder: Path, snapshot_index: int, config: dict) -> Pa ) snapshot_index = -1 - assert isinstance(snapshot_index, int), ( - f"snapshotindex must be an integer but was '{snapshot_index}'" - ) + assert isinstance( + snapshot_index, int + ), f"snapshotindex must be an integer but was '{snapshot_index}'" + return trained_models[snapshot_index] + + +def _get_detector_path(model_folder: Path, snapshot_index: int, config: dict) -> Path: + trained_models = get_detector_snapshots(model_folder / "train") + + if snapshot_index is None: + snapshot_index = config["snapshotindex"] + + if snapshot_index == "all": + print( + "snapshotindex is set to 'all' in the config.yaml file. Running video " + "analysis with all snapshots is very costly! Use the function " + "'evaluate_network' to choose the best the snapshot. For now, changing " + "snapshot index to -1. To evaluate another snapshot, you can change the " + "value in the config file or call `analyze_videos` with your desired " + "snapshot index." + ) + snapshot_index = -1 + + assert isinstance( + snapshot_index, int + ), f"snapshotindex must be an integer but was '{snapshot_index}'" return trained_models[snapshot_index] @@ -356,11 +444,13 @@ def _generate_output_data( "sigma": pose_config.get("sigma", 1), "PAFgraph": pose_config.get("partaffinityfield_graph"), "PAFinds": pose_config.get( - "paf_best", np.arange(len(pose_config.get("partaffinityfield_graph", []))) + "paf_best", + np.arange(len(pose_config.get("partaffinityfield_graph", []))), ), "all_joints": [[i] for i in range(len(pose_config["all_joints"]))], "all_joints_names": [ - pose_config["all_joints_names"][i] for i in range(len(pose_config["all_joints"])) + pose_config["all_joints_names"][i] + for i in range(len(pose_config["all_joints"])) ], "nframes": len(predictions), } diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml index 499ecb16b4..fae20770b4 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -5,6 +5,17 @@ criterion: locref_huber_loss: true loss_weight_locref: 0.02 type: PoseLoss +cropped_data: # Used only for Top-Down approach + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true + rotation: 30 + scale_jitter: + - 0.5 + - 1.25 + translation: 40 data: covering: true gaussian_noise: 12.75 @@ -23,30 +34,6 @@ model: backbone: pretrained: https://download.pytorch.org/models/resnet50-19c8e357.pth type: ResNet - heatmap_head: - channels: - - 2048 - - 1024 - - -1 - kernel_size: - - 2 - - 2 - strides: - - 2 - - 2 - type: SimpleHead - locref_head: - channels: - - 2048 - - 1024 - - -1 - kernel_size: - - 2 - - 2 - strides: - - 2 - - 2 - type: SimpleHead pose_model: stride: 8 target_generator: diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index 5de7eb560c..9adf688c22 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -27,7 +27,8 @@ from deeplabcut.pose_estimation_tensorflow.lib import trackingutils from deeplabcut.utils import auxfun_multianimal from deeplabcut.pose_estimation_pytorch.apis.utils import ( - get_model_snapshots, videos_in_folder, + get_model_snapshots, + videos_in_folder, ) @@ -48,7 +49,7 @@ def convert_detections2tracklets( identity_only=False, track_method="", ): - """ TODO: Documentation, clean & remove code duplication (with analyze video) """ + """TODO: Documentation, clean & remove code duplication (with analyze video)""" cfg = auxiliaryfunctions.read_config(config_path) track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method) @@ -93,9 +94,9 @@ def convert_detections2tracklets( # Check which snapshots are available and sort them by # iterations snapshots = get_model_snapshots(model_dir / "train") - assert len(snapshots) > 0, ( - f"No snapshots were found in the model directory {model_dir / 'train'}" - ) + assert ( + len(snapshots) > 0 + ), f"No snapshots were found in the model directory {model_dir / 'train'}" snapshot_index = cfg["snapshotindex"] if snapshot_index == "all": print( @@ -160,9 +161,7 @@ def convert_detections2tracklets( # TODO: adjust this for multi + unique bodyparts! # this is only for multianimal parts and uniquebodyparts as one (not one # uniquebodyparts guy tracked etc.) - bodypartlabels = [ - bpt for i, bpt in enumerate(joints) for _ in range(3) - ] + bodypartlabels = [bpt for bpt in joints for _ in range(3)] scorers = len(bodypartlabels) * [dlc_scorer] xyl_value = int(len(bodypartlabels) / 3) * ["x", "y", "likelihood"] df_index = pd.MultiIndex.from_arrays( @@ -201,7 +200,9 @@ def convert_detections2tracklets( ass_data[ind] = [ass.data for ass in assemblies] if ass_unique: ass_data["single"] = ass_unique - with open(data_filename.parent / (data_filename.stem + "_assemblies.pickle"), "wb") as file: + with open( + data_filename.parent / (data_filename.stem + "_assemblies.pickle"), "wb" + ) as file: pickle.dump(ass_data, file, pickle.HIGHEST_PROTOCOL) # Initialize storage of the 'single' individual track @@ -243,9 +244,7 @@ def convert_detections2tracklets( trackers = mot_tracker.track(xy) else: # Optimal identity assignment based on soft voting - mat = np.zeros( - (len(assemblies), inference_cfg["topktoretain"]) - ) + mat = np.zeros((len(assemblies), inference_cfg["topktoretain"])) for nrow, assembly in enumerate(assemblies): for k, v in assembly.soft_identity.items(): mat[nrow, k] = v diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index cd8ce70e57..21f1dd396e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -4,26 +4,38 @@ import pandas as pd import os import torch -from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model +from deeplabcut.utils import auxiliaryfunctions +import deeplabcut.pose_estimation_pytorch.apis.inference_utils as inference_utils +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_pose_model, + build_inference_transform, +) +from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS -from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction, get_scores -from deeplabcut.pose_estimation_pytorch.solvers.utils import get_paths, get_results_filename, save_predictions +from deeplabcut.pose_estimation_pytorch.solvers.inference import get_scores +from deeplabcut.pose_estimation_pytorch.solvers.utils import ( + get_paths, + get_results_filename, + save_predictions, +) from deeplabcut.pose_estimation_tensorflow import Plotting -from deeplabcut.pose_estimation_pytorch.post_processing import rmse_match_prediction_to_gt, oks_match_prediction_to_gt +from deeplabcut.pose_estimation_pytorch.post_processing import ( + rmse_match_prediction_to_gt, +) import albumentations as A from typing import Union def inference_network( - config_path: str, - shuffle: int = 0, - model_prefix: str = "", - load_epoch: Union[int, str] = -1, - stride: int = 8, - transform: Union[A.BasicTransform, A.Compose] = None, - plot: bool = False, - evaluate: bool = True) -> None: + config_path: str, + shuffle: int = 0, + model_prefix: str = "", + load_epoch: Union[int, str] = -1, + stride: int = 8, + transform: Union[A.BasicTransform, A.Compose] = None, + plot: bool = False, + evaluate: bool = True, +) -> None: """ Performs inference on the validation dataset and save the results as a dataframe @@ -31,8 +43,8 @@ def inference_network( - config_path : path to the project's config file - shuffle : shuffle index - model_prefix: model prefix - - load_epoch: - index (starting at 0) of the snapshot we want to load, + - load_epoch: + index (starting at 0) of the snapshot we want to load, if -1 loads the last one automatically for example if we have 3 models saved -snapshot-0.pt @@ -40,7 +52,7 @@ def inference_network( -snapshot-100.pt and we want to load the second one, load epoch should be 1 - stride : unused #TODO We clearly should remove this - - transform : + - transform : transformation pipeline for evaluation ** Should normalise the data the same way it was normalised during training ** - plot: whether to plot the predicted data or not @@ -57,60 +69,99 @@ def inference_network( modelfolder = os.path.join( cfg["project_path"], auxiliaryfunctions.get_model_folder( - train_fraction[0], shuffle, cfg, modelprefix=model_prefix, + train_fraction[0], + shuffle, + cfg, + modelprefix=model_prefix, ), ) - individuals = cfg.get('individuals', ['single']) + individuals = cfg.get("individuals", ["single"]) + max_num_animals = len(individuals) + num_joints = len(auxiliaryfunctions.get_bodyparts(cfg)) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) - device = config['device'] - - batch_size = config['batch_size'] - project = dlc.DLCProject(shuffle=shuffle, - proj_root=config['project_path']) - - valid_dataset = dlc.PoseDataset(project, - transform=transform, - mode='test') - valid_dataloader = torch.utils.data.DataLoader(valid_dataset, - batch_size=batch_size, - shuffle=False) - - # if images are resized for inference, + pytorch_config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + method = pytorch_config.get("method", "bu") + if method not in ["bu", "td"]: + raise ValueError( + f"Method should be set to either 'bu' (Bottom Up) or 'td' (Top Down), currently it is {method}" + ) + device = pytorch_config["device"] + + batch_size = pytorch_config["batch_size"] + + if transform is None: + print("No transform passed, using default normalisation from config") + transform = build_inference_transform(pytorch_config["data"]) + project = dlc.DLCProject(shuffle=shuffle, proj_root=pytorch_config["project_path"]) + + valid_dataset = dlc.PoseDataset(project, transform=transform, mode="test") + valid_dataloader = torch.utils.data.DataLoader( + valid_dataset, batch_size=batch_size, shuffle=False + ) + + # if images are resized for inference, # need to take that into account to go back to original space - images_resized_with_transform = config['data'].get('resize', False) - - names = get_paths(train_fraction=train_fraction[0], - model_prefix=model_prefix, - shuffle=shuffle, - cfg=valid_dataset.cfg, - train_iterations=load_epoch) - - results_filename = get_results_filename(names['evaluation_folder'], - names['dlc_scorer'], - names['dlc_scorer_legacy'], - names['model_path'][:-3]) - - pose_cfg = auxiliaryfunctions.read_plainconfig(config['pose_cfg_path']) - model = build_pose_model(config['model'], pose_cfg) - model.load_state_dict(torch.load(names['model_path'])) - - predictor = PREDICTORS.build(dict(config['predictor'])) - - # # You need to dropna() here because on some frames no keypoint is annotated - # # Thus the target_df (contains NaNs) may not match the valid_dataloader (has dropped them) - # target_df = valid_dataset.dataframe.dropna(axis = 0, how = "all") + images_resized_with_transform = pytorch_config["data"].get("resize", False) + + names = get_paths( + train_fraction=train_fraction[0], + model_prefix=model_prefix, + shuffle=shuffle, + cfg=valid_dataset.cfg, + train_iterations=load_epoch, + method=method, + ) + + results_filename = get_results_filename( + names["evaluation_folder"], + names["dlc_scorer"], + names["dlc_scorer_legacy"], + names["model_path"][:-3], + ) + + pose_cfg = auxiliaryfunctions.read_plainconfig(pytorch_config["pose_cfg_path"]) + model = build_pose_model(pytorch_config["model"], pose_cfg) + model.load_state_dict(torch.load(names["model_path"])) + + predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) + + if method.lower() == "td": + detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) + detector.load_state_dict(torch.load(names["detector_path"])) + top_down_predictor = PREDICTORS.build( + {"type": "TopDownPredictor", "format_bbox": "xyxy"} + ) # I don't think this top down predictor should depend on config since I feel like it's already pretty general + detector.eval() + detector.to(device) + target_df = valid_dataset.dataframe predicted_poses = [] model.eval() model.to(device) with torch.no_grad(): for item in valid_dataloader: - item['image'] = item['image'].to(device) - output = model(item['image']) - shape_image = item['image'].shape - scale_factor = (shape_image[2]/output[0].shape[2] , shape_image[3]/output[0].shape[3]) - predictions = predictor(output, scale_factor).cpu().numpy() + item["image"] = item["image"].to(device) + shape_image = item["image"].shape + + if method.lower() == "td": + predictions = inference_utils.get_predictions_top_down( + detector=detector, + top_down_predictor=top_down_predictor, + model=model, + pose_predictor=predictor, + images=item["image"], + max_num_animals=max_num_animals, + num_keypoints=num_joints, + device=device, + ) + elif method.lower() == "bu": + predictions = inference_utils.get_predictions_bottom_up( + model=model, + predictor=predictor, + images=item["image"], + ) + else: + raise ValueError("This error should not happen !") # Matching predictions to ground truth individuals in order to compute rmse and save as dataframe if len(individuals) > 1: @@ -118,58 +169,67 @@ def inference_network( # rmse is more practical than oks # since oks needs at least 2 annotated keypoints per animal (to compute area) match_individuals = rmse_match_prediction_to_gt( - predictions[b], - item['annotations']['keypoints'][b].cpu().numpy(), - individuals + predictions[b], + item["annotations"]["keypoints"][b].cpu().numpy(), + individuals, ) predictions[b] = predictions[b][match_individuals] + # TODO shifting error when padding # converts back to original image size if image was resized during the augmentation pipeline if images_resized_with_transform: for b in range(predictions.shape[0]): - resizing_factor = (item['original_size'][0][b]/shape_image[2]).item(), (item['original_size'][1][b]/shape_image[3]).item() - predictions[b, :, :, 0] = predictions[b, :, :, 0]*resizing_factor[1] + resizing_factor[1]/2 - predictions[b, :, :, 1] = predictions[b, :, :, 1]*resizing_factor[0] + resizing_factor[0]/2 + resizing_factor = ( + item["original_size"][0][b] / shape_image[2] + ).item(), (item["original_size"][1][b] / shape_image[3]).item() + predictions[b, :, :, 0] = ( + predictions[b, :, :, 0] * resizing_factor[1] + + resizing_factor[1] / 2 + ) + predictions[b, :, :, 1] = ( + predictions[b, :, :, 1] * resizing_factor[0] + + resizing_factor[0] / 2 + ) predicted_poses.append(predictions) predicted_poses = np.array(predicted_poses) - predicted_df = save_predictions(names, - cfg, - target_df.index, - predicted_poses.reshape(target_df.index.shape[0], -1), - results_filename) - + predicted_df = save_predictions( + names, + cfg, + target_df.index, + predicted_poses.reshape(target_df.index.shape[0], -1), + results_filename, + ) + # Convert dataframe to 'multianimal' format in any case, allows for similar post_processing try: - predicted_df.columns.get_level_values('individuals').unique().tolist() + predicted_df.columns.get_level_values("individuals").unique().tolist() except KeyError: new_cols = pd.MultiIndex.from_tuples( - [(col[0], 'single', col[1], col[2]) for col in predicted_df.columns], - names=['scorer', 'individuals', 'bodyparts', 'coords'] + [(col[0], "single", col[1], col[2]) for col in predicted_df.columns], + names=["scorer", "individuals", "bodyparts", "coords"], ) predicted_df.columns = new_cols if plot: foldername = f'{names["evaluation_folder"]}/LabeledImages_{names["dlc_scorer"]}-{load_epoch}' auxiliaryfunctions.attempttomakefolder(foldername) - combined_df = predicted_df.merge(target_df, - left_index=True, - right_index=True) - Plotting(valid_dataset.cfg, - valid_dataset.cfg['bodyparts'], - names['dlc_scorer'], - predicted_df.index, - combined_df, - foldername) + combined_df = predicted_df.merge(target_df, left_index=True, right_index=True) + Plotting( + valid_dataset.cfg, + valid_dataset.cfg["bodyparts"], + names["dlc_scorer"], + predicted_df.index, + combined_df, + foldername, + ) if evaluate: - scores = get_scores(pose_cfg, - predicted_df, - target_df) + scores = get_scores(pose_cfg, predicted_df, target_df) print(scores) -if __name__ == '__main__': +if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--config_path", type=str) parser.add_argument("--shuffle", type=int, default=0) @@ -178,9 +238,11 @@ def inference_network( parser.add_argument("--plot", type=bool, default=False) parser.add_argument("--evaluate", type=bool, default=True) args = parser.parse_args() - inference_network(config_path=args.config_path, - shuffle=args.shuffle, - model_prefix=args.modelprefix, - load_epoch=args.load_epoch, - plot=args.plot, - evaluate=args.evaluate) + inference_network( + config_path=args.config_path, + shuffle=args.shuffle, + model_prefix=args.modelprefix, + load_epoch=args.load_epoch, + plot=args.plot, + evaluate=args.evaluate, + ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py b/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py new file mode 100644 index 0000000000..aab6dcf636 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py @@ -0,0 +1,106 @@ +import torch +import numpy as np +from skimage.transform import resize +from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PREDICTORS, PoseModel +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector + +from typing import Union + + +def get_predictions_bottom_up( + model: PoseModel, predictor: BasePredictor, images: torch.Tensor +) -> np.array: + """Gets the predicted coordinates tensor for a bottom_up approach + + Model and images should already be on the same device + + Args: + model (PoseModel): bottom-up model + predictor (BasePredictor): predictor used to regress keypoints coordinates and scores + images (torch.Tensor): input images (should already be normalised and formatted if needed), + shape (batch_size, 3, height, width) + + Returns: + np.array: predictions tensor of shape (batch_size, num_animals, num_keypoints, 3) + """ + + output = model(images) + shape_image = images.shape + scale_factor = ( + shape_image[2] / output[0].shape[2], + shape_image[3] / output[0].shape[3], + ) + predictions = predictor(output, scale_factor) + + return predictions.cpu().numpy() + + +def get_predictions_top_down( + detector: BaseDetector, + top_down_predictor: BasePredictor, + model: PoseModel, + pose_predictor: BasePredictor, + images: torch.Tensor, + max_num_animals: int, + num_keypoints: int, + device: Union[torch.device, str], +) -> np.array: + """ + TODO probably quite bad design, most arguments could be stored somewhere else + Gets the predicted coordinates tensor for a bottom_up approach + + Detector, Model and images should already be on the same device + + Args: + detector (BaseDetector): detector used to detect bboxes, should be in eval mode + top_down_predictor (BasePredictor): Given the bboxes and the cropped keypoints coordinates, outputs the regressed keypoints + model (PoseModel): pose model + pose_predictor (BasePredictor): predictor used to regress keypoints coordinates and scores in the cropped images + images (torch.Tensor): input images (should already be normalised and formatted if needed), + shape (batch_size, 3, height, width) + max_num_animals (int) : maximum number of animals to predict + num_keypoints (int) : number of keypoints per animal in the dataset + device (Union[torch.device, str]): device everything should be on + + Returns: + np.array: predictions tensor of shape (batch_size, num_animals, num_keypoints, 3) + """ + batch_size = images.shape[0] + + output_detector = detector(images) + + boxes = torch.zeros((batch_size, max_num_animals, 4)) + for b, item in enumerate(output_detector): + boxes[b] = item["boxes"][ + :max_num_animals + ] # Boxes should be sorted by scores, only keep the maximum number allowed + + boxes = boxes.int() + cropped_kpts_total = torch.zeros((batch_size, max_num_animals, num_keypoints, 3)) + + for b in range(batch_size): + for j, box in enumerate(boxes[b]): + cropped_image = ( + images[b][:, box[1] : box[3], box[0] : box[2]] + .permute(1, 2, 0) + .cpu() + .numpy() + ) # needs to be (h,w,c) for resizing + cropped_image = resize(cropped_image, (256, 256)) # TODO: hardcoded for now + cropped_image = ( + torch.tensor(cropped_image.transpose(2, 0, 1)).unsqueeze(0).to(device) + ) + heatmaps = model(cropped_image) + + scale_factors_cropped = ( + cropped_image.shape[2] / heatmaps[0].shape[2], + cropped_image.shape[3] / heatmaps[0].shape[3], + ) + + cropped_kpts = pose_predictor(heatmaps, scale_factors_cropped) + cropped_kpts_total[b, j, :] = cropped_kpts[0, 0] + + final_predictions = top_down_predictor(boxes, cropped_kpts_total) + + return final_predictions.cpu().numpy() diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 381b33d060..2bc786443a 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -2,7 +2,11 @@ import deeplabcut.pose_estimation_pytorch as dlc import os from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.apis.utils import build_solver, build_transforms, update_config_parameters +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_solver, + build_transforms, + update_config_parameters, +) from deeplabcut.pose_estimation_pytorch.solvers.base import Solver from torch.utils.data import DataLoader import albumentations as A @@ -10,37 +14,46 @@ def train_network( - config_path: str, - shuffle: int = 1, - training_set_index: Union[int, str] = 0, - transform: Union[A.BaseCompose, A.BasicTransform] = None, - model_prefix: str = "", - **kwargs) -> Solver: - + config_path: str, + shuffle: int = 1, + training_set_index: Union[int, str] = 0, + transform: Union[A.BaseCompose, A.BasicTransform] = None, + transform_cropped: Union[A.BaseCompose, A.BasicTransform] = None, + model_prefix: str = "", + **kwargs +) -> Solver: """ Trains a network for a project - + Args: - config_path : path to the yaml config file of the project - shuffle : index of the shuffle we want to train on - training_set_index : training set index - - transform: if None, the augmentation pipeline is built from config files - Advice if you want ot use custom transformations: - Keep in mind that in order for transfer leanring to be efficient, your + - transform: Augmentation pipeline for the images + if None, the augmentation pipeline is built from config files + Advice if you want to use custom transformations: + Keep in mind that in order for transfer leanring to be efficient, your data statistical distribution should resemble the one used to pretrain your backbone - - In most cases (e.g bacbone was pretrained on ImageNet), that means it should be Normalized with + + In most cases (e.g bacbone was pretrained on ImageNet), that means it should be Normalized with + A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) + - transform_cropped: Augmentation pipeline for the cropped images around animals + if None, the augmentation pipeline is built from config files + Advice if you want to use custom transformations: + Keep in mind that in order for transfer leanring to be efficient, your + data statistical distribution should resemble the one used to pretrain your backbone + + In most cases (e.g bacbone was pretrained on ImageNet), that means it should be Normalized with A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) - - model_prefix: model prefix - - **kwargs : could be any entry of the pytorch_config dictionnary + - **kwargs : could be any entry of the pytorch_config dictionary to see the full list see the pytorch_cfg.yaml file in your project folder - + Returns: solver: solver used for training, stores data about losses during training """ - + cfg = auxiliaryfunctions.read_config(config_path) if training_set_index == "all": train_fraction = cfg["TrainingFraction"] @@ -49,7 +62,10 @@ def train_network( modelfolder = os.path.join( cfg["project_path"], auxiliaryfunctions.get_model_folder( - train_fraction[0], shuffle, cfg, modelprefix=model_prefix, + train_fraction[0], + shuffle, + cfg, + modelprefix=model_prefix, ), ) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") @@ -57,42 +73,75 @@ def train_network( update_config_parameters(pytorch_config=pytorch_config, **kwargs) if transform is None: print("No transform specified... using default") - transform = build_transforms(dict(pytorch_config['data'])) + transform = build_transforms(dict(pytorch_config["data"]), augment_bbox=True) - batch_size = pytorch_config['batch_size'] - epochs = pytorch_config['epochs'] + batch_size = pytorch_config["batch_size"] + epochs = pytorch_config["epochs"] - dlc.fix_seeds(pytorch_config['seed']) - project_train = dlc.DLCProject(proj_root=pytorch_config['project_path'], shuffle=shuffle) - project_valid = dlc.DLCProject(proj_root=pytorch_config['project_path'], shuffle=shuffle) - train_dataset = dlc.PoseDataset(project_train, - transform=transform, - mode='train') - valid_dataset = dlc.PoseDataset(project_valid, - transform=transform, - mode='test') + dlc.fix_seeds(pytorch_config["seed"]) + project_train = dlc.DLCProject( + proj_root=pytorch_config["project_path"], shuffle=shuffle + ) + project_valid = dlc.DLCProject( + proj_root=pytorch_config["project_path"], shuffle=shuffle + ) + train_dataset = dlc.PoseDataset(project_train, transform=transform, mode="train") + valid_dataset = dlc.PoseDataset(project_valid, transform=transform, mode="test") - train_dataloader = DataLoader(train_dataset, - batch_size=batch_size, - shuffle=True) + train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) - valid_dataloader = DataLoader(valid_dataset, - batch_size=batch_size, - shuffle=False) + valid_dataloader = DataLoader(valid_dataset, batch_size=batch_size, shuffle=False) solver = build_solver(pytorch_config) - solver.fit( - train_dataloader, - valid_dataloader, - train_fraction=train_fraction[0], - epochs=epochs, - shuffle=shuffle, - model_prefix=model_prefix, - ) + if pytorch_config.get("method", "bu").lower() == "td": + if transform_cropped == None: + print( + "No transform passed to augment cropped images, using default augmentations" + ) + transform_cropped = build_transforms( + pytorch_config["cropped_data"], augment_bbox=False + ) + + train_cropped_dataset = dlc.CroppedDataset( + project_train, transform=transform_cropped, mode="train" + ) + valid_cropped_dataset = dlc.CroppedDataset( + project_valid, transform=transform_cropped, mode="test" + ) + train_cropped_dataloader = DataLoader( + train_cropped_dataset, batch_size=batch_size, shuffle=True + ) + + valid_cropped_dataloader = DataLoader( + valid_cropped_dataset, batch_size=batch_size, shuffle=False + ) + solver.fit( + train_dataloader, + valid_dataloader, + train_cropped_dataloader, + valid_cropped_dataloader, + train_fraction=train_fraction[0], + epochs=epochs, + shuffle=shuffle, + model_prefix=model_prefix, + ) + elif pytorch_config.get("method", "bu").lower() == "bu": + solver.fit( + train_dataloader, + valid_dataloader, + train_fraction=train_fraction[0], + epochs=epochs, + shuffle=shuffle, + model_prefix=model_prefix, + ) + else: + raise ValueError( + "Method not supported, should be either 'bu' (Bottom Up) or 'td' (Top Down)" + ) return solver -if __name__ == '__main__': +if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--config-path", type=str) parser.add_argument("--shuffle", type=int, default=1) @@ -100,7 +149,7 @@ def train_network( parser.add_argument("--modelprefix", type=str, default="") args = parser.parse_args() solver = train_network( - config_path = args.config_path, + config_path=args.config_path, shuffle=args.shuffle, training_set_index=args.train_ind, model_prefix=args.modelprefix, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 84c56c238d..11731833e5 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -6,10 +6,19 @@ import yaml from deeplabcut.utils import auxfun_videos -from deeplabcut.pose_estimation_pytorch.models import PoseModel, BACKBONES, HEADS, LOSSES -from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SINGLE_ANIMAL_SOLVER +from deeplabcut.pose_estimation_pytorch.models import ( + PoseModel, + BACKBONES, + NECKS, + HEADS, + LOSSES, + DETECTORS, +) +from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SOLVERS from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS -from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS +from deeplabcut.pose_estimation_pytorch.models.target_generators import ( + TARGET_GENERATORS, +) from deeplabcut.pose_estimation_pytorch.solvers.schedulers import LRListScheduler from deeplabcut.pose_estimation_pytorch.solvers.base import Solver @@ -17,96 +26,165 @@ def build_pose_model(cfg: Dict, pytorch_cfg: Dict) -> PoseModel: """ Returns a pytorch pose model based on pytorch config - + Args: cfg : sub dict of the pytorch config that contains all information about the model pytorch_cfg : entire pytorch config""" - - #TODO not sure why exactly we would need those two dicts as entries - backbone = BACKBONES.build(dict(cfg['backbone'])) - head_heatmaps = HEADS.build(dict(cfg['heatmap_head'])) - head_locref = HEADS.build(dict(cfg['locref_head'])) - target_generator = TARGET_GENERATORS.build(dict(cfg['target_generator'])) - if cfg.get('neck'): - neck = None + + # TODO not sure why exactly we would need those two dicts as entries + backbone = BACKBONES.build(dict(cfg["backbone"])) + heads = [] + for head_config in cfg["heads"]: + heads.append(HEADS.build(dict(head_config))) + target_generator = TARGET_GENERATORS.build(dict(cfg["target_generator"])) + if cfg.get("neck"): + neck = NECKS.build(dict(cfg["neck"])) else: neck = None - pose_model = PoseModel(cfg=pytorch_cfg, - backbone=backbone, - head_heatmaps=head_heatmaps, - head_locref=head_locref, - target_generator=target_generator, - neck=neck, - **cfg['pose_model']) + pose_model = PoseModel( + cfg=pytorch_cfg, + backbone=backbone, + heads=heads, + target_generator=target_generator, + neck=neck, + **cfg["pose_model"], + ) return pose_model +def build_detector(detector_cfg: Dict): + """Builds detector related objects : detector, its optimizer and its scheduler + + Args: + detector_cfg (Dict): detector config dictionary + + Returns: + detector, detector_optimizer, detector_scheduler + """ + detector = DETECTORS.build(detector_cfg["detector_model"]) + + get_optimizer = getattr(torch.optim, detector_cfg["detector_optimizer"]["type"]) + detector_optimizer = get_optimizer( + params=detector.parameters(), **detector_cfg["detector_optimizer"]["params"] + ) + + if detector_cfg.get("detector_scheduler"): + if detector_cfg["detector_scheduler"]["type"] == "LRListScheduler": + _scheduler = LRListScheduler + else: + _scheduler = getattr( + torch.optim.lr_scheduler, detector_cfg["detector_scheduler"]["type"] + ) + detector_scheduler = _scheduler( + optimizer=detector_optimizer, **detector_cfg["detector_scheduler"]["params"] + ) + else: + detector_scheduler = None + + return detector, detector_optimizer, detector_scheduler + + def build_solver(pytorch_cfg: Dict) -> Solver: """ Build the solver object to run training - + Args: - pytorch_cfg: config dictionnary to build the solver + pytorch_cfg: config dictionary to build the solver Returns: solver : solver to train the model """ - pose_model = build_pose_model(pytorch_cfg['model'], pytorch_cfg) + pose_model = build_pose_model(pytorch_cfg["model"], pytorch_cfg) - get_optimizer = getattr(torch.optim, pytorch_cfg['optimizer']['type']) - optimizer = get_optimizer(params=pose_model.parameters(), **pytorch_cfg['optimizer']['params']) + get_optimizer = getattr(torch.optim, pytorch_cfg["optimizer"]["type"]) + optimizer = get_optimizer( + params=pose_model.parameters(), **pytorch_cfg["optimizer"]["params"] + ) - criterion = LOSSES.build(pytorch_cfg['criterion']) + criterion = LOSSES.build(pytorch_cfg["criterion"]) - predictor = PREDICTORS.build(dict(pytorch_cfg['predictor'])) + predictor = PREDICTORS.build(dict(pytorch_cfg["predictor"])) - if pytorch_cfg.get('scheduler'): - if pytorch_cfg['scheduler']['type'] == "LRListScheduler": + if pytorch_cfg.get("scheduler"): + if pytorch_cfg["scheduler"]["type"] == "LRListScheduler": _scheduler = LRListScheduler else: - _scheduler = getattr(torch.optim.lr_scheduler, - pytorch_cfg['scheduler']['type']) - scheduler = _scheduler(optimizer=optimizer, - **pytorch_cfg['scheduler']['params']) + _scheduler = getattr( + torch.optim.lr_scheduler, pytorch_cfg["scheduler"]["type"] + ) + scheduler = _scheduler( + optimizer=optimizer, **pytorch_cfg["scheduler"]["params"] + ) else: scheduler = None - if pytorch_cfg.get('logger'): - logger = LOGGER.build(dict(**pytorch_cfg['logger'], - model=pose_model)) + if pytorch_cfg.get("logger"): + logger = LOGGER.build(dict(**pytorch_cfg["logger"], model=pose_model)) else: logger = None - solver = SINGLE_ANIMAL_SOLVER.build(dict(**pytorch_cfg['solver'], - model=pose_model, - criterion=criterion, - optimizer=optimizer, - predictor=predictor, - cfg=pytorch_cfg, - device=pytorch_cfg['device'], - scheduler=scheduler, - logger=logger)) + if pytorch_cfg.get("method", "bu") == "bu": + solver = SOLVERS.build( + dict( + **pytorch_cfg["solver"], + model=pose_model, + criterion=criterion, + optimizer=optimizer, + predictor=predictor, + cfg=pytorch_cfg, + device=pytorch_cfg["device"], + scheduler=scheduler, + logger=logger, + ) + ) + elif pytorch_cfg.get("method", "bu") == "td": + detector, detector_optimizer, detector_scheduler = build_detector( + pytorch_cfg["detector"] + ) + + solver = SOLVERS.build( + dict( + **pytorch_cfg["solver"], + model=pose_model, + criterion=criterion, + optimizer=optimizer, + predictor=predictor, + cfg=pytorch_cfg, + device=pytorch_cfg["device"], + scheduler=scheduler, + logger=logger, + detector=detector, + detector_optimizer=detector_optimizer, + detector_scheduler=detector_scheduler, + ) + ) + else: + raise ValueError( + "The method in your pytorch config is invalid, possible values are " + "'bu' (Bottom Up) or 'td' (Top Down)." + ) return solver -def build_transforms(aug_cfg:dict) -> Union[A.BasicTransform, A.BaseCompose]: +def build_transforms( + aug_cfg: dict, augment_bbox: bool = False +) -> Union[A.BasicTransform, A.BaseCompose]: """ Returns the transformation pipeline based on config Args: aug_cfg : dict containing all transforms information + augment_bbox : whether the returned augmentation pipelines should keep track of bboxes or not Returns: transform: callable element that can augment images, keypoints and bboxes """ transforms = [] - if aug_cfg.get('resize', False): - input_size = aug_cfg.get('resize', False) - transforms.append( - A.Resize(input_size[0], input_size[1]) - ) + if aug_cfg.get("resize", False): + input_size = aug_cfg.get("resize", False) + transforms.append(A.Resize(input_size[0], input_size[1])) - # TODO code again this augmentation to match the symmetric_pair syntax in orignal dlc + # TODO code again this augmentation to match the symmetric_pair syntax in original dlc # if aug_cfg.get('flipr', False) and aug_cfg.get('symmetric_pair', False): # opt = aug_cfg.get("fliplr", False) # if type(opt) == int: @@ -115,31 +193,27 @@ def build_transforms(aug_cfg:dict) -> Union[A.BasicTransform, A.BaseCompose]: # p = 0.5 # transforms.append( # CustomHorizontalFlip( - + # symmetric_pairs = aug_cfg['symmetric_pairs'], # p=p # ) # ) - scale_jitter_lo, scale_jitter_up = aug_cfg.get('scale_jitter', (1, 1)) - rotation = aug_cfg.get('rotation', 0) - translation = aug_cfg.get('translation', 0) + scale_jitter_lo, scale_jitter_up = aug_cfg.get("scale_jitter", (1, 1)) + rotation = aug_cfg.get("rotation", 0) + translation = aug_cfg.get("translation", 0) transforms.append( A.Affine( - scale=(scale_jitter_lo, scale_jitter_up), - rotate=(-rotation, rotation), - translate_px=(-translation, translation), - p=0.5, - ) - ) - if aug_cfg.get('hist_eq', False): - transforms.append( - A.Equalize( - p=0.5 - ) + scale=(scale_jitter_lo, scale_jitter_up), + rotate=(-rotation, rotation), + translate_px=(-translation, translation), + p=0.5, ) - if aug_cfg.get('motion_blur', False): + ) + if aug_cfg.get("hist_eq", False): + transforms.append(A.Equalize(p=0.5)) + if aug_cfg.get("motion_blur", False): transforms.append(A.MotionBlur(p=0.5)) - #TODO Coarse dropout can mask a keypoint which messes up the training, implement new augmentation + # TODO Coarse dropout can mask a keypoint which messes up the training, implement new augmentation # if aug_cfg.get('covering', False): # transforms.append( # A.CoarseDropout( @@ -151,50 +225,96 @@ def build_transforms(aug_cfg:dict) -> Union[A.BasicTransform, A.BaseCompose]: # p=0.5 # ) # ) - #TODO implement elastic transform apply_to_keypoints in albumentations + # TODO implement elastic transform apply_to_keypoints in albumentations # if aug_cfg.get('elastic_transform', False): # transforms.append(A.ElasticTransform(sigma=5, p=0.5)) - #TODO implement iia grayscale augmentation with albumentation + # TODO implement iia grayscale augmentation with albumentation # if aug_cfg.get('grayscale', False): - if aug_cfg.get('gaussian_noise', False): - opt = aug_cfg.get('gaussian_noise', False) #std + if aug_cfg.get("gaussian_noise", False): + opt = aug_cfg.get("gaussian_noise", False) # std # TODO inherit custom gaussian transform to support per_channel = 0.5 if type(opt) == int or type(opt) == float: transforms.append( A.GaussNoise( - var_limit= (0, opt**2), - mean= 0, - per_channel= True, # Albumentations doesn't support per_cahnnel = 0.5 + var_limit=(0, opt**2), + mean=0, + per_channel=True, # Albumentations doesn't support per_cahnnel = 0.5 p=0.5, ) ) else: transforms.append( A.GaussNoise( - var_limit= (0, (0.05 * 255)**2), - mean= 0, - per_channel= True, + var_limit=(0, (0.05 * 255) ** 2), + mean=0, + per_channel=True, p=0.5, ) ) - if aug_cfg.get('auto_padding'): - params = aug_cfg.get('auto_padding') - pad_height_divisor = params.get('pad_height_divisor', 1) - pad_width_divisor = params.get('pad_width_divisor', 1) - transforms.append(A.PadIfNeeded( - min_height= None, - min_width= None, - pad_height_divisor=pad_height_divisor, - pad_width_divisor=pad_width_divisor) + if aug_cfg.get("auto_padding"): + params = aug_cfg.get("auto_padding") + pad_height_divisor = params.get("pad_height_divisor", 1) + pad_width_divisor = params.get("pad_width_divisor", 1) + transforms.append( + A.PadIfNeeded( + min_height=None, + min_width=None, + pad_height_divisor=pad_height_divisor, + pad_width_divisor=pad_width_divisor, + ) + ) + if aug_cfg.get("normalize_images"): + transforms.append( + A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ) - if aug_cfg.get('normalize_images'): - transforms.append(A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225])) - return A.Compose( - transforms, - keypoint_params=A.KeypointParams('xy', remove_invisible=False) - ) + if augment_bbox: + return A.Compose( + transforms, + keypoint_params=A.KeypointParams("xy", remove_invisible=False), + bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), + ) + else: + return A.Compose( + transforms, keypoint_params=A.KeypointParams("xy", remove_invisible=False) + ) + + +def build_inference_transform( + transform_cfg: dict, augment_bbox: bool = True +) -> Union[A.BasicTransform, A.BaseCompose]: + """Build transform pipeline for inference + + Mainly about normalising the images a giving them a specific shape + + Args: + transform_cfg (dict): dict containing information about the transforms to apply + should be the same as the one used for build_transforms to + ensure matching distributions between train and test + augment_bbox (bool): should always be True for inference + + Returns: + Union[A.BasicTransform, A.BaseCompose]: the transformation pipeline + """ + + list_transforms = [] + if transform_cfg.get("normalize_images"): + list_transforms.append( + A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) + ) + + if augment_bbox: + return A.Compose( + list_transforms, + keypoint_params=A.KeypointParams("xy", remove_invisible=False), + bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), + ) + else: + return A.Compose( + list_transforms, + keypoint_params=A.KeypointParams("xy", remove_invisible=False), + ) def read_yaml(path): @@ -214,8 +334,32 @@ def get_model_snapshots(model_folder: Path) -> List[Path]: the paths of snapshots in the folder, sorted by index in ascending order """ return sorted( - [file for file in model_folder.iterdir() if file.suffix == ".pt"], - key=lambda p: int(p.stem.split("-")[-1]) + [ + file + for file in model_folder.iterdir() + if ((file.suffix == ".pt") and ("detector" not in str(file))) + ], + key=lambda p: int(p.stem.split("-")[-1]), + ) + + +def get_detector_snapshots(model_folder: Path) -> List[Path]: + """ + Assumes that all snapshots are named using the pattern "detector-snapshot-{idx}.pt" + + Args: + model_folder: the path to the folder containing the snapshots + + Returns: + the paths of detector snapshots in the folder, sorted by index in ascending order + """ + return sorted( + [ + file + for file in model_folder.iterdir() + if ((file.suffix == ".pt") and ("detector" in str(file))) + ], + key=lambda p: int(p.stem.split("-")[-1]), ) @@ -234,28 +378,27 @@ def videos_in_folder( video_suffixes = [video_type] return [ - file for file in video_path.iterdir() - if video_path.stem in video_suffixes + file for file in video_path.iterdir() if video_path.stem in video_suffixes ] - assert video_path.exists(), ( - f"Could not find the video: {video_path}. Check access rights." - ) + assert ( + video_path.exists() + ), f"Could not find the video: {video_path}. Check access rights." return [video_path] def update_config_parameters(pytorch_config: dict, **kwargs) -> None: """ - Overwrites the pytorch config dictionnary to correspond to the command line input keys - + Overwrites the pytorch config dictionary to correspond to the command line input keys + Args: pytorch_config **kwargs : any arguments that can be found as entry for the pytorch config - + Return: None """ for key in kwargs.keys(): pytorch_config[key] = kwargs[key] - return \ No newline at end of file + return diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 299cafe0f5..7456b83b1f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -22,4 +22,5 @@ class BaseDataset(ABC): """ TODO """ + pass diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoproject.py b/deeplabcut/pose_estimation_pytorch/data/cocoproject.py index 914f1eefb7..e16e9f0b8d 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoproject.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoproject.py @@ -6,48 +6,55 @@ import json from .base import BaseProject + class COCOProject(BaseProject): """ TODO """ - def __init__(self, - proj_root, - shuffle: int = 0, - image_id_offset: int = 0, - keys_to_load: List[str] = ['images', 'annotations']): + def __init__( + self, + proj_root, + shuffle: int = 0, + image_id_offset: int = 0, + keys_to_load: List[str] = ["images", "annotations"], + ): super().__init__() self.proj_root = proj_root self.keys_to_load = keys_to_load - self.train_json_obj = self._load_json('train.json') if shuffle is None else self._load_json(f'train_shuffle{shuffle}.json') - self.test_json_obj = self._load_json('test.json') if shuffle is None else self._load_json(f'test_shuffle{shuffle}.json') - - def _load_json(self, json_fn): - path = os.path.join(self.proj_root, 'annotations', json_fn) - with open(path, 'r') as f: + self.train_json_obj = ( + self._load_json("train.json") + if shuffle is None + else self._load_json(f"train_shuffle{shuffle}.json") + ) + self.test_json_obj = ( + self._load_json("test.json") + if shuffle is None + else self._load_json(f"test_shuffle{shuffle}.json") + ) + + def _load_json(self, json_fn): + path = os.path.join(self.proj_root, "annotations", json_fn) + with open(path, "r") as f: json_obj = json.load(f) return json_obj def load_split(self): - ''' + """ We expected that coco project has train test split in train test json already - ''' + """ pass - def convert2dict(self, - mode: str = 'train'): - - json_obj = getattr(self, f'{mode}_json_obj') + def convert2dict(self, mode: str = "train"): + json_obj = getattr(self, f"{mode}_json_obj") - - for image in self.images - image_path = image['file_name'] - #if os.sep not in image_path: + for image in self.images: + image_path = image["file_name"] + # if os.sep not in image_path: # assuming the file_name is mmpose style, i.e. only the image name is stored # so we need to add back absolute path - image['file_name'] = os.path.join(self.proj_root, 'images', image_path) - + image["file_name"] = os.path.join(self.proj_root, "images", image_path) for key in self.keys_to_load: setattr(self, key, json_obj[key]) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 005ddc0795..46eda17ae8 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -3,6 +3,7 @@ import numpy as np import torch from torch.utils.data import Dataset +import albumentations as A from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions from deeplabcut.utils.auxiliaryfunctions import read_plainconfig, get_model_folder @@ -12,13 +13,12 @@ class PoseDataset(Dataset, BaseDataset): """ - Dataset for pose estimation + Dataset for pose estimation """ - def __init__(self, - project: DLCProject, - transform: object = None, - mode: str = 'train'): + def __init__( + self, project: DLCProject, transform: object = None, mode: str = "train" + ): """ Parameters @@ -45,44 +45,52 @@ def __init__(self, modelfolder = os.path.join( self.project.proj_root, get_model_folder( - self.cfg['TrainingFraction'][0], + self.cfg["TrainingFraction"][0], self.shuffle, self.cfg, - '', - ) + "", + ), ) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") pytorch_cfg = read_plainconfig(pytorch_config_path) - self.with_center = pytorch_cfg.get('with_center', False) - self.max_num_animals = len(self.cfg.get('individuals', ['0'])) - self.color_mode = pytorch_cfg.get('colormode', 'RGB') + self.with_center = pytorch_cfg.get("with_center", False) + self.individuals = self.cfg.get("individuals", ["single"]) + self.individual_to_idx = {} + for i, indiv in enumerate(self.individuals): + self.individual_to_idx[indiv] = i + self.max_num_animals = len(self.individuals) + self.color_mode = pytorch_cfg.get("colormode", "RGB") self.length = self.dataframe.shape[0] assert self.length == len(self.project.image_path2image_id.keys()) def __len__(self): return self.length - + def _calc_area_from_keypoints(self, keypoints): w = keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1) h = keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1) - return w*h - + return w * h + def _keypoint_in_boundary(self, keypoint, shape): - ''' - + """ + Parameters ---------- keypoint: [x, y] shape: (height, width) Returns ------- - bool : wether a keypoint lies inside the given shape''' + bool : whether a keypoint lies inside the given shape""" + + return ( + (keypoint[0] > 0) + and (keypoint[1] > 0) + and (keypoint[0] < shape[1]) + and (keypoint[1] < shape[0]) + ) - return (keypoint[0] > 0) and (keypoint[1] > 0) and (keypoint[0] < shape[1]) and (keypoint[1] < shape[0]) - - def __getitem__(self, - index: int) -> dict: + def __getitem__(self, index: int) -> dict: """ Parameters @@ -91,7 +99,7 @@ def __getitem__(self, ordered number of the item in the dataset Returns ------- - dictionnary corresponding to the image, annotations... + dictionary corresponding to the image, annotations... keys: -'image' : image -'annotations': @@ -117,7 +125,7 @@ def __getitem__(self, print(index) image = cv2.imread(image_file) - if self.color_mode == 'RGB': + if self.color_mode == "RGB": image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) original_size = image.shape @@ -134,11 +142,21 @@ def __getitem__(self, num_keypoints_returned = self.num_joints + 1 bodyparts += ["_center_"] + bboxes = np.full((self.max_num_animals, 4), -1) + labels = np.zeros((self.max_num_animals), dtype=np.int64) + bbox_labels = [ + "animal" + ] * self.max_num_animals # Not used but albumentation needs them + is_crowd = np.zeros((self.max_num_animals), dtype=np.int64) + ids = np.full((self.max_num_animals), -1, dtype=np.int64) + image_id = index for i, annotation_idx in enumerate(self.project.id2annotations_idx[image_id]): _annotation = self.project.annotations[annotation_idx] _keypoints, _undef_ids = self.project.annotation2keypoints(_annotation) _keypoints = np.array(_keypoints) + ids[i] = self.individual_to_idx[_annotation["individual"]] + if self.with_center: keypoints[i, :-1, :2] = _keypoints keypoints[i, :-1, 2] = _undef_ids @@ -147,6 +165,16 @@ def __getitem__(self, keypoints[i, :, :2] = _keypoints keypoints[i, :, 2] = _undef_ids + bboxes[i] = np.array(_annotation["bbox"]) + is_crowd[i] = _annotation["iscrowd"] + labels[i] = _annotation["category_id"] + + # Sometimes bbox coords are larger than the image because of the margin + h, w, _ = image.shape + bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) + bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) + bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) + bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) # Needs two be 2 dimensional for albumentations keypoints = keypoints.reshape((-1, 3)) @@ -160,15 +188,19 @@ def __getitem__(self, transformed = self.transform( image=image, keypoints=keypoints[:, :2], + bboxes=bboxes, class_labels=class_labels, + bbox_labels=bbox_labels, ) + bboxes = transformed["bboxes"] # Discard keypoints that aren't in the frame anymore - shape_transformed = transformed['image'].shape - transformed['keypoints'] = [ - keypoint if self._keypoint_in_boundary(keypoint, shape_transformed) + shape_transformed = transformed["image"].shape + transformed["keypoints"] = [ + keypoint + if self._keypoint_in_boundary(keypoint, shape_transformed) else (-1, -1) - for keypoint in transformed['keypoints'] + for keypoint in transformed["keypoints"] ] # Discard keypoints that are undefined @@ -177,43 +209,282 @@ def __getitem__(self, ] for label in undef_class_labels: new_index = transformed["class_labels"].index(label) - transformed['keypoints'][new_index] = (-1, -1) + transformed["keypoints"][new_index] = (-1, -1) else: transformed = {} - transformed['keypoints'] = keypoints[:, :2] - transformed['image'] = image - - image = torch.FloatTensor(transformed['image']).permute(2, 0, 1) # channels first - - assert len(transformed['keypoints']) == len(keypoints) - keypoints = np.array(transformed['keypoints']).reshape((n_annotations, num_keypoints_returned, 2)).astype(float) + transformed["keypoints"] = keypoints[:, :2] + transformed["image"] = image + + image = torch.tensor(transformed["image"], dtype=torch.float).permute( + 2, 0, 1 + ) # channels first + + assert len(transformed["keypoints"]) == len(keypoints) + keypoints = ( + np.array(transformed["keypoints"]) + .reshape((n_annotations, num_keypoints_returned, 2)) + .astype(float) + ) - #TODO Quite ugly - # + # TODO Quite ugly + # # Center keypoint needs to be computed after transformation because # it should depend on visible keypoints only (which may change after augmentation) if self.with_center: try: - keypoints[:, -1, :] = keypoints[:, :-1, :][~np.any(keypoints[:, :-1, :] == -1, axis=2)].reshape(n_annotations, -1, 2).mean(axis = 1) + keypoints[:, -1, :] = ( + keypoints[:, :-1, :][~np.any(keypoints[:, :-1, :] == -1, axis=2)] + .reshape(n_annotations, -1, 2) + .mean(axis=1) + ) except ValueError: # For at least one annotation every keypoint is out of the frame for i in range(keypoints.shape[0]): try: - keypoints[i, -1, :] = keypoints[i, :-1, :][~np.any(keypoints[i, :-1, :] == -1, axis=1)].mean(axis = 0) + keypoints[i, -1, :] = keypoints[i, :-1, :][ + ~np.any(keypoints[i, :-1, :] == -1, axis=1) + ].mean(axis=0) except ValueError: keypoints[i, -1, :] = np.array([-1, -1]) np.nan_to_num(keypoints, copy=False, nan=-1) area = self._calc_area_from_keypoints(keypoints) - res = {} - res['image'] = image - res['original_size'] = original_size # In order to convert back the keypoints to their original space - res['annotations'] = {} - res['annotations']['keypoints'] = keypoints - res['annotations']['area'] = area + res["image"] = image + res[ + "original_size" + ] = original_size # In order to convert back the keypoints to their original space + res["annotations"] = {} + res["annotations"]["keypoints"] = keypoints + res["annotations"]["area"] = area + res["annotations"]["ids"] = ids + res["annotations"]["boxes"] = torch.tensor(bboxes, dtype=torch.float) + res["annotations"]["image_id"] = image_id + res["annotations"]["is_crowd"] = is_crowd + res["annotations"]["labels"] = labels + + return res + + +class CroppedDataset(Dataset, BaseDataset): + def __init__( + self, project: DLCProject, transform: object = None, mode: str = "train" + ): + """ + + Parameters + ---------- + project: see class Project (wrapper for DLC original project class) + transform: transformation function: + + def transform(image, keypoints): + return image, keypoints + + mode: 'train' or 'test' + this parameter which dataframe parse from the Project (df_train or df_test) + + """ + super().__init__() + self.transform = transform + self.project = project + self.cfg = self.project.cfg + self.num_joints = len(self.cfg["bodyparts"]) + self.shuffle = self.project.shuffle + self.project.convert2dict(mode) + self.dataframe = self.project.dataframe + + self.annotations = self._compute_anno() + + modelfolder = os.path.join( + self.project.proj_root, + get_model_folder( + self.cfg["TrainingFraction"][0], + self.shuffle, + self.cfg, + "", + ), + ) + pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") + pytorch_cfg = read_plainconfig(pytorch_config_path) + self.with_center = pytorch_cfg.get("with_center", False) + self.individuals = self.cfg.get("individuals", ["single"]) + self.individual_to_idx = {} + for i, indiv in enumerate(self.individuals): + self.individual_to_idx[indiv] = i + self.max_num_animals = len(self.individuals) + self.color_mode = pytorch_cfg.get("colormode", "RGB") + + self.input_size = 256, 256 # (h, w) #TODO make that depend on pytorch config + self.crop = A.Compose( + [ + A.RandomCropNearBBox( + max_part_shift=0.0, + cropping_box_key="animal_bbox", + always_apply=True, + p=1.0, + ), + A.Resize(*self.input_size), + ], + keypoint_params=A.KeypointParams(format="xy", remove_invisible=False), + bbox_params=A.BboxParams(format="coco"), + ) + + # We must dropna because self.project.images doesn't contain imgaes with no labels so it can produce an indexnotfound error + # length is stored here to avoid repeating the computation + self.length = len(self.annotations) + + def __len__(self): + return self.length + + def _compute_anno(self): + annotations = [] + df_length = self.dataframe.shape[0] + + for index in range(df_length): + image_path = self.dataframe.index[index] + if isinstance(image_path, tuple): + image_path = os.path.join(self.cfg["project_path"], *image_path) + else: + image_path = os.path.join(self.cfg["project_path"], image_path) + + image_id = self.project.image_path2image_id[image_path] + annotations_ids = self.project.id2annotations_idx[image_id] + for ann_idx in annotations_ids: + ann = self.project.annotations[ann_idx] + if ( + ann["bbox"] == 0.0 + ).all(): # I think we don't want unnanotated cropped images + continue + ann["image_path"] = image_path + annotations.append(ann) + + return annotations + + def _calc_area_from_keypoints(self, keypoints): + w = keypoints[:, 0].max(axis=0) - keypoints[:, 0].min(axis=0) + h = keypoints[:, 1].max(axis=0) - keypoints[:, 1].min(axis=0) + return w * h + + def _keypoint_in_boundary(self, keypoint, shape): + """ + + Parameters + ---------- + keypoint: [x, y] + shape: (height, width) + Returns + ------- + bool : whether a keypoint lies inside the given shape""" + + return ( + (keypoint[0] > 0) + and (keypoint[1] > 0) + and (keypoint[0] < shape[1]) + and (keypoint[1] < shape[0]) + ) + + def __getitem__(self, index: int): + """ + Parameters + ---------- + index: int + ordered number of the item in the dataset + Returns + ------- + image: torch.FloatTensor \in [0, 255] + Tensor for the image from the dataset + keypoints: list of keypoints + train_dataset = PoseDataset(project, transform=transform) + im, keypoints = train_dataset[0] + + """ + # load images + ann = self.annotations[index] + image_file = ann["image_path"] + + image = cv2.imread(image_file) + if self.color_mode == "RGB": + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + original_size = image.shape + + if not self.with_center: + keypoints = np.zeros((self.num_joints, 3)) + num_keypoints_returned = self.num_joints + else: + keypoints = np.zeros((self.num_joints + 1, 3)) + num_keypoints_returned = self.num_joints + 1 + + _keypoints, _undef_ids = self.project.annotation2keypoints(ann) + if self.with_center: + keypoints[:-1, :2] = np.array(_keypoints) + keypoints[:-1, 2] = _undef_ids + else: + keypoints[:, :2] = np.array(_keypoints) + keypoints[:, 2] = _undef_ids + animal_id = self.individual_to_idx[ann["individual"]] + + crop_box = ann["bbox"].copy() + crop_box[2] += crop_box[0] + crop_box[3] += crop_box[1] + cropped = self.crop( + image=image, keypoints=keypoints[:, :2], bboxes=[], animal_bbox=crop_box + ) + image = cropped["image"] + keypoints = [ + (-1, -1) if (keypoints[i, 2] == 0) else keypoint + for i, keypoint in enumerate(cropped["keypoints"]) + ] + + if self.transform: + transformed = self.transform(image=image, keypoints=keypoints) + shape_transformed = transformed["image"].shape + transformed["keypoints"] = [ + (-1, -1) + if not self._keypoint_in_boundary(keypoint, shape_transformed) + else keypoint + for i, keypoint in enumerate(transformed["keypoints"]) + ] + else: + transformed = {} + transformed["keypoints"] = keypoints + transformed["image"] = image + + image = torch.tensor(transformed["image"], dtype=torch.float).permute( + 2, 0, 1 + ) # channels first + + assert len(transformed["keypoints"]) == len(keypoints) + keypoints = np.array(transformed["keypoints"]).astype(float) + + # Center keypoint needs to be computed after transformation because + # it should depend on visible keypoints only (which may change after augmentation) + if self.with_center: + try: + keypoints[-1, :] = np.nanmean( + keypoints[:-1, :][~np.any(keypoints[:-1, :] == -1, axis=1)].reshape( + -1, 2 + ), + axis=0, + ) + except ValueError: + keypoints[-1, :] = np.array([-1, -1]) + np.nan_to_num(keypoints, copy=False, nan=-1) + area = self._calc_area_from_keypoints(keypoints) + + # Animal_idx is always the first dimension even is there is only one animal + # This convention is the one adopted int this whole repository + res = {} + res["image"] = image + res[ + "original_size" + ] = original_size # In order to convert back the keypoints to their original space + res["annotations"] = {} + res["annotations"]["keypoints"] = keypoints[None, :] + res["annotations"]["area"] = np.array(area)[None] + res["annotations"]["ids"] = np.array(animal_id)[None] + res["annotations"]["path"] = image_file return res diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py index 89a8d1a48e..03e58360e1 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py @@ -12,7 +12,7 @@ class DLCProject(BaseProject): """ - Wrapper around the project containing information about the data, + Wrapper around the project containing information about the data, the actual annotations and the configs Methods: @@ -20,41 +20,48 @@ class DLCProject(BaseProject): - _init_annotation_image_correspondance: binds the image paths to corresponding annotations ensures there is no indexing offsets between images and annotations when going through the dataset - - load_split : split the annotation dataframe into train and test dataframes + - load_split : split the annotation dataframe into train and test dataframes based on project's split - annotation2keypoints : convert the coco annotations into array of keypoints also returns the array of the keypoints' visibility """ - def __init__(self, proj_root:str, - shuffle: int = 0, - image_id_offset: int = 0, - keys_to_load: List[str] = ['images', 'annotations']): + def __init__( + self, + proj_root: str, + shuffle: int = 0, + image_id_offset: int = 0, + keys_to_load: List[str] = ["images", "annotations"], + ): super().__init__() self.proj_root = proj_root self.shuffle = shuffle self.keys_to_load = keys_to_load self.image_id_offset = image_id_offset - config_file = os.path.join(self.proj_root, 'config.yaml') + config_file = os.path.join(self.proj_root, "config.yaml") self.cfg = deeplabcut.auxiliaryfunctions.read_config(config_file) - self.task = self.cfg['Task'] - self.scorer = self.cfg['scorer'] + self.task = self.cfg["Task"] + self.scorer = self.cfg["scorer"] self.datasets_folder = os.path.join( - self.proj_root, deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(self.cfg), + self.proj_root, + deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(self.cfg), + ) + tr_frac = int(self.cfg["TrainingFraction"][0] * 100) + self.path_dlc_data = os.path.join( + self.datasets_folder, f"CollectedData_{self.scorer}.h5" + ) + self.path_dlc_doc = os.path.join( + self.datasets_folder, + f"Documentation_data-{self.task}_{tr_frac}shuffle{self.shuffle}.pickle", ) - tr_frac = int(self.cfg['TrainingFraction'][0] * 100) - self.path_dlc_data = os.path.join(self.datasets_folder, f'CollectedData_{self.scorer}.h5') - self.path_dlc_doc = os.path.join(self.datasets_folder, - f'Documentation_data-{self.task}_{tr_frac}shuffle{self.shuffle}.pickle') self.dlc_df = pd.read_hdf(self.path_dlc_data) self.load_split() - self.dlc_df = self.dlc_df[~self.dlc_df.index.duplicated(keep = 'first')] - self.df_train = self.df_train[~self.df_train.index.duplicated(keep = 'first')] + self.dlc_df = self.dlc_df[~self.dlc_df.index.duplicated(keep="first")] + self.df_train = self.df_train[~self.df_train.index.duplicated(keep="first")] if hasattr(self, "df_test"): - self.df_test = self.df_test[~self.df_test.index.duplicated(keep = 'first')] + self.df_test = self.df_test[~self.df_test.index.duplicated(keep="first")] - def convert2dict(self, - mode: str = 'train'): + def convert2dict(self, mode: str = "train"): """ Parameters @@ -66,33 +73,33 @@ def convert2dict(self, """ try: - self.dataframe = getattr(self, f'df_{mode}') + self.dataframe = getattr(self, f"df_{mode}") except: - raise AttributeError(f"PoseDataset doesn't have df_{mode} attr. Do project.train_test_split() first!") + raise AttributeError( + f"PoseDataset doesn't have df_{mode} attr. Do project.train_test_split() first!" + ) + + data = df2generic(self.proj_root, self.dataframe, self.image_id_offset) - data = df2generic(self.proj_root, - self.dataframe, - self.image_id_offset) - self._init_annotation_image_correspondance(data) for key in self.keys_to_load: setattr(self, key, data[key]) - print('The data has been loaded!') + print("The data has been loaded!") + + def _init_annotation_image_correspondance(self, data: dict): + """data should be a COCO like dictionary of the pose dataset""" - def _init_annotation_image_correspondance(self, data:dict): - """data should be a COCO like dictionnary of the pose dataset""" - - # Path to id correspondance + # Path to id correspondence self.image_path2image_id = {} for i, image in enumerate(data["images"]): image_path = image["file_name"] - self.image_path2image_id[image_path] = image['id'] + self.image_path2image_id[image_path] = image["id"] # id to annotations list self.id2annotations_idx = {} - for i, annotation in enumerate(data['annotations']): - image_id = annotation['image_id'] + for i, annotation in enumerate(data["annotations"]): + image_id = annotation["image_id"] try: self.id2annotations_idx[image_id].append(i) except KeyError: @@ -107,7 +114,7 @@ def load_split(self): ------- """ - with open(self.path_dlc_doc, 'rb') as f: + with open(self.path_dlc_doc, "rb") as f: meta = pickle.load(f) train_ids = meta[1] @@ -136,8 +143,8 @@ def annotation2keypoints(annotation): undef_ids: array 0 means this keypoints is undefined, 1 means it is """ - x = annotation['keypoints'][::3] - y = annotation['keypoints'][1::3] + x = annotation["keypoints"][::3] + y = annotation["keypoints"][1::3] undef_ids = ((x > 0) & (y > 0)).astype(int) keypoints = [] diff --git a/deeplabcut/pose_estimation_pytorch/default_config.py b/deeplabcut/pose_estimation_pytorch/default_config.py index d7b94d8372..aa74784068 100644 --- a/deeplabcut/pose_estimation_pytorch/default_config.py +++ b/deeplabcut/pose_estimation_pytorch/default_config.py @@ -1,89 +1,84 @@ pytorch_cfg_template = {} -pytorch_cfg_template['cfg_path']='/data/quentin/datasets/daniel3mouse/config.yaml' -pytorch_cfg_template['seed']=42 -pytorch_cfg_template['device']='cuda:0' -pytorch_cfg_template['display_iters']=1000 -pytorch_cfg_template['save_epochs']=50 #not iterations, epochs +pytorch_cfg_template["cfg_path"] = "/data/quentin/datasets/daniel3mouse/config.yaml" +pytorch_cfg_template["seed"] = 42 +pytorch_cfg_template["device"] = "cuda:0" +pytorch_cfg_template["display_iters"] = 1000 +pytorch_cfg_template["save_epochs"] = 50 # not iterations, epochs -pytorch_cfg_template['data'] = { - 'scale_jitter' : [0.5, 1.25], - 'rotation' : 30, - 'translation' : 40, - 'hist_eq' : True, - 'motion_blur': True, - 'covering': True, - 'gaussian_noise': 0.05*255, - 'normalize_images': True +pytorch_cfg_template["data"] = { + "scale_jitter": [0.5, 1.25], + "rotation": 30, + "translation": 40, + "hist_eq": True, + "motion_blur": True, + "covering": True, + "gaussian_noise": 0.05 * 255, + "normalize_images": True, } -pytorch_cfg_template['model']= { - 'backbone': { - 'type': 'ResNet', - 'pretrained': 'https://download.pytorch.org/models/resnet50-19c8e357.pth', +pytorch_cfg_template["model"] = { + "backbone": { + "type": "ResNet", + "pretrained": "https://download.pytorch.org/models/resnet50-19c8e357.pth", }, - 'heatmap_head': { - 'type': 'SimpleHead', - 'channels': [2048, 1024, -1], #-1 acts as undefined here - 'kernel_size': [2, 2], - 'strides': [2, 2], + "heatmap_head": { + "type": "SimpleHead", + "channels": [2048, 1024, -1], # -1 acts as undefined here + "kernel_size": [2, 2], + "strides": [2, 2], }, - 'locref_head': { - 'type': 'SimpleHead', - 'channels': [2048, 1024, -1], #-1 acts as undefined here - 'kernel_size': [2, 2], - 'strides': [2, 2], + "locref_head": { + "type": "SimpleHead", + "channels": [2048, 1024, -1], # -1 acts as undefined here + "kernel_size": [2, 2], + "strides": [2, 2], }, - 'target_generator': { - 'type' : 'PlateauGenerator', - 'locref_stdev': 7.2801, - 'num_joints': -1, - 'pos_dist_thresh': 17, + "target_generator": { + "type": "PlateauGenerator", + "locref_stdev": 7.2801, + "num_joints": -1, + "pos_dist_thresh": 17, }, - 'pose_model': { - 'stride': 8, + "pose_model": { + "stride": 8, }, } -pytorch_cfg_template['optimizer'] = { - 'type' : 'SGD', - 'params': { - 'lr': 0.01, - } +pytorch_cfg_template["optimizer"] = { + "type": "SGD", + "params": { + "lr": 0.01, + }, } -pytorch_cfg_template['scheduler'] = { - 'type': 'LRListScheduler', - 'params': { - 'milestones': [10, 430], - 'lr_list': [[0.05], [0.005]] - } +pytorch_cfg_template["scheduler"] = { + "type": "LRListScheduler", + "params": {"milestones": [10, 430], "lr_list": [[0.05], [0.005]]}, } -pytorch_cfg_template['predictor'] = { - 'type': 'SinglePredictor', - 'num_animals': -1, - 'location_refinement': True, - 'locref_stdev': 7.2801, +pytorch_cfg_template["predictor"] = { + "type": "SinglePredictor", + "num_animals": -1, + "location_refinement": True, + "locref_stdev": 7.2801, } -pytorch_cfg_template['criterion'] = { - 'type' : 'PoseLoss', - 'loss_weight_locref': 0.02, - 'locref_huber_loss': True, +pytorch_cfg_template["criterion"] = { + "type": "PoseLoss", + "loss_weight_locref": 0.02, + "locref_huber_loss": True, } -pytorch_cfg_template['solver'] = { - 'type' : 'BottomUpSingleAnimalSolver' -} +pytorch_cfg_template["solver"] = {"type": "BottomUpSingleAnimalSolver"} -pytorch_cfg_template['pos_dist_thresh'] = 17 -pytorch_cfg_template['with_center'] = False -pytorch_cfg_template['batch_size'] = 1 -pytorch_cfg_template['epochs'] = 1000 +pytorch_cfg_template["pos_dist_thresh"] = 17 +pytorch_cfg_template["with_center"] = False +pytorch_cfg_template["batch_size"] = 1 +pytorch_cfg_template["epochs"] = 1000 -if __name__ == '__main__': +if __name__ == "__main__": import yaml - with open('pytorch_config.yaml', 'w') as f: + with open("pytorch_config.yaml", "w") as f: yaml.safe_dump(pytorch_cfg_template, f) diff --git a/deeplabcut/pose_estimation_pytorch/models/__init__.py b/deeplabcut/pose_estimation_pytorch/models/__init__.py index cf3a471411..ab699c2944 100644 --- a/deeplabcut/pose_estimation_pytorch/models/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/__init__.py @@ -1,6 +1,14 @@ -from deeplabcut.pose_estimation_pytorch.models.utils import _generate_heatmaps, gaussian_scmap +from deeplabcut.pose_estimation_pytorch.models.utils import ( + _generate_heatmaps, + gaussian_scmap, +) from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS +from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS from deeplabcut.pose_estimation_pytorch.models.criterion import LOSSES -from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS \ No newline at end of file +from deeplabcut.pose_estimation_pytorch.models.target_generators import ( + TARGET_GENERATORS, +) +from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py index fcdb8e759d..890e730bbe 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py @@ -1,2 +1,2 @@ from .resnet import ResNet -from .hrnet import HRNet \ No newline at end of file +from .hrnet import HRNet diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index 8e393c9f3c..c2af24e05d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -4,7 +4,8 @@ from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -BACKBONES = Registry('backbones', build_func=build_from_cfg) +BACKBONES = Registry("backbones", build_func=build_from_cfg) + class BaseBackbone(ABC, nn.Module): """ @@ -37,15 +38,15 @@ def _init_weights(self, pretrained: str = None): else: self.model.load_state_dict(torch.load(pretrained), strict=False) - def activate_batch_norm(self, activation: bool=False): + def activate_batch_norm(self, activation: bool = False): """Turns on or off batch norm layers updating their weights while training - - Prameters + + Parameters --------- activation: should batch_norm be activated or not for training""" self.batch_norm_on = activation - def train(self, mode = True): + def train(self, mode=True): # Bacth Norm should not be on for small batch sizes super(BaseBackbone, self).train(mode) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index bfc6ddb6ee..088b6ead11 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -3,18 +3,22 @@ import torch.nn as nn import torch.nn.functional as F -from deeplabcut.pose_estimation_pytorch.models.backbones.base import BaseBackbone, BACKBONES +from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( + BaseBackbone, + BACKBONES, +) + @BACKBONES.register_module class HRNet(BaseBackbone): """ - HRNet backbone, this version returns high resolution feature maps of size - 1/4 * original_image_size - This is obtained using bilinear interpolation and concatenation of all the outputs of the - HRNet stages - """ + HRNet backbone, this version returns high resolution feature maps of size + 1/4 * original_image_size + This is obtained using bilinear interpolation and concatenation of all the outputs of the + HRNet stages + """ - def __init__(self, model_name: str = 'hrnet_w32') -> nn.Module: + def __init__(self, model_name: str = "hrnet_w32") -> nn.Module: """ Constructs an ImageNet pre-trained HRNet from timm (https://github.com/rwightman/pytorch-image-models/blob/main/timm/models/hrnet.py) @@ -26,14 +30,42 @@ def __init__(self, model_name: str = 'hrnet_w32') -> nn.Module: """ super().__init__() _backbone = timm.create_model(model_name, pretrained=True) - _backbone.incre_modules = None #Necesssary to get high resolution features, if not set to None _backbone.forward_features will return low_res images + _backbone.incre_modules = None # Necesssary to get high resolution features, if not set to None _backbone.forward_features will return low_res images self.model = _backbone def forward(self, x): y_list = self.model.forward_features(x) x0_h, x0_w = y_list[0].size(2), y_list[0].size(3) - x = torch.cat([y_list[0], \ - F.interpolate(y_list[1], size=(x0_h, x0_w), mode='bilinear'), \ - F.interpolate(y_list[2], size=(x0_h, x0_w), mode='bilinear'), \ - F.interpolate(y_list[3], size=(x0_h, x0_w), mode='bilinear')], 1) + x = torch.cat( + [ + y_list[0], + F.interpolate(y_list[1], size=(x0_h, x0_w), mode="bilinear"), + F.interpolate(y_list[2], size=(x0_h, x0_w), mode="bilinear"), + F.interpolate(y_list[3], size=(x0_h, x0_w), mode="bilinear"), + ], + 1, + ) return x + + +@BACKBONES.register_module +class HRNetTopDown(BaseBackbone): + def __init__(self, model_name: str = "hrnet_w32"): + """ + Constructs an ImageNet pre-trained HRNet from timm + (https://github.com/rwightman/pytorch-image-models/blob/main/timm/models/hrnet.py) + + Parameters + ---------- + model_name: str + type of HRNet (e.g. 'hrnet_w32, 'hrnet_w48') + """ + super().__init__() + _backbone = timm.create_model(model_name, pretrained=True) + _backbone.incre_modules = None # Necesssary to get high resolution features, if not set to None _backbone.forward_features will return low_res images + self.model = _backbone + + def forward(self, x): + return self.model.forward_features(x)[ + 0 + ] # Only take the high resolution stream at the end diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 635734230f..15fd2ab04b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -1,16 +1,21 @@ import torch.nn as nn import torchvision -from deeplabcut.pose_estimation_pytorch.models.backbones.base import BaseBackbone, BACKBONES +from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( + BaseBackbone, + BACKBONES, +) + @BACKBONES.register_module class ResNet(BaseBackbone): """ - Typical ResNet backbone - """ + Typical ResNet backbone + """ - def __init__(self, model_name: str = 'resnet50', - pretrained: str = None) -> nn.Module: + def __init__( + self, model_name: str = "resnet50", pretrained: str = None + ) -> nn.Module: """ Parameters ---------- @@ -18,8 +23,8 @@ def __init__(self, model_name: str = 'resnet50', """ super().__init__() _backbone = torchvision.models.get_model(model_name) - _backbone._modules.pop('fc') - _backbone._modules.pop('avgpool') + _backbone._modules.pop("fc") + _backbone._modules.pop("avgpool") self.model = nn.Sequential(_backbone._modules) self._init_weights(pretrained) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index 0c57a66a0a..e95df55ee6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -3,60 +3,62 @@ from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry -LOSSES = Registry('losses', build_func=build_from_cfg) +LOSSES = Registry("losses", build_func=build_from_cfg) class WeightedMSELoss(nn.MSELoss): - def __init__(self): super(WeightedMSELoss, self).__init__() - self.mse_loss = nn.MSELoss(reduction='none') + self.mse_loss = nn.MSELoss(reduction="none") - def __call__(self, prediction, target, weights = 1): + def __call__(self, prediction, target, weights=1): loss_item = self.mse_loss(prediction, target) loss_item_weighted = loss_item * weights loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] if loss_without_zeros.nelement() == 0: - return torch.tensor(0.) + return torch.tensor(0.0) return torch.mean(loss_without_zeros) - -class WeightedHuberLoss(nn.HuberLoss): + +class WeightedHuberLoss(nn.HuberLoss): def __init__(self): super(WeightedHuberLoss, self).__init__() - self.huber_loss = nn.HuberLoss(reduction='none') + self.huber_loss = nn.HuberLoss(reduction="none") - def __call__(self, prediction, target, weights = 1): + def __call__(self, prediction, target, weights=1): loss_item = self.huber_loss(prediction, target) - loss_item_weighted = loss_item*weights + loss_item_weighted = loss_item * weights loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] if loss_without_zeros.nelement() == 0: - return torch.tensor(0.) + return torch.tensor(0.0) return torch.mean(loss_without_zeros) + class WeightedBCELoss(nn.BCEWithLogitsLoss): - def __init__(self): super(WeightedBCELoss, self).__init__() - self.BCELoss = nn.BCEWithLogitsLoss(reduction='none') + self.BCELoss = nn.BCEWithLogitsLoss(reduction="none") def __call__(self, prediction, target, weights=1): loss_item = self.BCELoss(prediction, target) - loss_item_weighted = loss_item*weights + loss_item_weighted = loss_item * weights loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] if loss_without_zeros.nelement() == 0: - return torch.tensor(0.) + return torch.tensor(0.0) return torch.mean(loss_without_zeros) + @LOSSES.register_module class PoseLoss(nn.Module): - def __init__(self, - loss_weight_locref: float = 0.1, - locref_huber_loss: bool = False, - apply_sigmoid: bool= False): + def __init__( + self, + loss_weight_locref: float = 0.1, + locref_huber_loss: bool = False, + apply_sigmoid: bool = False, + ): """ Parameters @@ -67,7 +69,7 @@ def __init__(self, locref_huber_loss: bool If `True` uses torch.nn.HuberLoss for locref (default is False) - apply_sigmoid : wether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) + apply_sigmoid : whether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) """ super(PoseLoss, self).__init__() @@ -99,16 +101,71 @@ def forward(self, prediction, target): """ heatmaps, locref = prediction if self.apply_sigmoid: - heatmap_loss = self.heatmap_criterion(self.sigmoid(heatmaps), - target['heatmaps'], - target.get('heatmaps_ignored', 1)) + heatmap_loss = self.heatmap_criterion( + self.sigmoid(heatmaps), + target["heatmaps"], + target.get("heatmaps_ignored", 1), + ) else: - heatmap_loss = self.heatmap_criterion(heatmaps, - target['heatmaps'], - target.get('heatmaps_ignored', 1)) - - locref_loss = self.locref_criterion(locref, - target['locref_maps'], - target['locref_masks']) - total_loss = locref_loss*self.loss_weight_locref + heatmap_loss + heatmap_loss = self.heatmap_criterion( + heatmaps, target["heatmaps"], target.get("heatmaps_ignored", 1) + ) + + locref_loss = self.locref_criterion( + locref, target["locref_maps"], target["locref_masks"] + ) + total_loss = locref_loss * self.loss_weight_locref + heatmap_loss return total_loss, heatmap_loss, locref_loss + + +@LOSSES.register_module +class HeatmapOnlyLoss(nn.Module): + def __init__(self, apply_sigmoid: bool = False): + """ + + Parameters + ---------- + loss_weight_locref: float + Weight for loss_locref part + (parsed from the pose_cfg.yaml from the dlc_models folder) + locref_huber_loss: bool + If `True` uses torch.nn.HuberLoss for locref + (default is False) + apply_sigmoid : whether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) + + """ + super(HeatmapOnlyLoss, self).__init__() + self.heatmap_criterion = WeightedBCELoss() + self.apply_sigmoid = apply_sigmoid + self.sigmoid = nn.Sigmoid() + + def forward(self, prediction, target): + """ + + Parameters + ---------- + prediction: tuple of Tensors `(heatmaps, locref)` of size `(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)` + Predicted heatmap and locref + target: dict = { + 'heatmaps': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'locref_maps': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'locref_masks': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'weights': torch.Tensor (optional, default is None) + } + Returns + ------- + loss: sum + """ + heatmaps = prediction[0] + if self.apply_sigmoid: + heatmap_loss = self.heatmap_criterion( + self.sigmoid(heatmaps), + target["heatmaps"], + target.get("heatmaps_ignored", 1), + ) + else: + heatmap_loss = self.heatmap_criterion( + heatmaps, target["heatmaps"], target.get("heatmaps_ignored", 1) + ) + + return heatmap_loss diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py new file mode 100644 index 0000000000..c7b794fc4d --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py @@ -0,0 +1,2 @@ +from .base import DETECTORS, BaseDetector +from .fasterRCNN import FasterRCNN diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py new file mode 100644 index 0000000000..736f4bdb3a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod +import torch +import torch.nn as nn +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + +DETECTORS = Registry("detectors", build_func=build_from_cfg) + + +class BaseDetector(ABC, nn.Module): + def __init__(self): + super().__init__() + + @abstractmethod + def forward(self, x): + pass + + @abstractmethod + def get_target(self, annotations): + pass + + def _init_weights(self, pretrained): + if not pretrained: + pass + else: + self.model.load_state_dict(torch.load(pretrained)) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py new file mode 100644 index 0000000000..880391e776 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -0,0 +1,50 @@ +import torchvision +from torchvision.models.detection.faster_rcnn import FastRCNNPredictor +from torchvision.models.detection import FasterRCNN_ResNet50_FPN_Weights +from .base import DETECTORS, BaseDetector + + +@DETECTORS.register_module +class FasterRCNN(BaseDetector): + def __init__( + self, + ): + super().__init__() + self.model = torchvision.models.detection.fasterrcnn_resnet50_fpn( + weights=FasterRCNN_ResNet50_FPN_Weights.COCO_V1 + ) + num_classes = 2 + in_features = self.model.roi_heads.box_predictor.cls_score.in_features + + self.model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes) + + def forward(self, x, targets=None): + return self.model(x, targets) + + def get_target(self, annotations): + """ + Returns target in a format FasterRCNN can handle + Args: + annotations : dict of annotations, must contain the keys 'area', 'labels', + 'is_crowd', 'image_id', 'boxes' + + Output: + list of the target dictionaries (not the same serialisation for batches as default pytorch does) + """ + res = [] + for i, _ in enumerate(annotations["image_id"]): + box_ann = annotations["boxes"][i].clone() + # bbox format conversion (x, y, w, h) -> (x1, y1, x2, y2) + box_ann[:, 2] += box_ann[:, 0] + box_ann[:, 3] += box_ann[:, 1] + res.append( + { + "area": annotations["area"][i], + "labels": annotations["labels"][i], + "image_id": annotations["image_id"][i], + "is_crowd": annotations["is_crowd"][i], + "boxes": box_ann, + } + ) + + return res diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py index d5dc94fb32..42a67771f5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py @@ -1,3 +1,3 @@ from .base import HEADS from .simple_head import SimpleHead -from .dekr_heads import HeatmapDEKRHead, OffsetDEKRHead \ No newline at end of file +from .dekr_heads import HeatmapDEKRHead, OffsetDEKRHead diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index a9bce43d79..3e1fc38ac9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -5,7 +5,8 @@ from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -HEADS = Registry('heads', build_func=build_from_cfg) +HEADS = Registry("heads", build_func=build_from_cfg) + class BaseHead(ABC, nn.Module): """ diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py index 07efab4122..2679f9ef5f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py @@ -6,31 +6,34 @@ from deeplabcut.pose_estimation_pytorch.models.modules import BasicBlock, AdaptBlock from .base import BaseHead + @HEADS.register_module class HeatmapDEKRHead(BaseHead): """ - DEKR head to compute the heatmaps corresponding to keypoints - based on: - Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression - Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang - CVPR - 2021 - Code based on: - https://github.com/HRNet/DEKR - """ + DEKR head to compute the heatmaps corresponding to keypoints + based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang + CVPR + 2021 + Code based on: + https://github.com/HRNet/DEKR + """ def __init__( - self, - channels, - num_blocks, - dilation_rate, - final_conv_kernel, - block = BasicBlock, - ): + self, + channels, + num_blocks, + dilation_rate, + final_conv_kernel, + block=BasicBlock, + ): super().__init__() self.bn_momentum = 0.1 self.inp_channels = channels[0] - self.num_joints_with_center = channels[2] #Should account for the center being a joint + self.num_joints_with_center = channels[ + 2 + ] # Should account for the center being a joint self.final_conv_kernel = final_conv_kernel self.transition_heatmap = self._make_transition_for_head( @@ -44,12 +47,11 @@ def __init__( dilation_rate, ) - def _make_transition_for_head(self, inplanes, outplanes): transition_layer = [ nn.Conv2d(inplanes, outplanes, 1, 1, 0, bias=False), nn.BatchNorm2d(outplanes), - nn.ReLU(True) + nn.ReLU(True), ] return nn.Sequential(*transition_layer) @@ -57,11 +59,7 @@ def _make_heatmap_head(self, block, num_blocks, num_channels, dilation_rate): heatmap_head_layers = [] feature_conv = self._make_layer( - block, - num_channels, - num_channels, - num_blocks, - dilation=dilation_rate + block, num_channels, num_channels, num_blocks, dilation=dilation_rate ) heatmap_head_layers.append(feature_conv) @@ -70,63 +68,61 @@ def _make_heatmap_head(self, block, num_blocks, num_channels, dilation_rate): out_channels=self.num_joints_with_center, kernel_size=self.final_conv_kernel, stride=1, - padding=1 if self.final_conv_kernel == 3 else 0 + padding=1 if self.final_conv_kernel == 3 else 0, ) heatmap_head_layers.append(heatmap_conv) - + return nn.ModuleList(heatmap_head_layers) - - def _make_layer( - self, block, inplanes, planes, blocks, stride=1, dilation=1): + + def _make_layer(self, block, inplanes, planes, blocks, stride=1, dilation=1): downsample = None if stride != 1 or inplanes != planes * block.expansion: downsample = nn.Sequential( - nn.Conv2d(inplanes, planes * block.expansion, - kernel_size=1, stride=stride, bias=False), + nn.Conv2d( + inplanes, + planes * block.expansion, + kernel_size=1, + stride=stride, + bias=False, + ), nn.BatchNorm2d(planes * block.expansion, momentum=self.bn_momentum), ) layers = [] - layers.append(block(inplanes, planes, - stride, downsample, dilation=dilation)) + layers.append(block(inplanes, planes, stride, downsample, dilation=dilation)) inplanes = planes * block.expansion for _ in range(1, blocks): layers.append(block(inplanes, planes, dilation=dilation)) return nn.Sequential(*layers) - + def forward(self, x): - - heatmap = self.head_heatmap[1]( - self.head_heatmap[0]( - self.transition_heatmap(x) - ) - ) + heatmap = self.head_heatmap[1](self.head_heatmap[0](self.transition_heatmap(x))) return heatmap - + @HEADS.register_module class OffsetDEKRHead(BaseHead): """ - DEKR head to compute the offset from the center corresponding to each keypoints - based on: - Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression - Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang - CVPR - 2021 - Code based on: - https://github.com/HRNet/DEKR - """ + DEKR head to compute the offset from the center corresponding to each keypoints + based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang + CVPR + 2021 + Code based on: + https://github.com/HRNet/DEKR + """ def __init__( - self, - channels, - num_offset_per_kpt, - num_blocks, - dilation_rate, - final_conv_kernel, - block = AdaptBlock, + self, + channels, + num_offset_per_kpt, + num_blocks, + dilation_rate, + final_conv_kernel, + block=AdaptBlock, ): super().__init__() self.inp_channels = channels[0] @@ -136,10 +132,10 @@ def __init__( self.bn_momentum = 0.1 self.offset_perkpt = num_offset_per_kpt self.num_joints_without_center = self.num_joints - self.offset_channels = self.offset_perkpt*self.num_joints_without_center - assert(self.offset_channels == channels[1]) + self.offset_channels = self.offset_perkpt * self.num_joints_without_center + assert self.offset_channels == channels[1] - self.num_blocks=num_blocks + self.num_blocks = num_blocks self.dilation_rate = dilation_rate self.final_conv_kernel = final_conv_kernel @@ -147,28 +143,32 @@ def __init__( self.inp_channels, self.offset_channels, ) - self.offset_feature_layers, self.offset_final_layer = \ - self._make_separete_regression_head( - block, - num_blocks=num_blocks, - num_channels_per_kpt=self.offset_perkpt, - dilation_rate=self.dilation_rate - ) - + ( + self.offset_feature_layers, + self.offset_final_layer, + ) = self._make_separete_regression_head( + block, + num_blocks=num_blocks, + num_channels_per_kpt=self.offset_perkpt, + dilation_rate=self.dilation_rate, + ) - def _make_layer( - self, block, inplanes, planes, blocks, stride=1, dilation=1): + def _make_layer(self, block, inplanes, planes, blocks, stride=1, dilation=1): downsample = None if stride != 1 or inplanes != planes * block.expansion: downsample = nn.Sequential( - nn.Conv2d(inplanes, planes * block.expansion, - kernel_size=1, stride=stride, bias=False), + nn.Conv2d( + inplanes, + planes * block.expansion, + kernel_size=1, + stride=stride, + bias=False, + ), nn.BatchNorm2d(planes * block.expansion, momentum=self.bn_momentum), ) layers = [] - layers.append(block(inplanes, planes, - stride, downsample, dilation=dilation)) + layers.append(block(inplanes, planes, stride, downsample, dilation=dilation)) inplanes = planes * block.expansion for _ in range(1, blocks): layers.append(block(inplanes, planes, dilation=dilation)) @@ -179,16 +179,16 @@ def _make_transition_for_head(self, inplanes, outplanes): transition_layer = [ nn.Conv2d(inplanes, outplanes, 1, 1, 0, bias=False), nn.BatchNorm2d(outplanes), - nn.ReLU(True) + nn.ReLU(True), ] return nn.Sequential(*transition_layer) def _make_separete_regression_head( - self, - block, - num_blocks, - num_channels_per_kpt, - dilation_rate, + self, + block, + num_blocks, + num_channels_per_kpt, + dilation_rate, ): offset_feature_layers = [] offset_final_layer = [] @@ -199,7 +199,7 @@ def _make_separete_regression_head( num_channels_per_kpt, num_channels_per_kpt, num_blocks, - dilation=dilation_rate + dilation=dilation_rate, ) offset_feature_layers.append(feature_conv) @@ -208,12 +208,12 @@ def _make_separete_regression_head( out_channels=2, kernel_size=self.final_conv_kernel, stride=1, - padding=1 if self.final_conv_kernel == 3 else 0 + padding=1 if self.final_conv_kernel == 3 else 0, ) offset_final_layer.append(offset_conv) return nn.ModuleList(offset_feature_layers), nn.ModuleList(offset_final_layer) - + def forward(self, x): final_offset = [] offset_feature = self.transition_offset(x) @@ -222,8 +222,13 @@ def forward(self, x): final_offset.append( self.offset_final_layer[j]( self.offset_feature_layers[j]( - offset_feature[:,j*self.offset_perkpt:(j+1)*self.offset_perkpt]))) + offset_feature[ + :, j * self.offset_perkpt : (j + 1) * self.offset_perkpt + ] + ) + ) + ) offset = torch.cat(final_offset, dim=1) - return offset \ No newline at end of file + return offset diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 06a8cc7da2..595c8a43da 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -1,5 +1,9 @@ +import torch import torch.nn as nn +from einops import rearrange +from timm.models.layers.weight_init import trunc_normal_ + from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS from .base import BaseHead @@ -7,30 +11,26 @@ @HEADS.register_module class SimpleHead(BaseHead): """ - Deconvolutional head to predict maps from the extracted features + Deconvolutional head to predict maps from the extracted features """ - def __init__(self, channels: list, - kernel_size: list, - strides: list, - pretrained: str = None): + def __init__( + self, channels: list, kernel_size: list, strides: list, pretrained: str = None + ): super().__init__() self.kernel_size = kernel_size self.strides = strides if len(kernel_size) == 1: - self.model = self._make_layer(channels[0], - channels[1], - kernel_size[0], - strides[0]) + self.model = self._make_layer( + channels[0], channels[1], kernel_size[0], strides[0] + ) else: layers = [] for i in range(len(channels) - 1): - up_layer = self._make_layer(channels[i], - channels[i + 1], - kernel_size[i], - strides[i] - ) + up_layer = self._make_layer( + channels[i], channels[i + 1], kernel_size[i], strides[i] + ) layers.append(up_layer) if i < len(channels) - 2: layers.append(nn.ReLU()) @@ -38,13 +38,10 @@ def __init__(self, channels: list, self._init_weights(pretrained) - def _make_layer(self, - input_channels, - output_channels, - kernel_size, - stride): - upsample_layer = nn.ConvTranspose2d(input_channels, output_channels, - kernel_size, stride=stride) + def _make_layer(self, input_channels, output_channels, kernel_size, stride): + upsample_layer = nn.ConvTranspose2d( + input_channels, output_channels, kernel_size, stride=stride + ) return upsample_layer def forward(self, x): @@ -52,39 +49,50 @@ def forward(self, x): return out -# class TransformerHead(BaseHead): -# -# def __init__(self, dim, hidden_heatmap_dim, -# heatmap_dim, apply_multi, -# heatmap_size, -# apply_init): -# super().__init__() -# self.mlp_head = nn.Sequential( -# nn.LayerNorm(dim * 3), -# nn.Linear(dim * 3, hidden_heatmap_dim), -# nn.LayerNorm(hidden_heatmap_dim), -# nn.Linear(hidden_heatmap_dim, heatmap_dim) -# ) if (dim*3 <= hidden_heatmap_dim*0.5 and apply_multi) else nn.Sequential( -# nn.LayerNorm(dim*3), -# nn.Linear(dim*3, heatmap_dim) -# ) -# self.heatmap_size = heatmap_size -# # trunc_normal_(self.keypoint_token, std=.02) -# if apply_init: -# self.apply(self._init_weights) -# -# def _init_weights(self, m): -# if isinstance(m, nn.Linear): -# trunc_normal_(m.weight, std=.02) -# if isinstance(m, nn.Linear) and m.bias is not None: -# nn.init.constant_(m.bias, 0) -# elif isinstance(m, nn.LayerNorm): -# nn.init.constant_(m.bias, 0) -# nn.init.constant_(m.weight, 1.0) -# -# def forward(self, x): -# x = self.mlp_head(x) -# x = rearrange(x,'b c (p1 p2) -> b c p1 p2', -# p1=self.heatmap_size[0], p2=self.heatmap_size[1]) -# -# return x + +@HEADS.register_module +class TransformerHead(BaseHead): + def __init__( + self, + dim, + hidden_heatmap_dim, + heatmap_dim, + apply_multi, + heatmap_size, + apply_init, + ): + super().__init__() + self.mlp_head = ( + nn.Sequential( + nn.LayerNorm(dim * 3), + nn.Linear(dim * 3, hidden_heatmap_dim), + nn.LayerNorm(hidden_heatmap_dim), + nn.Linear(hidden_heatmap_dim, heatmap_dim), + ) + if (dim * 3 <= hidden_heatmap_dim * 0.5 and apply_multi) + else nn.Sequential(nn.LayerNorm(dim * 3), nn.Linear(dim * 3, heatmap_dim)) + ) + self.heatmap_size = heatmap_size + # trunc_normal_(self.keypoint_token, std=.02) + if apply_init: + self.apply(self._init_weights) + + def _init_weights(self, m): + if isinstance(m, nn.Linear): + trunc_normal_(m.weight, std=0.02) + if isinstance(m, nn.Linear) and m.bias is not None: + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.LayerNorm): + nn.init.constant_(m.bias, 0) + nn.init.constant_(m.weight, 1.0) + + def forward(self, x): + x = self.mlp_head(x) + x = rearrange( + x, + "b c (p1 p2) -> b c p1 p2", + p1=self.heatmap_size[0], + p2=self.heatmap_size[1], + ) + + return x diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 4d7d696d00..3e5ae3080a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -4,36 +4,38 @@ from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict from torch import nn +from typing import List from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator class PoseModel(nn.Module): """ - Complete model architecture + Complete model architecture """ - def __init__(self, - cfg: dict, - backbone: torch.nn.Module, - head_heatmaps: torch.nn.Module, - head_locref: torch.nn.Module, - target_generator: BaseGenerator, - neck: torch.nn.Module = None, - stride: int = 8, - ): + def __init__( + self, + cfg: dict, + backbone: torch.nn.Module, + heads: List[nn.Module], + target_generator: BaseGenerator, + neck: torch.nn.Module = None, + stride: int = 8, + ): super().__init__() self.backbone = backbone - self.backbone.activate_batch_norm(cfg['batch_size'] >= 8) # We don't want batch norm to update for small batch sizes + self.backbone.activate_batch_norm( + cfg["batch_size"] >= 8 + ) # We don't want batch norm to update for small batch sizes - self.head_heatmaps = head_heatmaps - self.head_locref = head_locref + self.heads = nn.ModuleList(heads) self.neck = neck self.stride = stride self.cfg = cfg self.target_generator = target_generator self.sigmoid = nn.Sigmoid() - def forward(self, x:torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + def forward(self, x: torch.Tensor) -> List[torch.Tensor]: """ TODO Parameters @@ -42,33 +44,35 @@ def forward(self, x:torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: Returns ------- - heat_maps : heatmaps - loc_ref : locref maps + outputs : list of outputs, one output per head """ if x.dim() == 3: x = x[None, :] features = self.backbone(x) if self.neck: features = self.neck(features) - heat_maps = self.head_heatmaps(features) - loc_ref = self.head_locref(features) + outputs = [] + for head in self.heads: + outputs.append(head(features)) - return heat_maps, loc_ref + return outputs - def get_target(self, - annotations:dict, - prediction:Tuple[torch.Tensor, torch.Tensor], - image_size:Tuple[int, int]): + def get_target( + self, + annotations: dict, + prediction: Tuple[torch.Tensor, torch.Tensor], + image_size: Tuple[int, int], + ): """_summary_ Args: annotations (dict): dict of annotations - prediction (Tuple[torch.Tensor, torch.Tensor]): output of the model + prediction (Tuple[torch.Tensor, torch.Tensor]): output of the model (used here to compute the scaling factor of the model) image_size (Tuple[int, int]): image_size, used here to compute the scaling factor of the model Returns: targets : dict of the targets needed for model training """ - + return self.target_generator(annotations, prediction, image_size) diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py index e69de29bb2..0a4afdc6c1 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py @@ -0,0 +1,2 @@ +from .base import NECKS +from .transformer import Transformer diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/base.py b/deeplabcut/pose_estimation_pytorch/models/necks/base.py new file mode 100644 index 0000000000..9ca619e115 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/necks/base.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod +import torch +import torch.nn as nn +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + +NECKS = Registry("necks", build_func=build_from_cfg) + + +class BaseNeck(ABC, nn.Module): + def __init__(self): + super().__init__() + + @abstractmethod + def forward(self, x): + pass + + def _init_weights(self, pretrained): + if pretrained: + self.model.load_state_dict(torch.load(pretrained)) diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py index 38e7c51826..1ace20751c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py @@ -3,100 +3,120 @@ from einops import rearrange, repeat from torch import nn + class Residual(nn.Module): def __init__(self, fn): super().__init__() self.fn = fn + def forward(self, x, **kwargs): return self.fn(x, **kwargs) + x + class PreNorm(nn.Module): - def __init__(self, dim, fn,fusion_factor=1): + def __init__(self, dim, fn, fusion_factor=1): super().__init__() - self.norm = nn.LayerNorm(dim*fusion_factor) + self.norm = nn.LayerNorm(dim * fusion_factor) self.fn = fn + def forward(self, x, **kwargs): return self.fn(self.norm(x), **kwargs) + class FeedForward(nn.Module): - def __init__(self, dim, hidden_dim, dropout = 0.): + def __init__(self, dim, hidden_dim, dropout=0.0): super().__init__() self.net = nn.Sequential( nn.Linear(dim, hidden_dim), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden_dim, dim), - nn.Dropout(dropout) + nn.Dropout(dropout), ) + def forward(self, x): return self.net(x) -class Attention(nn.Module): - def __init__(self, dim, heads = 8, - dropout = 0., - num_keypoints=None, - scale_with_head=False): +class Attention(nn.Module): + def __init__( + self, dim, heads=8, dropout=0.0, num_keypoints=None, scale_with_head=False + ): super().__init__() self.heads = heads - self.scale = (dim//heads) ** -0.5 if scale_with_head else dim ** -0.5 + self.scale = (dim // heads) ** -0.5 if scale_with_head else dim**-0.5 - self.to_qkv = nn.Linear(dim, dim * 3, bias = False) - self.to_out = nn.Sequential( - nn.Linear(dim, dim), - nn.Dropout(dropout) - ) + self.to_qkv = nn.Linear(dim, dim * 3, bias=False) + self.to_out = nn.Sequential(nn.Linear(dim, dim), nn.Dropout(dropout)) self.num_keypoints = num_keypoints - def forward(self, x, mask = None): + def forward(self, x, mask=None): b, n, _, h = *x.shape, self.heads - qkv = self.to_qkv(x).chunk(3, dim = -1) - q, k, v = map(lambda t: rearrange(t, 'b n (h d) -> b h n d', h = h), qkv) + qkv = self.to_qkv(x).chunk(3, dim=-1) + q, k, v = map(lambda t: rearrange(t, "b n (h d) -> b h n d", h=h), qkv) - dots = torch.einsum('bhid,bhjd->bhij', q, k) * self.scale + dots = torch.einsum("bhid,bhjd->bhij", q, k) * self.scale mask_value = -torch.finfo(dots.dtype).max if mask is not None: - mask = F.pad(mask.flatten(1), (1, 0), value = True) - assert mask.shape[-1] == dots.shape[-1], 'mask has incorrect dimensions' + mask = F.pad(mask.flatten(1), (1, 0), value=True) + assert mask.shape[-1] == dots.shape[-1], "mask has incorrect dimensions" mask = mask[:, None, :] * mask[:, :, None] dots.masked_fill_(~mask, mask_value) del mask attn = dots.softmax(dim=-1) - out = torch.einsum('bhij,bhjd->bhid', attn, v) + out = torch.einsum("bhij,bhjd->bhid", attn, v) - out = rearrange(out, 'b h n d -> b n (h d)') - out = self.to_out(out) + out = rearrange(out, "b h n d -> b n (h d)") + out = self.to_out(out) return out -class TransformerLayer(nn.Module): - def __init__(self, dim, - depth, - heads, - mlp_dim, - dropout, - num_keypoints=None, - all_attn=False, - scale_with_head=False): +class TransformerLayer(nn.Module): + def __init__( + self, + dim, + depth, + heads, + mlp_dim, + dropout, + num_keypoints=None, + all_attn=False, + scale_with_head=False, + ): super().__init__() self.layers = nn.ModuleList([]) self.all_attn = all_attn self.num_keypoints = num_keypoints for _ in range(depth): - self.layers.append(nn.ModuleList([ - Residual(PreNorm(dim, Attention(dim, heads = heads, - dropout = dropout, - num_keypoints = num_keypoints, - scale_with_head = scale_with_head))), - Residual(PreNorm(dim, FeedForward(dim, mlp_dim, dropout = dropout))) - ])) - def forward(self, x, mask = None,pos=None): - for idx,(attn, ff) in enumerate(self.layers): - if idx>0 and self.all_attn: - x[:,self.num_keypoints:] += pos - x = attn(x, mask = mask) + self.layers.append( + nn.ModuleList( + [ + Residual( + PreNorm( + dim, + Attention( + dim, + heads=heads, + dropout=dropout, + num_keypoints=num_keypoints, + scale_with_head=scale_with_head, + ), + ) + ), + Residual( + PreNorm(dim, FeedForward(dim, mlp_dim, dropout=dropout)) + ), + ] + ) + ) + + def forward(self, x, mask=None, pos=None): + for idx, (attn, ff) in enumerate(self.layers): + if idx > 0 and self.all_attn: + x[:, self.num_keypoints :] += pos + x = attn(x, mask=mask) x = ff(x) - return x \ No newline at end of file + return x diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py index a532f5d57a..0ebf97de38 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py @@ -4,24 +4,36 @@ from timm.models.layers.weight_init import trunc_normal_ from .layers import TransformerLayer from .utils import make_sine_position_embedding +from .base import NECKS MIN_NUM_PATCHES = 16 BN_MOMENTUM = 0.1 -class Transformer(nn.Module): - def __init__(self, *, feature_size, - patch_size, num_keypoints, - dim, depth, heads, - mlp_dim=3, apply_init=False, - heatmap_size=[64,64], - channels = 32, - dropout = 0., - emb_dropout = 0., - pos_embedding_type="sine-full"): +@NECKS.register_module +class Transformer(nn.Module): + def __init__( + self, + *, + feature_size, + patch_size, + num_keypoints, + dim, + depth, + heads, + mlp_dim=3, + apply_init=False, + heatmap_size=[64, 64], + channels=32, + dropout=0.0, + emb_dropout=0.0, + pos_embedding_type="sine-full" + ): super().__init__() - num_patches = (feature_size[0] // (patch_size[0])) * (feature_size[1] // (patch_size[1])) + num_patches = (feature_size[0] // (patch_size[0])) * ( + feature_size[1] // (patch_size[1]) + ) patch_dim = channels * patch_size[0] * patch_size[1] self.inplanes = 64 @@ -30,59 +42,81 @@ def __init__(self, *, feature_size, self.num_keypoints = num_keypoints self.num_patches = num_patches self.pos_embedding_type = pos_embedding_type - self.all_attn = (self.pos_embedding_type == "sine-full") + self.all_attn = self.pos_embedding_type == "sine-full" self.keypoint_token = nn.Parameter(torch.zeros(1, self.num_keypoints, dim)) - h,w = feature_size[0] // (self.patch_size[0]), feature_size[1] // ( self.patch_size[1]) + h, w = feature_size[0] // (self.patch_size[0]), feature_size[1] // ( + self.patch_size[1] + ) self._make_position_embedding(w, h, dim, pos_embedding_type) - self.patch_to_embedding = nn.Linear(patch_dim, dim) self.dropout = nn.Dropout(emb_dropout) - self.transformer1 = TransformerLayer(dim, depth, heads, - mlp_dim, dropout, - num_keypoints=num_keypoints, - scale_with_head=True) - self.transformer2 = TransformerLayer(dim, depth, heads, - mlp_dim, dropout, - num_keypoints=num_keypoints, - all_attn=self.all_attn, - scale_with_head=True ) - self.transformer3 = TransformerLayer(dim, depth, heads, - mlp_dim, dropout, - num_keypoints=num_keypoints, - all_attn=self.all_attn, - scale_with_head=True) + self.transformer1 = TransformerLayer( + dim, + depth, + heads, + mlp_dim, + dropout, + num_keypoints=num_keypoints, + scale_with_head=True, + ) + self.transformer2 = TransformerLayer( + dim, + depth, + heads, + mlp_dim, + dropout, + num_keypoints=num_keypoints, + all_attn=self.all_attn, + scale_with_head=True, + ) + self.transformer3 = TransformerLayer( + dim, + depth, + heads, + mlp_dim, + dropout, + num_keypoints=num_keypoints, + all_attn=self.all_attn, + scale_with_head=True, + ) self.to_keypoint_token = nn.Identity() if apply_init: self.apply(self._init_weights) - def _make_position_embedding(self, w, h, d_model, pe_type='learnable'): - ''' + def _make_position_embedding(self, w, h, d_model, pe_type="learnable"): + """ d_model: embedding size in transformer encoder - ''' + """ with torch.no_grad(): self.pe_h = h self.pe_w = w length = h * w - if pe_type != 'learnable': - self.pos_embedding = nn.Parameter(make_sine_position_embedding(h, w, d_model), - requires_grad=False) + if pe_type != "learnable": + self.pos_embedding = nn.Parameter( + make_sine_position_embedding(h, w, d_model), requires_grad=False + ) else: - self.pos_embedding = nn.Parameter(torch.zeros(1, self.num_patches + - self.num_keypoints, - d_model)) + self.pos_embedding = nn.Parameter( + torch.zeros(1, self.num_patches + self.num_keypoints, d_model) + ) def _make_layer(self, block, planes, blocks, stride=1): downsample = None if stride != 1 or self.inplanes != planes * block.expansion: downsample = nn.Sequential( - nn.Conv2d(self.inplanes, planes * block.expansion, - kernel_size=1, stride=stride, bias=False), + nn.Conv2d( + self.inplanes, + planes * block.expansion, + kernel_size=1, + stride=stride, + bias=False, + ), nn.BatchNorm2d(planes * block.expansion, momentum=BN_MOMENTUM), ) @@ -97,38 +131,39 @@ def _make_layer(self, block, planes, blocks, stride=1): def _init_weights(self, m): print("Initialization...") if isinstance(m, nn.Linear): - trunc_normal_(m.weight, std=.02) + trunc_normal_(m.weight, std=0.02) if isinstance(m, nn.Linear) and m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, nn.LayerNorm): nn.init.constant_(m.bias, 0) nn.init.constant_(m.weight, 1.0) - def forward(self, feature, mask = None): + def forward(self, feature, mask=None): p = self.patch_size - x = rearrange(feature, 'b c (h p1) (w p2) -> b (h w) (p1 p2 c)', p1 = p[0], p2 = p[1]) + x = rearrange( + feature, "b c (h p1) (w p2) -> b (h w) (p1 p2 c)", p1=p[0], p2=p[1] + ) x = self.patch_to_embedding(x) b, n, _ = x.shape - keypoint_tokens = repeat(self.keypoint_token, '() n d -> b n d', b = b) - if self.pos_embedding_type in ["sine","sine-full"] : + keypoint_tokens = repeat(self.keypoint_token, "() n d -> b n d", b=b) + if self.pos_embedding_type in ["sine", "sine-full"]: x += self.pos_embedding[:, :n] x = torch.cat((keypoint_tokens, x), dim=1) else: x = torch.cat((keypoint_tokens, x), dim=1) - x += self.pos_embedding[:, :(n + self.num_keypoints)] + x += self.pos_embedding[:, : (n + self.num_keypoints)] x = self.dropout(x) - x1 = self.transformer1(x, mask,self.pos_embedding) - x2 = self.transformer2(x1, mask,self.pos_embedding) - x3 = self.transformer3(x2, mask,self.pos_embedding) + x1 = self.transformer1(x, mask, self.pos_embedding) + x2 = self.transformer2(x1, mask, self.pos_embedding) + x3 = self.transformer3(x2, mask, self.pos_embedding) - x1_out = self.to_keypoint_token(x1[:, 0:self.num_keypoints]) - x2_out = self.to_keypoint_token(x2[:, 0:self.num_keypoints]) - x3_out = self.to_keypoint_token(x3[:, 0:self.num_keypoints]) + x1_out = self.to_keypoint_token(x1[:, 0 : self.num_keypoints]) + x2_out = self.to_keypoint_token(x2[:, 0 : self.num_keypoints]) + x3_out = self.to_keypoint_token(x3[:, 0 : self.num_keypoints]) x = torch.cat((x1_out, x2_out, x3_out), dim=2) - - return x \ No newline at end of file + return x diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/utils.py b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py index 10835af80f..ad4070890b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py @@ -1,8 +1,8 @@ import torch import math -def make_sine_position_embedding(h, w, d_model, temperature=10000, - scale=2 * math.pi): + +def make_sine_position_embedding(h, w, d_model, temperature=10000, scale=2 * math.pi): area = torch.ones(1, h, w) y_embed = area.cumsum(1, dtype=torch.float32) x_embed = area.cumsum(2, dtype=torch.float32) @@ -17,10 +17,12 @@ def make_sine_position_embedding(h, w, d_model, temperature=10000, pos_x = x_embed[:, :, :, None] / dim_t pos_y = y_embed[:, :, :, None] / dim_t pos_x = torch.stack( - (pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4).flatten(3) + (pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4 + ).flatten(3) pos_y = torch.stack( - (pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4).flatten(3) + (pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4 + ).flatten(3) pos = torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2) pos = pos.flatten(2).permute(0, 2, 1) - return pos \ No newline at end of file + return pos diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index 66677afad4..1080063c4b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -1,3 +1,4 @@ from .base import PREDICTORS, BasePredictor from .single_predictor import SinglePredictor -from .dekr_predictor import DEKRPredictor \ No newline at end of file +from .dekr_predictor import DEKRPredictor +from .top_down_prediction import TopDownPredictor diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index 9eb954e424..fd850e670c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -15,11 +15,11 @@ from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -PREDICTORS = Registry('predictors', build_func=build_from_cfg) +PREDICTORS = Registry("predictors", build_func=build_from_cfg) class BasePredictor(ABC, nn.Module): - """ A base predictor """ + """A base predictor""" def __init__(self): super().__init__() diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index fea2a9e678..828c368c27 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -3,27 +3,34 @@ from typing import Tuple -from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS, BasePredictor +from deeplabcut.pose_estimation_pytorch.models.predictors import ( + PREDICTORS, + BasePredictor, +) + @PREDICTORS.register_module class DEKRPredictor(BasePredictor): """ - Regresses keypoints and assembles them (if multianimal project) from DEKR output - Based on: - Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression - Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang - CVPR - 2021 - Code based on: - https://github.com/HRNet/DEKR + Regresses keypoints and assembles them (if multianimal project) from DEKR output + Based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang + CVPR + 2021 + Code based on: + https://github.com/HRNet/DEKR """ - default_init = { - 'apply_sigmoid' : True, - 'detection_threshold' : 0.01 - } - - def __init__(self, num_animals: int, detection_threshold: float=0.01, apply_sigmoid: bool=True, use_heatmap = True): + default_init = {"apply_sigmoid": True, "detection_threshold": 0.01} + + def __init__( + self, + num_animals: int, + detection_threshold: float = 0.01, + apply_sigmoid: bool = True, + use_heatmap=True, + ): super().__init__() self.num_animals = num_animals @@ -32,7 +39,7 @@ def __init__(self, num_animals: int, detection_threshold: float=0.01, apply_sigm self.use_heatmap = use_heatmap def forward(self, outputs, scale_factors: Tuple[float, float]): - #TODO implement confidence scores for each keypoints + # TODO implement confidence scores for each keypoints heatmaps, offsets = outputs if self.apply_sigmoid: heatmaps = nn.Sigmoid()(heatmaps) @@ -44,32 +51,32 @@ def forward(self, outputs, scale_factors: Tuple[float, float]): center_heatmaps = heatmaps[:, -1] pose_ind, scores = self.get_top_values(center_heatmaps) - posemap = posemap.permute(0, 2, 3, 1).view(batch_size, h*w, -1, 2) - poses = torch.zeros(batch_size, pose_ind.shape[1], num_joints, 2).to(scores.device) + posemap = posemap.permute(0, 2, 3, 1).view(batch_size, h * w, -1, 2) + poses = torch.zeros(batch_size, pose_ind.shape[1], num_joints, 2).to( + scores.device + ) for i in range(batch_size): pose = posemap[i, pose_ind[i]] poses[i] = pose - ctr_score = scores[:, :,None].expand(batch_size, -1, num_joints)[:,:,:,None] + ctr_score = scores[:, :, None].expand(batch_size, -1, num_joints)[:, :, :, None] - poses[:, :, :, 0] = poses[:, :, :, 0]*scale_factors[1] + 0.5*scale_factors[1] - poses[:, :, :, 1] = poses[:, :, :, 1]*scale_factors[0] + 0.5*scale_factors[0] + poses[:, :, :, 0] = ( + poses[:, :, :, 0] * scale_factors[1] + 0.5 * scale_factors[1] + ) + poses[:, :, :, 1] = ( + poses[:, :, :, 1] * scale_factors[0] + 0.5 * scale_factors[0] + ) poses_w_scores = torch.cat([poses, ctr_score], dim=3) self.pose_nms(heatmaps, poses_w_scores) - + return poses_w_scores def get_locations(self, height: int, width: int, device: torch.device): - shifts_x = torch.arange( - 0, width, step=1, - dtype=torch.float32 - ).to(device) - shifts_y = torch.arange( - 0, height, step=1, - dtype=torch.float32 - ).to(device) - shift_y, shift_x = torch.meshgrid(shifts_y, shifts_x, indexing='ij') + shifts_x = torch.arange(0, width, step=1, dtype=torch.float32).to(device) + shifts_y = torch.arange(0, height, step=1, dtype=torch.float32).to(device) + shift_y, shift_x = torch.meshgrid(shifts_y, shifts_x, indexing="ij") shift_x = shift_x.reshape(-1) shift_y = shift_y.reshape(-1) locations = torch.stack((shift_x, shift_y), dim=1) @@ -77,50 +84,56 @@ def get_locations(self, height: int, width: int, device: torch.device): return locations def get_reg_poses(self, offsets: torch.Tensor, num_joints: int): - ''' + """ offsets : (batch_size, num_joints*2, h, w) - ''' + """ batch_size, _, h, w = offsets.shape - offsets = offsets.permute(0, 2, 3, 1).reshape(batch_size, h*w, num_joints, 2) + offsets = offsets.permute(0, 2, 3, 1).reshape(batch_size, h * w, num_joints, 2) locations = self.get_locations(h, w, offsets.device) locations = locations[None, :, None, :].expand(batch_size, -1, num_joints, -1) poses = locations - offsets return poses - + def offset_to_pose(self, offsets: torch.Tensor): - ''' + """ offsets : (batch_size, num_joints*2, h, w) RETURN --------- reg_poses : (batch_size, 2*num_joints, h, w) - ''' + """ batch_size, num_offset, h, w = offsets.shape - num_joints = int(num_offset/2) + num_joints = int(num_offset / 2) reg_poses = self.get_reg_poses(offsets, num_joints) - reg_poses = reg_poses.contiguous().view(batch_size, h*w, 2*num_joints).permute(0, 2, 1) - reg_poses = reg_poses.contiguous().view(batch_size,-1,h,w).contiguous() + reg_poses = ( + reg_poses.contiguous() + .view(batch_size, h * w, 2 * num_joints) + .permute(0, 2, 1) + ) + reg_poses = reg_poses.contiguous().view(batch_size, -1, h, w).contiguous() return reg_poses def max_pool(self, heatmap: torch.Tensor): - ''' + """ heatmap: (batch_size, h, w) - ''' + """ pool1 = torch.nn.MaxPool2d(3, 1, 1) pool2 = torch.nn.MaxPool2d(5, 1, 2) pool3 = torch.nn.MaxPool2d(7, 1, 3) - map_size = (heatmap.shape[1]+heatmap.shape[2])/2.0 - maxm = pool2(heatmap) # Here I think pool 2 is a good match for default 17 pos_dist_tresh + map_size = (heatmap.shape[1] + heatmap.shape[2]) / 2.0 + maxm = pool2( + heatmap + ) # Here I think pool 2 is a good match for default 17 pos_dist_tresh return maxm def get_top_values(self, heatmap: torch.Tensor): - ''' + """ heatmap: (batch_size, h, w) - ''' + """ maximum = self.max_pool(heatmap) maximum = torch.eq(maximum, heatmap) heatmap *= maximum @@ -131,7 +144,7 @@ def get_top_values(self, heatmap: torch.Tensor): scores, pos_ind = torch.topk(heatmap_flat, self.num_animals, dim=1) return pos_ind, scores - + ########## WIP to take heatmap into account for scoring ########## def get_heat_value(self, pose_coords, heatmaps): """ @@ -139,16 +152,18 @@ def get_heat_value(self, pose_coords, heatmaps): heatmaps : (batch_size, 1+num_joints, h, w) """ h, w = heatmaps.shape[2:] - heatmaps_nocenter = heatmaps[:, :-1].flatten(2, 3) # (batch_size, num_joints, h*w) + heatmaps_nocenter = heatmaps[:, :-1].flatten( + 2, 3 + ) # (batch_size, num_joints, h*w) # Predicted poses based on the offset can be outsied of the image - y = torch.clamp(torch.floor(pose_coords[:, :, :, 1]), 0, h-1).long() - x = torch.clamp(torch.floor(pose_coords[:, :, :, 0]), 0, w-1).long() + y = torch.clamp(torch.floor(pose_coords[:, :, :, 1]), 0, h - 1).long() + x = torch.clamp(torch.floor(pose_coords[:, :, :, 0]), 0, w - 1).long() - heatvals = torch.gather(heatmaps_nocenter, 2, y*w + x) + heatvals = torch.gather(heatmaps_nocenter, 2, y * w + x) return heatvals - + def pose_nms(self, heatmaps, poses): """ NMS for the regressed poses results. @@ -165,7 +180,7 @@ def pose_nms(self, heatmaps, poses): batch_size, num_people, num_joints, _ = pose_coords.shape heatvals = self.get_heat_value(pose_coords, heatmaps) - heat_score = (torch.sum(heatvals, dim=1)/num_joints)[:,0] + heat_score = (torch.sum(heatvals, dim=1) / num_joints)[:, 0] # pose_score = pose_score*heatvals # poses = torch.cat([pose_coord.cpu(), pose_score.cpu()], dim=2) @@ -173,7 +188,7 @@ def pose_nms(self, heatmaps, poses): # keep_pose_inds = nms_core(cfg, pose_coord, heat_score) # poses = poses[keep_pose_inds] # heat_score = heat_score[keep_pose_inds] - + # if len(keep_pose_inds) > cfg.DATASET.MAX_NUM_PEOPLE: # heat_score, topk_inds = torch.topk(heat_score, # cfg.DATASET.MAX_NUM_PEOPLE) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index 58d8d94c79..b84950bd2e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -1,67 +1,80 @@ import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.models.predictors.base import PREDICTORS, BasePredictor +from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( + PREDICTORS, + BasePredictor, +) + @PREDICTORS.register_module class SinglePredictor(BasePredictor): """ Predictor only intended for single animal pose estimation - + Regresses keypoints from heatmaps and locref_maps of baseline DLC model (ResNet + Deconv) """ default_init = { - 'location_refinement': True, - 'locref_stdev': 7.2801, - 'apply_sigmoid' : True + "location_refinement": True, + "locref_stdev": 7.2801, + "apply_sigmoid": True, } - def __init__(self, num_animals, location_refinement, locref_stdev, apply_sigmoid: bool= True): + def __init__( + self, num_animals, location_refinement, locref_stdev, apply_sigmoid: bool = True + ): super().__init__() - #TODO add num_animals in pytorch_cfg automatically + # TODO add num_animals in pytorch_cfg automatically self.num_animals = num_animals - assert(self.num_animals == 1, "SinglePredictor must only be used for single animal predictions") + assert ( + self.num_animals == 1, + "SinglePredictor must only be used for single animal predictions", + ) self.location_refinement = location_refinement self.locref_stdev = locref_stdev self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() def forward(self, output, scale_factors): - ''' + """ get predictions from model output output = heatmaps, locref heatmaps: torch.Tensor([batch_size, num_joints, height, width]) locref: torch.Tensor([batch_size, num_joints, height, width]) - ''' + """ heatmaps, locrefs = output if self.apply_sigmoid: heatmaps = self.sigmoid(heatmaps) heatmaps = heatmaps.permute(0, 2, 3, 1) batch_size, height, width, num_joints = heatmaps.shape - locrefs = locrefs.permute(0, 2, 3, 1).reshape(batch_size, height, width, num_joints, 2) - - poses = self.get_pose_prediction(heatmaps, locrefs*self.locref_stdev, scale_factors) + locrefs = locrefs.permute(0, 2, 3, 1).reshape( + batch_size, height, width, num_joints, 2 + ) + + poses = self.get_pose_prediction( + heatmaps, locrefs * self.locref_stdev, scale_factors + ) return poses - + def get_top_values(self, heatmap) -> torch.Tensor: batchsize, ny, nx, num_joints = heatmap.shape heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints) heatmap_top = torch.argmax(heatmap_flat, axis=1) - Y, X = heatmap_top//nx, heatmap_top%nx + Y, X = heatmap_top // nx, heatmap_top % nx return Y, X - + def get_pose_prediction(self, heatmap, locref, scale_factors): - ''' + """ heatmap shape : (batch_size, height, width, num_joints) locref shape : (batch_size, height, width, num_joints, 2) - + RETURN ---------- - pose : (batch_size, num_people = 1, num_joints, 3)''' + pose : (batch_size, num_people = 1, num_joints, 3)""" Y, X = self.get_top_values(heatmap) batch_size, num_joints = X.shape @@ -82,4 +95,79 @@ def get_pose_prediction(self, heatmap, locref, scale_factors): pose[:, :, :, 1] = Y pose[:, :, :, 2] = DZ[:, :, :, 2] - return pose \ No newline at end of file + return pose + + +@PREDICTORS.register_module +class HeatmapOnlyPredictor(BasePredictor): + """Predictor only intended for single animal pose estimation, without locref""" + + default_init = { + "location_refinement": True, + "locref_stdev": 7.2801, + "apply_sigmoid": True, + } + + def __init__(self, num_animals, apply_sigmoid: bool = True): + super().__init__() + # TODO add num_animals in pytorch_cfg automatically + self.num_animals = num_animals + assert ( + self.num_animals == 1, + "SinglePredictor must only be used for single animal predictions", + ) + self.apply_sigmoid = apply_sigmoid + self.sigmoid = nn.Sigmoid() + + def forward(self, output, scale_factors): + """ + get predictions from model output + output = heatmaps + heatmaps: torch.Tensor([batch_size, num_joints, height, width]) + locref: torch.Tensor([batch_size, num_joints, height, width]) + """ + heatmaps = output[0] + if self.apply_sigmoid: + heatmaps = self.sigmoid(heatmaps) + heatmaps = heatmaps.permute(0, 2, 3, 1) + + poses = self.get_pose_prediction(heatmaps, scale_factors) + return poses + + def get_top_values(self, heatmap) -> torch.Tensor: + batchsize, ny, nx, num_joints = heatmap.shape + heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints) + + heatmap_top = torch.argmax(heatmap_flat, axis=1) + + Y, X = heatmap_top // nx, heatmap_top % nx + return Y, X + + def get_pose_prediction(self, heatmap, scale_factors): + """ + TODO: optimize that so DZ looks right + heatmap shape : (batch_size, height, width, num_joints) + + RETURN + ---------- + pose : (batch_size, num_people = 1, num_joints, 3)""" + Y, X = self.get_top_values(heatmap) + batch_size, num_joints = X.shape + + DZ = torch.zeros((batch_size, 1, num_joints, 3)).to(X.device) + for b in range(batch_size): + for j in range(num_joints): + DZ[b, 0, j, 2] = heatmap[b, Y[b, j], X[b, j], j] + + X, Y = torch.unsqueeze(X, 1), torch.unsqueeze(Y, 1) + + X = X * scale_factors[1] + 0.5 * scale_factors[1] + DZ[:, :, :, 0] + Y = Y * scale_factors[0] + 0.5 * scale_factors[0] + DZ[:, :, :, 1] + # P = DZ[:, :, 2] + + pose = torch.empty((batch_size, 1, num_joints, 3)) + pose[:, :, :, 0] = X + pose[:, :, :, 1] = Y + pose[:, :, :, 2] = DZ[:, :, :, 2] + + return pose diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py new file mode 100644 index 0000000000..fb605b8630 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py @@ -0,0 +1,68 @@ +import torch +from typing import List + +from deeplabcut.pose_estimation_pytorch.models.predictors import ( + PREDICTORS, + BasePredictor, +) + + +@PREDICTORS.register_module +class TopDownPredictor(BasePredictor): + def __init__(self, format_bbox: str = "xyxy"): + """ + Predictor for regressing keypoints in a Top Down fashion based on bbox predictions + and regressed keypoints in cropped images + + Thus it should take as keypoint regressions outputs from another standard pose_estimation predictor + + Arguments: + - format_bbox : str, format of the bounding box prediction, either 'xyxy' or 'coco' + """ + super().__init__() + + self.format_bbox = format_bbox + + def _convert_bbox_to_coco(self, bboxes: torch.Tensor) -> torch.Tensor: + """Convert bboxes in the format (x1, y1, x2, y2) to coco format (x, y, w, h) + + Args: + bboxes (torch.Tensor): bboxes, shape (batch_size, max_num_animals, 4) + + Returns: + torch.Tensor: coco_bboxes, shape (batch_size, max_num_animals, 4) + """ + coco_bboxes = bboxes.clone() + coco_bboxes[:, :, 2] -= coco_bboxes[:, :, 0] + coco_bboxes[:, :, 3] -= coco_bboxes[:, :, 1] + + return coco_bboxes + + def forward( + self, bboxes: torch.Tensor, keypoints_cropped: torch.Tensor + ) -> torch.Tensor: + """Computes keypoints coordinates in the original image given predicted bbox and predicted + keypoints coordinates inside the bbox cropped image + + Args: + bboxes (torch.Tensor): shape : (batch_size, max_num_animals, 4), + keypoints_cropped (torch.Tensor): shape of keypoints (batch_size, max_num_animals, num_joints, 3) + + Returns: + torch.Tensor: keypoints (batch_size, max_num_animals, num_joints, 3) + """ + if self.format_bbox != "coco": + bboxes = self._convert_bbox_to_coco(bboxes) + + num_joints = keypoints_cropped.shape[2] + new_kpts = keypoints_cropped.clone() + + x_corners = (bboxes[:, :, 0]).unsqueeze(2).expand(-1, -1, num_joints) + y_corners = (bboxes[:, :, 1]).unsqueeze(2).expand(-1, -1, num_joints) + scales_x = (bboxes[:, :, 2] / 256).unsqueeze(2).expand(-1, -1, num_joints) + scales_y = (bboxes[:, :, 3] / 256).unsqueeze(2).expand(-1, -1, num_joints) + + new_kpts[:, :, :, 0] = scales_x * new_kpts[:, :, :, 0] + x_corners + new_kpts[:, :, :, 1] = scales_y * new_kpts[:, :, :, 1] + y_corners + + return new_kpts diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py index 7f9d5c7e67..cbfa9c62ae 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py @@ -1,4 +1,4 @@ from .base import BaseGenerator, TARGET_GENERATORS from .dekr_targets import DEKRGenerator from .gaussian_targets import GaussianGenerator -from .plateau_targets import PlateauGenerator \ No newline at end of file +from .plateau_targets import PlateauGenerator diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py index f0d7aef796..de4e961d75 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -4,10 +4,10 @@ from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -TARGET_GENERATORS = Registry('target_generators', build_func=build_from_cfg) +TARGET_GENERATORS = Registry("target_generators", build_func=build_from_cfg) -class BaseGenerator(ABC, nn.Module): +class BaseGenerator(ABC, nn.Module): def __init__(self): super().__init__() self.batch_norm_on = False @@ -15,5 +15,3 @@ def __init__(self): @abstractmethod def forward(self, x): pass - - \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index be6c6eeeca..a01cc666b9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -2,36 +2,44 @@ from typing import Tuple import torch -from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( + BaseGenerator, + TARGET_GENERATORS, +) + @TARGET_GENERATORS.register_module class DEKRGenerator(BaseGenerator): """ - Generate ground truth target for DEKR model training - based on: - Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression - Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang - CVPR - 2021 - Code based on: - https://github.com/HRNet/DEKR""" - - def __init__(self, num_joints:int, pos_dist_thresh:int, bg_weight:float = 0.1): + Generate ground truth target for DEKR model training + based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang + CVPR + 2021 + Code based on: + https://github.com/HRNet/DEKR""" + + def __init__(self, num_joints: int, pos_dist_thresh: int, bg_weight: float = 0.1): super().__init__() self.num_joints = num_joints self.pos_dist_thresh = pos_dist_thresh self.bg_weight = bg_weight - - self.num_joints_with_center = self.num_joints + 1 - def get_heat_val(self, sigma:float, x:float, y:float, x0:float, y0:float): + self.num_joints_with_center = self.num_joints + 1 - g = np.exp(- ((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma ** 2)) + def get_heat_val(self, sigma: float, x: float, y: float, x0: float, y0: float): + g = np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma**2)) return g - def forward(self, annotations:dict, prediction:Tuple[torch.Tensor, torch.Tensor], image_size:Tuple[int, int]): + def forward( + self, + annotations: dict, + prediction: Tuple[torch.Tensor, torch.Tensor], + image_size: Tuple[int, int], + ): """ Parameters @@ -39,7 +47,7 @@ def forward(self, annotations:dict, prediction:Tuple[torch.Tensor, torch.Tensor] annotations: dict, each entry should begin with the shape batch_size prediction: output of model, format could depend on the model, only used to compute output resolution image_size: size of image (only one tuple since for batch training all images should have the same size) - + Returns ------- #TODO locref is a bad name here and should be 'offset to center', but for code's simplicity it @@ -48,37 +56,54 @@ def forward(self, annotations:dict, prediction:Tuple[torch.Tensor, torch.Tensor] 'heatmaps' : heatmaps 'heatmaps_ignored': weights to apply to the heatmaps for loss computation 'locref_maps' : offset maps - 'locref_masks' : weights to apply to the offet maps for loss computation + 'locref_masks' : weights to apply to the offset maps for loss computation """ batch_size, _, output_h, output_w = prediction[0].shape output_res = output_h, output_w - stride_y, stride_x = image_size[0]/output_h, image_size[1]/output_w + stride_y, stride_x = image_size[0] / output_h, image_size[1] / output_w num_joints_without_center = self.num_joints num_joints_with_center = num_joints_without_center + 1 - coords = annotations['keypoints'].cpu().numpy() - area = annotations['area'].cpu().numpy() - - assert self.num_joints + 1 == coords.shape[2], \ - f'the number of joints should be {coords.shape}' - - #TODO make it possible to differenciate between center sigma and other sigmas - scale = max(1/stride_x, 1/stride_y) - sgm, ct_sgm = (self.pos_dist_thresh/2)*scale, (self.pos_dist_thresh)*scale - radius = self.pos_dist_thresh*scale - - hms = np.zeros((batch_size, num_joints_with_center, output_h, output_w), - dtype=np.float32) - ignored_hms = 2*np.ones((batch_size, num_joints_with_center, output_h, output_w), - dtype=np.float32) - offset_map = np.zeros((batch_size, num_joints_without_center*2, output_h, output_w), - dtype=np.float32) - weight_map = np.zeros((batch_size, num_joints_without_center*2, output_h, output_w), - dtype=np.float32) - area_map = np.zeros((batch_size, output_h, output_w), - dtype=np.float32) + coords = annotations["keypoints"].cpu().numpy() + num_animals = coords.shape[1] + area = annotations["area"].cpu().numpy() + + assert ( + self.num_joints + 1 == coords.shape[2] + ), f"the number of joints should be {coords.shape}" + + # TODO make it possible to differentiate between center sigma and other sigmas + scale = max(1 / stride_x, 1 / stride_y) + sgm, ct_sgm = (self.pos_dist_thresh / 2) * scale, (self.pos_dist_thresh) * scale + radius = self.pos_dist_thresh * scale + + hms = np.zeros( + (batch_size, num_joints_with_center, output_h, output_w), dtype=np.float32 + ) + ignored_hms = 2 * np.ones( + (batch_size, num_joints_with_center, output_h, output_w), dtype=np.float32 + ) + offset_map = np.zeros( + ( + batch_size, + num_joints_without_center * 2, + output_h, + output_w, + ), + dtype=np.float32, + ) + weight_map = np.zeros( + ( + batch_size, + num_joints_without_center * 2, + output_h, + output_w, + ), + dtype=np.float32, + ) + area_map = np.zeros((batch_size, output_h, output_w), dtype=np.float32) hms_list = [hms, ignored_hms] @@ -87,41 +112,46 @@ def forward(self, annotations:dict, prediction:Tuple[torch.Tensor, torch.Tensor] idx_center = len(p) - 1 ct_x = int(p[-1, 0]) ct_y = int(p[-1, 1]) - - ct_x_sm = (ct_x - stride_x/2)/stride_x - ct_y_sm = (ct_y - stride_y/2)/stride_y + + ct_x_sm = (ct_x - stride_x / 2) / stride_x + ct_y_sm = (ct_y - stride_y / 2) / stride_y for idx, pt in enumerate(p): if idx == idx_center: sigma = ct_sgm else: sigma = sgm - if np.any(pt <= 0.): + if np.any(pt <= 0.0): continue x, y = pt[0], pt[1] - x_sm, y_sm = (x - stride_x/2)/stride_x, (y - stride_y/2)/stride_y + x_sm, y_sm = (x - stride_x / 2) / stride_x, ( + y - stride_y / 2 + ) / stride_y - if x_sm < 0 or y_sm < 0 or \ - x_sm >= output_w or y_sm >= output_h: + if x_sm < 0 or y_sm < 0 or x_sm >= output_w or y_sm >= output_h: continue # HEATMAP COMPUTATION - ul = int(np.floor(x_sm - 3 * sigma - 1) - ), int(np.floor(y_sm - 3 * sigma - 1)) - br = int(np.ceil(x_sm + 3 * sigma + 2) - ), int(np.ceil(y_sm + 3 * sigma + 2)) + ul = int(np.floor(x_sm - 3 * sigma - 1)), int( + np.floor(y_sm - 3 * sigma - 1) + ) + br = int(np.ceil(x_sm + 3 * sigma + 2)), int( + np.ceil(y_sm + 3 * sigma + 2) + ) cc, dd = max(0, ul[0]), min(br[0], output_res[1]) aa, bb = max(0, ul[1]), min(br[1], output_res[0]) - joint_rg = np.zeros((bb-aa, dd-cc)) + joint_rg = np.zeros((bb - aa, dd - cc)) for sy in range(aa, bb): for sx in range(cc, dd): - joint_rg[sy-aa, sx - - cc] = self.get_heat_val(sigma, sx, sy, x_sm, y_sm) + joint_rg[sy - aa, sx - cc] = self.get_heat_val( + sigma, sx, sy, x_sm, y_sm + ) hms_list[0][b, idx, aa:bb, cc:dd] = np.maximum( - hms_list[0][b, idx, aa:bb, cc:dd], joint_rg) - hms_list[1][b, idx, aa:bb, cc:dd] = 1. + hms_list[0][b, idx, aa:bb, cc:dd], joint_rg + ) + hms_list[1][b, idx, aa:bb, cc:dd] = 1.0 # OFFSET COMPUTATION if idx != idx_center: @@ -134,15 +164,21 @@ def forward(self, annotations:dict, prediction:Tuple[torch.Tensor, torch.Tensor] for pos_y in range(start_y, end_y): offset_x = pos_x - x_sm offset_y = pos_y - y_sm - if offset_map[b, idx*2, pos_y, pos_x] != 0 \ - or offset_map[b, idx*2+1, pos_y, pos_x] != 0: + if ( + offset_map[b, idx * 2, pos_y, pos_x] != 0 + or offset_map[b, idx * 2 + 1, pos_y, pos_x] != 0 + ): if area_map[b, pos_y, pos_x] < area[b, person_id]: continue - offset_map[b, idx*2, pos_y, pos_x] = offset_x - offset_map[b, idx*2+1, pos_y, pos_x] = offset_y - #TODO find a decent constant make weights vary giving animal area - weight_map[b, idx*2, pos_y, pos_x] = 1.#/((scale**2)*np.sqrt(area[person_id])) - weight_map[b, idx*2+1, pos_y, pos_x] = 1.#/((scale**2)*np.sqrt(area[person_id])) + offset_map[b, idx * 2, pos_y, pos_x] = offset_x + offset_map[b, idx * 2 + 1, pos_y, pos_x] = offset_y + # TODO find a decent constant make weights vary giving animal area + weight_map[ + b, idx * 2, pos_y, pos_x + ] = 1.0 # /((scale**2)*np.sqrt(area[person_id])) + weight_map[ + b, idx * 2 + 1, pos_y, pos_x + ] = 1.0 # /((scale**2)*np.sqrt(area[person_id])) area_map[b, pos_y, pos_x] = area[b, person_id] hms_list[1][hms_list[1] == 2] = self.bg_weight @@ -151,6 +187,6 @@ def forward(self, annotations:dict, prediction:Tuple[torch.Tensor, torch.Tensor] "heatmaps": hms_list[0], "heatmaps_ignored": hms_list[1], "locref_maps": offset_map, - "locref_masks": weight_map + "locref_masks": weight_map, } - return targets \ No newline at end of file + return targets diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py index fcf6fefa32..d498028ad7 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py @@ -2,7 +2,11 @@ from typing import Tuple import torch -from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( + BaseGenerator, + TARGET_GENERATORS, +) + @TARGET_GENERATORS.register_module class GaussianGenerator(BaseGenerator): @@ -11,20 +15,22 @@ class GaussianGenerator(BaseGenerator): to train baseline deeplabcut model (ResNet + Deconv) """ - def __init__(self, locref_stdev:float, num_joints:int, pos_dist_thresh:int): + def __init__(self, locref_stdev: float, num_joints: int, pos_dist_thresh: int): super().__init__() - self.locref_scale = 1.0/locref_stdev + self.locref_scale = 1.0 / locref_stdev self.num_joints = num_joints self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh ** 2 - self.std = 2*self.dist_thresh / 3 # We think of dist_thresh as a radius and std is a 'diameter' - + self.dist_thresh_sq = self.dist_thresh**2 + self.std = ( + 2 * self.dist_thresh / 3 + ) # We think of dist_thresh as a radius and std is a 'diameter' - def forward(self, - annotations:dict, - prediction:Tuple[torch.Tensor, torch.Tensor], - image_size:Tuple[int, int] + def forward( + self, + annotations: dict, + prediction: Tuple[torch.Tensor, torch.Tensor], + image_size: Tuple[int, int], ): """ @@ -33,7 +39,7 @@ def forward(self, annotations: dict, each entry should begin with the shape batch_size prediction: output of model, format could depend on the model, only used to compute output resolution image_size: size of image (only one tuple since for batch training all images should have the same size) - + Returns ------- targets : dict of the taregts, keys: @@ -45,17 +51,13 @@ def forward(self, # stride = cfg['stride'] # Apparently, there is no stride in the cfg # stride = scale_factors # TODO just test batch_size, _, height, width = prediction[0].shape - stride_y, stride_x = image_size[0]/height, image_size[1]/width - coords = annotations['keypoints'].cpu().numpy() - scmap = np.zeros(( - batch_size, - height, - width, self.num_joints), dtype=np.float32) - - locref_map = np.zeros(( - batch_size, - height, - width, self.num_joints * 2), dtype=np.float32) + stride_y, stride_x = image_size[0] / height, image_size[1] / width + coords = annotations["keypoints"].cpu().numpy() + scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) + + locref_map = np.zeros( + (batch_size, height, width, self.num_joints * 2), dtype=np.float32 + ) locref_mask = np.zeros_like(locref_map, dtype=int) grid = np.mgrid[:height, :width].transpose((1, 2, 0)) @@ -66,12 +68,12 @@ def forward(self, for idx_animal, kpts_animal in enumerate(coords[b]): for i, coord in enumerate(kpts_animal): coord = np.array(coord)[::-1] - if np.any(coord <= 0.): + if np.any(coord <= 0.0): continue dist = np.linalg.norm(grid - coord, axis=2) ** 2 - scmap_j = np.exp(-dist / (2 * self.std ** 2)) + scmap_j = np.exp(-dist / (2 * self.std**2)) scmap[b, :, :, i] += scmap_j - locref_mask[b, dist <= self.dist_thresh_sq, i * 2:i*2+2] = 1 + locref_mask[b, dist <= self.dist_thresh_sq, i * 2 : i * 2 + 2] = 1 dx = coord[1] - grid.copy()[:, :, 1] dy = coord[0] - grid.copy()[:, :, 0] locref_map[b, :, :, i * 2 + 0] += dx * self.locref_scale @@ -85,4 +87,54 @@ def forward(self, "locref_masks": locref_mask, } - return targets \ No newline at end of file + return targets + + +@TARGET_GENERATORS.register_module +class GaussianWithoutLocref(BaseGenerator): + def __init__(self, num_joints, pos_dist_thresh): + super().__init__() + + self.num_joints = num_joints + self.dist_thresh = float(pos_dist_thresh) + self.dist_thresh_sq = self.dist_thresh**2 + self.std = 2 * self.dist_thresh / 3 + + def forward(self, annotations, prediction, image_size): + """ + + Parameters + ---------- + annotations : dict of annoations which should all be tensors of first dimension batch_size + prediction: model's output + image_size : size of input images + + Returns + ------- + + """ + batch_size, _, height, width = prediction[0].shape + stride_y, stride_x = image_size[0] / height, image_size[1] / width + coords = annotations["keypoints"].cpu().numpy() + scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) + + grid = np.mgrid[:height, :width].transpose((1, 2, 0)) + grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 + grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 + + for b in range(batch_size): + for idx_animal, kpts_animal in enumerate(coords[b]): + for i, coord in enumerate(kpts_animal): + coord = np.array(coord)[::-1] + if np.any(coord <= 0.0): + continue + dist = np.linalg.norm(grid - coord, axis=2) ** 2 + scmap_j = np.exp(-dist / (2 * self.std**2)) + scmap[b, :, :, i] += scmap_j + + scmap = scmap.transpose(0, 3, 1, 2) + targets = { + "heatmaps": scmap, + } + + return targets diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py index 64ae93a53a..ad57f85751 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py @@ -2,7 +2,11 @@ import torch from typing import Tuple -from deeplabcut.pose_estimation_pytorch.models.target_generators.base import BaseGenerator, TARGET_GENERATORS +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( + BaseGenerator, + TARGET_GENERATORS, +) + @TARGET_GENERATORS.register_module class PlateauGenerator(BaseGenerator): @@ -11,19 +15,20 @@ class PlateauGenerator(BaseGenerator): to train baseline deeplabcut model (ResNet + Deconv) """ - def __init__(self, locref_stdev:float, num_joints:int, pos_dist_thresh:int): + def __init__(self, locref_stdev: float, num_joints: int, pos_dist_thresh: int): super().__init__() - - self.locref_scale = 1.0/locref_stdev + + self.locref_scale = 1.0 / locref_stdev self.num_joints = num_joints self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh ** 2 + self.dist_thresh_sq = self.dist_thresh**2 - def forward(self, - annotations:dict, - prediction:Tuple[torch.Tensor, torch.Tensor], - image_size:Tuple[int, int] - ): + def forward( + self, + annotations: dict, + prediction: Tuple[torch.Tensor, torch.Tensor], + image_size: Tuple[int, int], + ): """ Parameters @@ -40,33 +45,29 @@ def forward(self, 'locref_masks' : weights to apply to the locref maps for loss computation """ batch_size, _, height, width = prediction[0].shape - stride_y, stride_x = image_size[0]/height, image_size[1]/width - coords = annotations['keypoints'].cpu().numpy() - scmap = np.zeros(( - batch_size, - height, - width, self.num_joints), dtype=np.float32) - - locref_map = np.zeros(( - batch_size, - height, - width, self.num_joints * 2), dtype=np.float32) + stride_y, stride_x = image_size[0] / height, image_size[1] / width + coords = annotations["keypoints"].cpu().numpy() + scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) + + locref_map = np.zeros( + (batch_size, height, width, self.num_joints * 2), dtype=np.float32 + ) locref_mask = np.zeros_like(locref_map, dtype=int) grid = np.mgrid[:height, :width].transpose((1, 2, 0)) grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 - + for b in range(batch_size): for idx_animal, kpts_animal in enumerate(coords[b]): for i, coord in enumerate(kpts_animal): coord = np.array(coord)[::-1] - if np.any(coord <= 0.): + if np.any(coord <= 0.0): continue dist = np.linalg.norm(grid - coord, axis=2) ** 2 - mask = (dist <= self.dist_thresh_sq) + mask = dist <= self.dist_thresh_sq scmap[b, (dist <= self.dist_thresh_sq), i] += 1 - locref_mask[b, dist <= self.dist_thresh_sq, i * 2:i*2+2] = 1 + locref_mask[b, dist <= self.dist_thresh_sq, i * 2 : i * 2 + 2] = 1 dx = coord[1] - grid.copy()[:, :, 1] dy = coord[0] - grid.copy()[:, :, 0] locref_map[b, mask, i * 2 + 0] += (dx * self.locref_scale)[mask] @@ -81,4 +82,52 @@ def forward(self, "locref_masks": locref_mask, } - return targets \ No newline at end of file + return targets + + +@TARGET_GENERATORS.register_module +class PlateauWithoutLocref(BaseGenerator): + def __init__(self, num_joints, pos_dist_thresh): + super().__init__() + + self.num_joints = num_joints + self.dist_thresh = float(pos_dist_thresh) + self.dist_thresh_sq = self.dist_thresh**2 + + def forward(self, annotations, prediction, image_size): + """ + + Parameters + ---------- + annotations : dict of annoations which should all be tensors of first dimension batch_size + prediction: model's output + image_size : size of input images + + Returns + ------- + + """ + batch_size, _, height, width = prediction[0].shape + stride_y, stride_x = image_size[0] / height, image_size[1] / width + coords = annotations["keypoints"].cpu().numpy() + scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) + + grid = np.mgrid[:height, :width].transpose((1, 2, 0)) + grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 + grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 + + for b in range(batch_size): + for idx_animal, kpts_animal in enumerate(coords[b]): + for i, coord in enumerate(kpts_animal): + coord = np.array(coord)[::-1] + if np.any(coord <= 0.0): + continue + dist = np.linalg.norm(grid - coord, axis=2) ** 2 + scmap[b, (dist <= self.dist_thresh_sq), i] += 1 + + scmap = scmap.transpose(0, 3, 1, 2) + targets = { + "heatmaps": scmap, + } + + return targets diff --git a/deeplabcut/pose_estimation_pytorch/models/utils.py b/deeplabcut/pose_estimation_pytorch/models/utils.py index 79b46d90b2..a6a2f18c35 100644 --- a/deeplabcut/pose_estimation_pytorch/models/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/utils.py @@ -2,31 +2,32 @@ import torch from typing import Tuple -''' FILE NOT USED ANYMORE ''' - -def generate_heatmaps(cfg: dict, - coords: np.array, - scale_factor, - heatmap_size: tuple = (64, 64), - heatmap_type: str = 'gaussian'): - if heatmap_type == 'gaussian': - scmap, weights, locref_map, locref_mask = gaussian_scmap(cfg, - coords, - scale_factor, - heatmap_size) - elif heatmap_type == 'plateau': - scmap, weights, locref_map, locref_mask = plateau_scmap(cfg, - coords, - scale_factor, - heatmap_size) +""" FILE NOT USED ANYMORE """ + + +def generate_heatmaps( + cfg: dict, + coords: np.array, + scale_factor, + heatmap_size: tuple = (64, 64), + heatmap_type: str = "gaussian", +): + if heatmap_type == "gaussian": + scmap, weights, locref_map, locref_mask = gaussian_scmap( + cfg, coords, scale_factor, heatmap_size + ) + elif heatmap_type == "plateau": + scmap, weights, locref_map, locref_mask = plateau_scmap( + cfg, coords, scale_factor, heatmap_size + ) else: - raise ValueError('Only gaussian heatmap is supported!') + raise ValueError("Only gaussian heatmap is supported!") scmap = torch.FloatTensor(scmap) if weights: weights = torch.FloatTensor(weights) locref_map = torch.FloatTensor(locref_map) locref_mask = torch.BoolTensor(locref_mask) - + return scmap, weights, locref_map, locref_mask @@ -37,7 +38,7 @@ def gaussian_scmap(cfg, coords, scale_factors, heatmap_size): Parameters ---------- cfg: dlc config - Standart dlc config in the dlc project folder + Standard dlc config in the dlc project folder joint_id: coords: list/np.array of coordinates data_item @@ -51,19 +52,17 @@ def gaussian_scmap(cfg, coords, scale_factors, heatmap_size): locref_scale = 1.0 / cfg["locref_stdev"] num_joints = cfg["num_joints"] stride_y, stride_x = scale_factors - scmap = np.zeros(( - heatmap_size[0], - heatmap_size[1], num_joints), dtype=np.float32) + scmap = np.zeros((heatmap_size[0], heatmap_size[1], num_joints), dtype=np.float32) - locref_map = np.zeros(( - heatmap_size[0], - heatmap_size[1], num_joints * 2), dtype=np.float32) + locref_map = np.zeros( + (heatmap_size[0], heatmap_size[1], num_joints * 2), dtype=np.float32 + ) locref_mask = np.zeros_like(locref_map, dtype=int) width = heatmap_size[1] height = heatmap_size[0] - dist_thresh = float(cfg['pos_dist_thresh']) - dist_thresh_sq = dist_thresh ** 2 + dist_thresh = float(cfg["pos_dist_thresh"]) + dist_thresh_sq = dist_thresh**2 std = dist_thresh / 4 grid = np.mgrid[:height, :width].transpose((1, 2, 0)) @@ -71,12 +70,12 @@ def gaussian_scmap(cfg, coords, scale_factors, heatmap_size): grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 for i, coord in enumerate(coords): coord = np.array(coord)[::-1] - if np.any(coord <= 0.): + if np.any(coord <= 0.0): continue dist = np.linalg.norm(grid - coord, axis=2) ** 2 - scmap_j = np.exp(-dist / (2 * std ** 2)) + scmap_j = np.exp(-dist / (2 * std**2)) scmap[:, :, i] = scmap_j - locref_mask[dist <= dist_thresh_sq, i * 2:i*2+2] = 1 + locref_mask[dist <= dist_thresh_sq, i * 2 : i * 2 + 2] = 1 dx = coord[1] - grid.copy()[:, :, 1] dy = coord[0] - grid.copy()[:, :, 0] locref_map[:, :, i * 2 + 0] = dx * locref_scale @@ -87,23 +86,21 @@ def gaussian_scmap(cfg, coords, scale_factors, heatmap_size): def plateau_scmap(cfg, coords, scale_factors, heatmap_size): """Computes target objectives with plateau function rather than gaussian""" - + locref_scale = 1.0 / cfg["locref_stdev"] num_joints = cfg["num_joints"] stride_y, stride_x = scale_factors - scmap = np.zeros(( - heatmap_size[0], - heatmap_size[1], num_joints), dtype=np.float32) + scmap = np.zeros((heatmap_size[0], heatmap_size[1], num_joints), dtype=np.float32) - locref_map = np.zeros(( - heatmap_size[0], - heatmap_size[1], num_joints * 2), dtype=np.float32) + locref_map = np.zeros( + (heatmap_size[0], heatmap_size[1], num_joints * 2), dtype=np.float32 + ) locref_mask = np.zeros_like(locref_map, dtype=int) width = heatmap_size[1] height = heatmap_size[0] - dist_thresh = float(cfg['pos_dist_thresh']) - dist_thresh_sq = dist_thresh ** 2 + dist_thresh = float(cfg["pos_dist_thresh"]) + dist_thresh_sq = dist_thresh**2 std = dist_thresh / 4 grid = np.mgrid[:height, :width].transpose((1, 2, 0)) @@ -113,24 +110,27 @@ def plateau_scmap(cfg, coords, scale_factors, heatmap_size): for i, coord in enumerate(coords): coord = np.array(coord)[::-1] - if np.any(coord <= 0.): + if np.any(coord <= 0.0): continue dist = np.linalg.norm(grid - coord, axis=2) ** 2 - mask = (dist <= dist_thresh_sq) + mask = dist <= dist_thresh_sq scmap[(dist <= dist_thresh_sq), i] = 1 - locref_mask[dist <= dist_thresh_sq, i * 2:i*2+2] = 1 + locref_mask[dist <= dist_thresh_sq, i * 2 : i * 2 + 2] = 1 dx = coord[1] - grid.copy()[:, :, 1] dy = coord[0] - grid.copy()[:, :, 0] locref_map[mask, i * 2 + 0] = (dx * locref_scale)[mask] locref_map[mask, i * 2 + 1] = (dy * locref_scale)[mask] weights = None - return scmap, weights, locref_map, locref_mask, + return ( + scmap, + weights, + locref_map, + locref_mask, + ) + # TODO: check this function and rewrite above -def _generate_heatmaps(keypoints, - heatmap_size, - image_size=(256, 256), - sigma=5): +def _generate_heatmaps(keypoints, heatmap_size, image_size=(256, 256), sigma=5): """ TODO: MAKE FASTER Parameters @@ -144,9 +144,9 @@ def _generate_heatmaps(keypoints, ------- """ - target = torch.zeros((keypoints.shape[0], - heatmap_size[1], - heatmap_size[0]), dtype=torch.float32) + target = torch.zeros( + (keypoints.shape[0], heatmap_size[1], heatmap_size[0]), dtype=torch.float32 + ) scale_x = heatmap_size[0] / image_size[0] scale_y = heatmap_size[1] / image_size[1] for joint_id in range(keypoints.shape[0]): @@ -160,12 +160,13 @@ def _generate_heatmaps(keypoints, y = y[:, None] if mu_x > 0: - target[joint_id] = torch.exp(- ((x - mu_x) ** 2 + (y - mu_y) ** 2) / (2 * sigma ** 2)) + target[joint_id] = torch.exp( + -((x - mu_x) ** 2 + (y - mu_y) ** 2) / (2 * sigma**2) + ) return target def sigmoid(tx: np.ndarray): - exp_x = np.exp(tx) - return exp_x / (1 + exp_x) \ No newline at end of file + return exp_x / (1 + exp_x) diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py index dfceeefec3..194cd6a7e1 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py @@ -1 +1,4 @@ -from deeplabcut.pose_estimation_pytorch.post_processing.match_predictions_to_gt import rmse_match_prediction_to_gt, oks_match_prediction_to_gt \ No newline at end of file +from deeplabcut.pose_estimation_pytorch.post_processing.match_predictions_to_gt import ( + rmse_match_prediction_to_gt, + oks_match_prediction_to_gt, +) diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 88fa1ba7bd..9869ca506d 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -1,11 +1,16 @@ import numpy as np -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import calc_object_keypoint_similarity +from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( + calc_object_keypoint_similarity, +) from scipy.optimize import linear_sum_assignment -def rmse_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individual_names: list): - ''' + +def rmse_match_prediction_to_gt( + pred_kpts: np.array, gt_kpts: np.array, individual_names: list +): + """ Hungarian algorithm predicted individuals to ground truth ones, using rmse - + Arguments --------- pred_kpts: (num_animals, num_keypoints, 3) @@ -15,7 +20,7 @@ def rmse_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individu Output ------ row_ind: array of the individuals indexes for prediction - ''' + """ num_animals, num_keypoints, _ = pred_kpts.shape if num_keypoints + 1 == gt_kpts.shape[1]: @@ -23,7 +28,7 @@ def rmse_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individu elif num_keypoints == gt_kpts.shape[1]: gt_kpts_without_ctr = gt_kpts.copy() else: - raise ValueError('Shape mismatch between ground truth and predictions') + raise ValueError("Shape mismatch between ground truth and predictions") # Computation of the number of annotated animals in the ground truth num_animals_gt = num_animals @@ -34,19 +39,23 @@ def rmse_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individu distance_matrix = np.zeros((num_animals_gt, num_animals)) for g in range(num_animals_gt): for p in range(num_animals): - distance_matrix[g, p] = np.linalg.norm(gt_kpts_without_ctr[g] - pred_kpts[p, :, :2]) + distance_matrix[g, p] = np.linalg.norm( + gt_kpts_without_ctr[g] - pred_kpts[p, :, :2] + ) row_ind, col_ind = linear_sum_assignment(distance_matrix) # if animals are missing in the frame, the predictions corresponding to nothing are not shuffled col_ind = extend_col_ind(col_ind, num_animals) - + return col_ind -def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individual_names: list): - ''' +def oks_match_prediction_to_gt( + pred_kpts: np.array, gt_kpts: np.array, individual_names: list +): + """ Hungarian algorithm predicted individuals to ground truth ones, using oks - + Arguments --------- pred_kpts: (num_animals, num_keypoints, 3) @@ -56,7 +65,7 @@ def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individua Output ------ row_ind: array of the individuals indexes for prediction - ''' + """ num_animals, num_keypoints, _ = pred_kpts.shape if num_keypoints + 1 == gt_kpts.shape[1]: @@ -64,7 +73,7 @@ def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individua elif num_keypoints == gt_kpts.shape[1]: gt_kpts_without_ctr = gt_kpts.copy() else: - raise ValueError('Shape mismatch between ground truth and predictions') + raise ValueError("Shape mismatch between ground truth and predictions") # Computation of the number of annotated animals in the ground truth num_animals_gt = num_animals @@ -73,7 +82,9 @@ def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individua num_animals_gt -= 1 oks_matrix = np.zeros((num_animals_gt, num_animals)) - gt_kpts_without_ctr[gt_kpts_without_ctr < 0] = np.nan # non visible keypoints should be nan to use calc_oks + gt_kpts_without_ctr[ + gt_kpts_without_ctr < 0 + ] = np.nan # non visible keypoints should be nan to use calc_oks idx_gt = -1 for g in range(num_animals): if np.isnan(gt_kpts_without_ctr[g]).all(): @@ -86,17 +97,18 @@ def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individua gt_kpts_without_ctr[g], 0.1, margin=0, - symmetric_kpts=None #TODO take into account symmetric keypoints + symmetric_kpts=None, # TODO take into account symmetric keypoints ) row_ind, col_ind = linear_sum_assignment(oks_matrix, maximize=True) # if animals are missing in the frame, the predictions corresponding to nothing are not shuffled col_ind = extend_col_ind(col_ind, num_animals) - + return col_ind + def extend_col_ind(col_ind, num_animals): existing_cols = set(col_ind) # Convert the array to a set for faster lookup missing_cols = [num for num in range(num_animals) if num not in existing_cols] extended_array = np.concatenate((col_ind, missing_cols)).astype(int) - return extended_array \ No newline at end of file + return extended_array diff --git a/deeplabcut/pose_estimation_pytorch/registry.py b/deeplabcut/pose_estimation_pytorch/registry.py index 583eff0d4b..6009059d2c 100644 --- a/deeplabcut/pose_estimation_pytorch/registry.py +++ b/deeplabcut/pose_estimation_pytorch/registry.py @@ -3,9 +3,9 @@ from typing import Any, Dict, Optional -def build_from_cfg(cfg: Dict, - registry: 'Registry', - default_args: Optional[Dict] = None) -> Any: +def build_from_cfg( + cfg: Dict, registry: "Registry", default_args: Optional[Dict] = None +) -> Any: """Build a module from config dict when it is a class configuration, or call a function from config dict when it is a function configuration. Args: @@ -21,22 +21,20 @@ def build_from_cfg(cfg: Dict, for name, value in default_args.items(): args.setdefault(name, value) - obj_type = args.pop('type') + obj_type = args.pop("type") if isinstance(obj_type, str): obj_cls = registry.get(obj_type) if obj_cls is None: - raise KeyError( - f'{obj_type} is not in the {registry.name} registry') + raise KeyError(f"{obj_type} is not in the {registry.name} registry") elif inspect.isclass(obj_type) or inspect.isfunction(obj_type): obj_cls = obj_type else: - raise TypeError( - f'type must be a str or valid type, but got {type(obj_type)}') + raise TypeError(f"type must be a str or valid type, but got {type(obj_type)}") try: return obj_cls(**args) except Exception as e: # Normal TypeError does not print class name. - raise type(e)(f'{obj_cls.__name__}: {e}') + raise type(e)(f"{obj_cls.__name__}: {e}") class Registry: @@ -63,7 +61,7 @@ def __init__(self, name, build_func=None, parent=None, scope=None): self._name = name self._module_dict = dict() self._children = dict() - self._scope = '.' + self._scope = "." if build_func is None: if parent is not None: @@ -86,12 +84,12 @@ def __contains__(self, key): return self.get(key) is not None def __repr__(self): - format_str = self.__class__.__name__ + \ - f'(name={self._name}, ' \ - f'items={self._module_dict})' + format_str = ( + self.__class__.__name__ + f"(name={self._name}, " + f"items={self._module_dict})" + ) return format_str - @staticmethod def split_scope_key(key): """Split scope and key. @@ -105,9 +103,9 @@ def split_scope_key(key): tuple[str | None, str]: The former element is the first scope of the key, which can be ``None``. The latter is the remaining key. """ - split_index = key.find('.') + split_index = key.find(".") if split_index != -1: - return key[:split_index], key[split_index + 1:] + return key[:split_index], key[split_index + 1 :] else: return None, key @@ -168,14 +166,16 @@ def _add_children(self, registry): assert isinstance(registry, Registry) assert registry.scope is not None - assert registry.scope not in self.children, \ - f'scope {registry.scope} exists in {self.name} registry' + assert ( + registry.scope not in self.children + ), f"scope {registry.scope} exists in {self.name} registry" self.children[registry.scope] = registry def _register_module(self, module, module_name=None, force=False): if not inspect.isclass(module) and not inspect.isfunction(module): - raise TypeError('module must be a class or a function, ' - f'but got {type(module)}') + raise TypeError( + "module must be a class or a function, " f"but got {type(module)}" + ) if module_name is None: module_name = module.__name__ @@ -183,12 +183,10 @@ def _register_module(self, module, module_name=None, force=False): module_name = [module_name] for name in module_name: if not force and name in self._module_dict: - raise KeyError(f'{name} is already registered ' - f'in {self.name}') + raise KeyError(f"{name} is already registered " f"in {self.name}") self._module_dict[name] = module def deprecated_register_module(self, cls=None, force=False): - if cls is None: return partial(self.deprecated_register_module, force=force) self._register_module(cls, force=force) @@ -207,7 +205,7 @@ def register_module(self, name=None, force=False, module=None): module (type): Module class or function to be registered. """ if not isinstance(force, bool): - raise TypeError(f'force must be a boolean, but got {type(force)}') + raise TypeError(f"force must be a boolean, but got {type(force)}") # NOTE: This is a walkaround to be compatible with the old api, # while it may introduce unexpected bugs. if isinstance(name, type): diff --git a/deeplabcut/pose_estimation_pytorch/solvers/__init__.py b/deeplabcut/pose_estimation_pytorch/solvers/__init__.py index 1aa5bf3680..aedc041baa 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/__init__.py @@ -1,3 +1,6 @@ from deeplabcut.pose_estimation_pytorch.solvers.logger import LOGGER -from deeplabcut.pose_estimation_pytorch.solvers.single_animal import SINGLE_ANIMAL_SOLVER - +from deeplabcut.pose_estimation_pytorch.solvers.base import SOLVERS +from deeplabcut.pose_estimation_pytorch.solvers.top_down import TopDownSolver +from deeplabcut.pose_estimation_pytorch.solvers.single_animal import ( + BottomUpSingleAnimalSolver, +) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index a5ef6bd72c..7a0e9f88c4 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -4,26 +4,31 @@ import numpy as np import torch +import torch.nn as nn from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from ..registry import Registry, build_from_cfg from .utils import * +SOLVERS = Registry("solvers", build_func=build_from_cfg) -class Solver(ABC): - - def __init__(self, - model: PoseModel, - criterion: torch.nn, - optimizer: torch.optim.Optimizer, - predictor: BasePredictor, - cfg: Dict, - device: str = 'cpu', - scheduler: Optional = None, - logger: Optional = None): +class Solver(ABC): + def __init__( + self, + model: PoseModel, + criterion: torch.nn, + optimizer: torch.optim.Optimizer, + predictor: BasePredictor, + cfg: Dict, + device: str = "cpu", + scheduler: Optional = None, + logger: Optional = None, + ): """Solver base class. A solvers contains helper methods for bundling a model, criterion and optimizer. @@ -40,7 +45,7 @@ def __init__(self, logger: logger to monitor training (e.g WandB logger) """ if cfg is None: - raise ValueError('') + raise ValueError("") self.model = model self.device = device self.cfg = cfg @@ -48,8 +53,7 @@ def __init__(self, self.scheduler = scheduler self.criterion = criterion self.predictor = predictor - self.history = {'train_loss': [], - 'eval_loss': []} + self.history = {"train_loss": [], "eval_loss": []} self.logger = logger if self.logger: logger.log_config(cfg) @@ -57,14 +61,15 @@ def __init__(self, self.stride = 8 # TODO: stride from config? def fit( - self, - train_loader: torch.utils.data.DataLoader, - valid_loader: torch.utils.data.DataLoader, - train_fraction: float = 0.95, - shuffle: int = 0, - model_prefix: str = '', - *, - epochs: int = 10000) -> None: + self, + train_loader: torch.utils.data.DataLoader, + valid_loader: torch.utils.data.DataLoader, + train_fraction: float = 0.95, + shuffle: int = 0, + model_prefix: str = "", + *, + epochs: int = 10000, + ) -> None: """ Train model for the specified number of steps. @@ -78,20 +83,19 @@ def fit( model_prefix: TODO discuss (mb better specify with config) epochs: The number of training iterations. """ - model_folder = get_model_folder(train_fraction, - shuffle, - model_prefix, - train_loader.dataset.cfg) + model_folder = get_model_folder( + train_fraction, shuffle, model_prefix, train_loader.dataset.cfg + ) save_epochs = 30 # TODO: read this value from config file for i in range(epochs): - train_loss = self.epoch(train_loader, mode='train', step=i + 1) + train_loss = self.epoch(train_loader, mode="train", step=i + 1) if self.scheduler: self.scheduler.step() - print(f'Training for epoch {i + 1} done, starting eval on validation data') - valid_loss = self.epoch(valid_loader, mode='eval', step=i + 1) + print(f"Training for epoch {i + 1} done, starting eval on validation data") + valid_loss = self.epoch(valid_loader, mode="eval", step=i + 1) - if (i + 1) % self.cfg['save_epochs'] == 0: + if (i + 1) % self.cfg["save_epochs"] == 0: print(f"Finished epoch {i + 1}; saving model") torch.save( self.model.state_dict(), @@ -99,9 +103,9 @@ def fit( ) print( - f'Epoch {i + 1}/{epochs}, ' - f'train loss {float(train_loss):.5f}, ' - f'valid loss {float(valid_loss):.5f}, ' + f"Epoch {i + 1}/{epochs}, " + f"train loss {float(train_loss):.5f}, " + f"valid loss {float(valid_loss):.5f}, " f'lr {self.optimizer.param_groups[0]["lr"]}' ) @@ -112,10 +116,12 @@ def fit( f"{model_folder}/train/snapshot-{epochs}.pt", ) - def epoch(self, - loader: torch.utils.data.DataLoader, - mode: str = 'train', - step: Optional[int] = None) -> np.array: + def epoch( + self, + loader: torch.utils.data.DataLoader, + mode: str = "train", + step: Optional[int] = None, + ) -> np.array: """ Parameters @@ -128,33 +134,35 @@ def epoch(self, ------- epoch_loss: Average of the loss over the batches. """ - if mode not in ['train', 'eval']: - raise ValueError(f'Solver mode must be train or eval, found mode={mode}.') + if mode not in ["train", "eval"]: + raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") to_mode = getattr(self.model, mode) to_mode() epoch_loss = [] - metrics={ - 'total_loss': [], - 'heatmap_loss': [], - 'locref_loss': [], + metrics = { + "total_loss": [], + "heatmap_loss": [], + "locref_loss": [], } for i, batch in enumerate(loader): loss, htmp_loss, locref_loss = self.step(batch, mode) epoch_loss.append(loss) - metrics['total_loss'].append(loss) - metrics['heatmap_loss'].append(htmp_loss) - metrics['locref_loss'].append(locref_loss) + metrics["total_loss"].append(loss) + metrics["heatmap_loss"].append(htmp_loss) + metrics["locref_loss"].append(locref_loss) - if (i+1)%self.cfg['display_iters'] == 0: - print(f"Number of iterations : {i+1}, loss : {loss}, lr : {self.optimizer.param_groups[0]['lr']}") + if (i + 1) % self.cfg["display_iters"] == 0: + print( + f"Number of iterations : {i+1}, loss : {loss}, lr : {self.optimizer.param_groups[0]['lr']}" + ) epoch_loss = np.mean(epoch_loss) - self.history[f'{mode}_loss'].append(epoch_loss) + self.history[f"{mode}_loss"].append(epoch_loss) if self.logger: for key in metrics.keys(): self.logger.log( - f'{mode} {key}', + f"{mode} {key}", np.nanmean(metrics[key]), step=step, ) @@ -162,14 +170,11 @@ def epoch(self, return epoch_loss @abstractmethod - def step(self, - batch: Tuple[torch.Tensor, torch.Tensor], - *args) -> Optional: + def step(self, batch: Tuple[torch.Tensor, torch.Tensor], *args) -> Optional: raise NotImplementedError @torch.no_grad() - def inference(self, - dataset: PoseDataset) -> np.array: + def inference(self, dataset: PoseDataset) -> np.array: # todo add scale predicted_poses = [] for item in dataset: @@ -190,9 +195,9 @@ class BottomUpSolver(Solver): Base solvers for bottom up pose estimation. """ - def step(self, - batch: Tuple[torch.Tensor, torch.Tensor], - mode: str = 'train') -> np.array: + def step( + self, batch: Tuple[torch.Tensor, torch.Tensor], mode: str = "train" + ) -> np.array: """Perform a single epoch gradient update or validation step. Parameters @@ -204,27 +209,30 @@ def step(self, ------- batch loss, heatmap_loss, locref_loss """ - if mode not in ['train', 'eval']: - raise ValueError(f'Solver must be in train or eval mode, but {mode} was found.') - if mode == 'train': + if mode not in ["train", "eval"]: + raise ValueError( + f"Solver must be in train or eval mode, but {mode} was found." + ) + if mode == "train": self.optimizer.zero_grad() - image = batch['image'] + image = batch["image"] image = image.to(self.device) prediction = self.model(image) - - target = self.model.get_target(batch['annotations'], prediction, image.shape[2:]) # (batch_size, channels, h, w) + + target = self.model.get_target( + batch["annotations"], prediction, image.shape[2:] + ) # (batch_size, channels, h, w) for key in target: if target[key] is not None: - target[key] = torch.Tensor(target[key]).to(self.device) + target[key] = torch.tensor(target[key]).to(self.device) total_loss, heatmap_loss, locref_loss = self.criterion(prediction, target) - if mode == 'train': + if mode == "train": total_loss.backward() self.optimizer.step() - return total_loss.detach().cpu().numpy(), heatmap_loss.detach().cpu().numpy(), locref_loss.detach().cpu().numpy(), #rmse #, rmse_pcutoff - - -class TopDownSolver(Solver): - # TODO - pass + return ( + total_loss.detach().cpu().numpy(), + heatmap_loss.detach().cpu().numpy(), + locref_loss.detach().cpu().numpy(), + ) # rmse #, rmse_pcutoff diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index b63f5e84d0..09ebe0bfcd 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -2,18 +2,21 @@ import pandas as pd import torch from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly, evaluate_assembly +from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( + Assembly, + evaluate_assembly, +) from torch import nn from typing import List, Tuple, Dict def get_prediction(cfg, output, stride=8): - ''' + """ get predictions from model output output = heatmaps, locref heatmaps: numpy.ndarray([batch_size, num_joints, height, width]) locref: numpy.ndarray([batch_size, num_joints, height, width]) - ''' + """ poses = [] heatmaps, locref = output @@ -23,13 +26,14 @@ def get_prediction(cfg, output, stride=8): for i in range(heatmaps.shape[0]): shape = locref[i].shape locref_i = np.reshape(locref, (shape[0], shape[1], -1, 2)) - if cfg['location_refinement']: - locref_i = locref_i * cfg['locref_stdev'] + if cfg["location_refinement"]: + locref_i = locref_i * cfg["locref_stdev"] pose = multi_pose_predict(heatmaps[i], locref_i, stride, 1) poses.append(pose) return np.stack(poses, axis=0) -#DEPRECATED + +# DEPRECATED def get_top_values(scmap, n_top=5): batchsize, ny, nx, num_joints = scmap.shape scmap_flat = scmap.reshape(batchsize, nx * ny, num_joints) @@ -46,7 +50,8 @@ def get_top_values(scmap, n_top=5): Y, X = np.unravel_index(scmap_top, (ny, nx)) return Y, X -#DEPRECATED + +# DEPRECATED def multi_pose_predict(scmap, locref, stride, num_outputs): Y, X = get_top_values(scmap[None], num_outputs) Y, X = Y[:, 0], X[:, 0] @@ -70,14 +75,17 @@ def multi_pose_predict(scmap, locref, stride, num_outputs): return pose -def get_scores(cfg: Dict, - prediction: pd.DataFrame, - target: pd.DataFrame, - bodyparts: List[str] = None) -> Dict: + +def get_scores( + cfg: Dict, + prediction: pd.DataFrame, + target: pd.DataFrame, + bodyparts: List[str] = None, +) -> Dict: """_summary_ Args: - cfg (Dict): config dictionnary + cfg (Dict): config dictionary prediction (pd.DataFrame): prediction df, should already be matched to ground truth using hungarian algorithm target (pd.DataFrame): ground truth dataframe @@ -87,31 +95,31 @@ def get_scores(cfg: Dict, Dict: scores dict, keys are : ['rmse', 'rmse_pcutoff', 'mAP', 'mAR', 'mAP_pcutoff', 'mAR_pcutoff'] """ - if cfg.get('pcutoff'): - pcutoff = cfg['pcutoff'] - rmse, rmse_p = get_rmse(prediction, target, pcutoff, - bodyparts = bodyparts) + if cfg.get("pcutoff"): + pcutoff = cfg["pcutoff"] + rmse, rmse_p = get_rmse(prediction, target, pcutoff, bodyparts=bodyparts) oks, oks_p = get_oks(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) else: - rmse, rmse_p = get_rmse(prediction, target, - bodyparts = bodyparts) + rmse, rmse_p = get_rmse(prediction, target, bodyparts=bodyparts) oks, oks_p = get_oks(prediction, target, bodyparts=bodyparts) scores = {} - scores['rmse'] = np.nanmean(rmse) - scores['rmse_pcutoff'] = np.nanmean(rmse_p) - scores['mAP'] = oks['mAP'] - scores['mAR'] = oks['mAR'] - scores['mAP_pcutoff'] = oks_p['mAP'] - scores['mAR_pcutoff'] = oks_p['mAR'] + scores["rmse"] = np.nanmean(rmse) + scores["rmse_pcutoff"] = np.nanmean(rmse_p) + scores["mAP"] = oks["mAP"] + scores["mAR"] = oks["mAR"] + scores["mAP_pcutoff"] = oks_p["mAP"] + scores["mAR_pcutoff"] = oks_p["mAR"] return scores -def get_rmse(prediction: pd.DataFrame, - target: pd.DataFrame, - pcutoff: float=-1, - bodyparts: List[str] =None) -> Tuple[float, float]: +def get_rmse( + prediction: pd.DataFrame, + target: pd.DataFrame, + pcutoff: float = -1, + bodyparts: List[str] = None, +) -> Tuple[float, float]: """Computes rmse for predictions Assumes hungarian algorithm matching has already be applied to match predicted animals and ground truth ones. @@ -119,7 +127,7 @@ def get_rmse(prediction: pd.DataFrame, Args: prediction (pd.DataFrame): prediction dataframe target (pd.DataFrame): target dataframe - pcutoff (float, optional): Confidence lower bound for a keypoint to be considred as detected. + pcutoff (float, optional): Confidence lower bound for a keypoint to be considered as detected. Defaults to -1. bodyparts (List[str], optional): list of the bodyparts names. Defaults to None. @@ -131,7 +139,9 @@ def get_rmse(prediction: pd.DataFrame, scorer_target = target.columns[0][0] mask = prediction[scorer_pred].xs("likelihood", level=2, axis=1) >= pcutoff if bodyparts: - diff = (target[scorer_target][bodyparts] - prediction[scorer_pred][bodyparts]) ** 2 + diff = ( + target[scorer_target][bodyparts] - prediction[scorer_pred][bodyparts] + ) ** 2 else: diff = (target[scorer_target] - prediction[scorer_pred]) ** 2 mse = diff.xs("x", level=2, axis=1) + diff.xs("y", level=2, axis=1) @@ -140,13 +150,16 @@ def get_rmse(prediction: pd.DataFrame, return rmse, rmse_p -def get_oks(prediction: pd.DataFrame, - target: pd.DataFrame, - oks_sigma=0.1, - margin=0, - symmetric_kpts=None, - pcutoff: float=-1, - bodyparts: List[str] =None) -> Tuple[Dict, Dict]: + +def get_oks( + prediction: pd.DataFrame, + target: pd.DataFrame, + oks_sigma=0.1, + margin=0, + symmetric_kpts=None, + pcutoff: float = -1, + bodyparts: List[str] = None, +) -> Tuple[Dict, Dict]: """Computes oks related scores for predictions Args: @@ -155,7 +168,7 @@ def get_oks(prediction: pd.DataFrame, oks_sigma (float, optional): Sigma for oks conputation. Defaults to 0.1. margin (int, optional): margin used for bbox computation. Defaults to 0. symmetric_kpts (_type_, optional): Not supported yet. Defaults to None. - pcutoff (float, optional): Confidence lower bound for a keypoint to be considred as detected. + pcutoff (float, optional): Confidence lower bound for a keypoint to be considered as detected. Defaults to -1. bodyparts (List[str], optional): list of the bodyparts names. Defaults to None. @@ -163,17 +176,17 @@ def get_oks(prediction: pd.DataFrame, oks_raw (Dict): oks scores without p_cutoff oks_pcutoff (Dict): oks scores with pcutoff """ - + scorer_pred = prediction.columns[0][0] scorer_target = target.columns[0][0] - + if bodyparts != None: idx_slice = pd.IndexSlice[:, :, bodyparts, :] prediction = prediction.loc[:, idx_slice] target = target.loc[:, idx_slice] mask = prediction[scorer_pred].xs("likelihood", level=2, axis=1) >= pcutoff - - # Convert predicitons to DLC assemblies + + # Convert predictions to DLC assemblies assemblies_pred_raw = conv_df_to_assemblies(prediction[scorer_pred]) assemblies_gt_raw = conv_df_to_assemblies(target[scorer_target]) @@ -185,7 +198,7 @@ def get_oks(prediction: pd.DataFrame, assemblies_gt_raw, oks_sigma, margin=margin, - symmetric_kpts=symmetric_kpts + symmetric_kpts=symmetric_kpts, ) oks_pcutoff = evaluate_assembly( @@ -193,23 +206,23 @@ def get_oks(prediction: pd.DataFrame, assemblies_gt_masked, oks_sigma, margin=margin, - symmetric_kpts=symmetric_kpts + symmetric_kpts=symmetric_kpts, ) return oks_raw, oks_pcutoff def conv_df_to_assemblies(df: pd.DataFrame): - ''' - Convert a dataframe to an assemblies dictionnary - + """ + Convert a dataframe to an assemblies dictionary + Arguments : df : dataframe of coordinates/predictions, df is expected to have a multi_index of shape (num_animals, num_keypoints, 2 or 3) - ''' + """ assemblies = {} - - num_animals=len(df.columns.get_level_values(0).unique()) - num_kpts=len(df.columns.get_level_values(1).unique()) + + num_animals = len(df.columns.get_level_values(0).unique()) + num_kpts = len(df.columns.get_level_values(1).unique()) for image_path in df.index: row = df.loc[image_path].to_numpy() row = row.reshape(num_animals, num_kpts, -1) @@ -221,4 +234,4 @@ def conv_df_to_assemblies(df: pd.DataFrame): kpt_lst.append(ass) assemblies[image_path] = kpt_lst - return assemblies \ No newline at end of file + return assemblies diff --git a/deeplabcut/pose_estimation_pytorch/solvers/logger.py b/deeplabcut/pose_estimation_pytorch/solvers/logger.py index dde8083afd..e57ef5fc6b 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/logger.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/logger.py @@ -5,8 +5,8 @@ from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -LOGGER = Registry('single_animal_solver', - build_func=build_from_cfg) +LOGGER = Registry("single_animal_solver", build_func=build_from_cfg) + @LOGGER.register_module class WandbLogger: @@ -15,10 +15,12 @@ class WandbLogger: (https://docs.wandb.ai/guides) """ - def __init__(self, - project_name: str = 'deeplabcut', - run_name: str = 'tmp', - model: PoseModel = None) -> None: + def __init__( + self, + project_name: str = "deeplabcut", + run_name: str = "tmp", + model: PoseModel = None, + ) -> None: """ Initialization of wandb logger. @@ -28,16 +30,14 @@ def __init__(self, run_name: the name of the wandb run model: model to log """ - self.run = wb.init(project=project_name, - name=run_name) + self.run = wb.init(project=project_name, name=run_name) if model is None: - raise ValueError('Specify the model to track!') + raise ValueError("Specify the model to track!") self.run.watch(model) - def log(self, - key: str = None, - value: str = None, - step: Optional[int] = None) -> None: + def log( + self, key: str = None, value: str = None, step: Optional[int] = None + ) -> None: """ Use this method to log data from runs, such as scalars, images, video, histograms, plots, and tables. @@ -48,14 +48,15 @@ def log(self, step: the global step in processing """ if key is None or value is None: - raise ValueError(f'Nothing to log. Key: {key} and value: {value} expected to be scalar, table or image.') + raise ValueError( + f"Nothing to log. Key: {key} and value: {value} expected to be scalar, table or image." + ) self.run.log({key: value}, step=step) def save(self): self.run.save(self.run.run.dir) - def log_config(self, - config: dict = None) -> None: + def log_config(self, config: dict = None) -> None: """ Use this method to save diff --git a/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py b/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py index 867ac6bb01..11679a5901 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py @@ -1,14 +1,15 @@ from torch.optim.lr_scheduler import _LRScheduler -class LRListScheduler(_LRScheduler): - def __init__(self, optimizer, last_epoch=-1, verbose=False, milestones=[10], lr_list=[0.001]): +class LRListScheduler(_LRScheduler): + def __init__( + self, optimizer, last_epoch=-1, verbose=False, milestones=[10], lr_list=[0.001] + ): self.milestones = milestones self.lr_list = lr_list super().__init__(optimizer, last_epoch, verbose) def get_lr(self): if self.last_epoch not in self.milestones: - return [group['lr'] for group in self.optimizer.param_groups] + return [group["lr"] for group in self.optimizer.param_groups] return [lr for lr in self.lr_list[self.milestones.index(self.last_epoch)]] - \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py b/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py index 04ca095cc9..f662b039e4 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py @@ -2,19 +2,13 @@ import pandas as pd import torch -from .base import BottomUpSolver -from ..registry import Registry, build_from_cfg -from ..utils import * -from ...pose_estimation_tensorflow import Plotting -from ...utils import auxiliaryfunctions +from deeplabcut.pose_estimation_pytorch.solvers.base import BottomUpSolver, SOLVERS -SINGLE_ANIMAL_SOLVER = Registry('single_animal_solver', - build_func=build_from_cfg) - -@SINGLE_ANIMAL_SOLVER.register_module +@SOLVERS.register_module class BottomUpSingleAnimalSolver(BottomUpSolver): """ To be extended if needed """ - pass \ No newline at end of file + + pass diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py new file mode 100644 index 0000000000..c432366a3a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py @@ -0,0 +1,249 @@ +from typing import Optional +from typing import Tuple, Dict +import torch +import torch.nn as nn +import numpy as np + +from deeplabcut.pose_estimation_pytorch.solvers.base import Solver, SOLVERS +from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector +from deeplabcut.pose_estimation_pytorch.solvers.utils import * + + +@SOLVERS.register_module +class TopDownSolver(Solver): + """ + Top down solver + + Currently very specific to FasterRCNN for detectpr since torchvison's implementation isn't flexible + """ + + def __init__( + self, + *args, + detector: BaseDetector, + detector_optimizer: torch.optim.Optimizer, + detector_criterion: nn.Module = None, # Not Used with fasterRCNN + detector_scheduler: Optional = None, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.detector = detector + self.detector_optimizer = detector_optimizer + self.detector_criterion = detector_criterion + self.detector_scheduler = detector_scheduler + self.detector.to(self.device) + + def fit( + self, + train_detector_loader: torch.utils.data.DataLoader, + valid_detector_loader: torch.utils.data.DataLoader, + train_pose_loader: torch.utils.data.DataLoader, + valid_pose_loader: torch.utils.data.DataLoader, + train_fraction: float = 0.95, + shuffle: int = 0, + model_prefix: str = "", + *, + epochs: int = 10000, + ): + """ + Train model for the specified number of steps. + + Parameters + ---------- + train_detector_loader: Data loader, which is an iterator over train instances. + Each batch contains image tensor and heat maps tensor input samples. + valid_detector_loader: Data loader used for validation of the detector model. + train_pose_loader: Data loader used for the pose detection part of the top down model + valid_pose_loader: Data loader used for validaton of the pose regression part of the top down model + train_fraction: TODO discuss (mb better specify with config) + shuffle: TODO discuss (mb better specify with config) + model_prefix: TODO discuss (mb better specify with config) + epochs: The number of training iterations. + """ + model_folder = get_model_folder( + train_fraction, shuffle, model_prefix, train_detector_loader.dataset.cfg + ) + + for i in range(epochs): + train_detector_loss, train_pose_loss = self.epoch( + train_detector_loader, train_pose_loader, mode="train", step=i + 1 + ) + if self.scheduler: + self.scheduler.step() + if self.detector_scheduler: + self.detector_scheduler.step() + print(f"Training for epoch {i + 1} done, starting eval on validation data") + valid_detector_loss, valid_pose_loss = self.epoch( + valid_detector_loader, valid_pose_loader, mode="eval", step=i + 1 + ) + + if (i + 1) % self.cfg["save_epochs"] == 0: + print(f"Finished epoch {i + 1}; saving model") + torch.save( + self.model.state_dict(), + f"{model_folder}/train/snapshot-{i + 1}.pt", + ) + torch.save( + self.detector.state_dict(), + f"{model_folder}/train/detector-snapshot-{i + 1}.pt", + ) + + print( + f"Epoch {i + 1}/{epochs}, " + f"train detector loss {train_detector_loss}, " + f"valid detector loss {valid_detector_loss}" + f"train pose loss {train_pose_loss}" + f"valid pose loss {valid_pose_loss}" + ) + + if epochs % self.cfg["save_epochs"] != 0: + print(f"Finished epoch {epochs}; saving model") + torch.save( + self.model.state_dict(), + f"{model_folder}/train/pose-snapshot-{epochs}.pt", + ) + torch.save( + self.detector.state_dict(), + f"{model_folder}/train/detector-snapshot-{epochs}.pt", + ) + + def step(self, *args): + # Unused in top down since we are dealing with two different step functions + pass + + def epoch( + self, + detector_loader: torch.utils.data.DataLoader, + pose_loader: torch.utils.data.DataLoader, + mode: str = "train", + step: Optional[int] = None, + ): + """ + + Parameters + ---------- + detector_loader: Data loader, which is an iterator over instances. + Each batch contains image tensors. + pose_loader: Data loader, Each batch contains a cropped image around an animal + mode: "train" or "eval" + step: the global step in processing, used to log metrics. + Returns + ------- + epoch_loss: Average of the loss over the batches. + """ + if mode not in ["train", "eval"]: + raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") + to_mode_pose = getattr(self.model, mode) + to_mode_pose() + to_mode_detector = getattr(self.detector, mode) + to_mode_detector() + epoch_detector_loss, epoch_pose_loss = [], [] + metrics = { + "total_pose_loss": [], + "detector_loss": [], + } + + # Pose model training + for i, batch in enumerate(pose_loader): + total_loss = self.step_pose(batch, mode) + epoch_pose_loss.append(total_loss) + + metrics["total_pose_loss"].append(total_loss) + + if mode == "eval" and i > 100: + break + + if (i + 1) % self.cfg["display_iters"] == 0: + print( + f"Number of iterations for pose: {i+1}, loss : {np.mean(metrics['total_pose_loss'])}, lr : {self.optimizer.param_groups[0]['lr']}" + ) + epoch_pose_loss = np.mean(epoch_pose_loss) + + # Detector training + for i, batch_d in enumerate(detector_loader): + detector_loss = self.step_detector(batch_d, mode) + epoch_detector_loss.append(detector_loss) + + metrics["detector_loss"].append(detector_loss) + + if mode == "eval" and i > 100: + break + + if (i + 1) % self.cfg["display_iters"] == 0: + print( + f"Number of iterations for detector: {i+1}, loss : {np.mean(metrics['detector_loss'])}, lr : {self.optimizer.param_groups[0]['lr']}" + ) + epoch_detector_loss = np.mean(epoch_detector_loss) + + # TODO is history really necessary here ? + # self.history[f'{mode}_loss'].append(epoch_loss) + + if self.logger: + for key in metrics.keys(): + self.logger.log( + f"{mode} {key}", + np.nanmean(metrics[key]), + step=step, + ) + + return epoch_detector_loss, epoch_pose_loss + + def step_detector(self, batch, mode: str = "train"): + if mode not in ["train", "eval"]: + raise ValueError( + f"Solver must be in train or eval mode, but {mode} was found." + ) + if mode == "train": + self.detector_optimizer.zero_grad() + + images = batch["image"] + images = images.to(self.device) + + target = self.detector.get_target( + batch["annotations"] + ) # (batch_size, channels, h, w) + for item in target: # target is a list here + for key in item: + if item[key] is not None: + item[key] = torch.tensor(item[key]).to(self.device) + + if mode == "train": + # For now only FasterRCNN is supported and it already returns the loss dict + # when calling forward() + losses_dict = self.detector(images, target) + loss = sum(l for l in losses_dict.values()) + + loss.backward() + self.detector_optimizer.step() + + return loss.detach().cpu().numpy() + else: + # No way to get losses in eval mode for the moment + return 0.0 + + def step_pose(self, batch, mode: str = "train"): + if mode not in ["train", "eval"]: + raise ValueError( + f"Solver must be in train or eval mode, but {mode} was found." + ) + if mode == "train": + self.optimizer.zero_grad() + + images = batch["image"] + images = images.to(self.device) + + prediction = self.model(images) + + target = self.model.get_target( + batch["annotations"], prediction, images.shape[2:] + ) # (batch_size, channels, h, w) + for key in target: + if target[key] is not None: + target[key] = torch.tensor(target[key]).to(self.device) + + total_loss = self.criterion(prediction, target) + if mode == "train": + total_loss.backward() + self.optimizer.step() + + return total_loss.detach().cpu().numpy() diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index c38acfc895..c5047eabdf 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -2,14 +2,14 @@ import numpy as np import os import pandas as pd -from deeplabcut import auxiliaryfunctions +from deeplabcut.utils import auxiliaryfunctions from typing import List, Union from ..utils import create_folder def get_dlc_scorer(train_fraction, shuffle, model_prefix, test_cfg, train_iterations): - dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.GetScorerName( + dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( test_cfg, shuffle, train_fraction, @@ -24,11 +24,8 @@ def get_evaluation_folder(train_fraction, shuffle, model_prefix, test_cfg): evaluation_folder = os.path.join( test_cfg["project_path"], str( - auxiliaryfunctions.GetEvaluationFolder( - train_fraction, - shuffle, - test_cfg, - modelprefix=model_prefix + auxiliaryfunctions.get_evaluation_folder( + train_fraction, shuffle, test_cfg, modelprefix=model_prefix ) ), ) @@ -40,11 +37,8 @@ def get_model_folder(train_fraction, shuffle, model_prefix, test_cfg): model_folder = os.path.join( test_cfg["project_path"], str( - auxiliaryfunctions.GetModelFolder( - train_fraction, - shuffle, - test_cfg, - modelprefix=model_prefix + auxiliaryfunctions.get_model_folder( + train_fraction, shuffle, test_cfg, modelprefix=model_prefix ) ), ) @@ -52,44 +46,46 @@ def get_model_folder(train_fraction, shuffle, model_prefix, test_cfg): return model_folder -def get_result_filename(evaluation_folder, - dlc_scorer, - dlc_scorerlegacy, - model_path): - _, results_filename, _ = auxiliaryfunctions.CheckifNotEvaluated(evaluation_folder, - dlc_scorer, - dlc_scorerlegacy, - os.path.basename(model_path)) +def get_result_filename(evaluation_folder, dlc_scorer, dlc_scorerlegacy, model_path): + _, results_filename, _ = auxiliaryfunctions.check_if_not_evaluated( + evaluation_folder, dlc_scorer, dlc_scorerlegacy, os.path.basename(model_path) + ) return results_filename -def get_model_path(model_folder: str, - load_epoch: int): - model_paths = glob.glob(f'{model_folder}/train/snapshot*') +def get_model_path(model_folder: str, load_epoch: int): + model_paths = glob.glob(f"{model_folder}/train/snapshot*") sorted_paths = sort_paths(model_paths) model_path = sorted_paths[load_epoch] return model_path +def get_detector_path(model_folder: str, load_epoch: int): + detector_paths = glob.glob(f"{model_folder}/train/detector-snapshot*") + sorted_paths = sort_paths(detector_paths) + detector_path = sorted_paths[load_epoch] + return detector_path + + def sort_paths(paths: list): - sorted_paths = sorted(paths, key=lambda i: int(os.path.basename(i).split('-')[-1][:-3])) + sorted_paths = sorted( + paths, key=lambda i: int(os.path.basename(i).split("-")[-1][:-3]) + ) return sorted_paths -def save_predictions(names, cfg, data_index, - predicted_poses, - results_filename): - if not os.path.exists(names['evaluation_folder']): - os.makedirs(names['evaluation_folder']) +def save_predictions(names, cfg, data_index, predicted_poses, results_filename): + if not os.path.exists(names["evaluation_folder"]): + os.makedirs(names["evaluation_folder"]) - results_path = f'{results_filename}' - num_animals = len(cfg.get('individuals', ['single'])) + results_path = f"{results_filename}" + num_animals = len(cfg.get("individuals", ["single"])) if num_animals == 1: # Single animal prediction deataframe index = pd.MultiIndex.from_product( [ - [names['dlc_scorer']], + [names["dlc_scorer"]], cfg["bodyparts"], ["x", "y", "likelihood"], ], @@ -99,63 +95,57 @@ def save_predictions(names, cfg, data_index, # Multi animal prediction dataframe index = pd.MultiIndex.from_product( [ - [names['dlc_scorer']], - cfg['individuals'], + [names["dlc_scorer"]], + cfg["individuals"], cfg["multianimalbodyparts"], ["x", "y", "likelihood"], ], names=["scorer", "individuals", "bodyparts", "coords"], ) - predicted_data = pd.DataFrame( - predicted_poses, columns=index, index=data_index - ) + predicted_data = pd.DataFrame(predicted_poses, columns=index, index=data_index) - predicted_data.to_hdf( - results_path, "df_with_missing" - ) + predicted_data.to_hdf(results_path, "df_with_missing") return predicted_data -def get_paths(train_fraction: float = 0.95, - shuffle: int = 0, - model_prefix: str = "", - cfg: dict = None, - train_iterations: int = 99): - dlc_scorer, dlc_scorer_legacy = get_dlc_scorer(train_fraction, - shuffle, - model_prefix, - cfg, - train_iterations) - evaluation_folder = get_evaluation_folder(train_fraction, - shuffle, - model_prefix, - cfg) - - model_folder = get_model_folder(train_fraction, - shuffle, - model_prefix, - cfg) +def get_paths( + train_fraction: float = 0.95, + shuffle: int = 0, + model_prefix: str = "", + cfg: dict = None, + train_iterations: int = 99, + method: str = "bu", +): + dlc_scorer, dlc_scorer_legacy = get_dlc_scorer( + train_fraction, shuffle, model_prefix, cfg, train_iterations + ) + evaluation_folder = get_evaluation_folder( + train_fraction, shuffle, model_prefix, cfg + ) + + model_folder = get_model_folder(train_fraction, shuffle, model_prefix, cfg) model_path = get_model_path(model_folder, train_iterations) + detector_path = None + if method.lower() == "td": + detector_path = get_detector_path(model_folder, train_iterations) + return { - 'dlc_scorer': dlc_scorer, - 'dlc_scorer_legacy': dlc_scorer_legacy, - 'evaluation_folder': evaluation_folder, - 'model_folder': model_folder, - 'model_path': model_path + "dlc_scorer": dlc_scorer, + "dlc_scorer_legacy": dlc_scorer_legacy, + "evaluation_folder": evaluation_folder, + "model_folder": model_folder, + "model_path": model_path, + "detector_path": detector_path, } -def get_results_filename(evaluation_folder, - dlc_scorer, - dlc_scorer_legacy, - model_path): - results_filename = get_result_filename(evaluation_folder, - dlc_scorer, - dlc_scorer_legacy, - model_path) +def get_results_filename(evaluation_folder, dlc_scorer, dlc_scorer_legacy, model_path): + results_filename = get_result_filename( + evaluation_folder, dlc_scorer, dlc_scorer_legacy, model_path + ) return results_filename diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index b8519a6cdd..c60c767053 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -5,103 +5,111 @@ import pandas as pd import torch -from deeplabcut.generate_training_dataset.trainingsetmanipulation import read_image_shape_fast -from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import calc_bboxes_from_keypoints +from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( + read_image_shape_fast, +) +from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import ( + calc_bboxes_from_keypoints, +) # Shaokai's function -def df2generic(proj_root, df, image_id_offset = 0): - +def df2generic(proj_root, df, image_id_offset=0): try: - individuals = df.columns.get_level_values('individuals').unique().tolist() + individuals = df.columns.get_level_values("individuals").unique().tolist() except KeyError: new_cols = pd.MultiIndex.from_tuples( - [(col[0], 'single', col[1], col[2]) for col in df.columns], - names=['scorer', 'individuals', 'bodyparts', 'coords'] + [(col[0], "single", col[1], col[2]) for col in df.columns], + names=["scorer", "individuals", "bodyparts", "coords"], ) df.columns = new_cols - individuals = df.columns.get_level_values('individuals').unique().tolist() - + individuals = df.columns.get_level_values("individuals").unique().tolist() + unique_bpts = [] - if 'single' in individuals: + if "single" in individuals: unique_bpts.extend( - df - .xs('single', level='individuals', axis=1) - .columns.get_level_values('bodyparts').unique() + df.xs("single", level="individuals", axis=1) + .columns.get_level_values("bodyparts") + .unique() ) multi_bpts = ( - df - .xs(individuals[0], level='individuals', axis=1) - .columns.get_level_values('bodyparts').unique().tolist() + df.xs(individuals[0], level="individuals", axis=1) + .columns.get_level_values("bodyparts") + .unique() + .tolist() ) - + coco_categories = [] # assuming all individuals have the same name and same category id individual = individuals[0] - + category = { "name": individual, "id": 0, "supercategory": "animal", - } - if individual == 'single': - category['keypoints'] = unique_bpts + if individual == "single": + category["keypoints"] = unique_bpts else: - category['keypoints'] = multi_bpts - + category["keypoints"] = multi_bpts + coco_categories.append(category) - + coco_images = [] - coco_annotations = [] + coco_annotations = [] annotation_id = 0 image_id = -1 for _, file_name in enumerate(df.index): - data = df.loc[file_name] + data = df.loc[file_name] # skipping all nan # if np.isnan(data.to_numpy()).all(): # continue - - image_id+=1 - + + image_id += 1 + for individual_id, individual in enumerate(individuals): - category_id = 0 - try: - kpts = data.xs(individual, level='individuals').to_numpy().reshape((-1, 2)) + category_id = 1 # 0 is for background by default + try: + kpts = ( + data.xs(individual, level="individuals").to_numpy().reshape((-1, 2)) + ) except: - # somehow there are duplicates. So only use the first occurence - data = data.iloc[0] - kpts = data.xs(individual, level='individuals').to_numpy().reshape((-1, 2)) - - keypoints = np.zeros((len(kpts),3)) - - keypoints[:,:2] = kpts - + # somehow there are duplicates. So only use the first occurrence + data = data.iloc[0] + kpts = ( + data.xs(individual, level="individuals").to_numpy().reshape((-1, 2)) + ) + + keypoints = np.zeros((len(kpts), 3)) + + keypoints[:, :2] = kpts + is_visible = ~pd.isnull(kpts).all(axis=1) - + keypoints[:, 2] = np.where(is_visible, 2, 0) - + num_keypoints = is_visible.sum() bbox_margin = 20 - + xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( - [keypoints], slack=bbox_margin, + [keypoints], + slack=bbox_margin, )[0][:4] - + w = xmax - xmin h = ymax - ymin area = w * h bbox = np.nan_to_num([xmin, ymin, w, h]) keypoints = np.nan_to_num(keypoints.flatten()) - + annotation_id += 1 annotation = { "image_id": image_id + image_id_offset, @@ -109,12 +117,13 @@ def df2generic(proj_root, df, image_id_offset = 0): "keypoints": keypoints, "id": annotation_id, "category_id": category_id, + "individual": individual, "area": area, "bbox": bbox, "iscrowd": 0, } - # adds an annotaion even if no keypoint is annotated for the current individual + # adds an annotation even if no keypoint is annotated for the current individual # This is not standard for COCO but is useful because each image will then have # the same number of annotations (i.e possible to train with batches without overcomplicating the code) coco_annotations.append(annotation) @@ -126,21 +135,21 @@ def df2generic(proj_root, df, image_id_offset = 0): else: image_path = os.path.join(proj_root, file_name) - _, height, width = read_image_shape_fast(image_path) - - - image = {'file_name' : image_path, - "width": width, - "height": height, - 'id': image_id + image_id_offset - } + + image = { + "file_name": image_path, + "width": width, + "height": height, + "id": image_id + image_id_offset, + } coco_images.append(image) - ret_obj = {'images': coco_images, - 'annotations': coco_annotations, - 'categories': coco_categories, - } + ret_obj = { + "images": coco_images, + "annotations": coco_annotations, + "categories": coco_categories, + } return ret_obj @@ -166,6 +175,7 @@ def fix_seeds(seed: int): torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False + def is_seq_of(seq, expected_type, seq_type=None): """Check whether it is a sequence of some type. Args: @@ -185,4 +195,4 @@ def is_seq_of(seq, expected_type, seq_type=None): for item in seq: if not isinstance(item, expected_type): return False - return True \ No newline at end of file + return True diff --git a/deeplabcut/utils/auxfun_models.py b/deeplabcut/utils/auxfun_models.py index efaf09c5dc..31cb6de3a1 100644 --- a/deeplabcut/utils/auxfun_models.py +++ b/deeplabcut/utils/auxfun_models.py @@ -47,7 +47,7 @@ def check_for_weights(modeltype, parent_path): """gets local path to network weights and checks if they are present. If not, downloads them from tensorflow.org""" # TODO: Adapt code for all PyTorch models - if any([torch_fam in modeltype for torch_fam in ["dekr"]]): + if any([torch_fam in modeltype for torch_fam in ["dekr", "token_pose"]]): return str(parent_path), num_shuffles if modeltype not in MODELTYPE_FILEPATH_MAP.keys(): diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 9692cd5142..f5fbb54bdd 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -637,6 +637,8 @@ def get_scorer_name( netname = "effnet_" + dlc_cfg["net_type"].split("-")[1] elif "dekr" in dlc_cfg["net_type"]: netname = "dekr_" + dlc_cfg["net_type"].split("_")[-1] + elif "token_pose" in dlc_cfg["net_type"]: + netname = "token_pose" + dlc_cfg["net_type"].split("_")[-1] scorer = ( "DLC_" From f8d96abb030c7c31465fabd4bc47ed4b327b9493 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Tue, 4 Jul 2023 17:56:50 +0200 Subject: [PATCH 025/293] fixed timm imports and added pytorch config to yaml --- deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py | 2 +- deeplabcut/pose_estimation_pytorch/models/necks/transformer.py | 2 +- requirements.txt | 1 + setup.py | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 595c8a43da..f7bd97aed0 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -2,7 +2,7 @@ import torch.nn as nn from einops import rearrange -from timm.models.layers.weight_init import trunc_normal_ +from timm.layers import trunc_normal_ from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS from .base import BaseHead diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py index 0ebf97de38..45f13174e6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py @@ -1,7 +1,7 @@ import torch from einops import rearrange, repeat from torch import nn -from timm.models.layers.weight_init import trunc_normal_ +from timm.layers import trunc_normal_ from .layers import TransformerLayer from .utils import make_sine_position_embedding from .base import NECKS diff --git a/requirements.txt b/requirements.txt index 4c8932b653..ab952b17c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # novel for pytorch DLC: albumentations +einops timm # old: dlclibrary diff --git a/setup.py b/setup.py index 7f0582ba18..16d58a08af 100644 --- a/setup.py +++ b/setup.py @@ -70,6 +70,7 @@ "deeplabcut/pose_cfg.yaml", "deeplabcut/inference_cfg.yaml", "deeplabcut/reid_cfg.yaml", + "deeplabcut/pose_estimation_pytorch/apis/pytorch_config.yaml", "deeplabcut/pose_estimation_tensorflow/models/pretrained/pretrained_model_urls.yaml", "deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml", "deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml", From 9c3f895615cde64b1ae4fc75983900a15721b0aa Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 7 Jul 2023 11:42:29 +0200 Subject: [PATCH 026/293] fix issue padding + no more shift for prediction --- .../apis/analyze_videos.py | 30 +++++++++++++++---- .../pose_estimation_pytorch/apis/utils.py | 14 +++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 37d6e73afa..ee7f3dfda1 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -101,12 +101,18 @@ def video_inference( f" resolution: w={vid_w}, h={vid_h}\n" ) - batch_ind = 0 # Index of the current img in batch - batch_frames = np.empty((batch_size, vid_h, vid_w, 3)) - pbar = tqdm(total=n_frames, file=sys.stdout) predictions = [] frame = video_reader.read_frame() + original_size = frame.shape + transformed_size = original_size + if transform: + # Apply transformation once only to see the shape after transformation + transformed_size = transform(image=frame)["image"].shape + + batch_ind = 0 # Index of the current img in batch + batch_frames = np.empty((batch_size, transformed_size[0], transformed_size[1], 3)) + with torch.no_grad(): while frame is not None: if frame.dtype != np.uint8: @@ -115,11 +121,11 @@ def video_inference( if colormode == "BGR": frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + if transform: + frame = transform(image=frame)["image"] + batch_frames[batch_ind] = frame if batch_ind == batch_size - 1: - if transform: - batch_frames = transform(image=batch_frames)["image"] - batch = torch.tensor( batch_frames, device=device, dtype=torch.float ).permute(0, 3, 1, 2) @@ -145,6 +151,18 @@ def video_inference( "Method must be either 'bu' (Bottom Up) or 'td' (Top Down)." ) for frame_pred in batched_predictions: + if frames_resized: + resizing_factor = (original_size[0] / transformed_size[0]), ( + original_size[1] / transformed_size[1] + ) + frame_pred[:, :, 0] = ( + frame_pred[:, :, 0] * resizing_factor[1] + + resizing_factor[1] / 2 + ) + frame_pred[:, :, 1] = ( + frame_pred[:, :, 1] * resizing_factor[0] + + resizing_factor[0] / 2 + ) predictions.append(frame_pred) frame = video_reader.read_frame() diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 11731833e5..c35d105221 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -262,6 +262,7 @@ def build_transforms( min_width=None, pad_height_divisor=pad_height_divisor, pad_width_divisor=pad_width_divisor, + position="top_left", ) ) if aug_cfg.get("normalize_images"): @@ -299,6 +300,19 @@ def build_inference_transform( """ list_transforms = [] + if transform_cfg.get("auto_padding"): + params = transform_cfg.get("auto_padding") + pad_height_divisor = params.get("pad_height_divisor", 1) + pad_width_divisor = params.get("pad_width_divisor", 1) + list_transforms.append( + A.PadIfNeeded( + min_height=None, + min_width=None, + pad_height_divisor=pad_height_divisor, + pad_width_divisor=pad_width_divisor, + position="top_left", + ) + ) if transform_cfg.get("normalize_images"): list_transforms.append( A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) From 8358f1c2d737f5d3a79b19713a4d8d6c16cce011 Mon Sep 17 00:00:00 2001 From: QuentinJGMace <95310069+QuentinJGMace@users.noreply.github.com> Date: Fri, 7 Jul 2023 11:45:57 +0200 Subject: [PATCH 027/293] Fix disappearing bboxes when augmenting --- .../pose_estimation_pytorch/data/dataset.py | 54 +++++++++++++------ .../models/detectors/fasterRCNN.py | 5 ++ .../models/predictors/dekr_predictor.py | 2 +- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 46eda17ae8..22f60e3cf6 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -142,11 +142,9 @@ def __getitem__(self, index: int) -> dict: num_keypoints_returned = self.num_joints + 1 bodyparts += ["_center_"] - bboxes = np.full((self.max_num_animals, 4), -1) + bbox_list = [] + bbox_label_list = [] labels = np.zeros((self.max_num_animals), dtype=np.int64) - bbox_labels = [ - "animal" - ] * self.max_num_animals # Not used but albumentation needs them is_crowd = np.zeros((self.max_num_animals), dtype=np.int64) ids = np.full((self.max_num_animals), -1, dtype=np.int64) image_id = index @@ -165,16 +163,28 @@ def __getitem__(self, index: int) -> dict: keypoints[i, :, :2] = _keypoints keypoints[i, :, 2] = _undef_ids - bboxes[i] = np.array(_annotation["bbox"]) + # If bbox has width and height > 0, add + annotation_bbox = np.array(_annotation["bbox"]) + if 0 < annotation_bbox[2] and 0 < annotation_bbox[3]: + bbox_list.append(annotation_bbox) + bbox_label_list.append(i) + is_crowd[i] = _annotation["iscrowd"] labels[i] = _annotation["category_id"] - # Sometimes bbox coords are larger than the image because of the margin - h, w, _ = image.shape - bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) - bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) - bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) + if len(bbox_list) > 0: + h, w, _ = image.shape + bboxes = np.stack(bbox_list, axis=0) + bbox_labels = np.array(bbox_label_list) + + # Sometimes bbox coords are larger than the image because of the margin + bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) + bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) + bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) + bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) + else: + bboxes = np.zeros((0, 4)) + bbox_labels = np.zeros((0,)) # Needs two be 2 dimensional for albumentations keypoints = keypoints.reshape((-1, 3)) @@ -212,9 +222,10 @@ def __getitem__(self, index: int) -> dict: transformed["keypoints"][new_index] = (-1, -1) else: - transformed = {} - transformed["keypoints"] = keypoints[:, :2] - transformed["image"] = image + transformed = { + "keypoints": keypoints[:, :2], + "image": image, + } image = torch.tensor(transformed["image"], dtype=torch.float).permute( 2, 0, 1 @@ -227,6 +238,16 @@ def __getitem__(self, index: int) -> dict: .astype(float) ) + # Pad bboxes and labels to always have shape (num_animals, 4) + # If we want the original index of the bboxes, we can use the bbox labels + bbox_tensor = torch.tensor(bboxes, dtype=torch.float) + if len(bbox_tensor) < self.max_num_animals: + missing_animals = self.max_num_animals - len(bbox_tensor) + bbox_tensor = torch.cat( + [bbox_tensor, torch.zeros((missing_animals, 4))], + dim=0, + ) + # TODO Quite ugly # # Center keypoint needs to be computed after transformation because @@ -260,7 +281,7 @@ def __getitem__(self, index: int) -> dict: res["annotations"]["keypoints"] = keypoints res["annotations"]["area"] = area res["annotations"]["ids"] = ids - res["annotations"]["boxes"] = torch.tensor(bboxes, dtype=torch.float) + res["annotations"]["boxes"] = bbox_tensor res["annotations"]["image_id"] = image_id res["annotations"]["is_crowd"] = is_crowd res["annotations"]["labels"] = labels @@ -290,7 +311,8 @@ def transform(image, keypoints): self.transform = transform self.project = project self.cfg = self.project.cfg - self.num_joints = len(self.cfg["bodyparts"]) + self.bodyparts = auxiliaryfunctions.get_bodyparts(self.cfg) + self.num_joints = len(self.bodyparts) self.shuffle = self.project.shuffle self.project.convert2dict(mode) self.dataframe = self.project.dataframe diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index 880391e776..12794b023c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -1,3 +1,5 @@ +import torch +from typing import List import torchvision from torchvision.models.detection.faster_rcnn import FastRCNNPredictor from torchvision.models.detection import FasterRCNN_ResNet50_FPN_Weights @@ -34,6 +36,9 @@ def get_target(self, annotations): res = [] for i, _ in enumerate(annotations["image_id"]): box_ann = annotations["boxes"][i].clone() + + mask = (box_ann[:, 2] > 0.0) & (box_ann[:, 3] > 0.0) + box_ann = box_ann[mask] # bbox format conversion (x, y, w, h) -> (x1, y1, x2, y2) box_ann[:, 2] += box_ann[:, 0] box_ann[:, 3] += box_ann[:, 1] diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 828c368c27..b38519e3d4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -69,7 +69,7 @@ def forward(self, outputs, scale_factors: Tuple[float, float]): ) poses_w_scores = torch.cat([poses, ctr_score], dim=3) - self.pose_nms(heatmaps, poses_w_scores) + # self.pose_nms(heatmaps, poses_w_scores) return poses_w_scores From 5c7fdecfc93e453aca3999bc466e18ad67884b1a Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Tue, 11 Jul 2023 09:18:20 +0200 Subject: [PATCH 028/293] fixed get_dlc_scorer to use num epochs and not -1, plotting --- .../pose_estimation_pytorch/apis/inference.py | 101 +++++++++++------- .../pose_estimation_pytorch/solvers/utils.py | 27 +++-- deeplabcut/utils/visualization.py | 87 ++++++++++++++- 3 files changed, 170 insertions(+), 45 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 21f1dd396e..e084e2c99f 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -1,15 +1,29 @@ +""" +DeepLabCut2.0 Toolbox (deeplabcut.org) +© A. & M. Mathis Labs +https://github.com/DeepLabCut/DeepLabCut +Please see AUTHORS for contributors. + +https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +Licensed under GNU Lesser General Public License v3.0 +""" import argparse -import deeplabcut.pose_estimation_pytorch as dlc +from pathlib import Path +from typing import Union + +import albumentations as A import numpy as np import pandas as pd import os import torch -from deeplabcut.utils import auxiliaryfunctions + +import deeplabcut.pose_estimation_pytorch as dlc import deeplabcut.pose_estimation_pytorch.apis.inference_utils as inference_utils from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_pose_model, build_inference_transform, ) +from deeplabcut.utils import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS from deeplabcut.pose_estimation_pytorch.solvers.inference import get_scores @@ -18,12 +32,10 @@ get_results_filename, save_predictions, ) -from deeplabcut.pose_estimation_tensorflow import Plotting from deeplabcut.pose_estimation_pytorch.post_processing import ( rmse_match_prediction_to_gt, ) -import albumentations as A -from typing import Union +from deeplabcut.utils.visualization import plot_evaluation_results def inference_network( @@ -33,32 +45,29 @@ def inference_network( load_epoch: Union[int, str] = -1, stride: int = 8, transform: Union[A.BasicTransform, A.Compose] = None, - plot: bool = False, + plot: Union[bool, str] = False, evaluate: bool = True, ) -> None: """ Performs inference on the validation dataset and save the results as a dataframe Args: - - config_path : path to the project's config file - - shuffle : shuffle index - - model_prefix: model prefix - - load_epoch: - index (starting at 0) of the snapshot we want to load, - if -1 loads the last one automatically - for example if we have 3 models saved - -snapshot-0.pt - -snapshot-50.pt - -snapshot-100.pt - and we want to load the second one, load epoch should be 1 - - stride : unused #TODO We clearly should remove this - - transform : - transformation pipeline for evaluation - ** Should normalise the data the same way it was normalised during training ** - - plot: whether to plot the predicted data or not - #TODO Currently does not work for multinaimal, should be False for multianimal project - otherwise it breaks - - evaluate: whether to compare predictions and ground truth + config_path: path to the project's config file + shuffle: shuffle index + model_prefix: model prefix + load_epoch: index (starting at 0) of the snapshot we want to load, if -1 loads + the last one automatically. For example if we have 3 models saved + - snapshot-0.pt + - snapshot-50.pt + - snapshot-100.pt + and we want to load the second one, load epoch should be 1 + stride: unused # TODO We clearly should remove this + transform: transformation pipeline for evaluation + ** Should normalise the data the same way it was normalised during training ** + plot: Plots the predictions on the train and test images. If provided it must + be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting + to ``True`` defaults as ``"bodypart"`` for multi-animal projects. + evaluate: whether to compare predictions and ground truth Returns: None @@ -87,13 +96,15 @@ def inference_network( ) device = pytorch_config["device"] - batch_size = pytorch_config["batch_size"] + # TODO: inference currently fails on batch_size > 1 + # batch_size = pytorch_config["batch_size"] + batch_size = 1 if transform is None: print("No transform passed, using default normalisation from config") transform = build_inference_transform(pytorch_config["data"]) - project = dlc.DLCProject(shuffle=shuffle, proj_root=pytorch_config["project_path"]) + project = dlc.DLCProject(shuffle=shuffle, proj_root=pytorch_config["project_path"]) valid_dataset = dlc.PoseDataset(project, transform=transform, mode="test") valid_dataloader = torch.utils.data.DataLoader( valid_dataset, batch_size=batch_size, shuffle=False @@ -213,17 +224,33 @@ def inference_network( predicted_df.columns = new_cols if plot: - foldername = f'{names["evaluation_folder"]}/LabeledImages_{names["dlc_scorer"]}-{load_epoch}' - auxiliaryfunctions.attempttomakefolder(foldername) - combined_df = predicted_df.merge(target_df, left_index=True, right_index=True) - Plotting( - valid_dataset.cfg, - valid_dataset.cfg["bodyparts"], - names["dlc_scorer"], - predicted_df.index, - combined_df, - foldername, + snapshot_name = Path(names["model_path"]).stem + folder_name = ( + f"{names['evaluation_folder']}/" + f"LabeledImages_{names['dlc_scorer']}_{snapshot_name}" ) + auxiliaryfunctions.attempttomakefolder(folder_name) + df_combined = predicted_df.merge(target_df, left_index=True, right_index=True) + + if isinstance(plot, str): + mode = plot + else: + mode = "bodypart" + + plot_evaluation_results( + df_combined=df_combined, + project_root=cfg["project_path"], + scorer=cfg["scorer"], + model_name=names["dlc_scorer"], + output_folder=folder_name, + in_train_set=False, + mode=mode, + colormap=cfg["colormap"], + dot_size=cfg["dotsize"], + alpha_value=cfg["alphavalue"], + p_cutoff=cfg["pcutoff"], + ) + if evaluate: scores = get_scores(pose_cfg, predicted_df, target_df) print(scores) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index c5047eabdf..19a7b10c23 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -1,19 +1,25 @@ import glob -import numpy as np import os +from pathlib import Path + import pandas as pd -from deeplabcut.utils import auxiliaryfunctions -from typing import List, Union +from typing import List -from ..utils import create_folder +from deeplabcut.utils import auxiliaryfunctions +from deeplabcut.pose_estimation_pytorch.utils import create_folder def get_dlc_scorer(train_fraction, shuffle, model_prefix, test_cfg, train_iterations): + model_folder = get_model_folder(train_fraction, shuffle, model_prefix, test_cfg) + snapshots = get_snapshots(Path(model_folder)) + snapshot = snapshots[train_iterations] + snapshot_epochs = int(snapshot.split("-")[-1]) + dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( test_cfg, shuffle, train_fraction, - train_iterations, + snapshot_epochs, modelprefix=model_prefix, ) @@ -46,11 +52,19 @@ def get_model_folder(train_fraction, shuffle, model_prefix, test_cfg): return model_folder +def get_snapshots(model_folder: Path) -> List[str]: + snapshots = [ + f.stem + for f in (model_folder / "train").iterdir() + if f.name.startswith("snapshot") and f.suffix == ".pt" + ] + return sorted(snapshots, key=lambda s: int(s.split("-")[-1])) + + def get_result_filename(evaluation_folder, dlc_scorer, dlc_scorerlegacy, model_path): _, results_filename, _ = auxiliaryfunctions.check_if_not_evaluated( evaluation_folder, dlc_scorer, dlc_scorerlegacy, os.path.basename(model_path) ) - return results_filename @@ -147,5 +161,4 @@ def get_results_filename(evaluation_folder, dlc_scorer, dlc_scorer_legacy, model results_filename = get_result_filename( evaluation_folder, dlc_scorer, dlc_scorer_legacy, model_path ) - return results_filename diff --git a/deeplabcut/utils/visualization.py b/deeplabcut/utils/visualization.py index f42874bf9b..70097d5361 100644 --- a/deeplabcut/utils/visualization.py +++ b/deeplabcut/utils/visualization.py @@ -23,11 +23,12 @@ import matplotlib.pyplot as plt import numpy as np +import pandas as pd from matplotlib.collections import LineCollection from skimage import io, color from tqdm import trange -from deeplabcut.utils import auxiliaryfunctions +from deeplabcut.utils import auxiliaryfunctions, auxfun_videos def get_cmap(n, name="hsv"): @@ -378,3 +379,87 @@ def make_labeled_images_from_dataframe( dpi=dpi, ) plt.close(fig) + + +def plot_evaluation_results( + df_combined: pd.DataFrame, + project_root: str, + scorer: str, + model_name: str, + output_folder: str, + in_train_set: bool, + mode: str = "bodypart", + colormap: str = "rainbow", + dot_size: int = 12, + alpha_value: float = 0.7, + p_cutoff: float = 0.6, +) -> None: + """ + Creates labeled images using the results of inference, and saves them to an output + folder. + + Args: + df_combined: dataframe with multiindex rows ("labeled-data", video_name, + image_name) and columns ("scorer", "individuals", "bodyparts", "coords"). + There should be two scorers: scorer (for ground truth data) and model_name + (for prediction data) + project_root: the project root path + scorer: the name of the scorer for ground truth data in df_combined + model_name: the name of the model for predictions in df_combined + output_folder: the name of the folder where images should be saved + in_train_set: whether df_combined is for train set images + mode: one of {"bodypart", "individual"}. Determines the keypoint color grouping + colormap: the colormap to use for keypoints + dot_size: the dot size to use for keypoints + alpha_value: the alpha value to use for keypoints + p_cutoff: the p-cutoff for "confident" keypoints + """ + for row_index, row in df_combined.iterrows(): + data_folder, video, image = row_index + image_path = Path(project_root) / data_folder / video / image + frame = auxfun_videos.imread(str(image_path), mode="skimage") + + individuals = len(row.index.levels[1]) + bodyparts = len(row.index.levels[2]) + df_gt = row[scorer] + df_predictions = row[model_name] + + # Shape (num_individuals, num_bodyparts, xy) + ground_truth = df_gt.to_numpy().reshape((individuals, bodyparts, 2)) + predictions = df_predictions.to_numpy().reshape((individuals, bodyparts, 3)) + + fig, ax = create_minimal_figure() + h, w, _ = np.shape(frame) + fig.set_size_inches(w / 100, h / 100) + ax.set_xlim(0, w) + ax.set_ylim(0, h) + ax.invert_yaxis() + + if mode == "bodypart": + colors = get_cmap(bodyparts, name=colormap) + predictions = predictions.swapaxes(0, 1) + ground_truth = ground_truth.swapaxes(0, 1) + elif mode == "individual": + colors = get_cmap(individuals, name=colormap) + else: + colors = [] + + ax = make_multianimal_labeled_image( + frame, + ground_truth, + predictions[:, :, :2], + predictions[:, :, 2:], + colors, + dot_size, + alpha_value, + p_cutoff, + ax=ax, + ) + + save_labeled_frame( + fig, + str(image_path), + output_folder, + belongs_to_train=in_train_set, + ) + erase_artists(ax) From 755b904ec1249127f331c73e82651f2de5c94c26 Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Wed, 12 Jul 2023 12:49:47 +0200 Subject: [PATCH 029/293] fix td inference, auto_padding_tokenpose --- .../generate_training_dataset/make_pytorch_config.py | 6 ++++++ .../pose_estimation_pytorch/apis/analyze_videos.py | 3 +++ .../pose_estimation_pytorch/apis/inference_utils.py | 9 ++++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 4d566bd9cc..fb4ec0024d 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -139,6 +139,12 @@ def make_pytorch_config( pytorch_config["method"] = "td" version = net_type.split("_")[-1] backbone_type = "hrnet_" + version + pytorch_config['data']['auto_padding'] = { + 'min_height': 64, + 'min_width': 64, + 'pad_width_divisor': 32, + 'pad_height_divisor': 32, + } pytorch_config["detector"] = make_detector_cfg() pytorch_config["model"] = make_token_pose_model_cfg( num_joints, backbone_type diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index ee7f3dfda1..e091c24ba2 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -89,6 +89,9 @@ def video_inference( # Set the model to eval mode and put it on the device model.eval() model.to(device) + if not detector is None: + detector.eval() + detector.to(device) print(f"Loading {video_path}") video_reader = VideoReader(str(video_path)) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py b/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py index aab6dcf636..887314b187 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py @@ -72,15 +72,18 @@ def get_predictions_top_down( boxes = torch.zeros((batch_size, max_num_animals, 4)) for b, item in enumerate(output_detector): - boxes[b] = item["boxes"][ + boxes[b][: min(max_num_animals, len(item["boxes"]))] = item["boxes"][ :max_num_animals ] # Boxes should be sorted by scores, only keep the maximum number allowed - boxes = boxes.int() - cropped_kpts_total = torch.zeros((batch_size, max_num_animals, num_keypoints, 3)) + cropped_kpts_total = torch.full( + (batch_size, max_num_animals, num_keypoints, 3), -1. + ) for b in range(batch_size): for j, box in enumerate(boxes[b]): + if (box == 0.0).all(): + continue cropped_image = ( images[b][:, box[1] : box[3], box[0] : box[2]] .permute(1, 2, 0) From 217b8611d2ef5fe81786ed866a6bc901804705ca Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Wed, 12 Jul 2023 14:30:50 +0200 Subject: [PATCH 030/293] resnet from timm, top_down solver --- .../make_pytorch_config.py | 20 ++- .../pose_estimation_pytorch/apis/train.py | 2 + .../models/backbones/hrnet.py | 10 +- .../models/backbones/resnet.py | 13 +- .../solvers/top_down.py | 162 ++++++++++++------ 5 files changed, 139 insertions(+), 68 deletions(-) diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index fb4ec0024d..02929fc6df 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -110,15 +110,15 @@ def make_pytorch_config( version = net_type.split("_")[-1] backbone_type = "hrnet_" + version num_offset_per_kpt = 15 - pytorch_config['data']['auto_padding'] = { - 'min_height': 64, - 'min_width': 64, - 'pad_width_divisor': 32, - 'pad_height_divisor': 32, + pytorch_config["data"]["auto_padding"] = { + "min_height": 64, + "min_width": 64, + "pad_width_divisor": 32, + "pad_height_divisor": 32, } - pytorch_config['model']['backbone'] = { - 'type': 'HRNet', - 'model_name': 'hrnet_' + version + pytorch_config["model"]["backbone"] = { + "type": "HRNet", + "model_name": "hrnet_" + version, } pytorch_config["model"]["heads"] = make_dekr_head_cfg( num_joints, backbone_type, num_offset_per_kpt @@ -292,4 +292,8 @@ def make_detector_cfg(): "params": {"milestones": [10, 430], "lr_list": [[0.05], [0.005]]}, } + detector_cfg["detector_max_epochs"] = 500 + + detector_cfg["detector_save_epochs"] = 100 + return detector_cfg diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 2bc786443a..0ff2361761 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -102,6 +102,7 @@ def train_network( pytorch_config["cropped_data"], augment_bbox=False ) + detector_epochs = pytorch_config["detector"].get("detector_max_epochs", epochs) train_cropped_dataset = dlc.CroppedDataset( project_train, transform=transform_cropped, mode="train" ) @@ -122,6 +123,7 @@ def train_network( valid_cropped_dataloader, train_fraction=train_fraction[0], epochs=epochs, + detector_epochs=detector_epochs, shuffle=shuffle, model_prefix=model_prefix, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index 088b6ead11..8eb4093a3b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -18,7 +18,9 @@ class HRNet(BaseBackbone): HRNet stages """ - def __init__(self, model_name: str = "hrnet_w32") -> nn.Module: + def __init__( + self, model_name: str = "hrnet_w32", pretrained: bool = True + ) -> nn.Module: """ Constructs an ImageNet pre-trained HRNet from timm (https://github.com/rwightman/pytorch-image-models/blob/main/timm/models/hrnet.py) @@ -29,7 +31,7 @@ def __init__(self, model_name: str = "hrnet_w32") -> nn.Module: type of HRNet (e.g. 'hrnet_w32, 'hrnet_w48') """ super().__init__() - _backbone = timm.create_model(model_name, pretrained=True) + _backbone = timm.create_model(model_name, pretrained=pretrained) _backbone.incre_modules = None # Necesssary to get high resolution features, if not set to None _backbone.forward_features will return low_res images self.model = _backbone @@ -50,7 +52,7 @@ def forward(self, x): @BACKBONES.register_module class HRNetTopDown(BaseBackbone): - def __init__(self, model_name: str = "hrnet_w32"): + def __init__(self, model_name: str = "hrnet_w32", pretrained: bool = True): """ Constructs an ImageNet pre-trained HRNet from timm (https://github.com/rwightman/pytorch-image-models/blob/main/timm/models/hrnet.py) @@ -61,7 +63,7 @@ def __init__(self, model_name: str = "hrnet_w32"): type of HRNet (e.g. 'hrnet_w32, 'hrnet_w48') """ super().__init__() - _backbone = timm.create_model(model_name, pretrained=True) + _backbone = timm.create_model(model_name, pretrained=pretrained) _backbone.incre_modules = None # Necesssary to get high resolution features, if not set to None _backbone.forward_features will return low_res images self.model = _backbone diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 15fd2ab04b..2c73147034 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -1,5 +1,6 @@ import torch.nn as nn -import torchvision +import timm +from typing import Union from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( BaseBackbone, @@ -14,7 +15,7 @@ class ResNet(BaseBackbone): """ def __init__( - self, model_name: str = "resnet50", pretrained: str = None + self, model_name: str = "resnet50", pretrained: bool = True ) -> nn.Module: """ Parameters @@ -22,11 +23,7 @@ def __init__( model_name """ super().__init__() - _backbone = torchvision.models.get_model(model_name) - _backbone._modules.pop("fc") - _backbone._modules.pop("avgpool") - self.model = nn.Sequential(_backbone._modules) - self._init_weights(pretrained) + self.model = timm.create_model(model_name, pretrained=pretrained) def forward(self, x): - return self.model(x) + return self.model.forward_features(x) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py index c432366a3a..027eb8f76c 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py @@ -44,6 +44,7 @@ def fit( model_prefix: str = "", *, epochs: int = 10000, + detector_epochs: int = 10000, ): """ Train model for the specified number of steps. @@ -58,40 +59,65 @@ def fit( train_fraction: TODO discuss (mb better specify with config) shuffle: TODO discuss (mb better specify with config) model_prefix: TODO discuss (mb better specify with config) - epochs: The number of training iterations. + epochs: The number of training epochs for pose_estimator. + detector_epochs: The number of training epochs for detector. """ model_folder = get_model_folder( train_fraction, shuffle, model_prefix, train_detector_loader.dataset.cfg ) + for i in range(detector_epochs): + train_detector_loss = self.epoch_detector( + train_detector_loader, mode="train", step=i + 1 + ) + if self.detector_scheduler: + self.detector_scheduler.step() + print(f"Training the detector for epoch {i + 1} done") + + # TODO no eval pass for the detector since fasterRCNN can't return a loss in eval mode + + if (i + 1) % self.cfg["detector"].get("detector_save_epochs", 1) == 0: + print(f"Finished epoch {i + 1}; saving detector") + torch.save( + self.detector.state_dict(), + f"{model_folder}/train/detector-snapshot-{i + 1}.pt", + ) + print( + f"Epoch {i + 1}/{epochs}, " + f"train detector loss {train_detector_loss}, " + ) + + if detector_epochs % self.cfg["detector"].get("detector_save_epochs", 1) != 0: + torch.save( + self.detector.state_dict(), + f"{model_folder}/train/detector-snapshot-{epochs}.pt", + ) + print(f"Finished epoch {detector_epochs}; saving model") + for i in range(epochs): - train_detector_loss, train_pose_loss = self.epoch( - train_detector_loader, train_pose_loader, mode="train", step=i + 1 + train_pose_loss = self.epoch_pose( + train_pose_loader, mode="train", step=i + 1 ) if self.scheduler: self.scheduler.step() - if self.detector_scheduler: - self.detector_scheduler.step() - print(f"Training for epoch {i + 1} done, starting eval on validation data") - valid_detector_loss, valid_pose_loss = self.epoch( - valid_detector_loader, valid_pose_loader, mode="eval", step=i + 1 + + print( + f"Training the pose estimator for epoch {i + 1} done, starting eval on validation data" + ) + + valid_pose_loss = self.epoch_pose( + valid_pose_loader, mode="eval", step=i + 1 ) if (i + 1) % self.cfg["save_epochs"] == 0: - print(f"Finished epoch {i + 1}; saving model") + print(f"Finished epoch {i + 1}; saving pose model") torch.save( self.model.state_dict(), f"{model_folder}/train/snapshot-{i + 1}.pt", ) - torch.save( - self.detector.state_dict(), - f"{model_folder}/train/detector-snapshot-{i + 1}.pt", - ) print( f"Epoch {i + 1}/{epochs}, " - f"train detector loss {train_detector_loss}, " - f"valid detector loss {valid_detector_loss}" f"train pose loss {train_pose_loss}" f"valid pose loss {valid_pose_loss}" ) @@ -102,63 +128,43 @@ def fit( self.model.state_dict(), f"{model_folder}/train/pose-snapshot-{epochs}.pt", ) - torch.save( - self.detector.state_dict(), - f"{model_folder}/train/detector-snapshot-{epochs}.pt", - ) + + def epoch(self, *args): + # Unused in top down since we are dealing with two different epoch functions + pass def step(self, *args): # Unused in top down since we are dealing with two different step functions pass - def epoch( + def epoch_detector( self, detector_loader: torch.utils.data.DataLoader, - pose_loader: torch.utils.data.DataLoader, mode: str = "train", step: Optional[int] = None, - ): - """ + ) -> float: + """Does an epoch for the detector over the dataset - Parameters + Args: ---------- detector_loader: Data loader, which is an iterator over instances. Each batch contains image tensors. - pose_loader: Data loader, Each batch contains a cropped image around an animal mode: "train" or "eval" step: the global step in processing, used to log metrics. + Returns ------- epoch_loss: Average of the loss over the batches. """ if mode not in ["train", "eval"]: raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") - to_mode_pose = getattr(self.model, mode) - to_mode_pose() to_mode_detector = getattr(self.detector, mode) to_mode_detector() - epoch_detector_loss, epoch_pose_loss = [], [] + epoch_detector_loss = [] metrics = { - "total_pose_loss": [], "detector_loss": [], } - # Pose model training - for i, batch in enumerate(pose_loader): - total_loss = self.step_pose(batch, mode) - epoch_pose_loss.append(total_loss) - - metrics["total_pose_loss"].append(total_loss) - - if mode == "eval" and i > 100: - break - - if (i + 1) % self.cfg["display_iters"] == 0: - print( - f"Number of iterations for pose: {i+1}, loss : {np.mean(metrics['total_pose_loss'])}, lr : {self.optimizer.param_groups[0]['lr']}" - ) - epoch_pose_loss = np.mean(epoch_pose_loss) - # Detector training for i, batch_d in enumerate(detector_loader): detector_loss = self.step_detector(batch_d, mode) @@ -166,8 +172,9 @@ def epoch( metrics["detector_loss"].append(detector_loss) - if mode == "eval" and i > 100: - break + # TODO good for evaluation speed up but should be optional + # if mode == "eval" and i > 100: + # break if (i + 1) % self.cfg["display_iters"] == 0: print( @@ -186,7 +193,66 @@ def epoch( step=step, ) - return epoch_detector_loss, epoch_pose_loss + return epoch_detector_loss + + def epoch_pose( + self, + pose_loader: torch.utils.data.DataLoader, + mode: str = "train", + step: Optional[int] = None, + ) -> float: + """Does an epoch for the pose_model over the dataset + + Args: + ---------- + pose_loader: Data loader, which is an iterator over instances. + Each batch contains cropped images around an animal. + mode: "train" or "eval" + step: the global step in processing, used to log metrics. + + Returns + ------- + epoch_loss: Average of the loss over the batches. + """ + + if mode not in ["train", "eval"]: + raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") + to_mode_pose = getattr(self.model, mode) + to_mode_pose() + epoch_pose_loss = [] + metrics = { + "total_pose_loss": [], + } + + # Pose model training + for i, batch in enumerate(pose_loader): + total_loss = self.step_pose(batch, mode) + epoch_pose_loss.append(total_loss) + + metrics["total_pose_loss"].append(total_loss) + + # TODO good for evaluation speed up but should be optional + # if mode == "eval" and i > 100: + # break + + if (i + 1) % self.cfg["display_iters"] == 0: + print( + f"Number of iterations for pose: {i+1}, loss : {np.mean(metrics['total_pose_loss'])}, lr : {self.optimizer.param_groups[0]['lr']}" + ) + epoch_pose_loss = np.mean(epoch_pose_loss) + + # TODO is history really necessary here ? + # self.history[f'{mode}_loss'].append(epoch_loss) + + if self.logger: + for key in metrics.keys(): + self.logger.log( + f"{mode} {key}", + np.nanmean(metrics[key]), + step=step, + ) + + return epoch_pose_loss def step_detector(self, batch, mode: str = "train"): if mode not in ["train", "eval"]: From 6ea8974e3077c186beba7ed8bbf90874e073e7f9 Mon Sep 17 00:00:00 2001 From: QuentinJGMace <95310069+QuentinJGMace@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:48:05 +0200 Subject: [PATCH 031/293] linting, defaultdict for loss, documentation --- .../make_pytorch_config.py | 10 ++-- .../apis/inference_utils.py | 2 +- .../models/criterion.py | 32 +++++++++---- .../pose_estimation_pytorch/solvers/base.py | 42 ++++++++-------- .../solvers/top_down.py | 48 +++++++++++++------ 5 files changed, 81 insertions(+), 53 deletions(-) diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 02929fc6df..16ce97b35b 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -139,11 +139,11 @@ def make_pytorch_config( pytorch_config["method"] = "td" version = net_type.split("_")[-1] backbone_type = "hrnet_" + version - pytorch_config['data']['auto_padding'] = { - 'min_height': 64, - 'min_width': 64, - 'pad_width_divisor': 32, - 'pad_height_divisor': 32, + pytorch_config["data"]["auto_padding"] = { + "min_height": 64, + "min_width": 64, + "pad_width_divisor": 32, + "pad_height_divisor": 32, } pytorch_config["detector"] = make_detector_cfg() pytorch_config["model"] = make_token_pose_model_cfg( diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py b/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py index 887314b187..c10d872dea 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py @@ -77,7 +77,7 @@ def get_predictions_top_down( ] # Boxes should be sorted by scores, only keep the maximum number allowed boxes = boxes.int() cropped_kpts_total = torch.full( - (batch_size, max_num_animals, num_keypoints, 3), -1. + (batch_size, max_num_animals, num_keypoints, 3), -1.0 ) for b in range(batch_size): diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index e95df55ee6..d08281cf7f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -1,6 +1,8 @@ import torch import torch.nn as nn +from typing import Tuple + from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry LOSSES = Registry("losses", build_func=build_from_cfg) @@ -82,7 +84,7 @@ def __init__( self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() - def forward(self, prediction, target): + def forward(self, prediction: Tuple[torch.Tensor], target: dict) -> dict: """ Parameters @@ -91,13 +93,17 @@ def forward(self, prediction, target): Predicted heatmap and locref target: dict = { 'heatmaps': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'heatmaps_ignored': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]) + weights for the heatmaps 'locref_maps': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), 'locref_masks': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'weights': torch.Tensor (optional, default is None) } Returns ------- - loss: sum + losses_dict: dict of the different unweighted loss components, keys: + - 'total_loss' + - 'heatmap_loss' + - 'locref_loss' """ heatmaps, locref = prediction if self.apply_sigmoid: @@ -115,7 +121,11 @@ def forward(self, prediction, target): locref, target["locref_maps"], target["locref_masks"] ) total_loss = locref_loss * self.loss_weight_locref + heatmap_loss - return total_loss, heatmap_loss, locref_loss + return { + "total_loss": total_loss, + "heatmap_loss": heatmap_loss, + "locref_loss": locref_loss, + } @LOSSES.register_module @@ -139,7 +149,7 @@ def __init__(self, apply_sigmoid: bool = False): self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() - def forward(self, prediction, target): + def forward(self, prediction: Tuple[torch.Tensor], target: dict) -> dict: """ Parameters @@ -148,13 +158,13 @@ def forward(self, prediction, target): Predicted heatmap and locref target: dict = { 'heatmaps': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'locref_maps': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'locref_masks': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'weights': torch.Tensor (optional, default is None) + 'heatmaps_ignored': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]) + weights for the heatmaps } Returns ------- - loss: sum + losses_dict: dict of the different unweighted loss components, keys: + - 'total_loss' """ heatmaps = prediction[0] if self.apply_sigmoid: @@ -168,4 +178,6 @@ def forward(self, prediction, target): heatmaps, target["heatmaps"], target.get("heatmaps_ignored", 1) ) - return heatmap_loss + return { + "total_loss": heatmap_loss, + } diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 7a0e9f88c4..771e0a4443 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -5,6 +5,7 @@ import numpy as np import torch import torch.nn as nn +from collections import defaultdict from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector @@ -121,7 +122,7 @@ def epoch( loader: torch.utils.data.DataLoader, mode: str = "train", step: Optional[int] = None, - ) -> np.array: + ) -> float: """ Parameters @@ -139,22 +140,17 @@ def epoch( to_mode = getattr(self.model, mode) to_mode() epoch_loss = [] - metrics = { - "total_loss": [], - "heatmap_loss": [], - "locref_loss": [], - } + metrics = defaultdict(list) for i, batch in enumerate(loader): - loss, htmp_loss, locref_loss = self.step(batch, mode) - epoch_loss.append(loss) + losses_dict = self.step(batch, mode) + epoch_loss.append(losses_dict["total_loss"]) - metrics["total_loss"].append(loss) - metrics["heatmap_loss"].append(htmp_loss) - metrics["locref_loss"].append(locref_loss) + for key in losses_dict.keys(): + metrics[key].append(losses_dict[key]) if (i + 1) % self.cfg["display_iters"] == 0: print( - f"Number of iterations : {i+1}, loss : {loss}, lr : {self.optimizer.param_groups[0]['lr']}" + f"Number of iterations : {i+1}, loss : {losses_dict['total_loss']}, lr : {self.optimizer.param_groups[0]['lr']}" ) epoch_loss = np.mean(epoch_loss) self.history[f"{mode}_loss"].append(epoch_loss) @@ -170,7 +166,7 @@ def epoch( return epoch_loss @abstractmethod - def step(self, batch: Tuple[torch.Tensor, torch.Tensor], *args) -> Optional: + def step(self, batch: Tuple[torch.Tensor, torch.Tensor], *args) -> dict: raise NotImplementedError @torch.no_grad() @@ -197,7 +193,7 @@ class BottomUpSolver(Solver): def step( self, batch: Tuple[torch.Tensor, torch.Tensor], mode: str = "train" - ) -> np.array: + ) -> dict: """Perform a single epoch gradient update or validation step. Parameters @@ -207,7 +203,11 @@ def step( Returns ------- - batch loss, heatmap_loss, locref_loss + dict : { + 'batch loss' : torch.Tensor, + 'heatmap_loss' : torch.Tensor, + 'locref_loss' : torch.Tensor + } """ if mode not in ["train", "eval"]: raise ValueError( @@ -226,13 +226,11 @@ def step( if target[key] is not None: target[key] = torch.tensor(target[key]).to(self.device) - total_loss, heatmap_loss, locref_loss = self.criterion(prediction, target) + losses_dict = self.criterion(prediction, target) if mode == "train": - total_loss.backward() + losses_dict["total_loss"].backward() self.optimizer.step() - return ( - total_loss.detach().cpu().numpy(), - heatmap_loss.detach().cpu().numpy(), - locref_loss.detach().cpu().numpy(), - ) # rmse #, rmse_pcutoff + for key in losses_dict.keys(): + losses_dict[key] = losses_dict[key].detach().cpu().numpy() + return losses_dict diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py index 027eb8f76c..540bfc0bb5 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py @@ -3,6 +3,7 @@ import torch import torch.nn as nn import numpy as np +from collections import defaultdict from deeplabcut.pose_estimation_pytorch.solvers.base import Solver, SOLVERS from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector @@ -161,9 +162,7 @@ def epoch_detector( to_mode_detector = getattr(self.detector, mode) to_mode_detector() epoch_detector_loss = [] - metrics = { - "detector_loss": [], - } + metrics = defaultdict(list) # Detector training for i, batch_d in enumerate(detector_loader): @@ -220,16 +219,15 @@ def epoch_pose( to_mode_pose = getattr(self.model, mode) to_mode_pose() epoch_pose_loss = [] - metrics = { - "total_pose_loss": [], - } + metrics = defaultdict(list) # Pose model training for i, batch in enumerate(pose_loader): - total_loss = self.step_pose(batch, mode) - epoch_pose_loss.append(total_loss) + losses_dict = self.step_pose(batch, mode) + epoch_pose_loss.append(losses_dict["total_loss"]) - metrics["total_pose_loss"].append(total_loss) + for key in losses_dict.keys(): + metrics["pose_" + key].append(losses_dict[key]) # TODO good for evaluation speed up but should be optional # if mode == "eval" and i > 100: @@ -237,7 +235,7 @@ def epoch_pose( if (i + 1) % self.cfg["display_iters"] == 0: print( - f"Number of iterations for pose: {i+1}, loss : {np.mean(metrics['total_pose_loss'])}, lr : {self.optimizer.param_groups[0]['lr']}" + f"Number of iterations for pose: {i+1}, loss : {np.mean(metrics['pose_total_loss'])}, lr : {self.optimizer.param_groups[0]['lr']}" ) epoch_pose_loss = np.mean(epoch_pose_loss) @@ -254,7 +252,16 @@ def epoch_pose( return epoch_pose_loss - def step_detector(self, batch, mode: str = "train"): + def step_detector(self, batch: dict, mode: str = "train") -> float: + """Performs a step for the detector over a batch + + Args: + batch: batch returned by the dataloader + mode: "train" or "eval". Defaults to "train". + + Returns: + loss : loss for the detector + """ if mode not in ["train", "eval"]: raise ValueError( f"Solver must be in train or eval mode, but {mode} was found." @@ -287,7 +294,16 @@ def step_detector(self, batch, mode: str = "train"): # No way to get losses in eval mode for the moment return 0.0 - def step_pose(self, batch, mode: str = "train"): + def step_pose(self, batch: dict, mode: str = "train") -> dict: + """Performs a step for the pose estimator over a batch + + Args: + batch: batch returned by the dataloader + mode: "train" or "eval". Defaults to "train". + + Returns: + dict : the loss components over the batch, should always contain "total_loss" key + """ if mode not in ["train", "eval"]: raise ValueError( f"Solver must be in train or eval mode, but {mode} was found." @@ -307,9 +323,11 @@ def step_pose(self, batch, mode: str = "train"): if target[key] is not None: target[key] = torch.tensor(target[key]).to(self.device) - total_loss = self.criterion(prediction, target) + losses_dict = self.criterion(prediction, target) if mode == "train": - total_loss.backward() + losses_dict["total_loss"].backward() self.optimizer.step() - return total_loss.detach().cpu().numpy() + for key in losses_dict.keys(): + losses_dict[key] = losses_dict[key].detach().cpu().numpy() + return losses_dict From 3c74e4bac21bf1601d9e67c0f9a405bd9d0425bb Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 19 Jul 2023 17:13:54 +0200 Subject: [PATCH 032/293] Matched method names and signatures to the TensorFlow API, cleaning, decoupling --- .../pose_estimation_pytorch/__init__.py | 2 +- .../pose_estimation_pytorch/apis/__init__.py | 2 +- .../apis/analyze_videos.py | 167 +++--- .../apis/convert_detections_to_tracklets.py | 121 ++--- .../pose_estimation_pytorch/apis/evaluate.py | 327 ++++++++++++ .../pose_estimation_pytorch/apis/inference.py | 474 +++++++++--------- .../apis/inference_utils.py | 109 ---- .../pose_estimation_pytorch/apis/train.py | 67 ++- .../pose_estimation_pytorch/apis/utils.py | 11 +- .../match_predictions_to_gt.py | 6 +- .../solvers/top_down.py | 2 +- .../pose_estimation_pytorch/solvers/utils.py | 54 +- deeplabcut/utils/auxiliaryfunctions.py | 6 +- deeplabcut/utils/visualization.py | 1 + requirements.txt | 23 +- setup.py | 11 +- 16 files changed, 843 insertions(+), 540 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/apis/evaluate.py delete mode 100644 deeplabcut/pose_estimation_pytorch/apis/inference_utils.py diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index edb6d33aeb..7c51953b58 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -4,6 +4,6 @@ from deeplabcut.pose_estimation_pytorch.apis import ( analyze_videos, convert_detections2tracklets, - inference_network, + evaluate_network, train_network, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py index 068923a387..884223f960 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py @@ -12,5 +12,5 @@ from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( convert_detections2tracklets, ) -from deeplabcut.pose_estimation_pytorch.apis.inference import inference_network +from deeplabcut.pose_estimation_pytorch.apis.evaluate import evaluate_network from deeplabcut.pose_estimation_pytorch.apis.train import train_network diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index e091c24ba2..482f4d285d 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -8,18 +8,19 @@ # # Licensed under GNU Lesser General Public License v3.0 # +import pickle import sys import time from pathlib import Path from typing import List, Tuple, Optional, Union import albumentations as A +import cv2 import numpy as np import pandas as pd import torch from tqdm import tqdm from skimage.util import img_as_ubyte -import cv2 import deeplabcut.pose_estimation_pytorch as dlc from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, VideoReader @@ -38,8 +39,9 @@ get_model_snapshots, get_detector_snapshots, videos_in_folder, + build_inference_transform, ) -from deeplabcut.pose_estimation_pytorch.apis.inference_utils import ( +from deeplabcut.pose_estimation_pytorch.apis.inference import ( get_predictions_bottom_up, get_predictions_top_down, ) @@ -61,6 +63,8 @@ def video_inference( frames_resized: Optional[bool] = False, ) -> List[np.ndarray]: """ + TODO: This should be refactored to use the `inference` code with a `video dataset` + Runs inference on all frames of a video Args: @@ -89,7 +93,7 @@ def video_inference( # Set the model to eval mode and put it on the device model.eval() model.to(device) - if not detector is None: + if detector is not None: detector.eval() detector.to(device) @@ -111,7 +115,7 @@ def video_inference( transformed_size = original_size if transform: # Apply transformation once only to see the shape after transformation - transformed_size = transform(image=frame)["image"].shape + transformed_size = transform(image=frame, keypoints=[])["image"].shape batch_ind = 0 # Index of the current img in batch batch_frames = np.empty((batch_size, transformed_size[0], transformed_size[1], 3)) @@ -125,7 +129,7 @@ def video_inference( frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) if transform: - frame = transform(image=frame)["image"] + frame = transform(image=frame, keypoints=[])["image"] batch_frames[batch_ind] = frame if batch_ind == batch_size - 1: @@ -135,9 +139,8 @@ def video_inference( if method.lower() == "td": batched_predictions = get_predictions_top_down( detector=detector, - top_down_predictor=top_down_predictor, model=model, - pose_predictor=predictor, + predictor=predictor, images=batch, max_num_animals=max_num_animals, num_keypoints=num_keypoints, @@ -153,6 +156,8 @@ def video_inference( raise ValueError( "Method must be either 'bu' (Bottom Up) or 'td' (Top Down)." ) + + # TODO: use resize_batch_predictions from `inference.py` for frame_pred in batched_predictions: if frames_resized: resizing_factor = (original_size[0] / transformed_size[0]), ( @@ -177,67 +182,83 @@ def video_inference( def analyze_videos( - config_path: str, - data_path: Union[str, List[str]], - output_folder: Optional[str] = None, - video_type: Optional[str] = None, - dataset_index: int = 0, + config: str, + videos: Union[str, List[str]], + videotype: Optional[str] = None, shuffle: int = 1, - snapshot_index: Optional[int] = None, - model_prefix: str = "", - batch_size: Optional[int] = None, - device: Optional[str] = None, + trainingsetindex: int = 0, + snapshotindex: Optional[int] = None, + device: Optional[str] = None, # TODO: Keep "device" instead of gputouse + # TODO: save_as_csv + destfolder: Optional[str] = None, + batchsize: Optional[int] = None, + modelprefix: str = "", transform: Optional[A.Compose] = None, - inv_transform: Optional[A.Compose] = None, overwrite: bool = False, + # TODO: other options such as auto_track ) -> List[Tuple[str, pd.DataFrame]]: - """ - Makes pose estimation predictions based on a trained model - TODO: finish doc + """Makes prediction based on a trained network. + + The index of the trained network is specified by parameters in the config file + (in particular the variable 'snapshot_index'). Args: - config_path: - data_path: - output_folder: - video_type: - dataset_index: - shuffle: - snapshot_index: - model_prefix: - batch_size: - device: - transform: - inv_transform: - overwrite: + config: full path of the config.yaml file for the project + videos: a str (or list of strings) containing the full paths to videos for + analysis or a path to the directory, where all the videos with same + extension are stored. + videotype: checks for the extension of the video in case the input to the video + is a directory. Only videos with this extension are analyzed. If left + unspecified, keeps videos with extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv'). + shuffle: An integer specifying the shuffle index of the training dataset used for + training the network. + trainingsetindex: Integer specifying which TrainingsetFraction to use. + device: the device to use for video analysis + destfolder: specifies the destination folder for analysis data. If ``None``, + the path of the video is used. Note that for subsequent analysis this + folder also needs to be passed + snapshotindex: index (starting at 0) of the snapshot to use to analyze the + videos. To evaluate the last one, use -1. For example if we have + - snapshot-0.pt + - snapshot-50.pt + - snapshot-100.pt + and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None, + the snapshotindex is loaded from the project configuration. + modelprefix: directory containing the deeplabcut models to use when evaluating + the network. By default, they are assumed to exist in the project folder. + batchsize: the batch size to use for inference. Takes the value from the + PyTorch config as a default + transform: Optional custom transforms to apply to the video + overwrite: Overwrite any existing videos Returns: - + A list containing tuples (video_name, df_video_predictions) """ # Create the output folder - _create_output_folder(output_folder) + _create_output_folder(destfolder) # Load the project configuration project = dlc.DLCProject( shuffle=shuffle, - proj_root=str(Path(config_path).parent), + proj_root=str(Path(config).parent), ) project.convert2dict(mode="test") project_path = Path(project.cfg["project_path"]) - train_fraction = project.cfg["TrainingFraction"][dataset_index] + train_fraction = project.cfg["TrainingFraction"][trainingsetindex] model_folder = project_path / auxiliaryfunctions.get_model_folder( train_fraction, shuffle, project.cfg, - modelprefix=model_prefix, + modelprefix=modelprefix, ) - model_path = _get_model_path(model_folder, snapshot_index, project.cfg) + model_path = _get_model_path(model_folder, snapshotindex, project.cfg) model_epochs = int(model_path.stem.split("-")[-1]) dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( project.cfg, shuffle, train_fraction, trainingsiterations=model_epochs, - modelprefix=model_prefix, + modelprefix=modelprefix, ) # Get general project parameters max_num_animals = len(project.cfg.get("individuals", ["single"])) @@ -253,9 +274,9 @@ def analyze_videos( # Get model parameters # TODO: Should we get the batch size from the inference pose_cfg? Or have an # inference pytorch_cfg? - if batch_size is None: - batch_size = pytorch_config.get("batch_size", 1) - pose_cfg["batch_size"] = batch_size + if batchsize is None: + batchsize = pytorch_config.get("batch_size", 1) + pose_cfg["batch_size"] = batchsize individuals = project.cfg.get("individuals", ["single"]) # Get data processing parameters @@ -266,25 +287,32 @@ def analyze_videos( # Load model, predictor model = build_pose_model(pytorch_config["model"], pose_cfg) model.load_state_dict(torch.load(model_path)) - predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config["predictor"])) - detector: BaseDetector = None - top_down_predictor: BasePredictor = None + predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) + detector = None + top_down_predictor = None if method.lower() == "td": - detector_path = _get_detector_path(model_folder, snapshot_index, project.cfg) + detector_path = _get_detector_path(model_folder, snapshotindex, project.cfg) detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) detector.load_state_dict(torch.load(detector_path)) - top_down_predictor = PREDICTORS.build( {"type": "TopDownPredictor", "format_bbox": "xyxy"} ) + + # Load inference + if transform is None: + print("No transform passed, using default normalisation from config") + transform = build_inference_transform( + pytorch_config["data"], augment_bbox=False + ) + # Reading video and init variables - videos = videos_in_folder(data_path, video_type) + videos = videos_in_folder(videos, videotype) results = [] for video in videos: - if output_folder is None: + if destfolder is None: output_path = video.parent else: - output_path = Path(output_folder) + output_path = Path(destfolder) output_prefix = video.stem + dlc_scorer output_h5 = output_path / f"{output_prefix}.h5" @@ -297,7 +325,7 @@ def analyze_videos( model=model, predictor=predictor, video_path=video, - batch_size=batch_size, + batch_size=batchsize, device=device, transform=transform, colormode=pytorch_config.get("colormode", "RGB"), @@ -317,22 +345,25 @@ def analyze_videos( pytorch_pose_config=pytorch_config, dlc_scorer=dlc_scorer, train_fraction=train_fraction, - batch_size=batch_size, + batch_size=batchsize, runtime=(runtime[0], runtime[1]), video=VideoReader(str(video)), ) + coordinate_labels = ["x", "y", "likelihood"] if len(individuals) > 1: print("Extracting ", len(individuals), "instances per bodypart") - xyz_labs_orig = ["x", "y", "likelihood"] - suffix = [str(s + 1) for s in range(len(individuals))] - suffix[0] = "" # first has empty suffix for backwards compatibility - xyz_labs = [x + s for s in suffix for x in xyz_labs_orig] - else: - xyz_labs = ["x", "y", "likelihood"] + # first has empty suffix for backwards compatibility + individual_suffixes = [str(s + 1) for s in range(len(individuals))] + individual_suffixes[0] = "" + coordinate_labels = [ + coord_label + s + for s in individual_suffixes + for coord_label in coordinate_labels + ] results_df_index = pd.MultiIndex.from_product( - [[dlc_scorer], pose_cfg["all_joints_names"], xyz_labs], + [[dlc_scorer], pose_cfg["all_joints_names"], coordinate_labels], names=["scorer", "bodyparts", "coords"], ) df = pd.DataFrame( @@ -352,6 +383,22 @@ def analyze_videos( output_data, metadata, str(output_h5) ) + # save an assemblies file for backwards-compatibility with tensorflow + if project.cfg["multianimalproject"]: + ass_file = output_path / f"{output_prefix}_assemblies.pickle" + assemblies = {} + for i, prediction in enumerate(predictions): + extra_column = np.full( + (prediction.shape[0], prediction.shape[1], 1), + -1.0, + dtype=np.float32, + ) + ass = np.concatenate((prediction, extra_column), axis=-1) + assemblies[i] = ass + + with open(ass_file, "wb") as handle: + pickle.dump(assemblies, handle, protocol=pickle.HIGHEST_PROTOCOL) + return results diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index 9adf688c22..5b3fe93fa2 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -17,15 +17,13 @@ import numpy as np import pandas as pd -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly -from scipy.optimize import linear_sum_assignment - from tqdm import tqdm from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_tensorflow import load_config from deeplabcut.pose_estimation_tensorflow.lib import trackingutils -from deeplabcut.utils import auxfun_multianimal +from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly +from deeplabcut.utils import auxfun_multianimal, read_pickle from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_model_snapshots, videos_in_folder, @@ -33,33 +31,34 @@ def convert_detections2tracklets( - config_path: str, - data_path: Union[str, List[str]], - output_folder: Optional[str] = None, - video_type: Optional[str] = None, + config: str, + videos: Union[str, List[str]], + videotype: Optional[str] = None, shuffle: int = 1, - dataset_index: int = 0, + trainingsetindex: int = 0, overwrite: bool = False, - ignore_bodyparts=None, - inference_cfg: Optional[str] = None, + destfolder: Optional[str] = None, + ignore_bodyparts: Optional[List[str]] = None, + inferencecfg: Optional[dict] = None, modelprefix="", - greedy=False, - calibrate=False, - window_size=0, + greedy=False, # TODO: Unused, remove + calibrate=False, # TODO: Unused, remove + window_size=0, # TODO: Unused, remove identity_only=False, track_method="", ): """TODO: Documentation, clean & remove code duplication (with analyze video)""" - cfg = auxiliaryfunctions.read_config(config_path) + cfg = auxiliaryfunctions.read_config(config) + inference_cfg = inferencecfg track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method) if len(cfg["multianimalbodyparts"]) == 1 and track_method != "box": warnings.warn("Switching to `box` tracker for single point tracking...") track_method = "box" cfg["default_track_method"] = track_method - auxiliaryfunctions.write_config(config_path, cfg) + auxiliaryfunctions.write_config(config, cfg) - train_fraction = cfg["TrainingFraction"][dataset_index] + train_fraction = cfg["TrainingFraction"][trainingsetindex] start_path = os.getcwd() # record cwd to return to this directory in the end # TODO: add cropping as in video analysis! @@ -80,10 +79,10 @@ def convert_detections2tracklets( raise ValueError("This function is only required for multianimal projects!") if inference_cfg is None: - path_inference_config = model_dir / "test" / "inference_cfg.yaml" - inference_cfg = auxfun_multianimal.read_inferencecfg(path_inference_config, cfg) - else: - auxfun_multianimal.check_inferencecfg_sanity(cfg, inference_cfg) + inference_cfg = auxfun_multianimal.read_inferencecfg( + model_dir / "test" / "inference_cfg.yaml", cfg + ) + auxfun_multianimal.check_inferencecfg_sanity(cfg, inference_cfg) if len(cfg["multianimalbodyparts"]) == 1 and track_method != "box": warnings.warn("Switching to `box` tracker for single point tracking...") @@ -122,17 +121,17 @@ def convert_detections2tracklets( ) # TODO: deal with lists of strings - videos = videos_in_folder(data_path, video_type) + videos = videos_in_folder(videos, videotype) if len(videos) == 0: - print(f"No videos were found in {data_path}") + print(f"No videos were found in {videos}") return for video in videos: print("Processing... ", video) - if output_folder is None: + if destfolder is None: output_path = video.parent else: - output_path = Path(output_folder) + output_path = Path(destfolder) output_path.mkdir(exist_ok=True, parents=True) video_name = video.stem @@ -159,13 +158,13 @@ def convert_detections2tracklets( n_joints = len(joints) # TODO: adjust this for multi + unique bodyparts! - # this is only for multianimal parts and uniquebodyparts as one (not one - # uniquebodyparts guy tracked etc.) - bodypartlabels = [bpt for bpt in joints for _ in range(3)] - scorers = len(bodypartlabels) * [dlc_scorer] - xyl_value = int(len(bodypartlabels) / 3) * ["x", "y", "likelihood"] + # this is only for multianimal parts and unique bodyparts as one (not one + # unique bodyparts guy tracked etc.) + bodypart_labels = [bpt for bpt in joints for _ in range(3)] + scorers = len(bodypart_labels) * [dlc_scorer] + xyl_value = int(len(bodypart_labels) / 3) * ["x", "y", "likelihood"] df_index = pd.MultiIndex.from_arrays( - np.vstack([scorers, bodypartlabels, xyl_value]), + np.vstack([scorers, bodypart_labels, xyl_value]), names=["scorer", "bodyparts", "coords"], ) image_names = [fn for fn in data if fn != "metadata"] @@ -193,24 +192,25 @@ def convert_detections2tracklets( tracklets = {} multi_bpts = cfg["multianimalbodyparts"] - ass_unique = {} - ass_assemblies = _conv_predictions_to_assemblies(image_names, data) - ass_data = dict() - for ind, assemblies in ass_assemblies.items(): - ass_data[ind] = [ass.data for ass in assemblies] - if ass_unique: - ass_data["single"] = ass_unique - with open( - data_filename.parent / (data_filename.stem + "_assemblies.pickle"), "wb" - ) as file: - pickle.dump(ass_data, file, pickle.HIGHEST_PROTOCOL) + ass_filename = data_filename.with_stem( + data_filename.stem + "_assemblies" + ).with_suffix(".pickle") + if not ass_filename.exists(): + raise FileNotFoundError( + f"Could not find the assembles file {ass_filename}. You're " + f"converting detections to tracklets using PyTorch, which " + "means the assemblies file must be created by the model when " + "analyzing the video!" + ) + + ass = read_pickle(ass_filename) # Initialize storage of the 'single' individual track if cfg["uniquebodyparts"]: tracklets["single"] = {} _single = {} for index, image_name in enumerate(image_names): - single_detection = ass_unique.get(index) + single_detection = ass["single"].get(index) if single_detection is None: continue imindex = int(re.findall(r"\d+", image_name)[0]) @@ -220,7 +220,7 @@ def convert_detections2tracklets( if inference_cfg["topktoretain"] == 1: tracklets[0] = {} for index, image_name in tqdm(enumerate(image_names)): - assemblies = ass_assemblies.get(index) + assemblies = ass.get(index) if assemblies is None: continue tracklets[0][image_name] = assemblies[0].data @@ -228,12 +228,21 @@ def convert_detections2tracklets( keep = set(multi_bpts).difference(ignore_bodyparts or []) keep_inds = sorted(multi_bpts.index(bpt) for bpt in keep) for index, image_name in tqdm(enumerate(image_names)): - assemblies = ass_assemblies.get(index) - if assemblies is None: + assemblies = ass.get(index) + if assemblies is None or len(assemblies) == 0: continue - animals = np.stack([ass.data for ass in assemblies]) - if not identity_only: + animals = np.stack([a for a in assemblies]) + if identity_only: + raise ValueError("Identity Only is currently not implemented") + # Optimal identity assignment based on soft voting + # mat = np.zeros((len(assemblies), inference_cfg["topktoretain"])) + # for row, a in enumerate(assemblies): + # for k, v in a.soft_identity.items(): + # mat[row, k] = v + # inds = linear_sum_assignment(mat, maximize=True) + # trackers = np.c_[inds][:, ::-1] + else: if track_method == "box": xy = trackingutils.calc_bboxes_from_keypoints( animals[:, keep_inds], @@ -242,24 +251,16 @@ def convert_detections2tracklets( else: xy = animals[:, keep_inds, :2] trackers = mot_tracker.track(xy) - else: - # Optimal identity assignment based on soft voting - mat = np.zeros((len(assemblies), inference_cfg["topktoretain"])) - for nrow, assembly in enumerate(assemblies): - for k, v in assembly.soft_identity.items(): - mat[nrow, k] = v - inds = linear_sum_assignment(mat, maximize=True) - trackers = np.c_[inds][:, ::-1] - trackingutils.fill_tracklets( - tracklets, trackers, animals, image_name - ) + + trackingutils.fill_tracklets( + tracklets, trackers, animals, image_name + ) tracklets["header"] = df_index with open(track_filename, "wb") as f: pickle.dump(tracklets, f, pickle.HIGHEST_PROTOCOL) os.chdir(str(start_path)) - print( "The tracklets were created (i.e., under the hood " "deeplabcut.convert_detections2tracklets was run). Now you can " diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py new file mode 100644 index 0000000000..eb74929a6b --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -0,0 +1,327 @@ +""" +DeepLabCut2.0 Toolbox (deeplabcut.org) +© A. & M. Mathis Labs +https://github.com/DeepLabCut/DeepLabCut +Please see AUTHORS for contributors. + +https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +Licensed under GNU Lesser General Public License v3.0 +""" +import argparse +from pathlib import Path +from typing import Dict, Iterable, Optional, Union, List + +import albumentations as A +import pandas as pd +import os +import torch + +import deeplabcut.pose_estimation_pytorch as dlc +from deeplabcut.pose_estimation_pytorch.apis.inference import inference +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_pose_model, + build_inference_transform, +) +from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS +from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS +from deeplabcut.pose_estimation_pytorch.solvers.inference import get_scores +from deeplabcut.pose_estimation_pytorch.solvers.utils import ( + get_model_folder, + get_paths, + get_results_filename, + get_snapshots, + build_predictions_df, +) +from deeplabcut.utils import auxiliaryfunctions +from deeplabcut.utils.visualization import plot_evaluation_results + + +def ensure_multianimal_df_format(df_predictions: pd.DataFrame) -> pd.DataFrame: + """ + Convert dataframe to 'multianimal' format (with an "individuals" columns index) + + Args: + df_predictions: the dataframe to convert + + Returns: + the dataframe in MA format + """ + df_predictions_ma = df_predictions.copy() + try: + df_predictions_ma.columns.get_level_values("individuals").unique().tolist() + except KeyError: + new_cols = pd.MultiIndex.from_tuples( + [(col[0], "single", col[1], col[2]) for col in df_predictions_ma.columns], + names=["scorer", "individuals", "bodyparts", "coords"], + ) + df_predictions_ma.columns = new_cols + return df_predictions_ma + + +def evaluate_snapshot( + cfg: Dict, + shuffle: int = 0, + trainingsetindex: int = -1, + snapshotindex: int = -1, + transform: Union[A.BasicTransform, A.Compose] = None, + plotting: Union[bool, str] = False, + show_errors: bool = True, + modelprefix: str = "", + batch_size: int = 1, +) -> None: + """Evaluates a snapshot. + The evaluation results are stored in the .h5 and TODO .csv file under the subdirectory + 'evaluation_results'. + + Args: + cfg: the content of the project's config file + shuffle: shuffle index + trainingsetindex: the training set fraction to use + modelprefix: model prefix + snapshotindex: index (starting at 0) of the snapshot we want to load. To + evaluate the last one, use -1. To evaluate all snapshots, use "all". For + example if we have 3 models saved + - snapshot-0.pt + - snapshot-50.pt + - snapshot-100.pt + and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None, + the snapshotindex is loaded from the project configuration. + transform: transformation pipeline for evaluation + ** Should normalise the data the same way it was normalised during training ** + plotting: Plots the predictions on the train and test images. If provided it must + be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting + to ``True`` defaults as ``"bodypart"`` for multi-animal projects. + show_errors: whether to compare predictions and ground truth + batch_size: the batch size to use for evaluation + + Returns: + None + """ + # reading pytorch config + train_fraction = cfg["TrainingFraction"][trainingsetindex] + modelfolder = os.path.join( + cfg["project_path"], + auxiliaryfunctions.get_model_folder( + train_fraction, + shuffle, + cfg, + modelprefix=modelprefix, + ), + ) + individuals = cfg.get("individuals", ["single"]) + bodyparts = auxiliaryfunctions.get_bodyparts(cfg) + max_individuals = len(individuals) + num_joints = len(bodyparts) + pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") + pytorch_config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + method = pytorch_config.get("method", "bu") + if method not in ["bu", "td"]: + raise ValueError( + f"Method should be set to either 'bu' (Bottom Up) or 'td' (Top Down), " + f"currently it is {method}" + ) + device = pytorch_config["device"] + + if transform is None: + print("No transform passed, using default normalisation from config") + transform = build_inference_transform(pytorch_config["data"]) + + # if images are resized for inference, need to map keypoints back to original space + images_resized_with_transform = pytorch_config["data"].get("resize", False) + + project = dlc.DLCProject(shuffle=shuffle, proj_root=pytorch_config["project_path"]) + names = get_paths( + train_fraction=train_fraction, + model_prefix=modelprefix, + shuffle=shuffle, + cfg=project.cfg, + train_iterations=snapshotindex, + method=method, + ) + results_filename = get_results_filename( + names["evaluation_folder"], + names["dlc_scorer"], + names["dlc_scorer_legacy"], + names["model_path"][:-3], + ) + + pose_cfg = auxiliaryfunctions.read_plainconfig(pytorch_config["pose_cfg_path"]) + model = build_pose_model(pytorch_config["model"], pose_cfg) + model.load_state_dict(torch.load(names["model_path"])) + + predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) + detector = None + if method.lower() == "td": + detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) + detector.load_state_dict(torch.load(names["detector_path"])) + + df_mode_predictions: List[pd.DataFrame] = [] + for mode in ["train", "test"]: + dataset = dlc.PoseDataset(project, transform=transform, mode=mode) + dataloader = torch.utils.data.DataLoader( + dataset, batch_size=batch_size, shuffle=False + ) + target_df = dataset.dataframe.copy() + predictions = inference( + dataloader=dataloader, + model=model, + predictor=predictor, + method=method, + max_individuals=max_individuals, + num_keypoints=num_joints, + device=device, + align_predictions_to_ground_truth=True, + images_resized_with_transform=images_resized_with_transform, + detector=detector, + ) + df_predictions = build_predictions_df( + dlc_scorer=names["dlc_scorer"], + individuals=individuals, + bodyparts=bodyparts, + df_index=target_df.index, + predictions=predictions.reshape(target_df.index.shape[0], -1), + ) + df_mode_predictions.append(df_predictions) + + df_predictions_ma = ensure_multianimal_df_format(df_predictions) + if plotting: + snapshot_name = Path(names["model_path"]).stem + folder_name = ( + f"{names['evaluation_folder']}/" + f"LabeledImages_{names['dlc_scorer']}_{snapshot_name}" + ) + auxiliaryfunctions.attempttomakefolder(folder_name) + df_combined = df_predictions_ma.merge( + target_df, left_index=True, right_index=True + ) + + if isinstance(plotting, str): + plot_mode = plotting + else: + plot_mode = "bodypart" + + plot_evaluation_results( + df_combined=df_combined, + project_root=cfg["project_path"], + scorer=cfg["scorer"], + model_name=names["dlc_scorer"], + output_folder=folder_name, + in_train_set=mode == "train", + mode=plot_mode, + colormap=cfg["colormap"], + dot_size=cfg["dotsize"], + alpha_value=cfg["alphavalue"], + p_cutoff=cfg["pcutoff"], + ) + + if show_errors: + scores = get_scores(pose_cfg, df_predictions, target_df) + print(f"Mode {mode} scores:", scores) + + # Create the output dataframe + df_all_predictions = pd.concat(df_mode_predictions, axis=0) + # Re-Index the DataFrame in the same order as the ground truth dataframe + df_all_predictions = df_all_predictions.reindex(project.dlc_df.index) + + output_filename = Path(results_filename) + output_filename.parent.mkdir(exist_ok=True) + + df_all_predictions.to_hdf(str(output_filename), "df_with_missing") + + +def evaluate_network( + config: str, + shuffles: Iterable[int] = (1,), + trainingsetindex: Union[int, str] = 0, + snapshotindex: Optional[Union[int, str]] = None, + plotting: Union[bool, str] = False, + show_errors: bool = True, + transform: Union[A.BasicTransform, A.Compose] = None, + modelprefix: str = "", + batch_size: int = 1, +) -> None: + """Evaluates a snapshot. + + The evaluation results are stored in the .h5 and .csv file under the subdirectory + 'evaluation_results'. + + Args: + config: path to the project's config file + shuffles: Iterable of integers specifying the shuffle indices to evaluate. + trainingsetindex: Integer specifying which training set fraction to use. + Evaluates all fractions if set to "all" + snapshotindex: index (starting at 0) of the snapshot we want to load. To + evaluate the last one, use -1. To evaluate all snapshots, use "all". For + example if we have 3 models saved + - snapshot-0.pt + - snapshot-50.pt + - snapshot-100.pt + and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None, + the snapshotindex is loaded from the project configuration. + plotting: Plots the predictions on the train and test images. If provided it must + be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting + to ``True`` defaults as ``"bodypart"`` for multi-animal projects. + show_errors: display train and test errors. + transform: transformation pipeline for evaluation + ** Should normalise the data the same way it was normalised during training ** + modelprefix: directory containing the deeplabcut models to use when evaluating + the network. By default, they are assumed to exist in the project folder. + batch_size: the batch size to use for evaluation + """ + cfg = auxiliaryfunctions.read_config(config) + + if isinstance(trainingsetindex, int): + train_set_indices = [trainingsetindex] + elif isinstance(trainingsetindex, str) and trainingsetindex.lower() == "all": + train_set_indices = list(range(len(cfg["TrainingFraction"]))) + else: + raise ValueError(f"Invalid trainingsetindex: {trainingsetindex}") + + if snapshotindex is None: + snapshotindex = cfg["snapshotindex"] + + for train_set_index in train_set_indices: + for shuffle in shuffles: + if isinstance(snapshotindex, str) and snapshotindex.lower() == "all": + model_folder = get_model_folder( + train_fraction=cfg["TrainingFraction"][train_set_index], + shuffle=shuffle, + model_prefix=modelprefix, + test_cfg=cfg, + ) + all_snapshots = get_snapshots(Path(model_folder)) + snapshot_indices = list(range(len(all_snapshots))) + elif isinstance(snapshotindex, int): + snapshot_indices = [snapshotindex] + else: + raise ValueError(f"Invalid snapshotindex: {snapshotindex}") + + for snapshot in snapshot_indices: + evaluate_snapshot( + cfg=cfg, + shuffle=shuffle, + trainingsetindex=train_set_index, + snapshotindex=snapshot, + transform=transform, + plotting=plotting, + show_errors=show_errors, + modelprefix=modelprefix, + batch_size=batch_size, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--config", type=str) + parser.add_argument("--modelprefix", type=str, default="") + parser.add_argument("--snapshotindex", type=int, default=49) + parser.add_argument("--plotting", type=bool, default=False) + parser.add_argument("--show_errors", type=bool, default=True) + args = parser.parse_args() + evaluate_network( + config=args.config, + modelprefix=args.modelprefix, + snapshotindex=args.snapshotindex, + plotting=args.plotting, + show_errors=args.show_errors, + ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index e084e2c99f..3e7c276a91 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -1,275 +1,281 @@ -""" -DeepLabCut2.0 Toolbox (deeplabcut.org) -© A. & M. Mathis Labs -https://github.com/DeepLabCut/DeepLabCut -Please see AUTHORS for contributors. - -https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -Licensed under GNU Lesser General Public License v3.0 -""" -import argparse -from pathlib import Path -from typing import Union - -import albumentations as A -import numpy as np -import pandas as pd -import os +from typing import Union, List, Dict, Tuple, Optional + import torch +import numpy as np +from skimage.transform import resize -import deeplabcut.pose_estimation_pytorch as dlc -import deeplabcut.pose_estimation_pytorch.apis.inference_utils as inference_utils -from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_pose_model, - build_inference_transform, -) -from deeplabcut.utils import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS -from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS -from deeplabcut.pose_estimation_pytorch.solvers.inference import get_scores -from deeplabcut.pose_estimation_pytorch.solvers.utils import ( - get_paths, - get_results_filename, - save_predictions, -) +from deeplabcut.pose_estimation_pytorch.models import PoseModel, PREDICTORS +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.post_processing import ( rmse_match_prediction_to_gt, ) -from deeplabcut.utils.visualization import plot_evaluation_results - - -def inference_network( - config_path: str, - shuffle: int = 0, - model_prefix: str = "", - load_epoch: Union[int, str] = -1, - stride: int = 8, - transform: Union[A.BasicTransform, A.Compose] = None, - plot: Union[bool, str] = False, - evaluate: bool = True, -) -> None: - """ - Performs inference on the validation dataset and save the results as a dataframe + + +def get_predictions_bottom_up( + model: PoseModel, predictor: BasePredictor, images: torch.Tensor +) -> np.array: + """Gets the predicted coordinates tensor for a bottom_up approach + + Model and images should already be on the same device Args: - config_path: path to the project's config file - shuffle: shuffle index - model_prefix: model prefix - load_epoch: index (starting at 0) of the snapshot we want to load, if -1 loads - the last one automatically. For example if we have 3 models saved - - snapshot-0.pt - - snapshot-50.pt - - snapshot-100.pt - and we want to load the second one, load epoch should be 1 - stride: unused # TODO We clearly should remove this - transform: transformation pipeline for evaluation - ** Should normalise the data the same way it was normalised during training ** - plot: Plots the predictions on the train and test images. If provided it must - be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting - to ``True`` defaults as ``"bodypart"`` for multi-animal projects. - evaluate: whether to compare predictions and ground truth + model (PoseModel): bottom-up model + predictor (BasePredictor): predictor used to regress keypoints coordinates and scores + images (torch.Tensor): input images (should already be normalised and formatted if needed), + shape (batch_size, 3, height, width) Returns: - None + np.array: predictions tensor of shape (batch_size, num_animals, num_keypoints, 3) """ - # reading pytorch config - cfg = auxiliaryfunctions.read_config(config_path) - train_fraction = cfg["TrainingFraction"] - modelfolder = os.path.join( - cfg["project_path"], - auxiliaryfunctions.get_model_folder( - train_fraction[0], - shuffle, - cfg, - modelprefix=model_prefix, - ), + output = model(images) + shape_image = images.shape + scale_factor = ( + shape_image[2] / output[0].shape[2], + shape_image[3] / output[0].shape[3], ) - individuals = cfg.get("individuals", ["single"]) - max_num_animals = len(individuals) - num_joints = len(auxiliaryfunctions.get_bodyparts(cfg)) - pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - pytorch_config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) - method = pytorch_config.get("method", "bu") - if method not in ["bu", "td"]: - raise ValueError( - f"Method should be set to either 'bu' (Bottom Up) or 'td' (Top Down), currently it is {method}" - ) - device = pytorch_config["device"] + predictions = predictor(output, scale_factor) + return predictions.cpu().numpy() - # TODO: inference currently fails on batch_size > 1 - # batch_size = pytorch_config["batch_size"] - batch_size = 1 - if transform is None: - print("No transform passed, using default normalisation from config") - transform = build_inference_transform(pytorch_config["data"]) +def get_predictions_top_down( + detector: BaseDetector, + model: PoseModel, + predictor: BasePredictor, + top_down_predictor: BasePredictor, + images: torch.Tensor, + max_num_animals: int, + num_keypoints: int, + device: Union[torch.device, str], +) -> np.array: + """ + TODO probably quite bad design, most arguments could be stored somewhere else + Gets the predicted coordinates tensor for a bottom_up approach - project = dlc.DLCProject(shuffle=shuffle, proj_root=pytorch_config["project_path"]) - valid_dataset = dlc.PoseDataset(project, transform=transform, mode="test") - valid_dataloader = torch.utils.data.DataLoader( - valid_dataset, batch_size=batch_size, shuffle=False - ) + Detector, Model and images should already be on the same device - # if images are resized for inference, - # need to take that into account to go back to original space - images_resized_with_transform = pytorch_config["data"].get("resize", False) - - names = get_paths( - train_fraction=train_fraction[0], - model_prefix=model_prefix, - shuffle=shuffle, - cfg=valid_dataset.cfg, - train_iterations=load_epoch, - method=method, - ) + Args: + detector (BaseDetector): detector used to detect bboxes, should be in eval mode + model (PoseModel): pose model + predictor (BasePredictor): predictor used to regress keypoints coordinates and scores in the cropped images + top_down_predictor (BasePredictor): Given the bboxes and the cropped keypoints + coordinates, outputs the regressed keypoints + images (torch.Tensor): input images (should already be normalised and formatted if needed), + shape (batch_size, 3, height, width) + max_num_animals (int) : maximum number of animals to predict + num_keypoints (int) : number of keypoints per animal in the dataset + device (Union[torch.device, str]): device everything should be on - results_filename = get_results_filename( - names["evaluation_folder"], - names["dlc_scorer"], - names["dlc_scorer_legacy"], - names["model_path"][:-3], + Returns: + np.array: predictions tensor of shape (batch_size, num_animals, num_keypoints, 3) + """ + batch_size = images.shape[0] + output_detector = detector(images) + + boxes = torch.zeros((batch_size, max_num_animals, 4)) + for b, item in enumerate(output_detector): + boxes[b][: min(max_num_animals, len(item["boxes"]))] = item["boxes"][ + :max_num_animals + ] # Boxes should be sorted by scores, only keep the maximum number allowed + boxes = boxes.int() + cropped_kpts_total = torch.full( + (batch_size, max_num_animals, num_keypoints, 3), -1.0 ) - pose_cfg = auxiliaryfunctions.read_plainconfig(pytorch_config["pose_cfg_path"]) - model = build_pose_model(pytorch_config["model"], pose_cfg) - model.load_state_dict(torch.load(names["model_path"])) + for b in range(batch_size): + for j, box in enumerate(boxes[b]): + if (box == 0.0).all(): + continue + cropped_image = ( + images[b][:, box[1] : box[3], box[0] : box[2]] + .permute(1, 2, 0) + .cpu() + .numpy() + ) # needs to be (h,w,c) for resizing + cropped_image = resize(cropped_image, (256, 256)) # TODO: hardcoded for now + cropped_image = ( + torch.tensor(cropped_image.transpose(2, 0, 1)).unsqueeze(0).to(device) + ) + heatmaps = model(cropped_image) + + scale_factors_cropped = ( + cropped_image.shape[2] / heatmaps[0].shape[2], + cropped_image.shape[3] / heatmaps[0].shape[3], + ) + + cropped_kpts = predictor(heatmaps, scale_factors_cropped) + cropped_kpts_total[b, j, :] = cropped_kpts[0, 0] + + final_predictions = top_down_predictor(boxes, cropped_kpts_total) + return final_predictions.cpu().numpy() + + +def match_predicted_individuals_to_annotations( + predictions: np.ndarray, + ground_truth: List[np.ndarray], + max_individuals: int, +) -> None: + """ + Uses RMSE to match predicted individuals to frame annotations for a batch of + frames. This method is preferred to OKS, as OKS needs at least 2 annotated + keypoints per animal (to compute area) + + The prediction arrays are modified in-place, where the order of elements are + swapped in 2nd dimension (individuals) such that the keypoints in predictions[b][i] + is matched to the ground truth annotations of ground_truth[b][i] + + Args: + predictions: (batch, individual, keypoints, 3) predicted keypoints + ground_truth: list containing "batch" (individual, keypoints, 2) ground truth + keypoint arrays + max_individuals: the maximum number of individuals in a frame + """ + if max_individuals > 1: + for b in range(predictions.shape[0]): + match_individuals = rmse_match_prediction_to_gt( + predictions[b], + ground_truth[b], + ) + predictions[b] = predictions[b][match_individuals] + + +def resize_batch_predictions( + predictions: np.ndarray, + original_sizes: np.ndarray, + image_shape: Tuple[int, int], +) -> None: + """ + TODO shifting error when padding + + Converts keypoint coordinates to their values in the original image. Call if the + image was resized during the image augmentation pipeline. + + Modifies the prediction array in-place. + + Args: + predictions: (batch, individual, keypoints, 3) predicted keypoints + original_sizes: shape (batch, 3); the original (w, h, c) for images + image_shape: the (width, height) for the image given to the model + """ + for b in range(predictions.shape[0]): + resizing_factor = ( + (original_sizes[b][0] / image_shape[0]).item(), + (original_sizes[b][1] / image_shape[1]).item(), + ) + predictions[b, :, :, 0] = ( + predictions[b, :, :, 0] * resizing_factor[1] + resizing_factor[1] / 2 + ) + predictions[b, :, :, 1] = ( + predictions[b, :, :, 1] * resizing_factor[0] + resizing_factor[0] / 2 + ) + - predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) +def inference( + dataloader: torch.utils.data.DataLoader, + model: PoseModel, + predictor: BasePredictor, + method: str, + max_individuals: int, + num_keypoints: int, + device: str, + align_predictions_to_ground_truth: bool, + images_resized_with_transform: bool, + detector: Optional[BaseDetector] = None, +) -> np.ndarray: + """ + Runs inference for a pose estimation model. + + Args: + dataloader: contains the data to run inference on + model: the pose estimation model to use for inference + predictor: predictor used to obtain keypoints from the model output + method: either `"td"` (top-down) or `"bu"` (bottom-up) + max_individuals: the maximum number of individuals detected in a frame + num_keypoints: the number of keypoints per individual + device: the device on which to run inference + align_predictions_to_ground_truth: whether to align predictions to ground truth + individuals in the output predictions (prediction i is closest to ground + truth individual i) + images_resized_with_transform: whether the image is resized by the transform + detector: None when `method="bu"`. The detector to use when `method="td"`. + Returns: + shape (num_images, individual, keypoints, 3): the predicted keypoints + """ if method.lower() == "td": - detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) - detector.load_state_dict(torch.load(names["detector_path"])) - top_down_predictor = PREDICTORS.build( - {"type": "TopDownPredictor", "format_bbox": "xyxy"} - ) # I don't think this top down predictor should depend on config since I feel like it's already pretty general + if detector is None: + raise ValueError( + f"A detector must be provided when running inference for a top-down " + f"pose estimator!" + ) detector.eval() detector.to(device) + elif method.lower() == "bu": + if detector is not None: + raise ValueError( + f"A detector was provided when running inference for a bottom-up " + f"which is not possible!" + ) + else: + raise ValueError(f"Unknown method: {method}. Choose 'td' or 'bu'.") - target_df = valid_dataset.dataframe - predicted_poses = [] model.eval() model.to(device) + predictor.eval() + predictor.to(device) + + top_down_predictor = None + if method == "td": + top_down_predictor = PREDICTORS.build( + {"type": "TopDownPredictor", "format_bbox": "xyxy"} + ) + top_down_predictor.eval() + top_down_predictor.to(device) + + predicted_poses = [] with torch.no_grad(): - for item in valid_dataloader: + for item in dataloader: item["image"] = item["image"].to(device) - shape_image = item["image"].shape - - if method.lower() == "td": - predictions = inference_utils.get_predictions_top_down( + image_shape = item["image"].shape # b, c, w, h + if method == "td": + predictions = get_predictions_top_down( detector=detector, - top_down_predictor=top_down_predictor, model=model, - pose_predictor=predictor, + predictor=predictor, + top_down_predictor=top_down_predictor, images=item["image"], - max_num_animals=max_num_animals, - num_keypoints=num_joints, + max_num_animals=max_individuals, + num_keypoints=num_keypoints, device=device, ) - elif method.lower() == "bu": - predictions = inference_utils.get_predictions_bottom_up( + else: + predictions = get_predictions_bottom_up( model=model, predictor=predictor, images=item["image"], ) - else: - raise ValueError("This error should not happen !") - - # Matching predictions to ground truth individuals in order to compute rmse and save as dataframe - if len(individuals) > 1: - for b in range(predictions.shape[0]): - # rmse is more practical than oks - # since oks needs at least 2 annotated keypoints per animal (to compute area) - match_individuals = rmse_match_prediction_to_gt( - predictions[b], - item["annotations"]["keypoints"][b].cpu().numpy(), - individuals, - ) - predictions[b] = predictions[b][match_individuals] - - # TODO shifting error when padding - # converts back to original image size if image was resized during the augmentation pipeline - if images_resized_with_transform: - for b in range(predictions.shape[0]): - resizing_factor = ( - item["original_size"][0][b] / shape_image[2] - ).item(), (item["original_size"][1][b] / shape_image[3]).item() - predictions[b, :, :, 0] = ( - predictions[b, :, :, 0] * resizing_factor[1] - + resizing_factor[1] / 2 - ) - predictions[b, :, :, 1] = ( - predictions[b, :, :, 1] * resizing_factor[0] - + resizing_factor[0] / 2 - ) - predicted_poses.append(predictions) - - predicted_poses = np.array(predicted_poses) - predicted_df = save_predictions( - names, - cfg, - target_df.index, - predicted_poses.reshape(target_df.index.shape[0], -1), - results_filename, - ) + if align_predictions_to_ground_truth: + match_predicted_individuals_to_annotations( + predictions=predictions, + ground_truth=[ + kpts.cpu().numpy() for kpts in item["annotations"]["keypoints"] + ], + max_individuals=max_individuals, + ) - # Convert dataframe to 'multianimal' format in any case, allows for similar post_processing - try: - predicted_df.columns.get_level_values("individuals").unique().tolist() - except KeyError: - new_cols = pd.MultiIndex.from_tuples( - [(col[0], "single", col[1], col[2]) for col in predicted_df.columns], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - predicted_df.columns = new_cols + if images_resized_with_transform: + original_sizes = torch.stack(item["original_size"], dim=1) + resize_batch_predictions( + predictions=predictions, + original_sizes=original_sizes.cpu().numpy(), + image_shape=(image_shape[2], image_shape[3]), + ) + predicted_poses.append(predictions) - if plot: - snapshot_name = Path(names["model_path"]).stem - folder_name = ( - f"{names['evaluation_folder']}/" - f"LabeledImages_{names['dlc_scorer']}_{snapshot_name}" - ) - auxiliaryfunctions.attempttomakefolder(folder_name) - df_combined = predicted_df.merge(target_df, left_index=True, right_index=True) - - if isinstance(plot, str): - mode = plot - else: - mode = "bodypart" - - plot_evaluation_results( - df_combined=df_combined, - project_root=cfg["project_path"], - scorer=cfg["scorer"], - model_name=names["dlc_scorer"], - output_folder=folder_name, - in_train_set=False, - mode=mode, - colormap=cfg["colormap"], - dot_size=cfg["dotsize"], - alpha_value=cfg["alphavalue"], - p_cutoff=cfg["pcutoff"], - ) + if len(predicted_poses) > 0: + predicted_poses = np.concatenate(predicted_poses, axis=0) + else: + predicted_poses = np.zeros((0, max_individuals, num_keypoints, 3)) - if evaluate: - scores = get_scores(pose_cfg, predicted_df, target_df) - print(scores) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("--config_path", type=str) - parser.add_argument("--shuffle", type=int, default=0) - parser.add_argument("--modelprefix", type=str, default="") - parser.add_argument("--load_epoch", type=int, default=49) - parser.add_argument("--plot", type=bool, default=False) - parser.add_argument("--evaluate", type=bool, default=True) - args = parser.parse_args() - inference_network( - config_path=args.config_path, - shuffle=args.shuffle, - model_prefix=args.modelprefix, - load_epoch=args.load_epoch, - plot=args.plot, - evaluate=args.evaluate, - ) + return predicted_poses diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py b/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py deleted file mode 100644 index c10d872dea..0000000000 --- a/deeplabcut/pose_estimation_pytorch/apis/inference_utils.py +++ /dev/null @@ -1,109 +0,0 @@ -import torch -import numpy as np -from skimage.transform import resize -from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PREDICTORS, PoseModel -from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor -from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector - -from typing import Union - - -def get_predictions_bottom_up( - model: PoseModel, predictor: BasePredictor, images: torch.Tensor -) -> np.array: - """Gets the predicted coordinates tensor for a bottom_up approach - - Model and images should already be on the same device - - Args: - model (PoseModel): bottom-up model - predictor (BasePredictor): predictor used to regress keypoints coordinates and scores - images (torch.Tensor): input images (should already be normalised and formatted if needed), - shape (batch_size, 3, height, width) - - Returns: - np.array: predictions tensor of shape (batch_size, num_animals, num_keypoints, 3) - """ - - output = model(images) - shape_image = images.shape - scale_factor = ( - shape_image[2] / output[0].shape[2], - shape_image[3] / output[0].shape[3], - ) - predictions = predictor(output, scale_factor) - - return predictions.cpu().numpy() - - -def get_predictions_top_down( - detector: BaseDetector, - top_down_predictor: BasePredictor, - model: PoseModel, - pose_predictor: BasePredictor, - images: torch.Tensor, - max_num_animals: int, - num_keypoints: int, - device: Union[torch.device, str], -) -> np.array: - """ - TODO probably quite bad design, most arguments could be stored somewhere else - Gets the predicted coordinates tensor for a bottom_up approach - - Detector, Model and images should already be on the same device - - Args: - detector (BaseDetector): detector used to detect bboxes, should be in eval mode - top_down_predictor (BasePredictor): Given the bboxes and the cropped keypoints coordinates, outputs the regressed keypoints - model (PoseModel): pose model - pose_predictor (BasePredictor): predictor used to regress keypoints coordinates and scores in the cropped images - images (torch.Tensor): input images (should already be normalised and formatted if needed), - shape (batch_size, 3, height, width) - max_num_animals (int) : maximum number of animals to predict - num_keypoints (int) : number of keypoints per animal in the dataset - device (Union[torch.device, str]): device everything should be on - - Returns: - np.array: predictions tensor of shape (batch_size, num_animals, num_keypoints, 3) - """ - batch_size = images.shape[0] - - output_detector = detector(images) - - boxes = torch.zeros((batch_size, max_num_animals, 4)) - for b, item in enumerate(output_detector): - boxes[b][: min(max_num_animals, len(item["boxes"]))] = item["boxes"][ - :max_num_animals - ] # Boxes should be sorted by scores, only keep the maximum number allowed - boxes = boxes.int() - cropped_kpts_total = torch.full( - (batch_size, max_num_animals, num_keypoints, 3), -1.0 - ) - - for b in range(batch_size): - for j, box in enumerate(boxes[b]): - if (box == 0.0).all(): - continue - cropped_image = ( - images[b][:, box[1] : box[3], box[0] : box[2]] - .permute(1, 2, 0) - .cpu() - .numpy() - ) # needs to be (h,w,c) for resizing - cropped_image = resize(cropped_image, (256, 256)) # TODO: hardcoded for now - cropped_image = ( - torch.tensor(cropped_image.transpose(2, 0, 1)).unsqueeze(0).to(device) - ) - heatmaps = model(cropped_image) - - scale_factors_cropped = ( - cropped_image.shape[2] / heatmaps[0].shape[2], - cropped_image.shape[3] / heatmaps[0].shape[3], - ) - - cropped_kpts = pose_predictor(heatmaps, scale_factors_cropped) - cropped_kpts_total[b, j, :] = cropped_kpts[0, 0] - - final_predictions = top_down_predictor(boxes, cropped_kpts_total) - - return final_predictions.cpu().numpy() diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 0ff2361761..82389b9e4e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -14,58 +14,55 @@ def train_network( - config_path: str, + config: str, shuffle: int = 1, - training_set_index: Union[int, str] = 0, + trainingsetindex: int = 0, transform: Union[A.BaseCompose, A.BasicTransform] = None, transform_cropped: Union[A.BaseCompose, A.BasicTransform] = None, - model_prefix: str = "", + modelprefix: str = "", **kwargs ) -> Solver: - """ - Trains a network for a project + """Trains a network for a project - Args: - - config_path : path to the yaml config file of the project - - shuffle : index of the shuffle we want to train on - - training_set_index : training set index + TODO: max_snapshots_to_keep - - transform: Augmentation pipeline for the images + Args: + config : path to the yaml config file of the project + shuffle : index of the shuffle we want to train on + trainingsetindex : training set index + transform: Augmentation pipeline for the images if None, the augmentation pipeline is built from config files Advice if you want to use custom transformations: - Keep in mind that in order for transfer leanring to be efficient, your + Keep in mind that in order for transfer learning to be efficient, your data statistical distribution should resemble the one used to pretrain your backbone - In most cases (e.g bacbone was pretrained on ImageNet), that means it should be Normalized with + In most cases (e.g backbone was pretrained on ImageNet), that means it should be Normalized with A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) - - transform_cropped: Augmentation pipeline for the cropped images around animals + transform_cropped: Augmentation pipeline for the cropped images around animals if None, the augmentation pipeline is built from config files Advice if you want to use custom transformations: - Keep in mind that in order for transfer leanring to be efficient, your + Keep in mind that in order for transfer learning to be efficient, your data statistical distribution should resemble the one used to pretrain your backbone - - In most cases (e.g bacbone was pretrained on ImageNet), that means it should be Normalized with + In most cases (e.g backbone was pretrained on ImageNet), that means it should be Normalized with A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) - - model_prefix: model prefix - - **kwargs : could be any entry of the pytorch_config dictionary + modelprefix: directory containing the deeplabcut configuration files to use + to train the network (and where snapshots will be saved). By default, they + are assumed to exist in the project folder. + **kwargs : could be any entry of the pytorch_config dictionary. Examples are to see the full list see the pytorch_cfg.yaml file in your project folder Returns: solver: solver used for training, stores data about losses during training """ - - cfg = auxiliaryfunctions.read_config(config_path) - if training_set_index == "all": - train_fraction = cfg["TrainingFraction"] - else: - train_fraction = [cfg["TrainingFraction"][training_set_index]] + cfg = auxiliaryfunctions.read_config(config) + train_fraction = cfg["TrainingFraction"][trainingsetindex] modelfolder = os.path.join( cfg["project_path"], auxiliaryfunctions.get_model_folder( - train_fraction[0], + train_fraction, shuffle, cfg, - modelprefix=model_prefix, + modelprefix=modelprefix, ), ) pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") @@ -94,7 +91,7 @@ def train_network( solver = build_solver(pytorch_config) if pytorch_config.get("method", "bu").lower() == "td": - if transform_cropped == None: + if transform_cropped is None: print( "No transform passed to augment cropped images, using default augmentations" ) @@ -121,20 +118,20 @@ def train_network( valid_dataloader, train_cropped_dataloader, valid_cropped_dataloader, - train_fraction=train_fraction[0], + train_fraction=train_fraction, epochs=epochs, detector_epochs=detector_epochs, shuffle=shuffle, - model_prefix=model_prefix, + model_prefix=modelprefix, ) elif pytorch_config.get("method", "bu").lower() == "bu": solver.fit( train_dataloader, valid_dataloader, - train_fraction=train_fraction[0], + train_fraction=train_fraction, epochs=epochs, shuffle=shuffle, - model_prefix=model_prefix, + model_prefix=modelprefix, ) else: raise ValueError( @@ -150,9 +147,9 @@ def train_network( parser.add_argument("--train-ind", type=int, default=0) parser.add_argument("--modelprefix", type=str, default="") args = parser.parse_args() - solver = train_network( - config_path=args.config_path, + _ = train_network( + config=args.config_path, shuffle=args.shuffle, - training_set_index=args.train_ind, - model_prefix=args.modelprefix, + trainingsetindex=args.train_ind, + modelprefix=args.modelprefix, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index c35d105221..9f158d6e4f 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -300,6 +300,10 @@ def build_inference_transform( """ list_transforms = [] + if transform_cfg.get("resize", False): + input_size = transform_cfg.get("resize", False) + list_transforms.append(A.Resize(input_size[0], input_size[1])) + if transform_cfg.get("auto_padding"): params = transform_cfg.get("auto_padding") pad_height_divisor = params.get("pad_height_divisor", 1) @@ -387,13 +391,12 @@ def videos_in_folder( video_path = Path(data_path) if video_path.is_dir(): if video_type is None: - video_suffixes = auxfun_videos.SUPPORTED_VIDEOS + video_suffixes = ["." + ext for ext in auxfun_videos.SUPPORTED_VIDEOS] else: video_suffixes = [video_type] - return [ - file for file in video_path.iterdir() if video_path.stem in video_suffixes - ] + video_suffixes = [s if s.startswith(".") else "." + s for s in video_suffixes] + return [file for file in video_path.iterdir() if file.suffix in video_suffixes] assert ( video_path.exists() diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 9869ca506d..e1263f379d 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -6,8 +6,9 @@ def rmse_match_prediction_to_gt( - pred_kpts: np.array, gt_kpts: np.array, individual_names: list -): + pred_kpts: np.ndarray, + gt_kpts: np.ndarray, +) -> np.ndarray: """ Hungarian algorithm predicted individuals to ground truth ones, using rmse @@ -15,7 +16,6 @@ def rmse_match_prediction_to_gt( --------- pred_kpts: (num_animals, num_keypoints, 3) gt_kpts: (num_animals, num_keypoints(+1 if with center), 2) - individual_names: names of individuals Output ------ diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py index 540bfc0bb5..098599ef35 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py @@ -84,7 +84,7 @@ def fit( f"{model_folder}/train/detector-snapshot-{i + 1}.pt", ) print( - f"Epoch {i + 1}/{epochs}, " + f"Epoch {i + 1}/{detector_epochs}, " f"train detector loss {train_detector_loss}, " ) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index 19a7b10c23..8571f9eeba 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -2,6 +2,7 @@ import os from pathlib import Path +import numpy as np import pandas as pd from typing import List @@ -89,18 +90,39 @@ def sort_paths(paths: list): return sorted_paths -def save_predictions(names, cfg, data_index, predicted_poses, results_filename): - if not os.path.exists(names["evaluation_folder"]): - os.makedirs(names["evaluation_folder"]) - - results_path = f"{results_filename}" - num_animals = len(cfg.get("individuals", ["single"])) - if num_animals == 1: - # Single animal prediction deataframe +def build_predictions_df( + dlc_scorer: str, + individuals: List[str], + bodyparts: List[str], + df_index: pd.Index, + predictions: np.ndarray, +) -> pd.DataFrame: + """Builds a predictions dataframe in the DLC format + + Builds a DataFrame in the DeepLabCut format, with MultiIndex columns. If there is + only one individual, the column levels are ("scorer", "bodyparts", "coords"). If + there are multiple individuals, the column levels are ("scorer", "individuals", + "bodyparts", "coords"). + + Args: + dlc_scorer: the DLC scorer that generated the predictions + individuals: the names of individuals in the project + bodyparts: the names of bodyparts in the project + df_index: the index to apply to the dataframe + predictions: the predictions made by the scorer. should be of shape + (len(df_index), len(bodyparts), 3) if len(individuals) == 1, and otherwise + (len(df_index), len(individuals), len(bodyparts), 3) + + Returns: + the dataframe containing the predictions in DLC format + """ + num_individuals = len(individuals) + if num_individuals == 1: + # Single animal prediction dataframe index = pd.MultiIndex.from_product( [ - [names["dlc_scorer"]], - cfg["bodyparts"], + [dlc_scorer], + bodyparts, ["x", "y", "likelihood"], ], names=["scorer", "bodyparts", "coords"], @@ -109,19 +131,15 @@ def save_predictions(names, cfg, data_index, predicted_poses, results_filename): # Multi animal prediction dataframe index = pd.MultiIndex.from_product( [ - [names["dlc_scorer"]], - cfg["individuals"], - cfg["multianimalbodyparts"], + [dlc_scorer], + individuals, + bodyparts, ["x", "y", "likelihood"], ], names=["scorer", "individuals", "bodyparts", "coords"], ) - predicted_data = pd.DataFrame(predicted_poses, columns=index, index=data_index) - - predicted_data.to_hdf(results_path, "df_with_missing") - - return predicted_data + return pd.DataFrame(predictions, columns=index, index=df_index) def get_paths( diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index f5fbb54bdd..88a5e4085b 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -610,7 +610,11 @@ def get_scorer_name( # [fn.split(".")[0] for fn in os.listdir(modelfolder) if "index" in fn] # ) Snapshots = np.array( - [fn.split(".")[0] for fn in os.listdir(modelfolder) if "snapshot" in fn] + [ + fn.split(".")[0] + for fn in os.listdir(modelfolder) + if ("index" in fn) or ("snapshot" in fn and not "detector" in fn) + ] ) increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) Snapshots = Snapshots[increasing_indices] diff --git a/deeplabcut/utils/visualization.py b/deeplabcut/utils/visualization.py index 70097d5361..c7b36dbade 100644 --- a/deeplabcut/utils/visualization.py +++ b/deeplabcut/utils/visualization.py @@ -463,3 +463,4 @@ def plot_evaluation_results( belongs_to_train=in_train_set, ) erase_artists(ax) + plt.close() diff --git a/requirements.txt b/requirements.txt index ab952b17c1..f48647dcf2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,28 +2,31 @@ albumentations einops timm -# old: +wandb + +# existing: dlclibrary ipython filterpy ruamel.yaml>=0.15.0 -intel-openmp +# intel-openmp imageio-ffmpeg -imgaug==0.4.0 +imgaug>=0.4.0 numba>=0.54.0 -matplotlib<=3.5.2 +matplotlib>=3.3 networkx>=2.6 numpy>=1.18.5 pandas>=1.0.1,!=1.5.0 +Pillow>=7.1 pyyaml scikit-image>=0.17 scikit-learn>=1.0 scipy>=1.9 statsmodels>=0.11 -tensorflow>=2.0,<2.13.0 -tables==3.7.0 -tensorpack==0.11 -tf_slim==1.1.0 -torch==1.12 +tensorflow>=2.0,<=2.10 +tables>=3.7.0 +tensorpack>=0.11 +tf_slim>=1.1.0 +torch>=2.0.0 +torchvision tqdm -Pillow>=7.1 diff --git a/setup.py b/setup.py index 16d58a08af..c293d31b83 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -DeepLabCut2.0-2.2 Toolbox (deeplabcut.org) +DeepLabCut2.0-2.3 Toolbox (deeplabcut.org) © A. & M. Mathis Labs https://github.com/DeepLabCut/DeepLabCut Please see AUTHORS for contributors. @@ -25,7 +25,9 @@ long_description_content_type="text/markdown", url="https://github.com/DeepLabCut/DeepLabCut", install_requires=[ + "albumentations", "dlclibrary", + "einops", "filterpy>=1.4.4", "ruamel.yaml>=0.15.0", "imgaug>=0.4.0", @@ -37,10 +39,12 @@ "pandas>=1.0.1,!=1.5.0", "scikit-image>=0.17", "scikit-learn>=1.0", - "scipy>=1.9", + "scipy>=1.4,<1.11.0", "statsmodels>=0.11", "tables>=3.7.0", - "torch<=1.12", + "timm", + "torch>=2.0.0", + "torchvision", "tensorpack>=0.11", "tf_slim>=1.1.0", "tqdm", @@ -60,6 +64,7 @@ ], # Last supported TF version on Windows Native is 2.10 "apple_mchips": ["tensorflow-macos<2.13.0", "tensorflow-metal"], "modelzoo": ["huggingface_hub"], + "wandb": ["wandb"], }, scripts=["deeplabcut/pose_estimation_tensorflow/models/pretrained/download.sh"], packages=setuptools.find_packages(), From f43a5326d1e3d437c963ac485dc27968f0b57b3f Mon Sep 17 00:00:00 2001 From: QuentinJGMace Date: Thu, 20 Jul 2023 15:44:07 +0200 Subject: [PATCH 033/293] top down optimization --- .../apis/analyze_videos.py | 292 ++++++++++++++++-- .../pose_estimation_pytorch/apis/inference.py | 83 ++++- .../models/predictors/top_down_prediction.py | 1 + deeplabcut/utils/auxiliaryfunctions.py | 5 +- 4 files changed, 335 insertions(+), 46 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 482f4d285d..03f94156ea 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -17,6 +17,7 @@ import albumentations as A import cv2 import numpy as np +from skimage.transform import resize import pandas as pd import torch from tqdm import tqdm @@ -43,7 +44,7 @@ ) from deeplabcut.pose_estimation_pytorch.apis.inference import ( get_predictions_bottom_up, - get_predictions_top_down, + get_detections_batch, ) @@ -57,11 +58,10 @@ def video_inference( colormode: Optional[str] = "RGB", method: Optional[str] = "bu", detector: Optional[BaseDetector] = None, - top_down_predictor: Optional[BasePredictor] = None, max_num_animals: Optional[int] = 1, num_keypoints: Optional[int] = 1, frames_resized: Optional[bool] = False, -) -> List[np.ndarray]: +) -> np.ndarray: """ TODO: This should be refactored to use the `inference` code with a `video dataset` @@ -87,15 +87,65 @@ def video_inference( for each frame in the video, a numpy array containing the output of the predictor for the frame """ + + if method.lower() == "bu": + return bottom_up_video_inference( + model, + predictor, + video_path, + batch_size, + device, + transform, + colormode, + frames_resized, + ) + elif method.lower() == "td": + return top_down_video_inference( + detector, + model, + predictor, + video_path, + batch_size, + device, + transform, + colormode, + max_num_animals, + num_keypoints, + frames_resized, + ) + + +def bottom_up_video_inference( + model: PoseModel, + predictor: BasePredictor, + video_path: Path, + batch_size: int = 1, + device: Optional[str] = None, + transform: Optional[A.Compose] = None, + colormode: Optional[str] = "RGB", + frames_resized: Optional[bool] = False, +) -> np.ndarray: + """Does batched inference for top down over a video + + Args: + model: pose_estimator + predictor: predictor to regress the coordinates inside the cropped image from the pose_estimator's outputs + video_path: path to the video/folder of videos + batch_size: batch_size. Defaults to 1. + device: device on which to run inference. Defaults to None. + transform: transform for inference (normalizing, padding...). Defaults to None. + colormode: "BGR" or "RGB. Defaults to "RGB". + frames_resized: Whether the frames were resized or not. Defaults to False. + + Returns: + List of pose predictions, each element has shape (max_num_animals, num_keypoints, 3) + """ if device is None: device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") # Set the model to eval mode and put it on the device model.eval() model.to(device) - if detector is not None: - detector.eval() - detector.to(device) print(f"Loading {video_path}") video_reader = VideoReader(str(video_path)) @@ -136,28 +186,11 @@ def video_inference( batch = torch.tensor( batch_frames, device=device, dtype=torch.float ).permute(0, 3, 1, 2) - if method.lower() == "td": - batched_predictions = get_predictions_top_down( - detector=detector, - model=model, - predictor=predictor, - images=batch, - max_num_animals=max_num_animals, - num_keypoints=num_keypoints, - device=device, - ) - elif method.lower() == "bu": - batched_predictions = get_predictions_bottom_up( - model=model, - predictor=predictor, - images=batch, - ) - else: - raise ValueError( - "Method must be either 'bu' (Bottom Up) or 'td' (Top Down)." - ) - - # TODO: use resize_batch_predictions from `inference.py` + batched_predictions = get_predictions_bottom_up( + model=model, + predictor=predictor, + images=batch, + ) for frame_pred in batched_predictions: if frames_resized: resizing_factor = (original_size[0] / transformed_size[0]), ( @@ -178,7 +211,206 @@ def video_inference( batch_ind = batch_ind % batch_size pbar.update(1) - return predictions + pbar.close() + + return np.array(predictions) + + +def top_down_video_inference( + detector: BaseDetector, + model: PoseModel, + predictor: BasePredictor, + video_path: Path, + batch_size: int = 1, + device: Optional[str] = None, + transform: Optional[A.Compose] = None, + colormode: Optional[str] = "RGB", + max_num_animals: Optional[int] = 1, + num_keypoints: Optional[int] = 1, + frames_resized: Optional[bool] = False, +) -> np.ndarray: + """Does batched inference for top down over a video + + Args: + detector: detector used to detect animals. + model: pose_estimator + predictor: predictor to regress the coordinates inside the cropped image from the pose_estimator's outputs + video_path: path to the video/folder of videos + batch_size: batch_size. Defaults to 1. + device: device on which to run inference. Defaults to None. + transform: transform for inference (normalizing, padding...). Defaults to None. + colormode: "BGR" or "RGB. Defaults to "RGB". + max_num_animals: Maximum number of animals. Defaults to 1. + num_keypoints: Number of keypoints. Defaults to 1. + frames_resized: Whether the frames were resized or not. Defaults to False. + + Returns: + tensor of pose predictions, shape (num_frames, max_num_animals, num_keypoints, 3) + """ + if device is None: + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + + # Set the models to eval mode and put it on the device + model.eval() + model.to(device) + detector.eval() + detector.to(device) + top_down_predictor = PREDICTORS.build( + {"type": "TopDownPredictor", "format_bbox": "xyxy"} + ) + print(f"Loading {video_path}") + video_reader = VideoReader(str(video_path)) + n_frames = video_reader.get_n_frames() + vid_w, vid_h = video_reader.dimensions + print( + f"Video metadata: \n" + f" n_frames: {n_frames}\n" + f" fps: {video_reader.fps}\n" + f" resolution: w={vid_w}, h={vid_h}\n" + ) + + pbar = tqdm(total=n_frames, file=sys.stdout) + predictions = [] + frame = video_reader.read_frame() + original_size = frame.shape + transformed_size = original_size + if transform: + # Apply transformation once only to see the shape after transformation + transformed_size = transform(image=frame, keypoints=[])["image"].shape + + # Animal detections + batch_ind_detect = 0 # Index of the current img in batch + batch_detect_frames = np.empty( + (batch_size, transformed_size[0], transformed_size[1], 3) + ) + + detections_list = [] + detections = torch.zeros((n_frames, max_num_animals, 4)) + with torch.no_grad(): + while frame is not None: + if frame.dtype != np.uint8: + frame = img_as_ubyte(frame) + + if colormode == "BGR": + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + + if transform: + frame = transform(image=frame, keypoints=[])["image"] + + batch_detect_frames[batch_ind_detect] = frame + if batch_ind_detect == batch_size - 1: + batch = torch.tensor( + batch_detect_frames, device=device, dtype=torch.float + ).permute(0, 3, 1, 2) + batched_detections = get_detections_batch( + detector, batch, max_num_animals + ) + for detect in batched_detections: + detections_list.append(detect) + + frame = video_reader.read_frame() + batch_ind_detect += 1 + batch_ind_detect = batch_ind_detect % batch_size + pbar.update(1) + if batch_ind_detect != 0: + batch = torch.tensor( + batch_detect_frames, device=device, dtype=torch.float + ).permute(0, 3, 1, 2) + batched_detections = get_detections_batch(detector, batch, max_num_animals) + for detect in batched_detections[:batch_ind_detect]: + detections_list.append(detect) + + pbar.close() + + print("Detections are done, moving to estimating poses...") + + detections = torch.stack(detections_list) + + # Pose estimation + batch_ind_pose = 0 # Index of the current pose img in batch + batch_pose_frames = torch.empty( + (batch_size, 3, 256, 256), device=device + ) # TODO 256 hardcoded + + # This array stores (image_idx, animal_idx, bbox_coords) + # To be able to go back to it from bacthed cropepd images + batch_image_infos = torch.zeros((batch_size, 6)) + cropped_predictions = torch.full( + (n_frames, max_num_animals, num_keypoints, 3), -1.0 + ) + + video_reader.reset() + frame = video_reader.read_frame() + frame_index = 0 + pbar = tqdm(total=n_frames, file=sys.stdout) + with torch.no_grad(): + while frame is not None: + if frame.dtype != np.uint8: + frame = img_as_ubyte(frame) + + if colormode == "BGR": + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + + if transform is not None: + frame = transform(image=frame, keypoints=[])["image"] + + for animal_idx, box in enumerate(detections[frame_index]): + if (box == 0.0).all(): + continue + cropped_image = frame[box[1] : box[3] + 1, box[0] : box[2] + 1, :] + cropped_image = resize( + cropped_image, (256, 256) + ) # TODO: hardcoded for now + cropped_image = torch.tensor(cropped_image.transpose(2, 0, 1)).to( + device + ) + batch_pose_frames[batch_ind_pose] = cropped_image + batch_image_infos[batch_ind_pose, 0] = frame_index + batch_image_infos[batch_ind_pose, 1] = animal_idx + batch_image_infos[batch_ind_pose, 2:] = box + + if batch_ind_pose == batch_size - 1: + model_outputs = model(batch_pose_frames) + # TODO hardcoded for now + scale_factors = ( + 256 / model_outputs[0].shape[2], + 256 / model_outputs[0].shape[3], + ) + pose_predictions = predictor(model_outputs, scale_factors) + for idx, prediction in enumerate(pose_predictions): + cropped_predictions[ + batch_image_infos[idx, 0].detach().int(), + batch_image_infos[idx, 1].detach().int(), + ] = prediction[0] + + batch_ind_pose += 1 + batch_ind_pose = batch_ind_pose % batch_size + + frame = video_reader.read_frame() + frame_index += 1 + pbar.update(1) + + # Left cropped images + if batch_ind_pose != 0: + model_outputs = model(batch_pose_frames) + # TODO hardcoded for now + scale_factors = ( + 256 / model_outputs[0].shape[2], + 256 / model_outputs[0].shape[3], + ) + pose_predictions = predictor(model_outputs, scale_factors) + for idx, prediction in enumerate(pose_predictions): + if batch_image_infos[idx, 0] != -1.0: + cropped_predictions[ + batch_image_infos[idx, 0].detach().int(), + batch_image_infos[idx, 1].detach().int(), + ] = prediction[0] + pbar.close() + predictions = top_down_predictor(detections, cropped_predictions) + + print("Keypoints coordinate prediction done !") + + return predictions.detach().cpu().numpy() def analyze_videos( @@ -367,7 +599,7 @@ def analyze_videos( names=["scorer", "bodyparts", "coords"], ) df = pd.DataFrame( - np.array(predictions).reshape((len(predictions), -1)), + predictions.reshape((len(predictions), -1)), columns=results_df_index, index=range(len(predictions)), ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 3e7c276a91..339fdd35d3 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -1,8 +1,8 @@ from typing import Union, List, Dict, Tuple, Optional import torch +from torchvision.transforms import Resize as TorchResize import numpy as np -from skimage.transform import resize from deeplabcut.pose_estimation_pytorch.models import PoseModel, PREDICTORS from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor @@ -46,7 +46,7 @@ def get_predictions_top_down( images: torch.Tensor, max_num_animals: int, num_keypoints: int, - device: Union[torch.device, str], + resize_object: TorchResize, ) -> np.array: """ TODO probably quite bad design, most arguments could be stored somewhere else @@ -86,16 +86,8 @@ def get_predictions_top_down( for j, box in enumerate(boxes[b]): if (box == 0.0).all(): continue - cropped_image = ( - images[b][:, box[1] : box[3], box[0] : box[2]] - .permute(1, 2, 0) - .cpu() - .numpy() - ) # needs to be (h,w,c) for resizing - cropped_image = resize(cropped_image, (256, 256)) # TODO: hardcoded for now - cropped_image = ( - torch.tensor(cropped_image.transpose(2, 0, 1)).unsqueeze(0).to(device) - ) + cropped_image = images[b][:, box[1] : box[3] + 1, box[0] : box[2] + 1] + cropped_image = resize_object(cropped_image).unsqueeze(0) heatmaps = model(cropped_image) scale_factors_cropped = ( @@ -110,6 +102,69 @@ def get_predictions_top_down( return final_predictions.cpu().numpy() +def get_detections_batch( + detector: BaseDetector, + images: torch.Tensor, + max_num_animals: int, +) -> torch.Tensor: + """Given a batch of images, outputs the predicted bboxes. + + Args: + detector: detector model + images: batch of images, shape (batch_size, 3, height, width) + max_num_animals: maximum number of accepted detections + + Returns: + The coordinates of the bounding boxes shape (batch_size, max_num_animals, 4) + """ + batch_size = images.shape[0] + + output_detector = detector(images) + + boxes = torch.zeros((batch_size, max_num_animals, 4)) + for b, item in enumerate(output_detector): + boxes[b][: min(max_num_animals, len(item["boxes"]))] = item["boxes"][ + :max_num_animals + ] # Boxes should be sorted by scores, only keep the maximum number allowed + boxes = boxes.int() + + return boxes + + +def get_pose_batch( + pose_model: PoseModel, + predictor: BasePredictor, + cropped_images: torch.Tensor, +) -> torch.Tensor: + """Given a batch of cropped images, outputs a batch of predicted pose coordinates. + Coordinates are still in cropped image space and needs to be handled accordingly to + be back in input space. + + Should only be used for top down with a predictor for single animal + + Args: + pose_model: pose_estimation model + predictor: regresses the coordinates of the keypoints inside the cropped images + Must be a single animal predictor + cropped_images: Batch of cropped images for the top down pose_estimation + + Returns: + Tensor of the estimated poses (inside the cropped image), shape (batch_size, num_joints, 3) + """ + outputs = pose_model(cropped_images) + + scale_factors_cropped = ( + cropped_images.shape[2] / outputs[0].shape[2], + cropped_images.shape[3] / outputs[0].shape[3], + ) + + # Predictor always returns num_animals as 2nd dimension even for single animal ones + # Hence the slicing + poses = predictor(outputs, scale_factors_cropped)[:, 0] + + return poses + + def match_predicted_individuals_to_annotations( predictions: np.ndarray, ground_truth: List[np.ndarray], @@ -225,6 +280,7 @@ def inference( predictor.to(device) top_down_predictor = None + resize_object = None if method == "td": top_down_predictor = PREDICTORS.build( {"type": "TopDownPredictor", "format_bbox": "xyxy"} @@ -232,6 +288,8 @@ def inference( top_down_predictor.eval() top_down_predictor.to(device) + resize_object = TorchResize((256, 256)) # TODO hardcoded 256 + predicted_poses = [] with torch.no_grad(): for item in dataloader: @@ -247,6 +305,7 @@ def inference( max_num_animals=max_individuals, num_keypoints=num_keypoints, device=device, + resize_object=resize_object, ) else: predictions = get_predictions_bottom_up( diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py index fb605b8630..ee560bc42e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py @@ -59,6 +59,7 @@ def forward( x_corners = (bboxes[:, :, 0]).unsqueeze(2).expand(-1, -1, num_joints) y_corners = (bboxes[:, :, 1]).unsqueeze(2).expand(-1, -1, num_joints) + # TODO harcoded 256 scales_x = (bboxes[:, :, 2] / 256).unsqueeze(2).expand(-1, -1, num_joints) scales_y = (bboxes[:, :, 3] / 256).unsqueeze(2).expand(-1, -1, num_joints) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 88a5e4085b..0923cbf2dd 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -606,9 +606,6 @@ def get_scorer_name( str(get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)), "train", ) - # Snapshots = np.array( - # [fn.split(".")[0] for fn in os.listdir(modelfolder) if "index" in fn] - # ) Snapshots = np.array( [ fn.split(".")[0] @@ -878,4 +875,4 @@ def find_next_unlabeled_folder(config_path, verbose=False): CheckifNotAnalyzed = check_if_not_analyzed CheckifNotEvaluated = check_if_not_evaluated GetEvaluationFolder = get_evaluation_folder -GetModelFolder = get_model_folder \ No newline at end of file +GetModelFolder = get_model_folder From 8ed7d0e19f31872e4b8eace77e0aab6e0b06987d Mon Sep 17 00:00:00 2001 From: QuentinJGMace <95310069+QuentinJGMace@users.noreply.github.com> Date: Fri, 28 Jul 2023 17:28:27 +0200 Subject: [PATCH 034/293] Dekr predictor improved * weight_norm * merge everything * minor change * tests * teleports prediction on heatmaps * cleaning --- .../pose_estimation_pytorch/data/dataset.py | 2 +- .../models/predictors/dekr_predictor.py | 40 +++++++++++++++++++ .../models/target_generators/dekr_targets.py | 10 +++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 22f60e3cf6..727b85f9e9 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -180,7 +180,7 @@ def __getitem__(self, index: int) -> dict: # Sometimes bbox coords are larger than the image because of the margin bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) - bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) + bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - np.spacing(0.) bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) else: bboxes = np.zeros((0, 4)) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index b38519e3d4..3a24531854 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -37,6 +37,7 @@ def __init__( self.detection_threshold = detection_threshold self.apply_sigmoid = apply_sigmoid self.use_heatmap = use_heatmap + self.max_absorb_distance = 75 def forward(self, outputs, scale_factors: Tuple[float, float]): # TODO implement confidence scores for each keypoints @@ -59,6 +60,8 @@ def forward(self, outputs, scale_factors: Tuple[float, float]): pose = posemap[i, pose_ind[i]] poses[i] = pose + poses = self._update_pose_with_heatmaps(poses, heatmaps[:, :-1]) + ctr_score = scores[:, :, None].expand(batch_size, -1, num_joints)[:, :, :, None] poses[:, :, :, 0] = ( @@ -146,6 +149,43 @@ def get_top_values(self, heatmap: torch.Tensor): return pos_ind, scores ########## WIP to take heatmap into account for scoring ########## + def _update_pose_with_heatmaps( + self, _poses: torch.Tensor, kpt_heatmaps: torch.Tensor + ): + """If a heatmap center is close enough from the regressed point, the final prediction is the center of this heatmap + + Args: + poses: poses tensor, shape (batch_size, num_animals, num_keypoints, 2) + kpt_heatmaps: heatmaps (does not contain the center heatmap), shape (batch_size, num_keypoints, h, w) + """ + poses = _poses.clone() + maxm = self.max_pool(kpt_heatmaps) + maxm = torch.eq(maxm, kpt_heatmaps).float() + kpt_heatmaps *= maxm + batch_size, num_keypoints, h, w = kpt_heatmaps.shape + kpt_heatmaps = kpt_heatmaps.view(batch_size, num_keypoints, -1) + val_k, ind = kpt_heatmaps.topk(self.num_animals, dim=2) + + x = ind % w + y = (ind / w).long() + heats_ind = torch.stack((x, y), dim=3) + + for b in range(batch_size): + for i in range(num_keypoints): + heat_ind = heats_ind[b, i].float() + pose_ind = poses[b, :, i] + pose_heat_diff = pose_ind[:, None, :] - heat_ind + pose_heat_diff.pow_(2) + pose_heat_diff = pose_heat_diff.sum(2) + pose_heat_diff.sqrt_() + keep_ind = torch.argmin(pose_heat_diff, dim=1) + + for p in range(keep_ind.shape[0]): + if pose_heat_diff[p, keep_ind[p]] < self.max_absorb_distance: + poses[b, p, i] = heat_ind[keep_ind[p]] + + return poses + def get_heat_value(self, pose_coords, heatmaps): """ pose_coords : (batch_size, num_people, num_joints, 2) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index a01cc666b9..c85ab8c431 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -173,12 +173,14 @@ def forward( offset_map[b, idx * 2, pos_y, pos_x] = offset_x offset_map[b, idx * 2 + 1, pos_y, pos_x] = offset_y # TODO find a decent constant make weights vary giving animal area - weight_map[ - b, idx * 2, pos_y, pos_x - ] = 1.0 # /((scale**2)*np.sqrt(area[person_id])) + weight_map[b, idx * 2, pos_y, pos_x] = 1.0 / np.sqrt( + area[b, person_id] + ) weight_map[ b, idx * 2 + 1, pos_y, pos_x - ] = 1.0 # /((scale**2)*np.sqrt(area[person_id])) + ] = 1.0 / np.sqrt( + area[b, person_id] + ) area_map[b, pos_y, pos_x] = area[b, person_id] hms_list[1][hms_list[1] == 2] = self.bg_weight From 49433c8b0a658372e7e50f73ef5a6423efb5c3e6 Mon Sep 17 00:00:00 2001 From: AlexEMG Date: Wed, 2 Aug 2023 16:52:09 +0200 Subject: [PATCH 035/293] expand readme and tests for model loading --- deeplabcut/pose_estimation_pytorch/README.md | 55 ++++++++++++------- .../tests/test_pose_model.py | 28 +++++++++- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/README.md b/deeplabcut/pose_estimation_pytorch/README.md index 3e613312a5..cd84bc0bf4 100644 --- a/deeplabcut/pose_estimation_pytorch/README.md +++ b/deeplabcut/pose_estimation_pytorch/README.md @@ -1,28 +1,50 @@ -# Pytorch DLC API +# PyTorch DeepLabCut API + +##### Structure of the repo: + +Like any ML model this repo contains: models (architectures), solvers (losses and optimizers), and data (data loaders). -##### The structure of the repo: -[Data](#data) [Models](#models) [Solvers](#solvers) -[Apis](#apis) +[Data](#data) +[APIs](#apis) + +## Models + +- [models](models): +The `deeplabcut.pose_estimations_pytorch.models` package contains all components related to building a model with `backbone`, `neck` (optional) and `head`. + +We provide sota models such as HRNet, BUCTD, TransPose, ... + +If you want to add a novel model, you have to divide it into backbone, neck and head. Often neck will be just the identity function. + +For instance, a [standard pose estimation HRNet](https://github.com/HRNet/HRNet-Human-Pose-Estimation) consists of HRNet backbone, an identity neck and a deconvolution head (Simple Head). + + + + +## Solvers + +- [solvers](solvers): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. ## Data + - [data](data/project.py#L7): The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. - `Project` class provides train and test splitting and converts dataset to required format. For instance, to [COCO]() format. Example: - + ```python3 import deeplabcut.pose_estimation_pytorch as dlc - + project = dlc.Project(proj_root=config['project_root']) project.train_test_split() ``` - `PoseDataset` class is an instance of [torch.utils.Dataset](https://pytorch.org/docs/stable/data.html), which converts raw images and keypoints to a tensor dataset for training and evaluation. Example: - + ```python3 transform = None train_dataset = dlc.PoseDataset(project, @@ -32,32 +54,27 @@ The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pyt transform=transform, mode='test') ``` - + > **Note** > `transform` is a `List` of transformations to be applied to images and keypoints sequentially, `None` by default. - + Example: - + ```python3 import albumentations as A - + transform = A.Compose([ A.Resize(width=256, height=256), ], keypoint_params=A.KeypointParams(format='xy')) - + ``` - + > **Warning** > By now supports only [albumentations](https://albumentations.ai), will be extended in the future. -## Models -- [models](models): -The `deeplabcut.pose_estimations_pytorch.models` package contains all related to building a model with `backbone`, `neck` (optional) and `head`. -## Solvers -- [solvers](solvers): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. ## Apis + - [apis](apis): The `deeplabcut.pose_estimations_pytorch.apis` contains functionalities for training and testing as well as the corresponding configuration file [config.yaml](apis/config.yaml). ## Registry - diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py index c61d58865f..4e6c35972f 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py @@ -3,11 +3,37 @@ def test_backbone(): + #TODO: load various backbones and check dimension of feature map pass def test_head(): + #TODO: simple test for head (is upsampling correct?) pass def test_pose_model(): - pass \ No newline at end of file + #TODO: Make specific model builds and test! + pass + +## Below we build specific models and check integrity +def test_timm_hrnet(): + #TODO: build timm_hrnet and check dimension of output + pass + +def test_msa_hrnet(): + # TODO: build microsoft asia hrnet and check dimension of output + # TODO: check if hyperparameters are loaded correctly (from the config file) + pass + +def test_msa_tokenpose(): + # TODO: build microsoft asia hrnet and check dimension of output + # TODO: check if hyperparameters are loaded correctly (from the config file) + #cf https://github.com/amathislab/BUCTDdev/blob/main/lib/models/transpose_h.py#L1 + pass + +def test_msa_hrnetCOAM(): + # TODO: build BUCTD COAM hrnet and check dimension of output + # TODO: check if hyperparameters are loaded correctly (from the config file) + pass + +#TODO: add other model variants our pipeline can build ;) From 35a93a1e866486292c208b3aac0510ceda30f591 Mon Sep 17 00:00:00 2001 From: AlexEMG Date: Wed, 2 Aug 2023 17:51:02 +0200 Subject: [PATCH 036/293] adding benchmark --- deeplabcut/pose_estimation_pytorch/benchmark/__init__.py | 0 .../benchmark/profile_HRNetCoAM.py | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 deeplabcut/pose_estimation_pytorch/benchmark/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py diff --git a/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py b/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py b/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py new file mode 100644 index 0000000000..edf53db361 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py @@ -0,0 +1,9 @@ +# Script for reproducing results in Zhou* & Stoffl* et al. for BUCTD with CoAM + +#path=datapath +#results=resultspath or put numbers + +#train model + +# evaluate and +# check if predicted is close to result From 98d898f2f1217835c41f70fd04ead3c1fe4ef9d5 Mon Sep 17 00:00:00 2001 From: QuentinJGMace <95310069+QuentinJGMace@users.noreply.github.com> Date: Fri, 4 Aug 2023 19:52:56 +0200 Subject: [PATCH 037/293] Tests * comprehensive test dataset * add tests build transforms * adding benchmark * expand readme and tests for model loading * add models tests --------- Co-authored-by: AlexEMG --- .../tests/test_api_utils.py | 137 +++++++++++++ .../tests/test_dataset.py | 153 +++++++++++--- .../tests/test_pose_model.py | 191 ++++++++++++++++-- 3 files changed, 437 insertions(+), 44 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py b/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py new file mode 100644 index 0000000000..7461b50827 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py @@ -0,0 +1,137 @@ +import os +import pytest +import numpy as np +import random + +import deeplabcut.pose_estimation_pytorch as dlc +import deeplabcut.pose_estimation_pytorch.apis.utils as dlc_api_utils +import deeplabcut.utils.auxiliaryfunctions as dlc_auxfun + +transform_dicts = [ + { + "auto_padding": { + "pad_height_divisor": 64, + "pad_width_divisor": 27, + } + }, + { + "resize": [512, 256], + }, + { + "covering": True, + "gaussian_noise": 12.75, + "hist_eq": True, + "motion_blur": True, + "normalize_images": True, + "rotation": 30, + "scale_jitter": [0.5, 1.25], + "translation": 40, + "auto_padding": { + "pad_width_divisor": 64, + "pad_height_divisor": 27, + }, + }, + { + "covering": True, + "gaussian_noise": 100, + "hist_eq": True, + "motion_blur": True, + "normalize_images": True, + "rotation": 180, + "scale_jitter": [0.03, 20], + "translation": 300, + "auto_padding": { + "pad_width_divisor": 64, + "pad_height_divisor": 27, + }, + }, +] + + +def _get_random_params(transform_idx): + return ( + transform_dicts[transform_idx], + ( + random.randint(100, 1000), + random.randint(100, 1000), + ), + random.randint(1, 100), + random.randint(1, 100), + ) + + +@pytest.mark.parametrize( + "transform_dict, size_image, num_keypoints, num_animals", + [_get_random_params(i) for i in range(4)], +) +def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals): + transform_bbox_aug = dlc_api_utils.build_transforms( + transform_dict, augment_bbox=True + ) + transform_no_bbox_aug = dlc_api_utils.build_transforms( + transform_dict, augment_bbox=False + ) + transform_inference = dlc_api_utils.build_inference_transform( + transform_dict, + augment_bbox=False, + ) + + w, h = size_image + for i in range(10): + test_image = np.random.randint(0, 255, (h, w, 3), dtype=np.uint8) + bboxes = np.random.randint(0, min(w - 1, h - 1), (num_animals, 4)) + bboxes[:, 2] = w - bboxes[:, 0] + bboxes[:, 3] = h - bboxes[:, 1] + keypoints = np.random.randint(0, min(w, h), (num_keypoints, 2)) + + with pytest.raises(Exception): + transformed = transform_no_bbox_aug( + image=test_image, keypoints=keypoints.copy(), bboxes=bboxes.copy() + ) + + with pytest.raises(Exception): + transformed = transform_inference(image=test_image) + transformed = transform_inference(image=test_image, bboxes=bboxes.copy()) + transformed = transform_inference( + image=test_image, keypoints=keypoints.copy(), bboxes=bboxes.copy() + ) + + transformed_with_bbox = transform_bbox_aug( + image=test_image, + keypoints=keypoints.copy(), + bboxes=bboxes.copy(), + bbox_labels=np.arange(num_animals), + ) + transformed_without_bbox = transform_no_bbox_aug( + image=test_image, keypoints=keypoints.copy() + ) + transformed_inference = transform_inference(image=test_image, keypoints=[]) + + if "resize" in transform_dict.keys(): + assert transformed_inference["image"].shape[:2] == ( + transform_dict["resize"][0], + transform_dict["resize"][1], + ) + assert transformed_with_bbox["image"].shape[:2] == ( + transform_dict["resize"][0], + transform_dict["resize"][1], + ) + assert transformed_without_bbox["image"].shape[:2] == ( + transform_dict["resize"][0], + transform_dict["resize"][1], + ) + + if "auto_padding" in transform_dict.keys(): + modh, modw = ( + transform_dict["auto_padding"]["pad_height_divisor"], + transform_dict["auto_padding"]["pad_width_divisor"], + ) + assert transformed_inference["image"].shape[0] % modh == 0 + assert transformed_with_bbox["image"].shape[0] % modh == 0 + assert transformed_without_bbox["image"].shape[0] % modh == 0 + assert transformed_inference["image"].shape[1] % modw == 0 + assert transformed_with_bbox["image"].shape[1] % modw == 0 + assert transformed_without_bbox["image"].shape[1] % modw == 0 + + assert len(transformed_with_bbox["keypoints"]) == len(keypoints) + assert len(transformed_without_bbox["keypoints"]) == len(keypoints) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py b/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py index 582c7b4629..b057e68032 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py @@ -1,45 +1,140 @@ import albumentations as A import numpy as np +import os +from torch.utils.data import DataLoader import pytest +import random import deeplabcut.pose_estimation_pytorch as dlc +import deeplabcut.utils.auxiliaryfunctions as dlc_auxfun -def _get_dataset(path, transform): - dlc_project = dlc.Project(path) - dlc_project.train_test_split() - dataset = dlc.PoseDataset(dlc_project, - transform=transform) + +def _get_dataset(path, transform, mode="train"): + dlc_project = dlc.DLCProject(path, shuffle=1) + dataset = dlc.PoseDataset(dlc_project, transform=transform, mode=mode) return dataset -@pytest.mark.parametrize('path', ['/mnt/md0/shaokai/DLC-ModelZoo/data/all_topview/openfield-Pranav-2018-08-20']) -def test_check_train_test(path): - dlc_project = dlc.Project(path) - dlc_project.train_test_split() - assert getattr(dlc_project, 'df_train', None) is not None - assert getattr(dlc_project, 'df_test', None) is not None +def _get_openfield_dataset(transform=None): + dlc_path = dlc_auxfun.get_deeplabcut_path() + repo_path = os.path.dirname(dlc_path) + openfield_path = os.path.join(repo_path, "examples", "openfield-Pranav-2018-10-30") + + return _get_dataset(openfield_path, transform=transform) + + +@pytest.mark.parametrize("batch_size", [1, 2, random.randint(2, 20)]) +def test_iter_all_dataset_no_transform(batch_size): + if batch_size > 1: # if batched, all images need to be the same size + transform = A.Compose( + [A.Resize(512, 512)], + keypoint_params=A.KeypointParams(format="xy"), + bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), + ) + else: + transform = None + dataset = _get_openfield_dataset(transform=transform) + dataloader = DataLoader(dataset, batch_size=batch_size) + key_set = set(["image", "original_size", "annotations"]) + anno_key_set = set( + ["keypoints", "area", "ids", "boxes", "image_id", "is_crowd", "labels"] + ) + + max_num_animals = dataset.max_num_animals + num_keypoints = dataset.num_joints + for i, item in enumerate(dataloader): + is_last_batch = i == (len(dataloader) - 1) + assert ( + set(item.keys()) == key_set + ), "the key returned don't match the required ones" + + anno = item["annotations"] + assert ( + set(anno.keys()) == anno_key_set + ), "the annotation keys returned don't match the required ones" + + assert (len(item["image"].shape) == 4) and ( + (item["image"].shape[:2] == (batch_size, 3)) or is_last_batch + ), "image shape is not (batch_size, 3, h, w)" + + b, _, h, w = item["image"].shape + kpts, bboxes = anno["keypoints"], anno["boxes"] + assert ( + kpts.shape == (batch_size, max_num_animals, num_keypoints, 2) + or is_last_batch + ), "keypoints have the wrong shape" + assert ( + bboxes.shape == (batch_size, max_num_animals, 4) or is_last_batch + ), "boxes have the wrong shape" + assert ((bboxes[:, :, 0] + bboxes[:, :, 2]) <= w).all() and ( + (bboxes[:, :, 1] + bboxes[:, :, 3]) <= h + ).all(), "boxes don't seem to be un the format (x, y, w, h)" + + +def _generate_random_test_values_aug(min_exa): + batch_size = random.randint(1, 20) + x_size = random.randint(50, 600) + y_size = random.randint(50, 600) + exageration = random.randint(min_exa, 99) + return (batch_size, x_size, y_size, exageration) -@pytest.mark.parametrize('path', ['/mnt/md0/shaokai/DLC-ModelZoo/data/all_topview/openfield-Pranav-2018-08-20']) -def test_resize_transform(path): - transform = A.Compose([ - A.Resize(width=256, height=256), ], - keypoint_params=A.KeypointParams(format='xy')) - dlc_project = dlc.Project(path) - dlc_project.train_test_split() - dataset = dlc.PoseDataset(dlc_project, transform=None) - dataset_resized = dlc.PoseDataset(dlc_project, transform=transform) - image_tensor_resized, keypoints_resized = dataset_resized[0] - image_tensor, keypoints = dataset[0] +@pytest.mark.parametrize( + "batch_size, x_size, y_size, exageration", + [ + (1, 512, 512, 1), + _generate_random_test_values_aug(1), + _generate_random_test_values_aug(50), + ], +) +def test_iter_all_augmented_dataset(batch_size, x_size, y_size, exageration): + transform = A.Compose( + [ + A.Affine( + scale=(1 - exageration * 0.01, 1 + exageration), + rotate=(-exageration * 2, exageration * 2), + translate_px=(-exageration * 10, exageration * 10), + ), + A.Resize(y_size, x_size), + ], + keypoint_params=A.KeypointParams(format="xy", remove_invisible=False), + bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), + ) + dataset = _get_openfield_dataset(transform=transform) + dataloader = DataLoader(dataset, batch_size=batch_size) + key_set = set(["image", "original_size", "annotations"]) + anno_key_set = set( + ["keypoints", "area", "ids", "boxes", "image_id", "is_crowd", "labels"] + ) - assert image_tensor_resized.shape == (3, transform.transforms[0].height, transform.transforms[0].width) + max_num_animals = dataset.max_num_animals + num_keypoints = dataset.num_joints + for i, item in enumerate(dataloader): + is_last_batch = i == (len(dataloader) - 1) + assert ( + set(item.keys()) == key_set + ), "the key returned don't match the required ones" - x_scale = image_tensor.shape[2] / image_tensor_resized.shape[2] - y_scale = image_tensor.shape[1] / image_tensor_resized.shape[1] + anno = item["annotations"] + assert ( + set(anno.keys()) == anno_key_set + ), "the annotation keys returned don't match the required ones" - x_scale_keypoints = keypoints[:, 0] / keypoints_resized[:, 0] - y_scale_keypoints = keypoints[:, 1] / keypoints_resized[:, 1] + assert (len(item["image"].shape) == 4) and ( + (item["image"].shape[:2] == (batch_size, 3)) or is_last_batch + ), "image shape is not (batch_size, 3, h, w)" - assert np.allclose(x_scale_keypoints, x_scale, atol=1e-4) - assert np.allclose(y_scale_keypoints, y_scale, atol=1e-4) \ No newline at end of file + kpts, bboxes = anno["keypoints"], anno["boxes"] + b, _, h, w = item["image"].shape + assert (h == y_size) and (w == x_size) + assert ( + kpts.shape == (batch_size, max_num_animals, num_keypoints, 2) + or is_last_batch + ), "keypoints have the wrong shape" + assert ( + bboxes.shape == (batch_size, max_num_animals, 4) or is_last_batch + ), "boxes have the wrong shape" + assert ((bboxes[:, :, 0] + bboxes[:, :, 2]) <= w).all() and ( + (bboxes[:, :, 1] + bboxes[:, :, 3]) <= h + ).all() diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py index 4e6c35972f..c766d53d3d 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py @@ -1,39 +1,200 @@ import pytest +import random +import torch import deeplabcut.pose_estimation_pytorch.models as dlc_models +from deeplabcut.pose_estimation_pytorch.models.modules import BasicBlock, AdaptBlock +backbones_dicts = [ + { + "type": "HRNet", + "model_name": "hrnet_w32", + "output_channels": 480, + "stride": 4, + }, + { + "type": "HRNet", + "model_name": "hrnet_w18", + "output_channels": 270, + "stride": 4, + }, + { + "type": "HRNet", + "model_name": "hrnet_w48", + "output_channels": 720, + "stride": 4, + }, + { + "type": "HRNetTopDown", + "model_name": "hrnet_w32", + "output_channels": 32, + "stride": 4, + }, + { + "type": "HRNetTopDown", + "model_name": "hrnet_w18", + "output_channels": 18, + "stride": 4, + }, + { + "type": "HRNetTopDown", + "model_name": "hrnet_w48", + "output_channels": 48, + "stride": 4, + }, + { + "type": "ResNet", + "model_name": "resnet50", + "output_channels": 2048, + "stride": 32, + }, +] -def test_backbone(): - #TODO: load various backbones and check dimension of feature map - pass +heads_dicts = [ + { + "type": "SimpleHead", + "channels": [2048, 1024, -1], + "kernel_size": [2, 2], + "strides": [2, 2], + "output_channels": -1, + "input_channels": 2048, + "total_stride": 4, + }, + { + "type": "TransformerHead", + "dim": 192, + "hidden_heatmap_dim": 384, + "heatmap_dim": -1, + "apply_multi": True, + "heatmap_size": [-1, -1], + "apply_init": True, + "total_stride": 1, + "input_channels": -1, + "output_channels": -1, + }, + { + "type": "HeatmapDEKRHead", + "channels": [480, 64, -1], + "num_blocks": 1, + "dilation_rate": 1, + "final_conv_kernel": 1, + "block": BasicBlock, + "total_stride": 1, + "input_channels": 480, + "output_channels": -1, + }, + { + "type": "OffsetDEKRHead", + "channels": [480, -1, -1], + "num_offset_per_kpt": 15, + "num_blocks": 1, + "dilation_rate": 1, + "final_conv_kernel": 1, + "total_stride": 1, + "input_channels": 480, + "output_channels": -1, + }, +] -def test_head(): - #TODO: simple test for head (is upsampling correct?) - pass +def _generate_random_backbone_inputs(i): + # Returns sizes that are divisible by 64to be able to predict consistently output szie + # (and be able to do the forward pass of HRNet) + x_size_tmp, y_size_tmp = random.randint(100, 1000), random.randint(100, 1000) + return ( + backbones_dicts[i], + (x_size_tmp - x_size_tmp % 64, y_size_tmp - y_size_tmp % 64), + ) -def test_pose_model(): - #TODO: Make specific model builds and test! - pass -## Below we build specific models and check integrity -def test_timm_hrnet(): - #TODO: build timm_hrnet and check dimension of output - pass +@pytest.mark.parametrize( + "backbone_dict, input_size", + [_generate_random_backbone_inputs(i) for i in range(len(backbones_dicts))], +) +def test_backbone(backbone_dict, input_size): + input_tensor = torch.Tensor(1, 3, input_size[1], input_size[0]) + + stride = backbone_dict.pop("stride") + output_channels = backbone_dict.pop("output_channels") + backbone = dlc_models.BACKBONES.build(backbone_dict) + + features = backbone(input_tensor) + _, c, h, w = features.shape + assert c == output_channels + assert h == input_size[1] // stride + assert w == input_size[0] // stride + + +def _generate_random_head_inputs(i): + # Returns sizes that are divisible by 64to be able to predict consistently output szie + # (and be able to do the forward pass of HRNet) + x_size_tmp, y_size_tmp = random.randint(8, 500), random.randint(8, 500) + num_kpts = random.randint(2, 50) + return ( + heads_dicts[i], + (x_size_tmp - x_size_tmp % 4, y_size_tmp - y_size_tmp % 4), + num_kpts, + ) + + +@pytest.mark.parametrize( + "head_dict, input_shape, num_keypoints", + [_generate_random_head_inputs(i) for i in range(len(heads_dicts))], +) +def test_head(head_dict, input_shape, num_keypoints): + w, h = input_shape + + head_type = head_dict["type"] + input_channels = head_dict.pop("input_channels") + output_channels = head_dict.pop("output_channels") + total_stride = head_dict.pop("total_stride") + if head_type == "SimpleHead": + output_channels = num_keypoints + head_dict["channels"][2] = output_channels + input_tensor = torch.zeros((1, input_channels, h, w)) + + elif head_type == "TransformerHead": + output_channels = num_keypoints + input_channels = num_keypoints + head_dict["heatmap_dim"] = h * w + head_dict["heatmap_size"] = [h, w] + input_tensor = torch.zeros((1, input_channels, head_dict["dim"] * 3)) + + elif head_type == "HeatmapDEKRHead": + output_channels = num_keypoints + 1 + head_dict["channels"][2] = output_channels + input_tensor = torch.zeros((1, input_channels, h, w)) + + elif head_type == "OffsetDEKRHead": + output_channels = num_keypoints * 2 + head_dict["channels"][1] = num_keypoints * head_dict["num_offset_per_kpt"] + head_dict["channels"][2] = num_keypoints + input_tensor = torch.zeros((1, input_channels, h, w)) + + head = dlc_models.HEADS.build(head_dict) + + output = head(input_tensor) + _, c_out, h_out, w_out = output.shape + assert (h_out == h * total_stride) and (w_out == w * total_stride) + assert c_out == output_channels + def test_msa_hrnet(): # TODO: build microsoft asia hrnet and check dimension of output # TODO: check if hyperparameters are loaded correctly (from the config file) pass + def test_msa_tokenpose(): # TODO: build microsoft asia hrnet and check dimension of output # TODO: check if hyperparameters are loaded correctly (from the config file) - #cf https://github.com/amathislab/BUCTDdev/blob/main/lib/models/transpose_h.py#L1 + # cf https://github.com/amathislab/BUCTDdev/blob/main/lib/models/transpose_h.py#L1 pass + def test_msa_hrnetCOAM(): # TODO: build BUCTD COAM hrnet and check dimension of output # TODO: check if hyperparameters are loaded correctly (from the config file) pass -#TODO: add other model variants our pipeline can build ;) + +# TODO: add other model variants our pipeline can build ;) \ No newline at end of file From e390dd57f7f70db2a25972cd1c53bba881d5bc7c Mon Sep 17 00:00:00 2001 From: Konrad <54865575+KonradDanielewski@users.noreply.github.com> Date: Wed, 2 Aug 2023 10:10:46 +0200 Subject: [PATCH 038/293] Konrad's additions AIResidency * AIResidency_Konrad * Fix dataset error, add missing file * remove docstrings - Anna's pr * remove docstrings - Rae's PR * remove docstrings - Rae's PR * remove docstrings - Rae's PR missed one * move write_config util to test_utils * clean training printout for TD --- .../apis/analyze_videos.py | 127 +++++++------- .../apis/convert_detections_to_tracklets.py | 13 +- .../pose_estimation_pytorch/apis/evaluate.py | 43 +++-- .../pose_estimation_pytorch/apis/inference.py | 11 +- .../pose_estimation_pytorch/apis/train.py | 17 +- .../pose_estimation_pytorch/apis/utils.py | 18 +- .../pose_estimation_pytorch/data/dataset.py | 157 +++++++----------- .../pose_estimation_pytorch/solvers/base.py | 143 ++++++---------- .../solvers/top_down.py | 124 ++++++-------- .../tests/test_gaussian_targets.py | 45 +++++ .../tests/test_get_predictions.py | 132 +++++++++++++++ .../tests/test_utils.py | 57 ++++++- 12 files changed, 526 insertions(+), 361 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 03f94156ea..748497c9bd 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -12,40 +12,39 @@ import sys import time from pathlib import Path -from typing import List, Tuple, Optional, Union +from typing import List, Optional, Tuple, Union import albumentations as A import cv2 +import deeplabcut.pose_estimation_pytorch as dlc import numpy as np -from skimage.transform import resize import pandas as pd import torch -from tqdm import tqdm -from skimage.util import img_as_ubyte - -import deeplabcut.pose_estimation_pytorch as dlc -from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, VideoReader -from deeplabcut.pose_estimation_pytorch.models.model import PoseModel -from deeplabcut.pose_estimation_pytorch.models.detectors import ( - DETECTORS, - BaseDetector, +from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( + convert_detections2tracklets, ) -from deeplabcut.pose_estimation_pytorch.models.predictors import ( - PREDICTORS, - BasePredictor, +from deeplabcut.pose_estimation_pytorch.apis.inference import ( + get_detections_batch, + get_predictions_bottom_up, ) from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_inference_transform, build_pose_model, - read_yaml, - get_model_snapshots, get_detector_snapshots, + get_model_snapshots, videos_in_folder, - build_inference_transform, ) -from deeplabcut.pose_estimation_pytorch.apis.inference import ( - get_predictions_bottom_up, - get_detections_batch, +from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS, BaseDetector +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.models.predictors import ( + PREDICTORS, + BasePredictor, ) +from deeplabcut.refine_training_dataset.stitch import stitch_tracklets +from deeplabcut.utils import VideoReader, auxfun_multianimal, auxiliaryfunctions +from skimage.transform import resize +from skimage.util import img_as_ubyte +from tqdm import tqdm def video_inference( @@ -426,6 +425,8 @@ def analyze_videos( batchsize: Optional[int] = None, modelprefix: str = "", transform: Optional[A.Compose] = None, + auto_track: Optional[bool] = True, + identity_only: Optional[bool] = False, overwrite: bool = False, # TODO: other options such as auto_track ) -> List[Tuple[str, pd.DataFrame]]: @@ -462,6 +463,15 @@ def analyze_videos( PyTorch config as a default transform: Optional custom transforms to apply to the video overwrite: Overwrite any existing videos + auto_track: By default, tracking and stitching are automatically performed, + producing the final h5 data file. This is equivalent to the behavior for + single-animal projects. + + If ``False``, one must run ``convert_detections2tracklets`` and + ``stitch_tracklets`` afterwards, in order to obtain the h5 file. + identity_only: sub-call for auto_track. If ``True`` and animal identity was + learned by the model, assembly and tracking rely exclusively on identity + prediction. Returns: A list containing tuples (video_name, df_video_predictions) @@ -497,8 +507,7 @@ def analyze_videos( num_keypoints = len(auxiliaryfunctions.get_bodyparts(project.cfg)) # Read the inference configuration, load the model - pytorch_config_path = model_folder / "train" / "pytorch_config.yaml" - pytorch_config = read_yaml(pytorch_config_path) + pytorch_config = auxiliaryfunctions.read_plainconfig(model_folder / "train" / "pytorch_config.yaml") pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" pose_cfg = auxiliaryfunctions.read_config(pose_cfg_path) method = pytorch_config.get("method", "bu") @@ -518,14 +527,14 @@ def analyze_videos( # Load model, predictor model = build_pose_model(pytorch_config["model"], pose_cfg) - model.load_state_dict(torch.load(model_path)) + model.load_state_dict(torch.load(model_path)["model_state_dict"]) predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) detector = None top_down_predictor = None if method.lower() == "td": detector_path = _get_detector_path(model_folder, snapshotindex, project.cfg) detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) - detector.load_state_dict(torch.load(detector_path)) + detector.load_state_dict(torch.load(detector_path)["detector_state_dict"]) top_down_predictor = PREDICTORS.build( {"type": "TopDownPredictor", "format_bbox": "xyxy"} ) @@ -581,43 +590,13 @@ def analyze_videos( runtime=(runtime[0], runtime[1]), video=VideoReader(str(video)), ) - - coordinate_labels = ["x", "y", "likelihood"] - if len(individuals) > 1: - print("Extracting ", len(individuals), "instances per bodypart") - # first has empty suffix for backwards compatibility - individual_suffixes = [str(s + 1) for s in range(len(individuals))] - individual_suffixes[0] = "" - coordinate_labels = [ - coord_label + s - for s in individual_suffixes - for coord_label in coordinate_labels - ] - - results_df_index = pd.MultiIndex.from_product( - [[dlc_scorer], pose_cfg["all_joints_names"], coordinate_labels], - names=["scorer", "bodyparts", "coords"], - ) - df = pd.DataFrame( - predictions.reshape((len(predictions), -1)), - columns=results_df_index, - index=range(len(predictions)), - ) - df.to_hdf( - str(output_h5), - "df_with_missing", - format="table", - mode="w", - ) - results.append((str(video), df)) output_data = _generate_output_data(pose_cfg, predictions) _ = auxfun_multianimal.SaveFullMultiAnimalData( output_data, metadata, str(output_h5) ) - # save an assemblies file for backwards-compatibility with tensorflow - if project.cfg["multianimalproject"]: - ass_file = output_path / f"{output_prefix}_assemblies.pickle" + if project.cfg["multianimalproject"] and len(individuals) > 1: + output_ass = output_path / f"{output_prefix}_assemblies.pickle" assemblies = {} for i, prediction in enumerate(predictions): extra_column = np.full( @@ -628,9 +607,43 @@ def analyze_videos( ass = np.concatenate((prediction, extra_column), axis=-1) assemblies[i] = ass - with open(ass_file, "wb") as handle: + with open(output_ass, "wb") as handle: pickle.dump(assemblies, handle, protocol=pickle.HIGHEST_PROTOCOL) + if auto_track: + convert_detections2tracklets( + config, + str(video), + videotype, + shuffle, + trainingsetindex, + overwrite=False, + identity_only=identity_only + ) + stitch_tracklets( + config, str(video), videotype, shuffle, trainingsetindex + ) + else: + results_df_index = pd.MultiIndex.from_product( + [ + [dlc_scorer], + pose_cfg["all_joints_names"], + ["x", "y", "likelihood"], + ], + names=["scorer", "bodyparts", "coords"], + ) + df = pd.DataFrame( + np.array(predictions).reshape((len(predictions), -1)), + columns=results_df_index, + index=range(len(predictions)), + ) + df.to_hdf( + str(output_h5), + "df_with_missing", + format="table", + mode="w", + ) + results.append((str(video), df)) return results diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index 5b3fe93fa2..61b49d3b46 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -8,26 +8,25 @@ # # Licensed under GNU Lesser General Public License v3.0 # +import os import pickle import re -import os import warnings from pathlib import Path from typing import Dict, List, Optional, Union import numpy as np import pandas as pd -from tqdm import tqdm - from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_tensorflow import load_config -from deeplabcut.pose_estimation_tensorflow.lib import trackingutils -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly -from deeplabcut.utils import auxfun_multianimal, read_pickle from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_model_snapshots, videos_in_folder, ) +from deeplabcut.pose_estimation_tensorflow import load_config +from deeplabcut.pose_estimation_tensorflow.lib import trackingutils +from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly +from deeplabcut.utils import auxfun_multianimal, read_pickle +from tqdm import tqdm def convert_detections2tracklets( diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index eb74929a6b..ef1dda4b0e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -8,29 +8,28 @@ Licensed under GNU Lesser General Public License v3.0 """ import argparse +import os from pathlib import Path -from typing import Dict, Iterable, Optional, Union, List +from typing import Dict, Iterable, List, Optional, Union import albumentations as A +import deeplabcut.pose_estimation_pytorch as dlc import pandas as pd -import os import torch - -import deeplabcut.pose_estimation_pytorch as dlc from deeplabcut.pose_estimation_pytorch.apis.inference import inference from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_pose_model, build_inference_transform, + build_pose_model, ) from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS from deeplabcut.pose_estimation_pytorch.solvers.inference import get_scores from deeplabcut.pose_estimation_pytorch.solvers.utils import ( + build_predictions_df, get_model_folder, get_paths, get_results_filename, get_snapshots, - build_predictions_df, ) from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.visualization import plot_evaluation_results @@ -112,8 +111,7 @@ def evaluate_snapshot( bodyparts = auxiliaryfunctions.get_bodyparts(cfg) max_individuals = len(individuals) num_joints = len(bodyparts) - pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - pytorch_config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + pytorch_config = auxiliaryfunctions.read_plainconfig(os.path.join(modelfolder, "train", "pytorch_config.yaml")) method = pytorch_config.get("method", "bu") if method not in ["bu", "td"]: raise ValueError( @@ -147,13 +145,15 @@ def evaluate_snapshot( pose_cfg = auxiliaryfunctions.read_plainconfig(pytorch_config["pose_cfg_path"]) model = build_pose_model(pytorch_config["model"], pose_cfg) - model.load_state_dict(torch.load(names["model_path"])) + model.load_state_dict(torch.load(names["model_path"])["model_state_dict"]) predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) detector = None if method.lower() == "td": detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) - detector.load_state_dict(torch.load(names["detector_path"])) + detector.load_state_dict( + torch.load(names["detector_path"])["detector_state_dict"] + ) df_mode_predictions: List[pd.DataFrame] = [] for mode in ["train", "test"]: @@ -267,6 +267,29 @@ def evaluate_network( modelprefix: directory containing the deeplabcut models to use when evaluating the network. By default, they are assumed to exist in the project folder. batch_size: the batch size to use for evaluation + + Examples: + If you want to evaluate without plotting predicitons with shuffle set to 1. + + >>> deeplabcut.evaluate_network( + '/analysis/project/reaching-task/config.yaml', shuffles=[1], + ) + + If you want to plot and evaluate with shuffle set to 0 and 1. + + >>> deeplabcut.evaluate_network( + '/analysis/project/reaching-task/config.yaml', + shuffles=[0, 1], + plotting=True, + ) + + If you want to plot assemblies for a maDLC project + + >>> deeplabcut.evaluate_network( + '/analysis/project/reaching-task/config.yaml', + shuffles=[1], + plotting="individual", + ) """ cfg = auxiliaryfunctions.read_config(config) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 339fdd35d3..e6808a3650 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -1,15 +1,14 @@ -from typing import Union, List, Dict, Tuple, Optional +from typing import Dict, List, Optional, Tuple, Union -import torch -from torchvision.transforms import Resize as TorchResize import numpy as np - -from deeplabcut.pose_estimation_pytorch.models import PoseModel, PREDICTORS -from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +import torch +from deeplabcut.pose_estimation_pytorch.models import PREDICTORS, PoseModel from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.post_processing import ( rmse_match_prediction_to_gt, ) +from torchvision.transforms import Resize as TorchResize def get_predictions_bottom_up( diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 82389b9e4e..45843f492d 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -1,6 +1,9 @@ import argparse -import deeplabcut.pose_estimation_pytorch as dlc import os +from typing import Optional, Union + +import albumentations as A +import deeplabcut.pose_estimation_pytorch as dlc from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_solver, @@ -9,8 +12,6 @@ ) from deeplabcut.pose_estimation_pytorch.solvers.base import Solver from torch.utils.data import DataLoader -import albumentations as A -from typing import Union def train_network( @@ -20,6 +21,8 @@ def train_network( transform: Union[A.BaseCompose, A.BasicTransform] = None, transform_cropped: Union[A.BaseCompose, A.BasicTransform] = None, modelprefix: str = "", + snapshot_path: Optional[str] = "", + detector_path: Optional[str] = "", **kwargs ) -> Solver: """Trains a network for a project @@ -48,6 +51,9 @@ def train_network( modelprefix: directory containing the deeplabcut configuration files to use to train the network (and where snapshots will be saved). By default, they are assumed to exist in the project folder. + snapshot_path: if resuming training, used to specify the snapshot from which to resume + detector_path: if resuming training of a top down model, used to specify the detector snapshot from + which to resume **kwargs : could be any entry of the pytorch_config dictionary. Examples are to see the full list see the pytorch_cfg.yaml file in your project folder @@ -65,8 +71,7 @@ def train_network( modelprefix=modelprefix, ), ) - pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - pytorch_config = auxiliaryfunctions.read_plainconfig(pytorch_config_path) + pytorch_config = auxiliaryfunctions.read_plainconfig(os.path.join(modelfolder, "train", "pytorch_config.yaml")) update_config_parameters(pytorch_config=pytorch_config, **kwargs) if transform is None: print("No transform specified... using default") @@ -89,7 +94,7 @@ def train_network( valid_dataloader = DataLoader(valid_dataset, batch_size=batch_size, shuffle=False) - solver = build_solver(pytorch_config) + solver = build_solver(pytorch_config, snapshot_path, detector_path) if pytorch_config.get("method", "bu").lower() == "td": if transform_cropped is None: print( diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 9f158d6e4f..f0ea9873b8 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -4,23 +4,22 @@ import albumentations as A import torch import yaml -from deeplabcut.utils import auxfun_videos - from deeplabcut.pose_estimation_pytorch.models import ( - PoseModel, BACKBONES, - NECKS, + DETECTORS, HEADS, LOSSES, - DETECTORS, + NECKS, + PoseModel, ) -from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SOLVERS from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS from deeplabcut.pose_estimation_pytorch.models.target_generators import ( TARGET_GENERATORS, ) -from deeplabcut.pose_estimation_pytorch.solvers.schedulers import LRListScheduler +from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SOLVERS from deeplabcut.pose_estimation_pytorch.solvers.base import Solver +from deeplabcut.pose_estimation_pytorch.solvers.schedulers import LRListScheduler +from deeplabcut.utils import auxfun_videos def build_pose_model(cfg: Dict, pytorch_cfg: Dict) -> PoseModel: @@ -85,7 +84,7 @@ def build_detector(detector_cfg: Dict): return detector, detector_optimizer, detector_scheduler -def build_solver(pytorch_cfg: Dict) -> Solver: +def build_solver(pytorch_cfg: Dict, snapshot_path: str, detector_path: str) -> Solver: """ Build the solver object to run training @@ -133,6 +132,7 @@ def build_solver(pytorch_cfg: Dict) -> Solver: predictor=predictor, cfg=pytorch_cfg, device=pytorch_cfg["device"], + snapshot_path=snapshot_path, scheduler=scheduler, logger=logger, ) @@ -151,6 +151,8 @@ def build_solver(pytorch_cfg: Dict) -> Solver: predictor=predictor, cfg=pytorch_cfg, device=pytorch_cfg["device"], + snapshot_path=snapshot_path, + detector_path=detector_path, scheduler=scheduler, logger=logger, detector=detector, diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 727b85f9e9..2ada713beb 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -1,12 +1,24 @@ -import cv2 +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + import os + +import albumentations as A +import cv2 import numpy as np import torch +from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions +from deeplabcut.utils.auxiliaryfunctions import get_model_folder, read_plainconfig from torch.utils.data import Dataset -import albumentations as A -from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions -from deeplabcut.utils.auxiliaryfunctions import read_plainconfig, get_model_folder from .base import BaseDataset from .dlcproject import DLCProject @@ -19,17 +31,6 @@ class PoseDataset(Dataset, BaseDataset): def __init__( self, project: DLCProject, transform: object = None, mode: str = "train" ): - """ - - Parameters - ---------- - project: see class Project (wrapper for DLC original project class) - transform: augmentation/normalization pipeline - - mode: 'train' or 'test' - this parameter which dataframe parse from the Project (df_tran or df_test) - - """ super().__init__() self.transform = transform self.project = project @@ -67,22 +68,12 @@ def __init__( def __len__(self): return self.length - def _calc_area_from_keypoints(self, keypoints): + def _calc_area_from_keypoints(self, keypoints: np.ndarray) -> np.ndarray: w = keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1) h = keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1) return w * h - - def _keypoint_in_boundary(self, keypoint, shape): - """ - - Parameters - ---------- - keypoint: [x, y] - shape: (height, width) - Returns - ------- - bool : whether a keypoint lies inside the given shape""" - + + def _keypoint_in_boundary(self, keypoint: list, shape: tuple): return ( (keypoint[0] > 0) and (keypoint[1] > 0) @@ -91,28 +82,6 @@ def _keypoint_in_boundary(self, keypoint, shape): ) def __getitem__(self, index: int) -> dict: - """ - - Parameters - ---------- - index: int - ordered number of the item in the dataset - Returns - ------- - dictionary corresponding to the image, annotations... - keys: - -'image' : image - -'annotations': - -'keypoints' : array of keypoints, invisible keypoints appear as (-1, -1) - -'area': array of animals area in this image - -'original_size' : original size of the image before applying transforms - useful to convert the predictions/ground truth back to - the input space - - train_dataset = PoseDataset(project, transform=transform) - pose_dict = train_dataset[0] - - """ # load images try: image_file = self.dataframe.index[index] @@ -293,20 +262,6 @@ class CroppedDataset(Dataset, BaseDataset): def __init__( self, project: DLCProject, transform: object = None, mode: str = "train" ): - """ - - Parameters - ---------- - project: see class Project (wrapper for DLC original project class) - transform: transformation function: - - def transform(image, keypoints): - return image, keypoints - - mode: 'train' or 'test' - this parameter which dataframe parse from the Project (df_train or df_test) - - """ super().__init__() self.transform = transform self.project = project @@ -353,14 +308,49 @@ def transform(image, keypoints): bbox_params=A.BboxParams(format="coco"), ) - # We must dropna because self.project.images doesn't contain imgaes with no labels so it can produce an indexnotfound error + # We must drop na because self.project.images doesn't contain imgaes with no + # labels so it can produce an indexnotfound error # length is stored here to avoid repeating the computation self.length = len(self.annotations) def __len__(self): return self.length + + def _keypoint_in_boundary(self, keypoint: list, shape: tuple): + """Summary: + Check if a keypoint lies inside the given shape. + + Args: + keypoint: [x,y] coordinates of the keypoints + shape: tuple representing the shape of the boundary (height, width) + + Returns: + Whether a keypoint lies inside the given shape + + Example: + input: + keypoint = [100, 50] + shape = (200, 300) + output: + _keypoint_in_boundary(keypoint, shape) = True + """ + return ( + (keypoint[0] > 0) + and (keypoint[1] > 0) + and (keypoint[0] < shape[1]) + and (keypoint[1] < shape[0]) + ) def _compute_anno(self): + """Summary: + Compute annotations for the dataset + + Args: + None + + Returns: + annotations: list of annotations containing information about keypoints and image paths. + """ annotations = [] df_length = self.dataframe.shape[0] @@ -384,45 +374,12 @@ def _compute_anno(self): return annotations - def _calc_area_from_keypoints(self, keypoints): + def _calc_area_from_keypoints(self, keypoints: np.ndarray) -> np.ndarray: w = keypoints[:, 0].max(axis=0) - keypoints[:, 0].min(axis=0) h = keypoints[:, 1].max(axis=0) - keypoints[:, 1].min(axis=0) return w * h - def _keypoint_in_boundary(self, keypoint, shape): - """ - - Parameters - ---------- - keypoint: [x, y] - shape: (height, width) - Returns - ------- - bool : whether a keypoint lies inside the given shape""" - - return ( - (keypoint[0] > 0) - and (keypoint[1] > 0) - and (keypoint[0] < shape[1]) - and (keypoint[1] < shape[0]) - ) - - def __getitem__(self, index: int): - """ - Parameters - ---------- - index: int - ordered number of the item in the dataset - Returns - ------- - image: torch.FloatTensor \in [0, 255] - Tensor for the image from the dataset - keypoints: list of keypoints - - train_dataset = PoseDataset(project, transform=transform) - im, keypoints = train_dataset[0] - - """ + def __getitem__(self, index: int) -> dict: # load images ann = self.annotations[index] image_file = ann["image_path"] diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 771e0a4443..8dd08f698c 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -1,17 +1,25 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + from abc import ABC, abstractmethod -from typing import Optional -from typing import Tuple, Dict +from collections import defaultdict +from typing import Dict, Optional, Tuple +import deeplabcut.pose_estimation_pytorch.data.dataset as deeplabcut_pose_estimation_pytorch_data_dataset +import deeplabcut.pose_estimation_pytorch.models.model as deeplabcut_pose_estimation_pytorch_models_model +import deeplabcut.pose_estimation_pytorch.models.predictors as deeplabcut_pose_estimation_pytorch_models_predictors +import deeplabcut.pose_estimation_pytorch.solvers.inference as deeplabcut_pose_estimation_pytorch_solvers_inference import numpy as np import torch -import torch.nn as nn -from collections import defaultdict -from deeplabcut.pose_estimation_pytorch.models.model import PoseModel -from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector -from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset -from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction -from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from ..registry import Registry, build_from_cfg from .utils import * @@ -19,47 +27,44 @@ class Solver(ABC): + """Solver base class. + + Contains helper methods for bundling a model, criterion and optimizer. + """ + def __init__( self, - model: PoseModel, + model: deeplabcut_pose_estimation_pytorch_models_model.PoseModel, criterion: torch.nn, optimizer: torch.optim.Optimizer, - predictor: BasePredictor, + predictor: deeplabcut_pose_estimation_pytorch_models_predictors.BasePredictor, cfg: Dict, device: str = "cpu", - scheduler: Optional = None, + snapshot_path: Optional[str] = "", + scheduler: torch.optim.lr_scheduler = None, logger: Optional = None, ): - """Solver base class. - - A solvers contains helper methods for bundling a model, criterion and optimizer. - - Parameters - ---------- - model: The neural network for solving pose estimation task. - criterion: The criterion computed from the difference between the prediction - and the target. - optimizer: A PyTorch optimizer for updating model parameters. - cfg: DeepLabCut pose_cfg for training. - See https://github.com/DeepLabCut/DeepLabCut/blob/main/deeplabcut/pose_cfg.yaml for more details. - scheduler: Optional. Scheduler for adjusting the lr of the optimizer. - logger: logger to monitor training (e.g WandB logger) - """ if cfg is None: raise ValueError("") self.model = model self.device = device self.cfg = cfg + self.model.to(device) self.optimizer = optimizer self.scheduler = scheduler self.criterion = criterion self.predictor = predictor self.history = {"train_loss": [], "eval_loss": []} self.logger = logger + self.starting_epoch = 0 if self.logger: logger.log_config(cfg) - self.model.to(device) - self.stride = 8 # TODO: stride from config? + if snapshot_path: + snapshot = torch.load(snapshot_path) + self.model.load_state_dict(snapshot["model_state_dict"]) + self.optimizer.load_state_dict(snapshot["optimizer_state_dict"]) + self.starting_epoch = snapshot["epoch"] + self.stride = 8 def fit( self, @@ -71,25 +76,11 @@ def fit( *, epochs: int = 10000, ) -> None: - """ - Train model for the specified number of steps. - - Parameters - ---------- - train_loader: Data loader, which is an iterator over train instances. - Each batch contains image tensor and heat maps tensor input samples. - valid_loader: Data loader used for validation of the model. - train_fraction: TODO discuss (mb better specify with config) - shuffle: TODO discuss (mb better specify with config) - model_prefix: TODO discuss (mb better specify with config) - epochs: The number of training iterations. - """ model_folder = get_model_folder( train_fraction, shuffle, model_prefix, train_loader.dataset.cfg ) - save_epochs = 30 # TODO: read this value from config file - for i in range(epochs): + for i in range(self.starting_epoch, epochs): train_loss = self.epoch(train_loader, mode="train", step=i + 1) if self.scheduler: self.scheduler.step() @@ -99,7 +90,13 @@ def fit( if (i + 1) % self.cfg["save_epochs"] == 0: print(f"Finished epoch {i + 1}; saving model") torch.save( - self.model.state_dict(), + { + "model_state_dict": self.model.state_dict(), + "epoch": i + 1, + "optimizer_state_dict": self.optimizer.state_dict(), + "train_loss": train_loss, + "validation_loss": valid_loss, + }, f"{model_folder}/train/snapshot-{i + 1}.pt", ) @@ -110,31 +107,12 @@ def fit( f'lr {self.optimizer.param_groups[0]["lr"]}' ) - if epochs % save_epochs != 0: - print(f"Finished epoch {epochs}; saving model") - torch.save( - self.model.state_dict(), - f"{model_folder}/train/snapshot-{epochs}.pt", - ) - def epoch( self, loader: torch.utils.data.DataLoader, mode: str = "train", step: Optional[int] = None, ) -> float: - """ - - Parameters - ---------- - loader: Data loader, which is an iterator over instances. - Each batch contains image tensor and heat maps tensor input samples. - mode: "train" or "eval" - step: the global step in processing, used to log metrics. - Returns - ------- - epoch_loss: Average of the loss over the batches. - """ if mode not in ["train", "eval"]: raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") to_mode = getattr(self.model, mode) @@ -150,13 +128,13 @@ def epoch( if (i + 1) % self.cfg["display_iters"] == 0: print( - f"Number of iterations : {i+1}, loss : {losses_dict['total_loss']}, lr : {self.optimizer.param_groups[0]['lr']}" + f"Number of iterations : {i+1}, loss : {losses_dict['total_loss']:.5f}, lr : {self.optimizer.param_groups[0]['lr']}" ) epoch_loss = np.mean(epoch_loss) self.history[f"{mode}_loss"].append(epoch_loss) if self.logger: - for key in metrics.keys(): + for key in metrics: self.logger.log( f"{mode} {key}", np.nanmean(metrics[key]), @@ -168,47 +146,30 @@ def epoch( @abstractmethod def step(self, batch: Tuple[torch.Tensor, torch.Tensor], *args) -> dict: raise NotImplementedError - + @torch.no_grad() - def inference(self, dataset: PoseDataset) -> np.array: - # todo add scale + def inference( + self, dataset: deeplabcut_pose_estimation_pytorch_data_dataset.PoseDataset + ) -> np.ndarray: predicted_poses = [] for item in dataset: if isinstance(item, tuple) or isinstance(item, list): item = item[0] - else: - item = item item = item.to(self.device) output = self.model(item) - pose = get_prediction(self.cfg, output, self.stride) + pose = deeplabcut_pose_estimation_pytorch_solvers_inference.get_prediction( + self.cfg, output, self.stride + ) predicted_poses.append(pose) predicted_poses = np.concatenate(predicted_poses) return predicted_poses class BottomUpSolver(Solver): - """ - Base solvers for bottom up pose estimation. - """ def step( self, batch: Tuple[torch.Tensor, torch.Tensor], mode: str = "train" ) -> dict: - """Perform a single epoch gradient update or validation step. - - Parameters - ---------- - batch: Tuple of input image(s) and target(s) for train or valid single step. - mode: `train` or `eval` - - Returns - ------- - dict : { - 'batch loss' : torch.Tensor, - 'heatmap_loss' : torch.Tensor, - 'locref_loss' : torch.Tensor - } - """ if mode not in ["train", "eval"]: raise ValueError( f"Solver must be in train or eval mode, but {mode} was found." @@ -224,7 +185,7 @@ def step( ) # (batch_size, channels, h, w) for key in target: if target[key] is not None: - target[key] = torch.tensor(target[key]).to(self.device) + target[key] = torch.Tensor(target[key]).to(self.device) losses_dict = self.criterion(prediction, target) if mode == "train": diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py index 098599ef35..62c35d5ea9 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py @@ -1,23 +1,27 @@ -from typing import Optional -from typing import Tuple, Dict -import torch -import torch.nn as nn -import numpy as np +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + from collections import defaultdict +from typing import Dict, Optional, Tuple -from deeplabcut.pose_estimation_pytorch.solvers.base import Solver, SOLVERS +import numpy as np +import torch +import torch.nn as nn from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector +from deeplabcut.pose_estimation_pytorch.solvers.base import SOLVERS, Solver from deeplabcut.pose_estimation_pytorch.solvers.utils import * @SOLVERS.register_module class TopDownSolver(Solver): - """ - Top down solver - - Currently very specific to FasterRCNN for detectpr since torchvison's implementation isn't flexible - """ - def __init__( self, *args, @@ -25,14 +29,22 @@ def __init__( detector_optimizer: torch.optim.Optimizer, detector_criterion: nn.Module = None, # Not Used with fasterRCNN detector_scheduler: Optional = None, + detector_path: Optional[str] = "", **kwargs, ): super().__init__(*args, **kwargs) self.detector = detector + self.detector.to(self.device) self.detector_optimizer = detector_optimizer self.detector_criterion = detector_criterion self.detector_scheduler = detector_scheduler - self.detector.to(self.device) + self.starting_epoch = 0 + self.starting_epoch_detector = 0 + if detector_path: + detector = torch.load(detector_path) + self.detector.load_state_dict(detector["detector_state_dict"]) + self.detector_optimizer.load_state_dict(detector["optimizer_state_dict"]) + self.starting_epoch_detector = detector["epoch"] def fit( self, @@ -47,27 +59,11 @@ def fit( epochs: int = 10000, detector_epochs: int = 10000, ): - """ - Train model for the specified number of steps. - - Parameters - ---------- - train_detector_loader: Data loader, which is an iterator over train instances. - Each batch contains image tensor and heat maps tensor input samples. - valid_detector_loader: Data loader used for validation of the detector model. - train_pose_loader: Data loader used for the pose detection part of the top down model - valid_pose_loader: Data loader used for validaton of the pose regression part of the top down model - train_fraction: TODO discuss (mb better specify with config) - shuffle: TODO discuss (mb better specify with config) - model_prefix: TODO discuss (mb better specify with config) - epochs: The number of training epochs for pose_estimator. - detector_epochs: The number of training epochs for detector. - """ model_folder = get_model_folder( train_fraction, shuffle, model_prefix, train_detector_loader.dataset.cfg ) - for i in range(detector_epochs): + for i in range(self.starting_epoch_detector, detector_epochs): train_detector_loss = self.epoch_detector( train_detector_loader, mode="train", step=i + 1 ) @@ -80,12 +76,17 @@ def fit( if (i + 1) % self.cfg["detector"].get("detector_save_epochs", 1) == 0: print(f"Finished epoch {i + 1}; saving detector") torch.save( - self.detector.state_dict(), + { + "detector_state_dict": self.detector.state_dict(), + "epoch": i + 1, + "optimizer_state_dict": self.detector_optimizer.state_dict(), + "train_loss": train_detector_loss, + }, f"{model_folder}/train/detector-snapshot-{i + 1}.pt", ) print( f"Epoch {i + 1}/{detector_epochs}, " - f"train detector loss {train_detector_loss}, " + f"train detector loss {train_detector_loss:.5f}" ) if detector_epochs % self.cfg["detector"].get("detector_save_epochs", 1) != 0: @@ -95,7 +96,7 @@ def fit( ) print(f"Finished epoch {detector_epochs}; saving model") - for i in range(epochs): + for i in range(self.starting_epoch, epochs): train_pose_loss = self.epoch_pose( train_pose_loader, mode="train", step=i + 1 ) @@ -113,14 +114,20 @@ def fit( if (i + 1) % self.cfg["save_epochs"] == 0: print(f"Finished epoch {i + 1}; saving pose model") torch.save( - self.model.state_dict(), + { + "model_state_dict": self.model.state_dict(), + "epoch": i + 1, + "optimizer_state_dict": self.optimizer.state_dict(), + "train_loss": train_pose_loss, + "validation_loss": valid_pose_loss, + }, f"{model_folder}/train/snapshot-{i + 1}.pt", ) print( f"Epoch {i + 1}/{epochs}, " - f"train pose loss {train_pose_loss}" - f"valid pose loss {valid_pose_loss}" + f"train pose loss {train_pose_loss:.5f}, " + f"valid pose loss {valid_pose_loss:.5f}" ) if epochs % self.cfg["save_epochs"] != 0: @@ -135,7 +142,7 @@ def epoch(self, *args): pass def step(self, *args): - # Unused in top down since we are dealing with two different step functions + """Unused in top down since we are dealing with two different step functions.""" pass def epoch_detector( @@ -144,19 +151,7 @@ def epoch_detector( mode: str = "train", step: Optional[int] = None, ) -> float: - """Does an epoch for the detector over the dataset - - Args: - ---------- - detector_loader: Data loader, which is an iterator over instances. - Each batch contains image tensors. - mode: "train" or "eval" - step: the global step in processing, used to log metrics. - - Returns - ------- - epoch_loss: Average of the loss over the batches. - """ + if mode not in ["train", "eval"]: raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") to_mode_detector = getattr(self.detector, mode) @@ -177,7 +172,7 @@ def epoch_detector( if (i + 1) % self.cfg["display_iters"] == 0: print( - f"Number of iterations for detector: {i+1}, loss : {np.mean(metrics['detector_loss'])}, lr : {self.optimizer.param_groups[0]['lr']}" + f"Number of iterations for detector: {i+1}, loss : {np.mean(metrics['detector_loss']):.5f}, lr : {self.optimizer.param_groups[0]['lr']}" ) epoch_detector_loss = np.mean(epoch_detector_loss) @@ -200,20 +195,7 @@ def epoch_pose( mode: str = "train", step: Optional[int] = None, ) -> float: - """Does an epoch for the pose_model over the dataset - - Args: - ---------- - pose_loader: Data loader, which is an iterator over instances. - Each batch contains cropped images around an animal. - mode: "train" or "eval" - step: the global step in processing, used to log metrics. - - Returns - ------- - epoch_loss: Average of the loss over the batches. - """ - + if mode not in ["train", "eval"]: raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") to_mode_pose = getattr(self.model, mode) @@ -235,7 +217,7 @@ def epoch_pose( if (i + 1) % self.cfg["display_iters"] == 0: print( - f"Number of iterations for pose: {i+1}, loss : {np.mean(metrics['pose_total_loss'])}, lr : {self.optimizer.param_groups[0]['lr']}" + f"Number of iterations for pose: {i+1}, loss : {np.mean(metrics['pose_total_loss']):.5f}, lr : {self.optimizer.param_groups[0]['lr']}" ) epoch_pose_loss = np.mean(epoch_pose_loss) @@ -295,15 +277,7 @@ def step_detector(self, batch: dict, mode: str = "train") -> float: return 0.0 def step_pose(self, batch: dict, mode: str = "train") -> dict: - """Performs a step for the pose estimator over a batch - - Args: - batch: batch returned by the dataloader - mode: "train" or "eval". Defaults to "train". - - Returns: - dict : the loss components over the batch, should always contain "total_loss" key - """ + if mode not in ["train", "eval"]: raise ValueError( f"Solver must be in train or eval mode, but {mode} was found." diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py new file mode 100644 index 0000000000..5cdaf3f798 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py @@ -0,0 +1,45 @@ +import pytest +import torch +from deeplabcut.pose_estimation_pytorch.models.target_generators import gaussian_targets + + +@pytest.mark.parametrize( + "batch_size, num_keypoints, image_size", + [(2, 2, (64, 64)), (1, 5, (48, 64)), (15, 50, (64, 48))], +) +def test_gaussian_target_generation( + batch_size: int, num_keypoints: int, image_size: tuple, num_animals=1 +): + # generate annotations + annotations = { + "keypoints": torch.randint( + 1, min(image_size), (batch_size, num_animals, num_keypoints, 2) + ) + } # batch size, num animals, num keypoints, 2 for x,y + # generate predictions + prediction = [ + torch.rand((batch_size, num_keypoints, image_size[0], image_size[1])) + ] # batch size, num keypoints , imageh, imagew + + # generate heatmap + output = gaussian_targets.GaussianGenerator(5.0, num_keypoints, 17) + output = torch.tensor( + output(annotations, prediction, image_size)["heatmaps"].reshape( + batch_size, num_keypoints, image_size[0] * image_size[1] + ) + ) + + # get coords of max value of the heatmap + gaus_max = torch.argmax(output, dim=2) + + # get unraveled coords + x = gaus_max % image_size[1] + y = gaus_max // image_size[1] + + # get heatmap center tensor + predict_kp = torch.stack((x, y), dim=-1) + # Remove num_animals dimension - only one animal is supported + annotations["keypoints"] = torch.squeeze(annotations["keypoints"], dim=1) + + # compare heatmap center to annotation + assert torch.eq(annotations["keypoints"], predict_kp).all().item() diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py new file mode 100644 index 0000000000..9b1e1e71f6 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py @@ -0,0 +1,132 @@ +from itertools import product + +import deeplabcut +import pytest +import torch +from deeplabcut.generate_training_dataset.make_pytorch_config import * +from deeplabcut.pose_estimation_pytorch.apis import inference_utils, utils +from deeplabcut.pose_estimation_pytorch.default_config import * +from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS, BaseDetector +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.models.predictors import ( + PREDICTORS, + BasePredictor, +) +from deeplabcut.pose_estimation_pytorch.tests.test_utils import ( + write_config +) +from deeplabcut.utils import auxiliaryfunctions + +# Check implemented net types +single_nets = [ + "resnet_50", +] +multi_nets = [ + "dekr_w18", +] +multi_nets_td = [ + "token_pose_w32", +] + +single = [ele for ele in product(single_nets, [False])] +multi = [ele for ele in product(multi_nets, [True])] +multi_td = [ele for ele in product(multi_nets_td, [True])] + +params_bu = single + multi + +@pytest.mark.parametrize("net_type, multianimal", params_bu) +def test_get_predictions_bottom_up( + net_type: str, + multianimal: bool, + batch_size: int = 8, + image_shape: tuple = (128, 256), +): + # Create a batch image tensors + images = torch.rand((batch_size, 3, image_shape[1], image_shape[0])) * 100 + # Create config and pytorch_config dicts + cfg = write_config(multianimal) + # read N animals and N keypoints from config + num_animals = len(cfg["individuals"]) if "individuals" in cfg.keys() else 1 + num_keypoints = ( + len(cfg["multianimalbodyparts"]) + if cfg["multianimalproject"] + else len(cfg["bodyparts"]) + ) + pytorch_config = make_pytorch_config( + cfg, net_type, config_template=pytorch_cfg_template.copy() + ) + # Pretrained set to False to initialize model without using a snapshot + pytorch_config["model"]["backbone"]["pretrained"] = False + # build model + model = utils.build_pose_model(pytorch_config["model"], pytorch_config) + + # build predictor + predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config["predictor"])) + + # get predictions + with torch.no_grad(): + output = inference_utils.get_predictions_bottom_up(model, predictor, images) + + # Generate test tensor with expected output shape + test = torch.randint(1, 12, (batch_size, num_animals, num_keypoints, 3)) + + assert test.shape == output.shape + + +# Doesn't work yet since top down doesn't fully work +@pytest.mark.parametrize("net_type, multianimal", multi_td) +def test_get_predicitons_top_down( + net_type: str, + multianimal: bool, + batch_size: int = 8, + num_animals: int = 10, + num_keypoints: int = 10, + image_shape: tuple = (128, 256), +): + # Create a batch image tensors + images = torch.rand((batch_size, 3, image_shape[1], image_shape[0])) * 100 + # Create config and pytorch_config dicts + cfg = write_config(multianimal) + pytorch_config = make_pytorch_config( + cfg, net_type, config_template=pytorch_cfg_template.copy() + ) + # Pretrained set to False to initialize model without using a snapshot + pytorch_config["model"]["backbone"]["pretrained"] = False + # read N animals and N keypoints from config + num_animals = len(cfg["individuals"]) if "individuals" in cfg.keys() else 1 + num_keypoints = ( + len(cfg["multianimalbodyparts"]) + if cfg["multianimalproject"] + else len(cfg["bodyparts"]) + ) + + # build detector + detector: BaseDetector = DETECTORS.build( + dict(pytorch_config["detector"]["detector_model"]) + ) + # build model + model = utils.build_pose_model(pytorch_config["model"], pytorch_config) + + # build predictors + top_down_predictor: BasePredictor = PREDICTORS.build( + {"type": "TopDownPredictor", "format_bbox": "xyxy"} + ) + pose_predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config["predictor"])) + + # get predictions + with torch.no_grad(): + output = inference_utils.get_predictions_top_down( + detector, + top_down_predictor, + model, + pose_predictor, + images, + num_animals, + num_keypoints, + device="cpu", + ) + + # Generate test tensor with expected output shape + test = torch.randint(1, 12, (batch_size, num_animals, num_keypoints, 3)) + + assert test.shape == output.shape diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py index d4adb601a4..11fcd8c581 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py @@ -20,4 +20,59 @@ def test_generate_heatmaps(): heatmap_size, image_size, sigma=sigma) - assert heatmaps.shape == (number_of_joints, heatmap_size[0], heatmap_size[1]) \ No newline at end of file + assert heatmaps.shape == (number_of_joints, heatmap_size[0], heatmap_size[1]) + +#Create fake config dict for testing purposes +def write_config(multianimal: bool) -> dict: + cfg_file = {} + if multianimal: # parameters specific to multianimal project + cfg_file["multianimalproject"] = multianimal + cfg_file["identity"] = False + cfg_file["individuals"] = ["individual1", "individual2", "individual3"] + cfg_file["multianimalbodyparts"] = ["bodypart1", "bodypart2", "bodypart3"] + cfg_file["uniquebodyparts"] = [] + cfg_file["bodyparts"] = "MULTI!" + cfg_file["skeleton"] = [ + ["bodypart1", "bodypart2"], + ["bodypart2", "bodypart3"], + ["bodypart1", "bodypart3"], + ] + cfg_file["default_augmenter"] = "multi-animal-imgaug" + cfg_file["default_net_type"] = "dlcrnet_ms5" + cfg_file["default_track_method"] = "ellipse" + else: + cfg_file["multianimalproject"] = False + cfg_file["bodyparts"] = ["bodypart1", "bodypart2", "bodypart3", "objectA"] + cfg_file["skeleton"] = [["bodypart1", "bodypart2"], ["objectA", "bodypart3"]] + cfg_file["default_augmenter"] = "default" + cfg_file["default_net_type"] = "resnet_50" + + # common parameters: + cfg_file["Task"] = "test" + cfg_file["scorer"] = "experimenter" + cfg_file["video_sets"] = "placeholder" + cfg_file["project_path"] = "fake\\path" + cfg_file["date"] = "Oct30" + cfg_file["cropping"] = False + cfg_file["start"] = 0 + cfg_file["stop"] = 1 + cfg_file["numframes2pick"] = 20 + cfg_file["TrainingFraction"] = [0.95] + cfg_file["iteration"] = 0 + cfg_file["snapshotindex"] = -1 + cfg_file["x1"] = 0 + cfg_file["x2"] = 640 + cfg_file["y1"] = 277 + cfg_file["y2"] = 624 + cfg_file[ + "batch_size" + ] = 8 # batch size during inference (video - analysis); see https://www.biorxiv.org/content/early/2018/10/30/457242 + cfg_file["corner2move2"] = (50, 50) + cfg_file["move2corner"] = True + cfg_file["skeleton_color"] = "black" + cfg_file["pcutoff"] = 0.6 + cfg_file["dotsize"] = 12 # for plots size of dots + cfg_file["alphavalue"] = 0.7 # for plots transparency of markers + cfg_file["colormap"] = "rainbow" # for plots type of colormap + + return cfg_file \ No newline at end of file From 923c1bc122bc56c82bb489e65ee85694b5fa6eb2 Mon Sep 17 00:00:00 2001 From: Rae <64082964+rizarae-p@users.noreply.github.com> Date: Fri, 4 Aug 2023 13:24:49 +0200 Subject: [PATCH 039/293] Added docstrings and tests (Ai residency contribution) * added docstrings and tests * Update deeplabcut/pose_estimation_pytorch/solvers/top_down.py * removed local file-dependent tests * Update base.py --------- --- .../models/backbones/__init__.py | 14 +- .../models/backbones/base.py | 80 +++- .../models/backbones/hrnet.py | 102 ++-- .../models/backbones/resnet.py | 59 ++- .../models/modules/__init__.py | 8 +- .../models/modules/conv_block.py | 235 +++++++--- .../models/modules/conv_module.py | 187 +++++--- .../models/necks/__init__.py | 14 +- .../models/necks/base.py | 47 +- .../models/necks/layers.py | 231 +++++++-- .../models/necks/transformer.py | 180 +++++-- .../models/necks/utils.py | 36 +- .../models/predictors/__init__.py | 18 +- .../models/predictors/base.py | 37 +- .../models/predictors/dekr_predictor.py | 193 ++++++-- .../models/predictors/single_predictor.py | 218 +++++++-- .../models/predictors/top_down_prediction.py | 42 +- .../pose_estimation_pytorch/registry.py | 173 +++++-- .../solvers/__init__.py | 15 +- .../pose_estimation_pytorch/solvers/base.py | 99 ++++ .../solvers/inference.py | 270 +++++++---- .../pose_estimation_pytorch/solvers/logger.py | 94 ++-- .../solvers/top_down.py | 38 ++ .../pose_estimation_pytorch/solvers/utils.py | 438 +++++++++++++++--- deeplabcut/pose_estimation_pytorch/utils.py | 30 +- tests/test_dekr_predictor.py | 24 + ...se_estimation_pytorch_solvers_inference.py | 79 ++++ ...t_pose_estimation_pytorch_solvers_utils.py | 279 +++++++++++ 28 files changed, 2667 insertions(+), 573 deletions(-) create mode 100644 tests/test_dekr_predictor.py create mode 100644 tests/test_pose_estimation_pytorch_solvers_inference.py create mode 100644 tests/test_pose_estimation_pytorch_solvers_utils.py diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py index 890e730bbe..0eff99af7a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py @@ -1,2 +1,12 @@ -from .resnet import ResNet -from .hrnet import HRNet +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from deeplabcut.pose_estimation_pytorch.models.backbones.hrnet import HRNet +from deeplabcut.pose_estimation_pytorch.models.backbones.resnet import ResNet diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index c2af24e05d..6d1e0d8127 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -1,34 +1,54 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from abc import ABC, abstractmethod -import torch.nn as nn + import torch from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - BACKBONES = Registry("backbones", build_func=build_from_cfg) -class BaseBackbone(ABC, nn.Module): +class BaseBackbone(ABC, torch.nn.Module): + """Base Backbone class for pose estimation. + + Attributes: + batch_norm_on: Indicates whether batch normalization is activated during training. """ - Backbone for pose estimation""" def __init__(self): + """Initialize the BaseBackbone.""" super().__init__() self.batch_norm_on = False @abstractmethod - def forward(self, x): - pass + def forward(self, x: torch.Tensor): + """Abstract method for the forward pass through the backbone. - def _init_weights(self, pretrained: str = None): + Args: + x: Input tensor of shape (batch_size, channels, height, width). + + Returns: + Output tensor. """ + pass - Parameters - ---------- - pretrained + def _init_weights(self, pretrained: str = None): + """Initialize the backbone with pretrained weights. - Returns - ------- + Args: + pretrained: Path to the pretrained weights. + Defaults to None. + Returns: + None """ if not pretrained: pass @@ -39,22 +59,36 @@ def _init_weights(self, pretrained: str = None): self.model.load_state_dict(torch.load(pretrained), strict=False) def activate_batch_norm(self, activation: bool = False): - """Turns on or off batch norm layers updating their weights while training + """Activate or deactivate batch normalization layers during training. + + Args: + activation: Activate or deactivate batch normalization. + Defaults to False. - Parameters - --------- - activation: should batch_norm be activated or not for training""" + Returns: + None + """ self.batch_norm_on = activation def train(self, mode=True): - # Bacth Norm should not be on for small batch sizes - super(BaseBackbone, self).train(mode) + """Set the training mode with optional batch normalization activation. + + Args: + mode: Training mode. Defaults to True. + + Returns: + None + + Note: + Batch Norm should not be on for small batch sizes. + """ + super().train(mode) if not self.batch_norm_on: - for m in self.modules(): - if isinstance(m, nn.BatchNorm2d): - m.eval() - m.weight.requires_grad = False - m.bias.requires_grad = False + for module in self.modules(): + if isinstance(module, torch.nn.BatchNorm2d): + module.eval() + module.weight.requires_grad = False + module.bias.requires_grad = False return diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index 8eb4093a3b..ab00a7bb1f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -1,41 +1,65 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import timm import torch -import torch.nn as nn -import torch.nn.functional as F - from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( - BaseBackbone, BACKBONES, + BaseBackbone, ) +from torch.nn import functional as F @BACKBONES.register_module class HRNet(BaseBackbone): - """ - HRNet backbone, this version returns high resolution feature maps of size - 1/4 * original_image_size - This is obtained using bilinear interpolation and concatenation of all the outputs of the - HRNet stages + """HRNet backbone. + + This version returns high-resolution feature maps of size 1/4 * original_image_size. + This is obtained using bilinear interpolation and concatenation of all the outputs + of the HRNet stages. + + Args: + model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). + pretrained: If True, loads the model with ImageNet pre-trained weights. """ def __init__( self, model_name: str = "hrnet_w32", pretrained: bool = True - ) -> nn.Module: - """ - Constructs an ImageNet pre-trained HRNet from timm - (https://github.com/rwightman/pytorch-image-models/blob/main/timm/models/hrnet.py) + ) -> torch.nn.Module: + """Constructs an ImageNet pre-trained HRNet from timm. - Parameters - ---------- - model_name: str - type of HRNet (e.g. 'hrnet_w32, 'hrnet_w48') + Args: + model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). + pretrained: If True, loads the model with ImageNet pre-trained weights. """ super().__init__() _backbone = timm.create_model(model_name, pretrained=pretrained) - _backbone.incre_modules = None # Necesssary to get high resolution features, if not set to None _backbone.forward_features will return low_res images + _backbone.incre_modules = None # Necessary to get high-resolution features; otherwise, _backbone.forward_features will return low-resolution images. self.model = _backbone - def forward(self, x): + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass through the HRNet backbone. + + Args: + x: Input tensor of shape (batch_size, channels, height, width). + + Returns: + Output tensor with concatenated high-resolution feature maps. + + Example: + >>> import torch + >>> from deeplabcut.pose_estimation_pytorch.models.backbones import HRNet + >>> backbone = HRNet(model_name='hrnet_w32', pretrained=False) + >>> x = torch.randn(1, 3, 256, 256) + >>> y = backbone(x) + """ y_list = self.model.forward_features(x) x0_h, x0_w = y_list[0].size(2), y_list[0].size(3) x = torch.cat( @@ -52,22 +76,42 @@ def forward(self, x): @BACKBONES.register_module class HRNetTopDown(BaseBackbone): + """HRNet backbone for the top-down approach. + This version returns only the high-resolution stream. + + Args: + model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). + pretrained: If True, loads the model with ImageNet pre-trained weights. + """ + def __init__(self, model_name: str = "hrnet_w32", pretrained: bool = True): - """ - Constructs an ImageNet pre-trained HRNet from timm - (https://github.com/rwightman/pytorch-image-models/blob/main/timm/models/hrnet.py) + """Constructs an ImageNet pre-trained HRNet from timm. - Parameters - ---------- - model_name: str - type of HRNet (e.g. 'hrnet_w32, 'hrnet_w48') + Args: + model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). + pretrained: If True, loads the model with ImageNet pre-trained weights. """ super().__init__() _backbone = timm.create_model(model_name, pretrained=pretrained) - _backbone.incre_modules = None # Necesssary to get high resolution features, if not set to None _backbone.forward_features will return low_res images + _backbone.incre_modules = None # Necessary to get high-resolution features; otherwise, _backbone.forward_features will return low-resolution images. self.model = _backbone - def forward(self, x): + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass through the HRNet backbone. + + Args: + x: Input tensor of shape (batch_size, channels, height, width). + + Returns: + Output tensor with the high-resolution stream. + + Example: + >>> import torch + >>> from deeplabcut.pose_estimation_pytorch.models.backbones import HRNetTopDown + >>> backbone = HRNetTopDown(model_name='hrnet_w32', pretrained=False) + >>> x = torch.randn(1, 3, 256, 256) + >>> y = backbone(x) + """ return self.model.forward_features(x)[ 0 - ] # Only take the high resolution stream at the end + ] # Only take the high-resolution stream at the end diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 2c73147034..a7eb21a5cb 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -1,29 +1,62 @@ -import torch.nn as nn +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import timm -from typing import Union - from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( - BaseBackbone, BACKBONES, + BaseBackbone, ) +import torch.nn as nn @BACKBONES.register_module class ResNet(BaseBackbone): - """ - Typical ResNet backbone + """ResNet backbone. + + This class represents a typical ResNet backbone for pose estimation. + + Args: + model_name: Name of the ResNet model to use, e.g., 'resnet50', 'resnet101', etc. + Defaults to 'resnet50'. + pretrained: If True, the backbone will be initialized with ImageNet pre-trained weights. + Defaults to True. """ - def __init__( - self, model_name: str = "resnet50", pretrained: bool = True - ) -> nn.Module: - """ - Parameters - ---------- - model_name + def __init__(self, model_name: str = "resnet50", pretrained: bool = True) -> None: + """Initialize the ResNet backbone. + + Args: + model_name: Name of the ResNet model to use, e.g., 'resnet50', 'resnet101', etc. + Defaults to 'resnet50'. + pretrained: If True, the backbone will be initialized with ImageNet pre-trained weights. + Defaults to True. """ super().__init__() self.model = timm.create_model(model_name, pretrained=pretrained) def forward(self, x): + """Forward pass through the ResNet backbone. + + Args: + x: Input tensor. + + Returns: + torch.Tensor: Output tensor. + Example: + >>> import torch + >>> from deeplabcut.pose_estimation_pytorch.models.backbones import ResNet + >>> backbone = ResNet(model_name='resnet50', pretrained=False) + >>> x = torch.randn(1, 3, 256, 256) + >>> y = backbone(x) + + Expected Output Shape: + If input size is (batch_size, 3, shape_x, shape_y), the output shape will be (batch_size, 3, shape_x//32, shape_y//32) + """ return self.model.forward_features(x) diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py index 32b6c66152..c9e41e3769 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py @@ -5,9 +5,5 @@ # (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) # ------------------------------------------------------------------------------ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from .conv_block import BasicBlock, Bottleneck, AdaptBlock -from .conv_module import HighResolutionModule +from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import AdaptBlock, BasicBlock, Bottleneck +from deeplabcut.pose_estimation_pytorch.models.modules.conv_module import HighResolutionModule diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py index 7cc79dcd6b..929f2a8808 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py @@ -5,57 +5,115 @@ # (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) # Modified by Zigang Geng (zigang@mail.ustc.edu.cn). # ------------------------------------------------------------------------------ - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +from abc import ABC, abstractmethod +from typing import Optional import torch import torch.nn as nn import torchvision.ops as ops -from abc import ABC, abstractmethod +from deeplabcut.pose_estimation_pytorch.registry import (Registry, + build_from_cfg) -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - -BLOCKS = Registry('blocks', build_func=build_from_cfg) +BLOCKS = Registry("blocks", build_func=build_from_cfg) class BaseBlock(ABC, nn.Module): + """Abstract Base class for defining custom blocks. + + This class defines an abstract base class for creating custom blocks used in the HigherHRNet for Human Pose Estimation. + + Attributes: + bn_momentum: Batch normalization momentum. + + Methods: + forward(x): Abstract method for defining the forward pass of the block. + _init_weights(pretrained): Method for initializing block weights from pretrained models. + + """ def __init__(self): super().__init__() self.bn_momentum = 0.1 @abstractmethod - def forward(self, x): + def forward(self, x: torch.Tensor): + """Abstract method for defining the forward pass of the block. + + Args: + x: Input tensor. + + Returns: + Output tensor. + """ pass - + def _init_weights(self, pretrained): + """Method for initializing block weights from pretrained models. + + Args: + pretrained: Path to pretrained model weights. + """ if not pretrained: pass else: self.load_state_dict(torch.load(pretrained)) + @BLOCKS.register_module class BasicBlock(BaseBlock): + """Basic Residual Block. + + This class defines a basic residual block used in HigherHRNet. + + Attributes: + expansion: The expansion factor used in the block. + + Args: + in_channels: Number of input channels. + out_channels: Number of output channels. + stride: Stride value for the convolutional layers. Default is 1. + downsample: Downsample layer to be used in the residual connection. Default is None. + dilation: Dilation rate for the convolutional layers. Default is 1. + """ + expansion = 1 - def __init__(self, inplanes, planes, stride=1, - downsample=None, dilation=1): + def __init__(self, in_channels: int, out_channels: int, stride: int = 1, downsample: Optional[nn.Module] = None, dilation: int = 1): super(BasicBlock, self).__init__() - self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=3, stride=stride, - padding=dilation, bias=False, dilation=dilation) - self.bn1 = nn.BatchNorm2d(planes, momentum=self.bn_momentum) + self.conv1 = nn.Conv2d( + in_channels, + out_channels, + kernel_size=3, + stride=stride, + padding=dilation, + bias=False, + dilation=dilation, + ) + self.bn1 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum) self.relu = nn.ReLU(inplace=True) - self.conv2 = nn.Conv2d(inplanes, planes, kernel_size=3, stride=stride, - padding=dilation, bias=False, dilation=dilation) - self.bn2 = nn.BatchNorm2d(planes, momentum=self.bn_momentum) + self.conv2 = nn.Conv2d( + in_channels, + out_channels, + kernel_size=3, + stride=stride, + padding=dilation, + bias=False, + dilation=dilation, + ) + self.bn2 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum) self.downsample = downsample self.stride = stride + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass through the BasicBlock. - def forward(self, x): + Args: + x: Input tensor. + + Returns: + Output tensor. + """ residual = x out = self.conv1(x) @@ -73,27 +131,57 @@ def forward(self, x): return out + @BLOCKS.register_module class Bottleneck(BaseBlock): + """Bottleneck Residual Block. + + This class defines a bottleneck residual block used in HigherHRNet. + + Attributes: + expansion: The expansion factor used in the block. + + Args: + in_channels: Number of input channels. + out_channels: Number of output channels. + stride: Stride value for the convolutional layers. Default is 1. + downsample: Downsample layer to be used in the residual connection. Default is None. + dilation: Dilation rate for the convolutional layers. Default is 1. + """ + expansion = 4 - def __init__(self, inplanes, planes, stride=1, - downsample=None, dilation=1): + def __init__(self, in_channels: int, out_channels: int, stride: int = 1, downsample: Optional[nn.Module] = None, dilation: int = 1): super(Bottleneck, self).__init__() - self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=1, bias=False) - self.bn1 = nn.BatchNorm2d(planes, momentum=self.bn_momentum) - self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=stride, - padding=dilation, bias=False, dilation=dilation) - self.bn2 = nn.BatchNorm2d(planes, momentum=self.bn_momentum) - self.conv3 = nn.Conv2d(planes, planes * self.expansion, kernel_size=1, - bias=False) - self.bn3 = nn.BatchNorm2d(planes * self.expansion, - momentum=self.bn_momentum) + self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) + self.bn1 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum) + self.conv2 = nn.Conv2d( + out_channels, + out_channels, + kernel_size=3, + stride=stride, + padding=dilation, + bias=False, + dilation=dilation, + ) + self.bn2 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum) + self.conv3 = nn.Conv2d( + out_channels, out_channels * self.expansion, kernel_size=1, bias=False + ) + self.bn3 = nn.BatchNorm2d(out_channels * self.expansion, momentum=self.bn_momentum) self.relu = nn.ReLU(inplace=True) self.downsample = downsample self.stride = stride - def forward(self, x): + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass through the Bottleneck block. + + Args: + x : Input tensor. + + Returns: + Output tensor. + """ residual = x out = self.conv1(x) @@ -118,44 +206,85 @@ def forward(self, x): @BLOCKS.register_module class AdaptBlock(BaseBlock): + """Adaptive Residual Block with Deformable Convolution. + + This class defines an adaptive residual block with deformable convolution used in HigherHRNet. + + Attributes: + expansion: The expansion factor used in the block. + + Args: + in_channels: Number of input channels. + out_channels: Number of output channels. + stride: Stride value for the convolutional layers. Default is 1. + downsample: Downsample layer to be used in the residual connection. Default is None. + dilation: Dilation rate for the convolutional layers. Default is 1. + deformable_groups: Number of deformable groups in the deformable convolution. Default is 1. + """ + expansion = 1 - def __init__(self, inplanes, outplanes, stride=1, - downsample=None, dilation=1, deformable_groups=1): + def __init__( + self, + in_channels: int, + out_channels: int, + stride: int = 1, + downsample: Optional[nn.Module] = None, + dilation: int = 1, + deformable_groups: int = 1, + ): super(AdaptBlock, self).__init__() - regular_matrix = torch.tensor([[-1, -1, -1, 0, 0, 0, 1, 1, 1],\ - [-1, 0, 1, -1 ,0 ,1 ,-1, 0, 1]]) - self.register_buffer('regular_matrix', regular_matrix.float()) + regular_matrix = torch.tensor( + [[-1, -1, -1, 0, 0, 0, 1, 1, 1], [-1, 0, 1, -1, 0, 1, -1, 0, 1]] + ) + self.register_buffer("regular_matrix", regular_matrix.float()) self.downsample = downsample - self.transform_matrix_conv = nn.Conv2d(inplanes, 4, 3, 1, 1, bias=True) - self.translation_conv = nn.Conv2d(inplanes, 2, 3, 1, 1, bias=True) - self.adapt_conv = ops.DeformConv2d(inplanes, outplanes, kernel_size=3, stride=stride, \ - padding=dilation, dilation=dilation, bias=False, groups=deformable_groups) - self.bn = nn.BatchNorm2d(outplanes, momentum=self.bn_momentum) + self.transform_matrix_conv = nn.Conv2d(in_channels, 4, 3, 1, 1, bias=True) + self.translation_conv = nn.Conv2d(in_channels, 2, 3, 1, 1, bias=True) + self.adapt_conv = ops.DeformConv2d( + in_channels, + out_channels, + kernel_size=3, + stride=stride, + padding=dilation, + dilation=dilation, + bias=False, + groups=deformable_groups, + ) + self.bn = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum) self.relu = nn.ReLU(inplace=True) - - def forward(self, x): + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass through the AdaptBlock. + + Args: + x: Input tensor. + + Returns: + Output tensor. + """ residual = x N, _, H, W = x.shape transform_matrix = self.transform_matrix_conv(x) - transform_matrix = transform_matrix.permute(0,2,3,1).reshape((N*H*W,2,2)) + transform_matrix = transform_matrix.permute(0, 2, 3, 1).reshape( + (N * H * W, 2, 2) + ) offset = torch.matmul(transform_matrix, self.regular_matrix) - offset = offset-self.regular_matrix - offset = offset.transpose(1,2).reshape((N,H,W,18)).permute(0,3,1,2) + offset = offset - self.regular_matrix + offset = offset.transpose(1, 2).reshape((N, H, W, 18)).permute(0, 3, 1, 2) translation = self.translation_conv(x) - offset[:,0::2,:,:] += translation[:,0:1,:,:] - offset[:,1::2,:,:] += translation[:,1:2,:,:] - + offset[:, 0::2, :, :] += translation[:, 0:1, :, :] + offset[:, 1::2, :, :] += translation[:, 1:2, :, :] + out = self.adapt_conv(x, offset) out = self.bn(out) - + if self.downsample is not None: residual = self.downsample(x) - + out += residual out = self.relu(out) - - return out + return out diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py index 2772ddf3a5..101fc3df05 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py @@ -5,27 +5,48 @@ # (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) # Modified by Zigang Geng (zigang@mail.ustc.edu.cn). # ------------------------------------------------------------------------------ - -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -import os import logging +import os +from typing import List, Optional import torch import torch.nn as nn +from deeplabcut.pose_estimation_pytorch.models.modules import BasicBlock + BN_MOMENTUM = 0.1 logger = logging.getLogger(__name__) class HighResolutionModule(nn.Module): - def __init__(self, num_branches, blocks, num_blocks, num_inchannels, - num_channels, fuse_method, multi_scale_output=True): + """High-Resolution Module. + + This class implements the High-Resolution Module used in HigherHRNet for Human Pose Estimation. + + Args: + num_branches: Number of branches in the module. + block: The block type used in each branch of the module. + num_blocks: List containing the number of blocks in each branch. + num_inchannels: List containing the number of input channels for each branch. + num_channels: List containing the number of output channels for each branch. + fuse_method: The fusion method used in the module. + multi_scale_output: Whether to output multi-scale features. Default is True. + """ + + def __init__( + self, + num_branches: int, + block: BasicBlock, + num_blocks: int, + num_inchannels: int, + num_channels: int, + fuse_method: str, + multi_scale_output: bool = True, + ): super(HighResolutionModule, self).__init__() self._check_branches( - num_branches, blocks, num_blocks, num_inchannels, num_channels) + num_branches, block, num_blocks, num_inchannels, num_channels + ) self.num_inchannels = num_inchannels self.fuse_method = fuse_method @@ -34,64 +55,81 @@ def __init__(self, num_branches, blocks, num_blocks, num_inchannels, self.multi_scale_output = multi_scale_output self.branches = self._make_branches( - num_branches, blocks, num_blocks, num_channels) + num_branches, block, num_blocks, num_channels + ) self.fuse_layers = self._make_fuse_layers() self.relu = nn.ReLU(True) - def _check_branches(self, num_branches, blocks, num_blocks, - num_inchannels, num_channels): + def _check_branches( + self, num_branches: int, block: BasicBlock, num_blocks: int, num_inchannels: int, num_channels: int + ): if num_branches != len(num_blocks): - error_msg = 'NUM_BRANCHES({}) <> NUM_BLOCKS({})'.format( - num_branches, len(num_blocks)) + error_msg = "NUM_BRANCHES({}) <> NUM_BLOCKS({})".format( + num_branches, len(num_blocks) + ) logger.error(error_msg) raise ValueError(error_msg) if num_branches != len(num_channels): - error_msg = 'NUM_BRANCHES({}) <> NUM_CHANNELS({})'.format( - num_branches, len(num_channels)) + error_msg = "NUM_BRANCHES({}) <> NUM_CHANNELS({})".format( + num_branches, len(num_channels) + ) logger.error(error_msg) raise ValueError(error_msg) if num_branches != len(num_inchannels): - error_msg = 'NUM_BRANCHES({}) <> NUM_INCHANNELS({})'.format( - num_branches, len(num_inchannels)) + error_msg = "NUM_BRANCHES({}) <> NUM_INCHANNELS({})".format( + num_branches, len(num_inchannels) + ) logger.error(error_msg) raise ValueError(error_msg) - def _make_one_branch(self, branch_index, block, num_blocks, num_channels, - stride=1): + def _make_one_branch(self, branch_index: int, block: BasicBlock, num_blocks: int, num_channels: int, stride: int = 1) -> nn.Sequential: downsample = None - if stride != 1 or \ - self.num_inchannels[branch_index] != num_channels[branch_index] * block.expansion: + if ( + stride != 1 + or self.num_inchannels[branch_index] + != num_channels[branch_index] * block.expansion + ): downsample = nn.Sequential( - nn.Conv2d(self.num_inchannels[branch_index], - num_channels[branch_index] * block.expansion, - kernel_size=1, stride=stride, bias=False), - nn.BatchNorm2d(num_channels[branch_index] * block.expansion, - momentum=BN_MOMENTUM), + nn.Conv2d( + self.num_inchannels[branch_index], + num_channels[branch_index] * block.expansion, + kernel_size=1, + stride=stride, + bias=False, + ), + nn.BatchNorm2d( + num_channels[branch_index] * block.expansion, momentum=BN_MOMENTUM + ), ) layers = [] - layers.append(block(self.num_inchannels[branch_index], - num_channels[branch_index], stride, downsample)) - self.num_inchannels[branch_index] = \ - num_channels[branch_index] * block.expansion + layers.append( + block( + self.num_inchannels[branch_index], + num_channels[branch_index], + stride, + downsample, + ) + ) + self.num_inchannels[branch_index] = num_channels[branch_index] * block.expansion for i in range(1, num_blocks[branch_index]): - layers.append(block(self.num_inchannels[branch_index], - num_channels[branch_index])) + layers.append( + block(self.num_inchannels[branch_index], num_channels[branch_index]) + ) return nn.Sequential(*layers) - def _make_branches(self, num_branches, block, num_blocks, num_channels): + def _make_branches(self, num_branches: int, block: BasicBlock, num_blocks: int, num_channels: int) -> nn.ModuleList: branches = [] for i in range(num_branches): - branches.append( - self._make_one_branch(i, block, num_blocks, num_channels)) + branches.append(self._make_one_branch(i, block, num_blocks, num_channels)) return nn.ModuleList(branches) - def _make_fuse_layers(self): + def _make_fuse_layers(self) -> nn.ModuleList: if self.num_branches == 1: return None @@ -102,44 +140,73 @@ def _make_fuse_layers(self): fuse_layer = [] for j in range(num_branches): if j > i: - fuse_layer.append(nn.Sequential( - nn.Conv2d(num_inchannels[j], - num_inchannels[i], - 1, - 1, - 0, - bias=False), - nn.BatchNorm2d(num_inchannels[i]), - nn.Upsample(scale_factor=2**(j-i), mode='nearest'))) + fuse_layer.append( + nn.Sequential( + nn.Conv2d( + num_inchannels[j], + num_inchannels[i], + 1, + 1, + 0, + bias=False, + ), + nn.BatchNorm2d(num_inchannels[i]), + nn.Upsample(scale_factor=2 ** (j - i), mode="nearest"), + ) + ) elif j == i: fuse_layer.append(None) else: conv3x3s = [] - for k in range(i-j): + for k in range(i - j): if k == i - j - 1: num_outchannels_conv3x3 = num_inchannels[i] - conv3x3s.append(nn.Sequential( - nn.Conv2d(num_inchannels[j], - num_outchannels_conv3x3, - 3, 2, 1, bias=False), - nn.BatchNorm2d(num_outchannels_conv3x3))) + conv3x3s.append( + nn.Sequential( + nn.Conv2d( + num_inchannels[j], + num_outchannels_conv3x3, + 3, + 2, + 1, + bias=False, + ), + nn.BatchNorm2d(num_outchannels_conv3x3), + ) + ) else: num_outchannels_conv3x3 = num_inchannels[j] - conv3x3s.append(nn.Sequential( - nn.Conv2d(num_inchannels[j], - num_outchannels_conv3x3, - 3, 2, 1, bias=False), - nn.BatchNorm2d(num_outchannels_conv3x3), - nn.ReLU(True))) + conv3x3s.append( + nn.Sequential( + nn.Conv2d( + num_inchannels[j], + num_outchannels_conv3x3, + 3, + 2, + 1, + bias=False, + ), + nn.BatchNorm2d(num_outchannels_conv3x3), + nn.ReLU(True), + ) + ) fuse_layer.append(nn.Sequential(*conv3x3s)) fuse_layers.append(nn.ModuleList(fuse_layer)) return nn.ModuleList(fuse_layers) - def get_num_inchannels(self): + def get_num_inchannels(self) -> int: return self.num_inchannels - def forward(self, x): + def forward(self, x) -> List: + """Forward pass through the HighResolutionModule. + + Args: + x: List of input tensors for each branch. + + Returns: + List of output tensors after processing through the module. + """ if self.num_branches == 1: return [self.branches[0](x[0])] diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py index 0a4afdc6c1..139b541af6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py @@ -1,2 +1,12 @@ -from .base import NECKS -from .transformer import Transformer +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS +from deeplabcut.pose_estimation_pytorch.models.necks.transformer import Transformer diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/base.py b/deeplabcut/pose_estimation_pytorch/models/necks/base.py index 9ca619e115..dd049b0b01 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/base.py @@ -1,19 +1,58 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from abc import ABC, abstractmethod + import torch -import torch.nn as nn from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg NECKS = Registry("necks", build_func=build_from_cfg) -class BaseNeck(ABC, nn.Module): +class BaseNeck(ABC, torch.nn.Module): + """Base Neck class for pose estimation. + + This class defines the base Neck for pose estimation models. + + Attributes: + None + """ + def __init__(self): + """Initialize the BaseNeck. + + Args: + None + """ super().__init__() @abstractmethod - def forward(self, x): + def forward(self, x: torch.Tensor): + """Abstract method for the forward pass through the Neck. + + Args: + x: Input tensor. + + Returns: + Output tensor. + """ pass - def _init_weights(self, pretrained): + def _init_weights(self, pretrained: str): + """Initialize the Neck with pretrained weights. + + Args: + pretrained: Path to the pretrained weights. + + Returns: + None + """ if pretrained: self.model.load_state_dict(torch.load(pretrained)) diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py index 1ace20751c..edfb9a55c9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py @@ -1,56 +1,180 @@ +""" +DeepLabCut Toolbox (deeplabcut.org) +© A. & M.W. Mathis Labs +https://github.com/DeepLabCut/DeepLabCut + +Please see AUTHORS for contributors. +https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS + +Licensed under GNU Lesser General Public License v3.0 +""" + import torch import torch.nn.functional as F from einops import rearrange, repeat -from torch import nn -class Residual(nn.Module): - def __init__(self, fn): +class Residual(torch.nn.Module): + """Residual block module. + + This module implements a residual block for the transformer layers. + + Attributes: + fn: The function to apply in the residual block. + """ + + def __init__(self, fn: torch.nn.Module): + """Initialize the Residual block. + + Args: + fn: The function to apply in the residual block. + """ super().__init__() self.fn = fn - def forward(self, x, **kwargs): + def forward(self, x: torch.Tensor, **kwargs): + """Forward pass through the Residual block. + + Args: + x: Input tensor. + **kwargs: Additional keyword arguments for the function. + + Returns: + Output tensor. + """ return self.fn(x, **kwargs) + x -class PreNorm(nn.Module): - def __init__(self, dim, fn, fusion_factor=1): +class PreNorm(torch.nn.Module): + """PreNorm block module. + + This module implements pre-normalization for the transformer layers. + + Attributes: + dim: Dimension of the input tensor. + fn: The function to apply after normalization. + fusion_factor: Fusion factor for layer normalization. + Defaults to 1. + """ + + def __init__(self, dim: int, fn: torch.nn.Module, fusion_factor: int = 1): + """Initialize the PreNorm block. + + Args: + dim: Dimension of the input tensor. + fn: The function to apply after normalization. + fusion_factor: Fusion factor for layer normalization. + Defaults to 1. + """ super().__init__() - self.norm = nn.LayerNorm(dim * fusion_factor) + self.norm = torch.nn.LayerNorm(dim * fusion_factor) self.fn = fn def forward(self, x, **kwargs): + """Forward pass through the PreNorm block. + + Args: + x: Input tensor. + **kwargs: Additional keyword arguments for the function. + + Returns: + Output tensor. + """ return self.fn(self.norm(x), **kwargs) -class FeedForward(nn.Module): - def __init__(self, dim, hidden_dim, dropout=0.0): +class FeedForward(torch.nn.Module): + """FeedForward block module. + + This module implements the feedforward layer in the transformer layers. + + Attributes: + dim: Dimension of the input tensor. + hidden_dim: Dimension of the hidden layer. + dropout: Dropout rate. Defaults to 0.0. + """ + + def __init__(self, dim: int, hidden_dim: int, dropout: float = 0.0): + """Initialize the FeedForward block. + + Args: + dim: Dimension of the input tensor. + hidden_dim: Dimension of the hidden layer. + dropout: Dropout rate. Defaults to 0.0. + """ super().__init__() - self.net = nn.Sequential( - nn.Linear(dim, hidden_dim), - nn.GELU(), - nn.Dropout(dropout), - nn.Linear(hidden_dim, dim), - nn.Dropout(dropout), + self.net = torch.nn.Sequential( + torch.nn.Linear(dim, hidden_dim), + torch.nn.GELU(), + torch.nn.Dropout(dropout), + torch.nn.Linear(hidden_dim, dim), + torch.nn.Dropout(dropout), ) - def forward(self, x): + def forward(self, x: torch.Tensor): + """Forward pass through the FeedForward block. + + Args: + x: Input tensor. + + Returns: + Output tensor. + """ return self.net(x) -class Attention(nn.Module): +class Attention(torch.nn.Module): + """Attention block module. + + This module implements the attention mechanism in the transformer layers. + + Attributes: + dim: Dimension of the input tensor. + heads: Number of attention heads. Defaults to 8. + dropout: Dropout rate. Defaults to 0.0. + num_keypoints: Number of keypoints. Defaults to None. + scale_with_head: Scale attention with the number of heads. + Defaults to False. + """ + def __init__( - self, dim, heads=8, dropout=0.0, num_keypoints=None, scale_with_head=False + self, + dim: int, + heads: int = 8, + dropout: float = 0.0, + num_keypoints: int = None, + scale_with_head: bool = False, ): + """Initialize the Attention block. + + Args: + dim: Dimension of the input tensor. + heads: Number of attention heads. Defaults to 8. + dropout: Dropout rate. Defaults to 0.0. + num_keypoints: Number of keypoints. Defaults to None. + scale_with_head: Scale attention with the number of heads. + Defaults to False. + """ super().__init__() self.heads = heads self.scale = (dim // heads) ** -0.5 if scale_with_head else dim**-0.5 - self.to_qkv = nn.Linear(dim, dim * 3, bias=False) - self.to_out = nn.Sequential(nn.Linear(dim, dim), nn.Dropout(dropout)) + self.to_qkv = torch.nn.Linear(dim, dim * 3, bias=False) + self.to_out = torch.nn.Sequential( + torch.nn.Linear(dim, dim), torch.nn.Dropout(dropout) + ) self.num_keypoints = num_keypoints - def forward(self, x, mask=None): + def forward(self, x: torch.Tensor, mask: torch.Tensor = None): + """Forward pass through the Attention block. + + Args: + x: Input tensor. + mask: Attention mask. Defaults to None. + + Returns: + Output tensor. + """ b, n, _, h = *x.shape, self.heads qkv = self.to_qkv(x).chunk(3, dim=-1) q, k, v = map(lambda t: rearrange(t, "b n (h d) -> b h n d", h=h), qkv) @@ -74,25 +198,54 @@ def forward(self, x, mask=None): return out -class TransformerLayer(nn.Module): +class TransformerLayer(torch.nn.Module): + """TransformerLayer block module. + + This module implements the Transformer layer in the transformer model. + + Attributes: + dim: Dimension of the input tensor. + depth: Depth of the transformer layer. + heads: Number of attention heads. + mlp_dim: Dimension of the MLP layer. + dropout: Dropout rate. + num_keypoints: Number of keypoints. Defaults to None. + all_attn: Apply attention to all keypoints. + Defaults to False. + scale_with_head: Scale attention with the number of heads. + Defaults to False. + """ + def __init__( self, - dim, - depth, - heads, - mlp_dim, - dropout, - num_keypoints=None, - all_attn=False, - scale_with_head=False, + dim: int, + depth: int, + heads: int, + mlp_dim: int, + dropout: float, + num_keypoints: int = None, + all_attn: bool = False, + scale_with_head: bool = False, ): + """Initialize the TransformerLayer block. + + Args: + dim: Dimension of the input tensor. + depth: Depth of the transformer layer. + heads: Number of attention heads. + mlp_dim: Dimension of the MLP layer. + dropout: Dropout rate. + num_keypoints: Number of keypoints. Defaults to None. + all_attn: Apply attention to all keypoints. Defaults to False. + scale_with_head: Scale attention with the number of heads. Defaults to False. + """ super().__init__() - self.layers = nn.ModuleList([]) + self.layers = torch.nn.ModuleList([]) self.all_attn = all_attn self.num_keypoints = num_keypoints for _ in range(depth): self.layers.append( - nn.ModuleList( + torch.nn.ModuleList( [ Residual( PreNorm( @@ -113,7 +266,19 @@ def __init__( ) ) - def forward(self, x, mask=None, pos=None): + def forward( + self, x: torch.Tensor, mask: torch.Tensor = None, pos: torch.Tensor = None + ): + """Forward pass through the TransformerLayer block. + + Args: + x: Input tensor. + mask: Attention mask. Defaults to None. + pos: Positional encoding. Defaults to None. + + Returns: + Output tensor. + """ for idx, (attn, ff) in enumerate(self.layers): if idx > 0 and self.all_attn: x[:, self.num_keypoints :] += pos diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py index 45f13174e6..2a29d6dd9b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py @@ -1,33 +1,95 @@ +""" +DeepLabCut Toolbox (deeplabcut.org) +© A. & M.W. Mathis Labs +https://github.com/DeepLabCut/DeepLabCut + +Please see AUTHORS for contributors. +https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS + +Licensed under GNU Lesser General Public License v3.0 +""" +from typing import Tuple + import torch from einops import rearrange, repeat -from torch import nn from timm.layers import trunc_normal_ + +from .base import NECKS from .layers import TransformerLayer from .utils import make_sine_position_embedding -from .base import NECKS MIN_NUM_PATCHES = 16 BN_MOMENTUM = 0.1 @NECKS.register_module -class Transformer(nn.Module): +class Transformer(torch.nn.Module): + """Transformer Neck for pose estimation. + title={TokenPose: Learning Keypoint Tokens for Human Pose Estimation}, + author={Yanjie Li and Shoukui Zhang and Zhicheng Wang and Sen Yang and Wankou Yang and Shu-Tao Xia and Erjin Zhou}, + booktitle={IEEE/CVF International Conference on Computer Vision (ICCV)}, + year={2021} + + Args: + feature_size: Size of the input feature map (height, width). + patch_size: Size of each patch used in the transformer. + num_keypoints: Number of keypoints in the pose estimation task. + dim: Dimension of the transformer. + depth: Number of transformer layers. + heads: Number of self-attention heads in the transformer. + mlp_dim: Dimension of the MLP used in the transformer. + Defaults to 3. + apply_init: Whether to apply weight initialization. + Defaults to False. + heatmap_size: Size of the heatmap. Defaults to [64, 64]. + channels: Number of channels in each patch. Defaults to 32. + dropout: Dropout rate for embeddings. Defaults to 0.0. + emb_dropout: Dropout rate for transformer layers. + Defaults to 0.0. + pos_embedding_type: Type of positional embedding. + Either 'sine-full', 'sine', or 'learnable'. + Defaults to "sine-full". + + Examples: + # Creating a Transformer neck with sine positional embedding + transformer = Transformer( + feature_size=(128, 128), + patch_size=(16, 16), + num_keypoints=17, + dim=256, + depth=6, + heads=8, + pos_embedding_type="sine" + ) + + # Creating a Transformer neck with learnable positional embedding + transformer = Transformer( + feature_size=(256, 256), + patch_size=(32, 32), + num_keypoints=17, + dim=512, + depth=12, + heads=16, + pos_embedding_type="learnable" + ) + """ + def __init__( self, *, - feature_size, - patch_size, - num_keypoints, - dim, - depth, - heads, - mlp_dim=3, - apply_init=False, - heatmap_size=[64, 64], - channels=32, - dropout=0.0, - emb_dropout=0.0, - pos_embedding_type="sine-full" + feature_size: Tuple[int, int], + patch_size: Tuple[int, int], + num_keypoints: int, + dim: int, + depth: int, + heads: int, + mlp_dim: int = 3, + apply_init: bool = False, + heatmap_size: Tuple[int, int] = (64, 64), + channels: int = 32, + dropout: float = 0.0, + emb_dropout: float = 0.0, + pos_embedding_type: str = "sine-full" ): super().__init__() @@ -44,15 +106,17 @@ def __init__( self.pos_embedding_type = pos_embedding_type self.all_attn = self.pos_embedding_type == "sine-full" - self.keypoint_token = nn.Parameter(torch.zeros(1, self.num_keypoints, dim)) + self.keypoint_token = torch.nn.Parameter( + torch.zeros(1, self.num_keypoints, dim) + ) h, w = feature_size[0] // (self.patch_size[0]), feature_size[1] // ( self.patch_size[1] ) self._make_position_embedding(w, h, dim, pos_embedding_type) - self.patch_to_embedding = nn.Linear(patch_dim, dim) - self.dropout = nn.Dropout(emb_dropout) + self.patch_to_embedding = torch.nn.Linear(patch_dim, dim) + self.dropout = torch.nn.Dropout(emb_dropout) self.transformer1 = TransformerLayer( dim, @@ -84,40 +148,61 @@ def __init__( scale_with_head=True, ) - self.to_keypoint_token = nn.Identity() + self.to_keypoint_token = torch.nn.Identity() if apply_init: self.apply(self._init_weights) - def _make_position_embedding(self, w, h, d_model, pe_type="learnable"): - """ - d_model: embedding size in transformer encoder + def _make_position_embedding( + self, w: int, h: int, d_model: int, pe_type="learnable" + ): + """Create position embeddings for the transformer. + + Args: + w: Width of the input feature map. + h: Height of the input feature map. + d_model: Dimension of the transformer encoder. + pe_type: Type of position embeddings. + Either "learnable" or "sine". Defaults to "learnable". """ with torch.no_grad(): self.pe_h = h self.pe_w = w length = h * w if pe_type != "learnable": - self.pos_embedding = nn.Parameter( + self.pos_embedding = torch.nn.Parameter( make_sine_position_embedding(h, w, d_model), requires_grad=False ) else: - self.pos_embedding = nn.Parameter( + self.pos_embedding = torch.nn.Parameter( torch.zeros(1, self.num_patches + self.num_keypoints, d_model) ) - def _make_layer(self, block, planes, blocks, stride=1): + def _make_layer( + self, block: torch.nn.Module, planes: int, blocks: int, stride: int = 1 + ) -> torch.nn.Sequential: + """Create a layer of the transformer encoder. + + Args: + block: The basic building block of the layer. + planes: Number of planes in the layer. + blocks: Number of blocks in the layer. + stride: Stride value. Defaults to 1. + + Returns: + The layer of the transformer encoder. + """ downsample = None if stride != 1 or self.inplanes != planes * block.expansion: - downsample = nn.Sequential( - nn.Conv2d( + downsample = torch.nn.Sequential( + torch.nn.Conv2d( self.inplanes, planes * block.expansion, kernel_size=1, stride=stride, bias=False, ), - nn.BatchNorm2d(planes * block.expansion, momentum=BN_MOMENTUM), + torch.nn.BatchNorm2d(planes * block.expansion, momentum=BN_MOMENTUM), ) layers = [] @@ -126,19 +211,38 @@ def _make_layer(self, block, planes, blocks, stride=1): for i in range(1, blocks): layers.append(block(self.inplanes, planes)) - return nn.Sequential(*layers) + return torch.nn.Sequential(*layers) - def _init_weights(self, m): + def _init_weights(self, m: torch.nn.Module): + """Initialize the weights of the model. + + Args: + m: A module of the model. + """ print("Initialization...") - if isinstance(m, nn.Linear): + if isinstance(m, torch.nn.Linear): trunc_normal_(m.weight, std=0.02) - if isinstance(m, nn.Linear) and m.bias is not None: - nn.init.constant_(m.bias, 0) - elif isinstance(m, nn.LayerNorm): - nn.init.constant_(m.bias, 0) - nn.init.constant_(m.weight, 1.0) + if isinstance(m, torch.nn.Linear) and m.bias is not None: + torch.nn.init.constant_(m.bias, 0) + elif isinstance(m, torch.nn.LayerNorm): + torch.nn.init.constant_(m.bias, 0) + torch.nn.init.constant_(m.weight, 1.0) + + def forward(self, feature: torch.Tensor, mask = None) -> torch.Tensor: + """Forward pass through the Transformer neck. - def forward(self, feature, mask=None): + Args: + feature: Input feature map. + mask: Mask to apply to the transformer. + Defaults to None. + + Returns: + Output tensor from the transformer neck. + + Examples: + # Assuming feature is a torch.Tensor of shape (batch_size, channels, height, width) + output = transformer(feature) + """ p = self.patch_size x = rearrange( diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/utils.py b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py index ad4070890b..1a3db92c40 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py @@ -1,8 +1,40 @@ -import torch +""" +DeepLabCut Toolbox (deeplabcut.org) +© A. & M.W. Mathis Labs +https://github.com/DeepLabCut/DeepLabCut + +Please see AUTHORS for contributors. +https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS + +Licensed under GNU Lesser General Public License v3.0 +""" + import math +import torch + + +def make_sine_position_embedding( + h: int, w: int, d_model: int, temperature: int = 10000, scale: float = 2 * math.pi +) -> torch.Tensor: + """Generate sine position embeddings for a given height, width, and model dimension. + + Args: + h: Height of the embedding. + w: Width of the embedding. + d_model: Dimension of the model. + temperature: Temperature parameter for position embedding calculation. + Defaults to 10000. + scale: Scaling factor for position embedding. Defaults to 2 * math.pi. + + Returns: + Sine position embeddings with shape (batch_size, d_model, h * w). -def make_sine_position_embedding(h, w, d_model, temperature=10000, scale=2 * math.pi): + Example: + >>> h, w, d_model = 10, 20, 512 + >>> pos_emb = make_sine_position_embedding(h, w, d_model) + >>> print(pos_emb.shape) # Output: torch.Size([1, 512, 200]) + """ area = torch.ones(1, h, w) y_embed = area.cumsum(1, dtype=torch.float32) x_embed = area.cumsum(2, dtype=torch.float32) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index 1080063c4b..95f7ec3a5d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -1,4 +1,14 @@ -from .base import PREDICTORS, BasePredictor -from .single_predictor import SinglePredictor -from .dekr_predictor import DEKRPredictor -from .top_down_prediction import TopDownPredictor +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from deeplabcut.pose_estimation_pytorch.models.predictors.base import PREDICTORS, BasePredictor +from deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor import DEKRPredictor +from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import SinglePredictor +from deeplabcut.pose_estimation_pytorch.models.predictors.top_down_prediction import TopDownPredictor diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index fd850e670c..24b4fe428d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -8,18 +8,36 @@ # # Licensed under GNU Lesser General Public License v3.0 # -from abc import ABC, abstractmethod -import torch.nn as nn +from abc import ABC, abstractmethod from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - +from torch import nn PREDICTORS = Registry("predictors", build_func=build_from_cfg) class BasePredictor(ABC, nn.Module): - """A base predictor""" + """The base Predictor class. + + This class is an abstract base class (ABC) for defining predictors used in the DeepLabCut Toolbox. + All predictor classes should inherit from this base class and implement the forward method. + Regresses keypoint coordinates from model's output maps + + Attributes: + num_animals: Number of animals in the project. Should be set in subclasses. + + Example: + # Create a subclass that inherits from BasePredictor and implements the forward method. + class MyPredictor(BasePredictor): + def __init__(self, num_animals): + super().__init__() + self.num_animals = num_animals + + def forward(self, outputs): + # Implement the forward pass of your custom predictor here. + pass + """ def __init__(self): super().__init__() @@ -28,4 +46,15 @@ def __init__(self): @abstractmethod def forward(self, outputs): + """Abstract method for the forward pass of the Predictor. + + Args: + outputs: Output tensors from previous layers. + + Returns: + Tensor: Output tensor. + + Raises: + NotImplementedError: This method must be implemented in subclasses. + """ pass diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 3a24531854..37f720f105 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -1,8 +1,17 @@ -import torch -import torch.nn as nn +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from typing import Tuple +import torch from deeplabcut.pose_estimation_pytorch.models.predictors import ( PREDICTORS, BasePredictor, @@ -11,8 +20,10 @@ @PREDICTORS.register_module class DEKRPredictor(BasePredictor): - """ - Regresses keypoints and assembles them (if multianimal project) from DEKR output + """DEKR Predictor class for multi-animal pose estimation. + + This class regresses keypoints and assembles them (if multianimal project) + from the output of DEKR (Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression). Based on: Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang @@ -20,6 +31,27 @@ class DEKRPredictor(BasePredictor): 2021 Code based on: https://github.com/HRNet/DEKR + + Args: + num_animals (int): Number of animals in the project. + detection_threshold (float, optional): Threshold for detection. Defaults to 0.01. + apply_sigmoid (bool, optional): Apply sigmoid to heatmaps. Defaults to True. + use_heatmap (bool, optional): Use heatmap. Defaults to True. + + Attributes: + num_animals (int): Number of animals in the project. + detection_threshold (float): Threshold for detection. + apply_sigmoid (bool): Apply sigmoid to heatmaps. + use_heatmap (bool): Use heatmap. + + Example: + # Create a DEKRPredictor instance with 2 animals. + predictor = DEKRPredictor(num_animals=2) + + # Make a forward pass with outputs and scale factors. + outputs = (heatmaps, offsets) # tuple of heatmaps and offsets + scale_factors = (0.5, 0.5) # tuple of scale factors for the poses + poses_with_scores = predictor.forward(outputs, scale_factors) """ default_init = {"apply_sigmoid": True, "detection_threshold": 0.01} @@ -29,8 +61,19 @@ def __init__( num_animals: int, detection_threshold: float = 0.01, apply_sigmoid: bool = True, - use_heatmap=True, + use_heatmap: bool = True, ): + """Initializes the DEKRPredictor class. + + Args: + num_animals: Number of animals in the project. + detection_threshold: Threshold for detection. Defaults to 0.01. + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + use_heatmap: Use heatmap. Defaults to True. + + Returns: + None + """ super().__init__() self.num_animals = num_animals @@ -39,11 +82,26 @@ def __init__( self.use_heatmap = use_heatmap self.max_absorb_distance = 75 - def forward(self, outputs, scale_factors: Tuple[float, float]): - # TODO implement confidence scores for each keypoints + def forward(self, outputs: Tuple, scale_factors: Tuple[float, float]): + """Forward pass of DEKRPredictor. + + Args: + outputs: Tuple of heatmaps and offsets. + scale_factors: Scale factors for the poses. + + Returns: + Poses with scores. + + Example: + # Assuming you have 'outputs' (heatmaps and offsets) and 'scale_factors' for poses + poses_with_scores = predictor.forward(outputs, scale_factors) + + Notes: + TODO: implement confidence scores for each keypoints + """ heatmaps, offsets = outputs if self.apply_sigmoid: - heatmaps = nn.Sigmoid()(heatmaps) + heatmaps = torch.nn.Sigmoid()(heatmaps) posemap = self.offset_to_pose(offsets) batch_size, num_joints_with_center, h, w = heatmaps.shape @@ -76,7 +134,23 @@ def forward(self, outputs, scale_factors: Tuple[float, float]): return poses_w_scores - def get_locations(self, height: int, width: int, device: torch.device): + def get_locations( + self, height: int, width: int, device: torch.device + ) -> torch.Tensor: + """Get locations for offsets. + + Args: + height: Height of the offsets. + width: Width of the offsets. + device: Device to use. + + Returns: + Offset locations. + + Example: + # Assuming you have 'height', 'width', and 'device' + locations = predictor.get_locations(height, width, device) + """ shifts_x = torch.arange(0, width, step=1, dtype=torch.float32).to(device) shifts_y = torch.arange(0, height, step=1, dtype=torch.float32).to(device) shift_y, shift_x = torch.meshgrid(shifts_y, shifts_x, indexing="ij") @@ -86,9 +160,19 @@ def get_locations(self, height: int, width: int, device: torch.device): return locations - def get_reg_poses(self, offsets: torch.Tensor, num_joints: int): - """ - offsets : (batch_size, num_joints*2, h, w) + def get_reg_poses(self, offsets: torch.Tensor, num_joints: int) -> torch.Tensor: + """Get the regression poses from offsets. + + Args: + offsets: Offsets tensor. + num_joint: Number of joints. + + Returns: + Regression poses. + + Example: + # Assuming you have 'offsets' tensor and 'num_joints' + regression_poses = predictor.get_reg_poses(offsets, num_joints) """ batch_size, _, h, w = offsets.shape offsets = offsets.permute(0, 2, 3, 1).reshape(batch_size, h * w, num_joints, 2) @@ -98,13 +182,18 @@ def get_reg_poses(self, offsets: torch.Tensor, num_joints: int): return poses - def offset_to_pose(self, offsets: torch.Tensor): - """ - offsets : (batch_size, num_joints*2, h, w) + def offset_to_pose(self, offsets: torch.Tensor) -> torch.Tensor: + """Convert offsets to poses. + + Args: + offsets: Offsets tensor. + + Returns: + Poses from offsets. - RETURN - --------- - reg_poses : (batch_size, 2*num_joints, h, w) + Example: + # Assuming you have 'offsets' tensor + poses = predictor.offset_to_pose(offsets) """ batch_size, num_offset, h, w = offsets.shape num_joints = int(num_offset / 2) @@ -119,9 +208,18 @@ def offset_to_pose(self, offsets: torch.Tensor): return reg_poses - def max_pool(self, heatmap: torch.Tensor): - """ - heatmap: (batch_size, h, w) + def max_pool(self, heatmap: torch.Tensor) -> torch.Tensor: + """Apply max pooling to the heatmap. + + Args: + heatmap: Heatmap tensor. + + Returns: + Max pooled heatmap. + + Example: + # Assuming you have 'heatmap' tensor + max_pooled_heatmap = predictor.max_pool(heatmap) """ pool1 = torch.nn.MaxPool2d(3, 1, 1) pool2 = torch.nn.MaxPool2d(5, 1, 2) @@ -133,9 +231,20 @@ def max_pool(self, heatmap: torch.Tensor): return maxm - def get_top_values(self, heatmap: torch.Tensor): - """ - heatmap: (batch_size, h, w) + def get_top_values( + self, heatmap: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Get top values from the heatmap. + + Args: + heatmap: Heatmap tensor. + + Returns: + Position indices and scores. + + Example: + # Assuming you have 'heatmap' tensor + positions, scores = predictor.get_top_values(heatmap) """ maximum = self.max_pool(heatmap) maximum = torch.eq(maximum, heatmap) @@ -186,10 +295,21 @@ def _update_pose_with_heatmaps( return poses - def get_heat_value(self, pose_coords, heatmaps): - """ - pose_coords : (batch_size, num_people, num_joints, 2) - heatmaps : (batch_size, 1+num_joints, h, w) + def get_heat_value( + self, pose_coords: torch.Tensor, heatmaps: torch.Tensor + ) -> torch.Tensor: + """Get heat values for pose coordinates and heatmaps. + + Args: + pose_coords: Pose coordinates tensor (batch_size, num_people, num_joints, 2) + heatmaps: Heatmaps tensor (batch_size, 1+num_joints, h, w). + + Returns: + Heat values. + + Example: + # Assuming you have 'pose_coords' and 'heatmaps' tensors + heat_values = predictor.get_heat_value(pose_coords, heatmaps) """ h, w = heatmaps.shape[2:] heatmaps_nocenter = heatmaps[:, :-1].flatten( @@ -204,13 +324,19 @@ def get_heat_value(self, pose_coords, heatmaps): return heatvals - def pose_nms(self, heatmaps, poses): - """ - NMS for the regressed poses results. + def pose_nms(self, heatmaps: torch.Tensor, poses: torch.Tensor): + """Non-Maximum Suppression (NMS) for regressed poses. Args: - heatmaps (Tensor): Avg of the heatmaps at all scales (batch_size, 1+num_joints, h, w) - poses (List): Gather of the pose proposals (batch_size, num_people, num_joints, 3) + heatmaps: Heatmaps tensor. + poses: Pose proposals. + + Returns: + None + + Example: + # Assuming you have 'heatmaps' and 'poses' tensors + predictor.pose_nms(heatmaps, poses) """ pose_scores = poses[:, :, :, 2] pose_coords = poses[:, :, :, :2] @@ -222,6 +348,7 @@ def pose_nms(self, heatmaps, poses): heatvals = self.get_heat_value(pose_coords, heatmaps) heat_score = (torch.sum(heatvals, dim=1) / num_joints)[:, 0] + # return heat_score # pose_score = pose_score*heatvals # poses = torch.cat([pose_coord.cpu(), pose_score.cpu()], dim=2) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index b84950bd2e..fb1a3dc755 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -1,6 +1,17 @@ -import torch -import torch.nn as nn +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +from typing import Tuple +import torch from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( PREDICTORS, BasePredictor, @@ -9,10 +20,16 @@ @PREDICTORS.register_module class SinglePredictor(BasePredictor): - """ - Predictor only intended for single animal pose estimation + """Predictor class for single animal pose estimation. - Regresses keypoints from heatmaps and locref_maps of baseline DLC model (ResNet + Deconv) + Args: + num_animals: Number of animals in the project. + location_refinement: Enable location refinement. + locref_stdev: Standard deviation for location refinement. + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + + Returns: + Regressed keypoints from heatmaps and locref_maps of baseline DLC model (ResNet + Deconv). """ default_init = { @@ -22,10 +39,27 @@ class SinglePredictor(BasePredictor): } def __init__( - self, num_animals, location_refinement, locref_stdev, apply_sigmoid: bool = True + self, + num_animals: int, + location_refinement: bool, + locref_stdev: float, + apply_sigmoid: bool = True, ): + """Initializes the SinglePredictor class. + + Args: + num_animals: Number of animals in the project. + location_refinement : Enable location refinement. + locref_stdev: Standard deviation for location refinement. + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + + Returns: + None + + Notes: + TODO: add num_animals in pytorch_cfg automatically + """ super().__init__() - # TODO add num_animals in pytorch_cfg automatically self.num_animals = num_animals assert ( self.num_animals == 1, @@ -34,14 +68,28 @@ def __init__( self.location_refinement = location_refinement self.locref_stdev = locref_stdev self.apply_sigmoid = apply_sigmoid - self.sigmoid = nn.Sigmoid() - - def forward(self, output, scale_factors): - """ - get predictions from model output - output = heatmaps, locref - heatmaps: torch.Tensor([batch_size, num_joints, height, width]) - locref: torch.Tensor([batch_size, num_joints, height, width]) + self.sigmoid = torch.nn.Sigmoid() + + def forward( + self, output: Tuple[torch.Tensor, torch.Tensor], scale_factors + ) -> torch.Tensor: + """Forward pass of SinglePredictor. Gets predictions from model output. + + Args: + output: Output tensors from previous layers. + output = heatmaps, locref + heatmaps: torch.Tensor([batch_size, num_joints, height, width]) + locref: torch.Tensor([batch_size, num_joints, height, width]) + scale_factors: Scale factors for the poses. + + Returns: + Poses with scores. + + Example: + >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) + >>> output = (torch.rand(32, 17, 64, 64), torch.rand(32, 17, 64, 64)) + >>> scale_factors = (0.5, 0.5) + >>> poses = predictor.forward(output, scale_factors) """ heatmaps, locrefs = output if self.apply_sigmoid: @@ -58,7 +106,22 @@ def forward(self, output, scale_factors): ) return poses - def get_top_values(self, heatmap) -> torch.Tensor: + def get_top_values( + self, heatmap: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Get the top values from the heatmap. + + Args: + heatmap: Heatmap tensor. + + Returns: + Y and X indices of the top values. + + Example: + >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) + >>> heatmap = torch.rand(32, 17, 64, 64) + >>> Y, X = predictor.get_top_values(heatmap) + """ batchsize, ny, nx, num_joints = heatmap.shape heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints) @@ -67,14 +130,26 @@ def get_top_values(self, heatmap) -> torch.Tensor: Y, X = heatmap_top // nx, heatmap_top % nx return Y, X - def get_pose_prediction(self, heatmap, locref, scale_factors): + def get_pose_prediction( + self, heatmap: torch.Tensor, locref: torch.Tensor, scale_factors + ) -> torch.Tensor: + """Gets the pose prediction given the heatmaps and locref. + + Args: + heatmap: Heatmap tensor with the following format (batch_size, height, width, num_joints) + locref: Locref tensor with the following format (batch_size, height, width, num_joints, 2) + scale_factors: Scale factors for the poses. + + Returns: + Pose predictions of the format: (batch_size, num_people = 1, num_joints, 3) + + Example: + >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) + >>> heatmap = torch.rand(32, 17, 64, 64) + >>> locref = torch.rand(32, 17, 64, 64, 2) + >>> scale_factors = (0.5, 0.5) + >>> poses = predictor.get_pose_prediction(heatmap, locref, scale_factors) """ - heatmap shape : (batch_size, height, width, num_joints) - locref shape : (batch_size, height, width, num_joints, 2) - - RETURN - ---------- - pose : (batch_size, num_people = 1, num_joints, 3)""" Y, X = self.get_top_values(heatmap) batch_size, num_joints = X.shape @@ -100,7 +175,15 @@ def get_pose_prediction(self, heatmap, locref, scale_factors): @PREDICTORS.register_module class HeatmapOnlyPredictor(BasePredictor): - """Predictor only intended for single animal pose estimation, without locref""" + """Predictor only intended for single animal pose estimation, without locref. + + Args: + num_animals: Number of animals in the project. + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + + Returns: + Regressed keypoints from heatmaps. + """ default_init = { "location_refinement": True, @@ -108,23 +191,48 @@ class HeatmapOnlyPredictor(BasePredictor): "apply_sigmoid": True, } - def __init__(self, num_animals, apply_sigmoid: bool = True): + def __init__(self, num_animals: int, apply_sigmoid: bool = True): + """Initializes the HeatmapOnlyPredictor class. + + Args: + num_animals: Number of animals in the project. + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + + Returns: + None + + Notes: + TODO: add num_animals in pytorch_cfg automatically + """ super().__init__() - # TODO add num_animals in pytorch_cfg automatically self.num_animals = num_animals assert ( self.num_animals == 1, "SinglePredictor must only be used for single animal predictions", ) self.apply_sigmoid = apply_sigmoid - self.sigmoid = nn.Sigmoid() - - def forward(self, output, scale_factors): - """ - get predictions from model output - output = heatmaps - heatmaps: torch.Tensor([batch_size, num_joints, height, width]) - locref: torch.Tensor([batch_size, num_joints, height, width]) + self.sigmoid = torch.nn.Sigmoid() + + def forward( + self, output: Tuple[torch.Tensor, torch.Tensor], scale_factors + ) -> torch.Tensor: + """Forward pass of HeatmapOnlyPredictor. Computes predictions from the trained model output. + + Args: + output: Output tensors from previous layers. + output = heatmaps + heatmaps: torch.Tensor([batch_size, num_joints, height, width]) + locref: torch.Tensor([batch_size, num_joints, height, width]) + scale_factors: Scale factors for the poses. + + Returns: + Poses with scores. + + Example: + >>> predictor = HeatmapOnlyPredictor(num_animals=1, apply_sigmoid=True) + >>> output = (torch.rand(32, 17, 64, 64), torch.rand(32, 17, 64, 64)) + >>> scale_factors = (0.5, 0.5) + >>> poses = predictor.forward(output, scale_factors) """ heatmaps = output[0] if self.apply_sigmoid: @@ -134,7 +242,22 @@ def forward(self, output, scale_factors): poses = self.get_pose_prediction(heatmaps, scale_factors) return poses - def get_top_values(self, heatmap) -> torch.Tensor: + def get_top_values( + self, heatmap: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Get the top values from the heatmap. + + Args: + heatmap: Heatmap tensor. + + Returns: + Y and X indices of the top values. + + Example: + >>> predictor = HeatmapOnlyPredictor(num_animals=1, apply_sigmoid=True) + >>> heatmap = torch.rand(32, 17, 64, 64) + >>> Y, X = predictor.get_top_values(heatmap) + """ batchsize, ny, nx, num_joints = heatmap.shape heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints) @@ -143,14 +266,25 @@ def get_top_values(self, heatmap) -> torch.Tensor: Y, X = heatmap_top // nx, heatmap_top % nx return Y, X - def get_pose_prediction(self, heatmap, scale_factors): - """ - TODO: optimize that so DZ looks right - heatmap shape : (batch_size, height, width, num_joints) + def get_pose_prediction(self, heatmap: torch.Tensor, scale_factors): + """Get the pose prediction from heatmaps. - RETURN - ---------- - pose : (batch_size, num_people = 1, num_joints, 3)""" + Args: + heatmap: Heatmap tensor with shape (batch_size, height, width, num_joints) + scale_factors: Scale factors for the poses. + + Returns: + Pose predictions following the format: (batch_size, num_people = 1, num_joints, 3) + + Notes: + TODO: optimize that so DZ looks right + + Example: + >>> predictor = HeatmapOnlyPredictor(num_animals=1, apply_sigmoid=True) + >>> heatmap = torch.rand(32, 17, 64, 64) + >>> scale_factors = (0.5, 0.5) + >>> poses = predictor.get_pose_prediction(heatmap, scale_factors) + """ Y, X = self.get_top_values(heatmap) batch_size, num_joints = X.shape diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py index ee560bc42e..efcb76458c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py @@ -1,6 +1,15 @@ -import torch -from typing import List +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import torch from deeplabcut.pose_estimation_pytorch.models.predictors import ( PREDICTORS, BasePredictor, @@ -9,16 +18,15 @@ @PREDICTORS.register_module class TopDownPredictor(BasePredictor): - def __init__(self, format_bbox: str = "xyxy"): - """ - Predictor for regressing keypoints in a Top Down fashion based on bbox predictions - and regressed keypoints in cropped images + """Predictor for regressing keypoints in a Top Down fashion based on bbox predictions + and regressed keypoints in cropped images. - Thus it should take as keypoint regressions outputs from another standard pose_estimation predictor + Args: + format_bbox: Format of the bounding box prediction, + either 'xyxy' or 'coco'. Defaults to "xyxy". + """ - Arguments: - - format_bbox : str, format of the bounding box prediction, either 'xyxy' or 'coco' - """ + def __init__(self, format_bbox: str = "xyxy"): super().__init__() self.format_bbox = format_bbox @@ -27,10 +35,10 @@ def _convert_bbox_to_coco(self, bboxes: torch.Tensor) -> torch.Tensor: """Convert bboxes in the format (x1, y1, x2, y2) to coco format (x, y, w, h) Args: - bboxes (torch.Tensor): bboxes, shape (batch_size, max_num_animals, 4) + bboxes: Bounding boxes of the shape (batch_size, max_num_animals, 4) Returns: - torch.Tensor: coco_bboxes, shape (batch_size, max_num_animals, 4) + coco_bboxes, shape (batch_size, max_num_animals, 4) """ coco_bboxes = bboxes.clone() coco_bboxes[:, :, 2] -= coco_bboxes[:, :, 0] @@ -42,14 +50,14 @@ def forward( self, bboxes: torch.Tensor, keypoints_cropped: torch.Tensor ) -> torch.Tensor: """Computes keypoints coordinates in the original image given predicted bbox and predicted - keypoints coordinates inside the bbox cropped image + keypoints coordinates inside the bbox cropped image. Args: - bboxes (torch.Tensor): shape : (batch_size, max_num_animals, 4), - keypoints_cropped (torch.Tensor): shape of keypoints (batch_size, max_num_animals, num_joints, 3) + bboxes: Bounding boxes of the shape (batch_size, max_num_animals, 4) + keypoints_cropped: Keypoints with the shape (batch_size, max_num_animals, num_joints, 3) Returns: - torch.Tensor: keypoints (batch_size, max_num_animals, num_joints, 3) + Keypoints tensor of the shape: (batch_size, max_num_animals, num_joints, 3) """ if self.format_bbox != "coco": bboxes = self._convert_bbox_to_coco(bboxes) @@ -59,7 +67,7 @@ def forward( x_corners = (bboxes[:, :, 0]).unsqueeze(2).expand(-1, -1, num_joints) y_corners = (bboxes[:, :, 1]).unsqueeze(2).expand(-1, -1, num_joints) - # TODO harcoded 256 + # TODO hardcoded 256 scales_x = (bboxes[:, :, 2] / 256).unsqueeze(2).expand(-1, -1, num_joints) scales_y = (bboxes[:, :, 3] / 256).unsqueeze(2).expand(-1, -1, num_joints) diff --git a/deeplabcut/pose_estimation_pytorch/registry.py b/deeplabcut/pose_estimation_pytorch/registry.py index 6009059d2c..a2322ca09a 100644 --- a/deeplabcut/pose_estimation_pytorch/registry.py +++ b/deeplabcut/pose_estimation_pytorch/registry.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import inspect from functools import partial from typing import Any, Dict, Optional @@ -6,15 +16,31 @@ def build_from_cfg( cfg: Dict, registry: "Registry", default_args: Optional[Dict] = None ) -> Any: - """Build a module from config dict when it is a class configuration, or - call a function from config dict when it is a function configuration. + """Builds a module from the configuration dictionary when it represents a class configuration, + or call a function from the configuration dictionary when it represents a function configuration. + Args: - cfg (dict): Config dict. It should at least contain the key "type". - registry (:obj:`Registry`): The registry to search the type from. - default_args (dict, optional): Default initialization arguments. + cfg: Configuration dictionary. It should at least contain the key "type". + registry: The registry to search the type from. + default_args: Default initialization arguments. + Defaults to None. + Returns: - object: The constructed object. + Any: The constructed object. + + Example: + >>> from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + >>> class Model: + >>> def __init__(self, param): + >>> self.param = param + >>> cfg = {"type": "Model", "param": 10} + >>> registry = Registry("models") + >>> registry.register_module(Model) + >>> obj = build_from_cfg(cfg, registry) + >>> assert isinstance(obj, Model) + >>> assert obj.param == 10 """ + args = cfg.copy() if default_args is not None: @@ -39,22 +65,30 @@ def build_from_cfg( class Registry: """A registry to map strings to classes or functions. - Registered object could be built from registry. Meanwhile, registered - functions could be called from registry. + Registered objects could be built from the registry. Meanwhile, registered + functions could be called from the registry. Args: - name (str): Registry name. - build_func(func, optional): Build function to construct instance from - Registry, func:`build_from_cfg` is used if neither ``parent`` or - ``build_func`` is specified. If ``parent`` is specified and - ``build_func`` is not given, ``build_func`` will be inherited - from ``parent``. Default: None. - parent (Registry, optional): Parent registry. The class registered in - children registry could be built from parent. Default: None. - scope (str, optional): The scope of registry. It is the key to search - for children registry. If not specified, scope will be the name of - the package where class is defined, e.g. mmdet, mmcls, mmseg. - Default: None. + name: Registry name. + build_func: Builds function to construct an instance from + the Registry. If neither ``parent`` nor + ``build_func`` is specified, the ``build_from_cfg`` + function is used. If ``parent`` is specified and + ``build_func`` is not given, ``build_func`` will be + inherited from ``parent``. Default: None. + parent: Parent registry. The class registered in + children's registry could be built from the parent. + Default: None. + scope: The scope of the registry. It is the key to search + for children's registry. If not specified, scope will be the + name of the package where the class is defined, e.g. mmdet, mmcls, mmseg. + Default: None. + + Attributes: + name: Registry name. + module_dict: The dictionary containing registered modules. + children: The dictionary containing children registries. + scope: The scope of the registry. """ def __init__(self, name, build_func=None, parent=None, scope=None): @@ -127,10 +161,20 @@ def children(self): def get(self, key): """Get the registry record. + Args: - key (str): The class name in string format. + key: The class name in string format. + Returns: class: The corresponding class. + + Example: + >>> from deeplabcut.pose_estimation_pytorch.registry import Registry + >>> registry = Registry("models") + >>> class Model: + >>> pass + >>> registry.register_module(Model, "Model") + >>> assert registry.get("Model") == Model """ scope, real_key = self.split_scope_key(key) if scope is None or scope == self._scope: @@ -149,21 +193,48 @@ def get(self, key): return parent.get(key) def build(self, *args, **kwargs): + """Builds an instance from the registry. + + Args: + *args: Arguments passed to the build function. + **kwargs: Keyword arguments passed to the build function. + + Returns: + Any: The constructed object. + + Example: + >>> from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + >>> class Model: + >>> def __init__(self, param): + >>> self.param = param + >>> cfg = {"type": "Model", "param": 10} + >>> registry = Registry("models") + >>> registry.register_module(Model) + >>> obj = registry.build(cfg, param=20) + >>> assert isinstance(obj, Model) + >>> assert obj.param == 20 + """ return self.build_func(*args, **kwargs, registry=self) def _add_children(self, registry): """Add children for a registry. - The ``registry`` will be added as children based on its scope. - The parent registry could build objects from children registry. + + Args: + registry: The registry to be added as children based on its scope. + + Returns: + None + Example: + >>> from deeplabcut.pose_estimation_pytorch.registry import Registry >>> models = Registry('models') >>> mmdet_models = Registry('models', parent=models) - >>> @mmdet_models.register_module() - >>> class ResNet: + >>> class Model: >>> pass - >>> resnet = models.build(dict(type='mmdet.ResNet')) + >>> mmdet_models.register_module(Model) + >>> obj = models.build(dict(type='mmdet.Model')) + >>> assert isinstance(obj, Model) """ - assert isinstance(registry, Registry) assert registry.scope is not None assert ( @@ -172,6 +243,26 @@ def _add_children(self, registry): self.children[registry.scope] = registry def _register_module(self, module, module_name=None, force=False): + """Register a module. + + Args: + module: Module class or function to be registered. + module_name: The module name(s) to be registered. + If not specified, the class name will be used. + force: Whether to override an existing class with the same name. + Default: False. + + Returns: + None + + Example: + >>> from deeplabcut.pose_estimation_pytorch.registry import Registry + >>> registry = Registry("models") + >>> class Model: + >>> pass + >>> registry._register_module(Model, "Model") + >>> assert registry.get("Model") == Model + """ if not inspect.isclass(module) and not inspect.isfunction(module): raise TypeError( "module must be a class or a function, " f"but got {type(module)}" @@ -187,6 +278,24 @@ def _register_module(self, module, module_name=None, force=False): self._module_dict[name] = module def deprecated_register_module(self, cls=None, force=False): + """Decorator to register a class in the registry. + + Args: + cls: The class to be registered. + force: Whether to override an existing class with the same name. + Default: False. + + Returns: + type: The input class. + + Example: + >>> from deeplabcut.pose_estimation_pytorch.registry import Registry + >>> registry = Registry("models") + >>> @registry.deprecated_register_module() + >>> class Model: + >>> pass + >>> assert registry.get("Model") == Model + """ if cls is None: return partial(self.deprecated_register_module, force=force) self._register_module(cls, force=force) @@ -198,11 +307,11 @@ def register_module(self, name=None, force=False, module=None): name or the specified name, and value is the class itself. It can be used as a decorator or a normal function. Args: - name (str | None): The module name to be registered. If not - specified, the class name will be used. - force (bool, optional): Whether to override an existing class with - the same name. Default: False. - module (type): Module class or function to be registered. + name: The module name to be registered. If not + specified, the class name will be used. + force: Whether to override an existing class with + the same name. Default: False. + module: Module class or function to be registered. """ if not isinstance(force, bool): raise TypeError(f"force must be a boolean, but got {type(force)}") diff --git a/deeplabcut/pose_estimation_pytorch/solvers/__init__.py b/deeplabcut/pose_estimation_pytorch/solvers/__init__.py index aedc041baa..727f85e06d 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/__init__.py @@ -1,6 +1,17 @@ -from deeplabcut.pose_estimation_pytorch.solvers.logger import LOGGER +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + from deeplabcut.pose_estimation_pytorch.solvers.base import SOLVERS -from deeplabcut.pose_estimation_pytorch.solvers.top_down import TopDownSolver +from deeplabcut.pose_estimation_pytorch.solvers.logger import LOGGER from deeplabcut.pose_estimation_pytorch.solvers.single_animal import ( BottomUpSingleAnimalSolver, ) +from deeplabcut.pose_estimation_pytorch.solvers.top_down import TopDownSolver diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index 8dd08f698c..f852c1c922 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -44,6 +44,32 @@ def __init__( scheduler: torch.optim.lr_scheduler = None, logger: Optional = None, ): + """Constructor of the Solver class. + + Args: + model: The neural network for solving pose estimation task. + criterion: The criterion computed from the difference + between the prediction and the target. + optimizer: A PyTorch optimizer for + updating model parameters. + cfg: DeepLabCut pose_cfg for training. + See https://github.com/DeepLabCut/DeepLabCut/blob/main/deeplabcut/pose_cfg.yaml + for more details. + device: str representing the device on which a torch.Tensor + is or will be allocated. + Possible value are: ('cpu', 'cuda' or 'mps') + snapshot_path: path of the snapshot/weights file to load before training + scheduler: Scheduler for adjusting the + lr of the optimizer. + logger: logger to monitor training (e.g WandB logger) + + Returns: + None + + Notes/TODO: + Read stride from config file + """ + if cfg is None: raise ValueError("") self.model = model @@ -76,6 +102,26 @@ def fit( *, epochs: int = 10000, ) -> None: + + """Train model for the specified number of steps. + + Args: + train_loader: Data loader, which is an iterator over train instances. + Each batch contains image tensor and heat maps tensor input samples. + valid_loader: Data loader used for validation of the model. + train_fraction: Fraction used for training. Defaults to 0.95. + shuffle: Shuffle id to use from the randomized training sets. Defaults to 0. + model_prefix: Defaults to "". + epochs: The number of training epochs. Defaults to 10000. + + Returns: + None + + Example: + solver = Solver(model, criterion, optimizer, predictor, cfg, device='cuda') + solver.fit(train_loader, valid_loader, epochs=50) + """ + model_folder = get_model_folder( train_fraction, shuffle, model_prefix, train_loader.dataset.cfg ) @@ -113,6 +159,25 @@ def epoch( mode: str = "train", step: Optional[int] = None, ) -> float: + + """Facilitates training over an epoch. Returns the loss over the batches. + + Args: + loader: Data loader, which is an iterator over instances. + Each batch contains image tensor and heat maps tensor input samples. + mode: str identifier to instruct the Solver whether to train or evaluate. + Possible values are: "train" or "eval". + Defaults to "train". + step: the global step in processing, used to log metrics. Defaults to None. + + Raises: + ValueError: "Solver mode must be train or eval, found mode={mode}." + This error is raised when the given mode is invalid (eg. not train nor eval) + + Returns: + epoch_loss: Average of the loss over the batches. + """ + if mode not in ["train", "eval"]: raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") to_mode = getattr(self.model, mode) @@ -151,6 +216,19 @@ def step(self, batch: Tuple[torch.Tensor, torch.Tensor], *args) -> dict: def inference( self, dataset: deeplabcut_pose_estimation_pytorch_data_dataset.PoseDataset ) -> np.ndarray: + + """Run inference on the given dataset and obtain predicted poses. + + Args: + dataset: The dataset to run the inference over + + Returns: + Numpy array containing the predicted poses + + Notes/TODO: + Add scale + """ + predicted_poses = [] for item in dataset: if isinstance(item, tuple) or isinstance(item, list): @@ -167,9 +245,30 @@ def inference( class BottomUpSolver(Solver): + """Base solvers for bottom up pose estimation.""" + def step( self, batch: Tuple[torch.Tensor, torch.Tensor], mode: str = "train" ) -> dict: + + """Perform a single epoch gradient update or validation step. + + Args: + batch: Tuple of input image(s) and target(s) for train or valid single step. + mode: `train` or `eval`. Defaults to "train". + + Raises: + ValueError: "Solver must be in train or eval mode, but {mode} was found." + + Returns + ------- + dict : { + 'batch loss' : torch.Tensor, + 'heatmap_loss' : torch.Tensor, + 'locref_loss' : torch.Tensor + } + """ + if mode not in ["train", "eval"]: raise ValueError( f"Solver must be in train or eval mode, but {mode} was found." diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index 09ebe0bfcd..a943fe04cf 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -1,21 +1,53 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +from typing import Dict, List, Tuple + import numpy as np import pandas as pd -import torch -from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( Assembly, evaluate_assembly, ) from torch import nn -from typing import List, Tuple, Dict +#DEPRECATED +def get_prediction( + cfg: dict, output: Tuple[np.ndarray, np.ndarray], stride: int = 8 +) -> np.ndarray: + """Generates pose predictions from the model outputwhich is a tuple given by (heatmaps,location refinement fields)). -def get_prediction(cfg, output, stride=8): - """ - get predictions from model output - output = heatmaps, locref - heatmaps: numpy.ndarray([batch_size, num_joints, height, width]) - locref: numpy.ndarray([batch_size, num_joints, height, width]) + It uses the predicted heatmaps to estimate the keypoints' locations and applies location refinement if enabled. + Refer to: https://www.nature.com/articles/s41592-022-01443-0 for more information about the overall process. + + Args: + cfg: config file in dict + output: heatmaps, locref + heatmaps: the probability that a + keypoint occurs at a particular location + locref: location refinement fields + that predict offsets to mitigate quantization errors due to downsampled score maps + stride: window stride; defaults to 8 + + Returns: + Array of poses + + Examples: + >>> # Define the cfg dictionary and model output + >>> cfg = {'location_refinement': True, 'locref_stdev': 0.1} + >>> heatmaps = np.random.rand(1, 17, 128, 128) + >>> locref = np.random.rand(1, 17, 128, 128) + >>> output = (heatmaps, locref) + >>> # Get the predicted poses + >>> poses = get_prediction(cfg, output) """ poses = [] @@ -30,50 +62,8 @@ def get_prediction(cfg, output, stride=8): locref_i = locref_i * cfg["locref_stdev"] pose = multi_pose_predict(heatmaps[i], locref_i, stride, 1) poses.append(pose) - return np.stack(poses, axis=0) - - -# DEPRECATED -def get_top_values(scmap, n_top=5): - batchsize, ny, nx, num_joints = scmap.shape - scmap_flat = scmap.reshape(batchsize, nx * ny, num_joints) - if n_top == 1: - scmap_top = np.argmax(scmap_flat, axis=1)[None] - else: - scmap_top = np.argpartition(scmap_flat, -n_top, axis=1)[:, -n_top:] - for ix in range(batchsize): - vals = scmap_flat[ix, scmap_top[ix], np.arange(num_joints)] - arg = np.argsort(-vals, axis=0) - scmap_top[ix] = scmap_top[ix, arg, np.arange(num_joints)] - scmap_top = scmap_top.swapaxes(0, 1) - - Y, X = np.unravel_index(scmap_top, (ny, nx)) - return Y, X - - -# DEPRECATED -def multi_pose_predict(scmap, locref, stride, num_outputs): - Y, X = get_top_values(scmap[None], num_outputs) - Y, X = Y[:, 0], X[:, 0] - num_joints = scmap.shape[2] - - DZ = np.zeros((num_outputs, num_joints, 3)) - indices = np.indices((num_outputs, num_joints)) - x = X[indices[0], indices[1]] - y = Y[indices[0], indices[1]] - DZ[:, :, :2] = locref[y, x, indices[1], :] - DZ[:, :, 2] = scmap[y, x, indices[1]] - - X = X.astype("float32") * stride[1] + 0.5 * stride[1] + DZ[:, :, 0] - Y = Y.astype("float32") * stride[0] + 0.5 * stride[0] + DZ[:, :, 1] - P = DZ[:, :, 2] - pose = np.empty((num_joints, num_outputs * 3), dtype="float32") - pose[:, 0::3] = X.T - pose[:, 1::3] = Y.T - pose[:, 2::3] = P.T - - return pose + return np.stack(poses, axis=0) def get_scores( @@ -82,18 +72,40 @@ def get_scores( target: pd.DataFrame, bodyparts: List[str] = None, ) -> Dict: - """_summary_ + """Computes for the different scores given the grount truth and the predictions. + + The different scores computed are based on the COCO metrics: https://cocodataset.org/#keypoints-eval + RMSE (Root Mean Square Error) + OKS mAP (Mean Average Precision) + OKS mAR (Mean Average Recall) Args: - cfg (Dict): config dictionary - prediction (pd.DataFrame): prediction df, should already be matched to ground truth using - hungarian algorithm - target (pd.DataFrame): ground truth dataframe - bodyparts (List[str], optional): names of the bodyparts. Defaults to None. + cfg: config file in a dictionary + prediction: prediction df, should already be matched to ground truth using + Hungarian Algorithm (Ref: https://brilliant.org/wiki/hungarian-matching/) + target: ground truth dataframe + bodyparts: names of the bodyparts. Defaults to None. Returns: - Dict: scores dict, keys are : - ['rmse', 'rmse_pcutoff', 'mAP', 'mAR', 'mAP_pcutoff', 'mAR_pcutoff'] + scores: A dictionary of scores containign the following keys: + ['rmse', 'rmse_pcutoff', 'mAP', 'mAR', 'mAP_pcutoff', 'mAR_pcutoff'] + + Examples: + >>> # Define the cfg dictionary, prediction, and target DataFrames + >>> cfg = {'pcutoff': 0.5} + >>> prediction = pd.DataFrame(...) # Your DataFrame here + >>> target = pd.DataFrame(...) # Your DataFrame here + >>> # Compute the scores + >>> scores = get_scores(cfg, prediction, target) + >>> print(scores) + { + 'rmse': 0.156, + 'rmse_pcutoff': 0.115, + 'mAP': 0.842, + 'mAR': 0.745, + 'mAP_pcutoff': 0.913, + 'mAR_pcutoff': 0.825 + } # Sample output scores """ if cfg.get("pcutoff"): pcutoff = cfg["pcutoff"] @@ -120,20 +132,30 @@ def get_rmse( pcutoff: float = -1, bodyparts: List[str] = None, ) -> Tuple[float, float]: - """Computes rmse for predictions - Assumes hungarian algorithm matching has already be applied to match predicted animals - and ground truth ones. + """Computes the root mean square error (rmse) for predictions vs the ground truth labels + + Assumes hungarian algorithm matching (https://brilliant.org/wiki/hungarian-matching/)) + has already be applied to match predicted animals and ground truth ones. Args: - prediction (pd.DataFrame): prediction dataframe - target (pd.DataFrame): target dataframe - pcutoff (float, optional): Confidence lower bound for a keypoint to be considered as detected. + prediction: prediction dataframe + target: target dataframe + pcutoff: Confidence lower bound for a keypoint to be considered as detected. Defaults to -1. - bodyparts (List[str], optional): list of the bodyparts names. Defaults to None. + bodyparts: list of the bodyparts names. Defaults to None. Returns: rmse: rmse without cutoff rmse_p : rmse with cutoff + + Example: + >>> # Define the prediction and target DataFrames + >>> prediction = pd.DataFrame(...) # Your DataFrame here + >>> target = pd.DataFrame(...) # Your DataFrame here + >>> # Compute the RMSE values + >>> rmse, rmse_pcutoff = get_rmse(prediction, target, pcutoff=0.5) + >>> print(rmse, rmse_pcutoff) + 0.145 0.105 # Sample output RMSE values """ scorer_pred = prediction.columns[0][0] scorer_target = target.columns[0][0] @@ -160,27 +182,38 @@ def get_oks( pcutoff: float = -1, bodyparts: List[str] = None, ) -> Tuple[Dict, Dict]: - """Computes oks related scores for predictions + """Computes the object keypoint similarity (OKS) scores for predictions. + + OKS is defined in https://cocodataset.org/#keypoints-eval Args: - prediction (pd.DataFrame): prediction dataframe - target (pd.DataFrame): target dataframe - oks_sigma (float, optional): Sigma for oks conputation. Defaults to 0.1. - margin (int, optional): margin used for bbox computation. Defaults to 0. - symmetric_kpts (_type_, optional): Not supported yet. Defaults to None. - pcutoff (float, optional): Confidence lower bound for a keypoint to be considered as detected. + prediction: prediction dataframe + target: target dataframe + oks_sigma: Sigma for oks conputation. Defaults to 0.1. + margin: margin used for bbox computation. Defaults to 0. + symmetric_kpts: Not supported yet. Defaults to None. + pcutoff: Confidence lower bound for a keypoint to be considered as detected. Defaults to -1. - bodyparts (List[str], optional): list of the bodyparts names. Defaults to None. + bodyparts: list of the bodyparts names. Defaults to None. Returns: - oks_raw (Dict): oks scores without p_cutoff - oks_pcutoff (Dict): oks scores with pcutoff + oks_raw: oks scores without p_cutoff + oks_pcutoff: oks scores with pcutoff + + Examples: + >>> # Define the prediction and target DataFrames + >>> prediction = pd.DataFrame(...) # Your DataFrame here + >>> target = pd.DataFrame(...) # Your DataFrame here + >>> # Compute the OKS scores + >>> oks, oks_pcutoff = get_oks(prediction, target, oks_sigma=0.2, pcutoff=0.5) + >>> print(oks, oks_pcutoff) + {'mAP': 0.842, 'mAR': 0.745} {'mAP': 0.913, 'mAR': 0.825} # Sample output OKS scores """ scorer_pred = prediction.columns[0][0] scorer_target = target.columns[0][0] - if bodyparts != None: + if bodyparts is not None: idx_slice = pd.IndexSlice[:, :, bodyparts, :] prediction = prediction.loc[:, idx_slice] target = target.loc[:, idx_slice] @@ -212,12 +245,15 @@ def get_oks( return oks_raw, oks_pcutoff -def conv_df_to_assemblies(df: pd.DataFrame): - """ - Convert a dataframe to an assemblies dictionary +def conv_df_to_assemblies(df: pd.DataFrame) -> dict: + """Convert a dataframe to an assemblies dictionary. + + Args: + df : dataframe of coordinates/predictions, + df is expected to have a multi_index of shape (num_animals, num_keypoints, 2 or 3) - Arguments : - df : dataframe of coordinates/predictions, df is expected to have a multi_index of shape (num_animals, num_keypoints, 2 or 3) + Returns: + assemblies: dictionary of the assemblies of keypoints """ assemblies = {} @@ -235,3 +271,71 @@ def conv_df_to_assemblies(df: pd.DataFrame): assemblies[image_path] = kpt_lst return assemblies + + +# DEPRECATED +def get_top_values(scmap: np.array, n_top: int = 5) -> Tuple[np.array, np.array]: + """This function computes for the top n values from a given scoremap. + + Args: + scmap: score map; + which encode the probability that a keypoint occurs at a particular location + n_top: top n elements in the set. Defaults to 5. + + Returns: + Top n values of in the scoreemap + """ + batchsize, ny, nx, num_joints = scmap.shape + scmap_flat = scmap.reshape(batchsize, nx * ny, num_joints) + if n_top == 1: + scmap_top = np.argmax(scmap_flat, axis=1)[None] + else: + scmap_top = np.argpartition(scmap_flat, -n_top, axis=1)[:, -n_top:] + for ix in range(batchsize): + vals = scmap_flat[ix, scmap_top[ix], np.arange(num_joints)] + arg = np.argsort(-vals, axis=0) + scmap_top[ix] = scmap_top[ix, arg, np.arange(num_joints)] + scmap_top = scmap_top.swapaxes(0, 1) + + Y, X = np.unravel_index(scmap_top, (ny, nx)) + return Y, X + + +# DEPRECATED +def multi_pose_predict( + scmap: np.array, locref: np.array, stride: int, num_outputs: int +) -> np.array: + """This function generates the multi pose predictions from the model of the output (heatmaps and loc refinement fields). + + Refer to: https://www.nature.com/articles/s41592-022-01443-0 for more information about the overall process. + + Args: + scmap: score map; which encode the probability that a keypoint occurs at a particular location + locref: location refinement fields that predict offsets to mitigate quantization errors due to downsampled score maps + stride: window stride; defaults to 8 + num_outputs: The expected number of outputs. + + Returns: + pose: Multi-pose predictions + """ + Y, X = get_top_values(scmap[None], num_outputs) + Y, X = Y[:, 0], X[:, 0] + num_joints = scmap.shape[2] + + DZ = np.zeros((num_outputs, num_joints, 3)) + indices = np.indices((num_outputs, num_joints)) + x = X[indices[0], indices[1]] + y = Y[indices[0], indices[1]] + DZ[:, :, :2] = locref[y, x, indices[1], :] + DZ[:, :, 2] = scmap[y, x, indices[1]] + + X = X.astype("float32") * stride[1] + 0.5 * stride[1] + DZ[:, :, 0] + Y = Y.astype("float32") * stride[0] + 0.5 * stride[0] + DZ[:, :, 1] + P = DZ[:, :, 2] + + pose = np.empty((num_joints, num_outputs * 3), dtype="float32") + pose[:, 0::3] = X.T + pose[:, 1::3] = Y.T + pose[:, 2::3] = P.T + + return pose diff --git a/deeplabcut/pose_estimation_pytorch/solvers/logger.py b/deeplabcut/pose_estimation_pytorch/solvers/logger.py index e57ef5fc6b..b2723e444f 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/logger.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/logger.py @@ -1,18 +1,35 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + from typing import Optional +import deeplabcut.pose_estimation_pytorch.registry as deeplabcut_pose_estimation_pytorch_registry import wandb as wb - from deeplabcut.pose_estimation_pytorch.models.model import PoseModel -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -LOGGER = Registry("single_animal_solver", build_func=build_from_cfg) +LOGGER = deeplabcut_pose_estimation_pytorch_registry.Registry( + "single_animal_solver", + build_func=deeplabcut_pose_estimation_pytorch_registry.build_from_cfg, +) @LOGGER.register_module class WandbLogger: - """ - Wandb logger to track experiments and log data. - (https://docs.wandb.ai/guides) + """Wandb logger to track experiments and log data. + + Refer to: https://docs.wandb.ai/guides for more information on wandb. + + Attributes: + run (wandb.Run): The wandb run object associated with the current experiment. + """ def __init__( @@ -21,15 +38,18 @@ def __init__( run_name: str = "tmp", model: PoseModel = None, ) -> None: - """ - Initialization of wandb logger. + """Initialize the WandbLogger class. + + Args: + project_name: The name of the wandb project. Defaults to "deeplabcut". + run_name: The name of the wandb run. Defaults to "tmp". + model: The model to log. Defaults to None. + + Example: + logger = WandbLogger(project_name="my_project", run_name="exp1", model=my_model) - Parameters - ---------- - project_name: the name of the wandb project - run_name: the name of the wandb run - model: model to log """ + self.run = wb.init(project=project_name, name=run_name) if model is None: raise ValueError("Specify the model to track!") @@ -38,14 +58,17 @@ def __init__( def log( self, key: str = None, value: str = None, step: Optional[int] = None ) -> None: - """ - Use this method to log data from runs, such as scalars, images, video, histograms, plots, and tables. + """Logs data from runs, such as scalars, images, video, histograms, plots, and tables. + + Args: + key The name of the logged value. + value: Data to log. + step: The global step in processing. Defaults to None. + + Example: + logger = WandbLogger() + logger.log(key="loss", value=0.123, step=100) - Parameters - ---------- - key: name of the logged value - value: data to log - step: the global step in processing """ if key is None or value is None: raise ValueError( @@ -54,14 +77,33 @@ def log( self.run.log({key: value}, step=step) def save(self): - self.run.save(self.run.run.dir) + """Syncs all files to wandb with the policy specified. - def log_config(self, config: dict = None) -> None: + Notes: + self.run: A run is a unit of computation logged by wandb. + self.run.run.dir: The directory where files associated with the run are saved. + + Example: + logger = WandbLogger() + # Training and logging + logger.save() """ - Use this method to save + self.run.save(self.run.dir) + + def log_config(self, config: dict = None) -> None: + """Updates the current run with the given config dict. + + Notes: + self.run: A run is a unit of computation logged by wandb. + self.run.config: Config object associated with this run. + + Args: + config: Experiment config file. + + Example: + logger = WandbLogger() + config = {"learning_rate": 0.001, "batch_size": 32} + logger.log_config(config) - Parameters - ---------- - config: experiment config """ self.run.config.update(config) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py index 62c35d5ea9..a1b5fe8519 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py @@ -22,6 +22,44 @@ @SOLVERS.register_module class TopDownSolver(Solver): + """Top Down Solver Class + + This class is used for training the top-down pose estimation model + based on a detector model such as FasterRCNN. + Only supports FasterRCNN as a detector for now (https://github.com/rbgirshick/fast-rcnn) + + Attributes: + detector: The detector model used in the top-down approach. + detector_optimizer: Optimizer for the detector model. + detector_criterion: Criterion for the detector model (not used with FasterRCNN). + detector_scheduler: Scheduler for the detector model (optional). + detector_path: Path to a pre-trained detector model checkpoint (optional). + + Examples: + # Initialize the top-down solver with a FasterRCNN detector and its optimizer + detector = FasterRCNN() + detector_optimizer = torch.optim.SGD(detector.parameters(), lr=0.001, momentum=0.9) + solver = TopDownSolver(detector=detector, detector_optimizer=detector_optimizer) + + # Load data loaders for training and validation + train_detector_loader = torch.utils.data.DataLoader(...) + valid_detector_loader = torch.utils.data.DataLoader(...) + train_pose_loader = torch.utils.data.DataLoader(...) + valid_pose_loader = torch.utils.data.DataLoader(...) + + # Train the top-down pose estimation model + solver.fit( + train_detector_loader=train_detector_loader, + valid_detector_loader=valid_detector_loader, + train_pose_loader=train_pose_loader, + valid_pose_loader=valid_pose_loader, + train_fraction=0.95, + shuffle=0, + model_prefix="my_model", + epochs=10000 + ) + """ + def __init__( self, *args, diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index 8571f9eeba..b8b5a82157 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -1,46 +1,352 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + import glob import os +import re +import warnings from pathlib import Path +from typing import Dict, List, Tuple +import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils +import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions import numpy as np import pandas as pd -from typing import List -from deeplabcut.utils import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.utils import create_folder +def verify_paths( + paths: List[str], pattern: str = r"^(.*)?snapshot-(\d+)\.pt$" +) -> List[str]: + """Verify the input list of strings if each string follows the regular expression pattern. + + Args: + paths: List of paths + pattern: Regular expression pattern for the path + + Returns: + valid_paths: List of strings from `paths` that follow the given pattern. + + Raises: + Warning: Thrown if an invalid path is in `paths`. Notifies user of each + incorrectly-formatted string found in `paths`. + + Example: + Inputs: + paths = ['proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-5.pt', + 'proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt', + 'proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-1.pt'] + pattern = r"^(.*)?snapshot-(\d+)\.pt$" + Output: + valid_paths = ['proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-1.pt', + 'proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-5.pt', + 'proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt'] + """ + valid_paths = [x for x in paths if re.match(pattern, x)] + invalid_paths = [x for x in paths if x not in valid_paths] + + if len(invalid_paths) > 0: + warnings.warn("Invalid paths found and ignored:" + "\n".join(invalid_paths)) + + return valid_paths + + +def sort_paths( + paths: List[str], pattern: str = r"^(.*)?snapshot-(\d+)\.pt$" +) -> List[str]: + """Sort a list of paths following a specific regular expression pattern. + + Default pattern for each path in list: path/to/snapshot-epoch_number.pt + Paths not following this format will be ignored, not included in the + list of paths sorted, and a warning will be issued providing the list of invalid paths + + Args: + paths: List of string paths (of the snapshots) + pattern: Regular expression pattern for the file path format of model snapshots + + Returns: + sorted_paths: List of valid string paths sorted in ascending epoch number order + + Examples: + 1) Input: + paths = ["/path/to/snapshot-100.pt", + "/path/to/snapshot-10.pt", + "/path/to/snapshot-5.pt", + "/path/to/snapshot-50.pt"] + pattern = r"^(.*)?snapshot-(\d+)\.pt$" + + Output: + sorted_paths = ["/path/to/snapshot-5.pt", + "/path/to/snapshot-10.pt", + "/path/to/snapshot-50.pt", + "/path/to/snapshot-100.pt"] -def get_dlc_scorer(train_fraction, shuffle, model_prefix, test_cfg, train_iterations): + 2) Input: + paths = ["path/to/snapshot-5.pt","path/to/snapshot-1.pt"] + pattern = r"^(.*)?snapshot-(\d+)\.pt$" + + Output: + sorted_paths = ["path/to/snapshot-1.pt","path/to/snapshot-5.pt"] + + 3) Input: + paths = ["path/to/snapshots-5.pt","path/to/snapshot-1.pt"] + + Output: sorted_paths = ["path/to/snapshot-1.pt"] + Warning: "Invalid paths found and ignored: path/to/snapshots-5.pt" + + 4) Input: + paths = ["path\to\snapshot-5.pt","path\to\snapshot-1.pt"] + + Output: + sorted_paths = ["path\to\snapshot-1.pt","path\to\snapshot-5.pt"] + + 5) Input: + paths = ["path/to/snapshot-5.weights","path/to/snapshot-1.pt"] + + Output: + sorted_paths = ["path/to/snapshot-1.pt"] + Warning: "Invalid paths found and ignored: path/to/snapshots-5.weights" + """ + verified_paths = verify_paths(paths, pattern) + sorted_paths = sorted( + verified_paths, key=lambda i: int(re.match(pattern, i).group(2)) + ) + return sorted_paths + + +def get_detector_path(model_folder: str, load_epoch: int) -> str: + """Given model_folder, load_epoch number, returns the detector path (str). + + Merely calls the verify_directory function with the detector flag + + Args: + model_folder: String path to the model folder + load_epoch: snapshot epoch number for the model that you want to use + + Returns: + Path of the detector directory with the given epoch id + + Example: + Input: + model_folder = 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/' + load_epoch = 10 + + Output: + 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/detector-snapshot-10.pt' + """ + return get_verified_path(model_folder, load_epoch, mode = "detector") + +def get_dlc_scorer( + train_fraction: float, + shuffle: int, + model_prefix: str, + test_cfg: dict, + train_iterations: str, +) -> Tuple[str]: + """Return dlc_scorer given the ff parameters: + train_faction, shuffle, model_prefix, test_cfg, and train_iterations. + + Args: + train_fraction: fraction of the dataset assigned for training + shuffle: shuffle id + model_prefix: keep as default (included for backwards + compatibility); default value is "" + test_cfg: contents of the config file in a dict + train_iterations: the iteration number of the snapshot + + Returns: + dlc_scorer: the scorer/network name for the particular set of given parameters + dlc_scorer_legacy: dlc_scorer version that starts with DeepCut instead of DLC + + Example: + Input: + train_fraction = 0.95 + shuffle = 1 + model_prefix = "" + test_cfg = dict from auxiliaryfunctions.read_cfg(configpath) + train_iterations = 10 + Output: + ('DLC_model_w32_behaviordateshuffle1_10','DeepCut_model_w32_behaviordateshuffle1_10') + + """ model_folder = get_model_folder(train_fraction, shuffle, model_prefix, test_cfg) snapshots = get_snapshots(Path(model_folder)) snapshot = snapshots[train_iterations] snapshot_epochs = int(snapshot.split("-")[-1]) - dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( - test_cfg, - shuffle, - train_fraction, - snapshot_epochs, - modelprefix=model_prefix, + ( + dlc_scorer, + dlc_scorer_legacy, + ) = auxiliaryfunctions.get_scorer_name( + test_cfg, shuffle, train_fraction, snapshot_epochs, modelprefix=model_prefix ) return dlc_scorer, dlc_scorer_legacy -def get_evaluation_folder(train_fraction, shuffle, model_prefix, test_cfg): - evaluation_folder = os.path.join( - test_cfg["project_path"], - str( - auxiliaryfunctions.get_evaluation_folder( - train_fraction, shuffle, test_cfg, modelprefix=model_prefix - ) - ), +def get_snapshots(model_folder: Path) -> List[str]: + """Get snapshots in a given Path + + Args: + model_folder: path containing the snapshots + + Returns: + List of snapshot paths + """ + snapshots = [ + f.stem + for f in (model_folder / "train").iterdir() + if f.name.startswith("snapshot") and f.suffix == ".pt" + ] + return sorted(snapshots, key=lambda s: int(s.split("-")[-1])) + + +def get_verified_path( + directory_path: str, load_epoch: int, mode: str = "model" +) -> str: + """Helper function for the get_model_path and get_detector_path functions. + + Verifies the directories and returns the specific directory given the parameters: + directory_path, load_epoch, and mode ("model" for + model_path and "detector" for detector_path) + + Args: + directory_path: String path to the model folder + load_epoch: snapshot epoch number for the model that you want to use + mode: "model" for loading dlc-models; "detector" for loading detector snapshots + + Returns: + Path of the directory with the given epoch id and mode (model or detector) + + Raises: + FileNotFoundError: + a) when given diirectory does not exist + b) when the desired snapshot does not exist in the folder + c) when there are no snapshots in the model_folder + d) when there are no snapshots following the valid format in the directory + + Example: + Input: + model_folder = 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/' + load_epoch = 1 + mode = "model" + + Output: + 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt' + """ + if not os.path.exists(directory_path): + raise FileNotFoundError(f"Path {directory_path} does not exist.") + + directory_paths = [] + mode_prefix = "" + + # Assigns the proper prefix and paths given the verification mode: for either model paths or detector paths + if mode == "detector": + mode_prefix = "detector-" + directory_paths = glob.glob(os.path.join(directory_path,"train",f"{mode_prefix}snapshot*")) + # else: + # directory_paths = glob.glob(f"{directory_path}/train/snapshot*") + + # If there are no snapshots inside the given directory, raise a FileNotFoundError + if len(directory_paths) == 0: + raise FileNotFoundError( + f"Path {directory_path} exists, but there are no snapshots in it. " + "Make sure that the {mode}_folder given has a filetree with files " + "of the form <{mode}_path>/{mode_prefix}snapshot*." + ) + + if load_epoch >= len(directory_paths): + raise FileNotFoundError( + f"Model {directory_path}{mode_prefix}snapshot for the given load_epoch does not exist." + "Make sure that the {mode}_folder given has a filetree with the correct model." + ) + sorted_paths = [] + if mode == "detector": + sorted_paths = sort_paths( + directory_paths, r"^(.*)?detector-snapshot-(\d+)\.pt$" + ) + else: + sorted_paths = sort_paths(directory_paths) + + if len(sorted_paths) == 0: + raise FileNotFoundError( + f"Path {directory_path} exists, but the snapshots inside it are all in an invalid format. " + "Make sure that the snapshots are named in the ff format: " + "<{mode}_path>/{mode_prefix}snapshot-epoch_no.pt" + ) + + return sorted_paths[load_epoch] + + +def get_results_filename( + evaluation_folder: str, dlc_scorer: str, dlc_scorerlegacy: str, model_path: str +) -> str: + """Returns the file path of the results given by the ff parameters: + evaluation_folder, dlc_scorer, dlc_scorerlegacy, and model_path. + + Also, checks and informs the user if the network given has already been evaluated. + + Args: + evaluation_folder: path of the evaluation folder + dlc_scorer: dlc_scorer name (str) + dlc_scorerlegacy: dlc_scorerlegacy (str); dlc_scorer name that starts with 'DeepCut' instead of 'DLC' + model_path: path of the model used + + Returns: + results_filename: file path (string) of the results + + Example: + Input: + evaluation_folder = 0.95 + dlc_scorer = 1 + dlc_scorerlegacy = "" + model_path = "proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/" + Output: + 'proj_name/evaluation-results/iteration-0/behaviordate-trainset95shuffle1/DLC_dekr_w32_behaviordateshuffle1_1-snapshot-10.h5' + """ + ( + _, + results_filename, + _, + ) = auxiliaryfunctions.check_if_not_evaluated( + evaluation_folder, dlc_scorer, dlc_scorerlegacy, os.path.basename(model_path) ) - create_folder(evaluation_folder) - return evaluation_folder + return results_filename + + +def get_model_folder( + train_fraction: float, shuffle: int, model_prefix: str, test_cfg: dict +) -> str: + """Returns the model folder path given the ff parameters: + train_faction, shuffle, model_prefix, and test_cfg + + Args: + train_fraction: fraction of the dataset assigned for training + shuffle: shuffle id + model_prefix: keep as default (included for backwards compatibility); default value is "" + test_cfg: contents of the config file in a dict + + Returns: + model_folder: the path of the model folder + + Example: + Input: + train_fraction = 0.95 + shuffle = 1 + model_prefix = "" + test_cfg = dict from auxiliaryfunctions.read_cfg(configpath) + Output: + 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1' -def get_model_folder(train_fraction, shuffle, model_prefix, test_cfg): + """ model_folder = os.path.join( test_cfg["project_path"], str( @@ -49,45 +355,69 @@ def get_model_folder(train_fraction, shuffle, model_prefix, test_cfg): ) ), ) - create_folder(model_folder) + + if not os.path.exists(model_folder): + pytorch_utils.create_folder(model_folder) + return model_folder -def get_snapshots(model_folder: Path) -> List[str]: - snapshots = [ - f.stem - for f in (model_folder / "train").iterdir() - if f.name.startswith("snapshot") and f.suffix == ".pt" - ] - return sorted(snapshots, key=lambda s: int(s.split("-")[-1])) +def get_model_path(model_folder: str, load_epoch: int) -> str: + """Given model_folder and load_epoch number, returns the model path (str). + Merely calls the verify_directory function -def get_result_filename(evaluation_folder, dlc_scorer, dlc_scorerlegacy, model_path): - _, results_filename, _ = auxiliaryfunctions.check_if_not_evaluated( - evaluation_folder, dlc_scorer, dlc_scorerlegacy, os.path.basename(model_path) - ) - return results_filename + Args: + model_folder: String path to the model folder + load_epoch: snapshot epoch number for the model that you want to use + + Returns: + Path of the model directory with the given epoch id + Example: + Input: + model_folder = 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/' + load_epoch = 10 -def get_model_path(model_folder: str, load_epoch: int): - model_paths = glob.glob(f"{model_folder}/train/snapshot*") - sorted_paths = sort_paths(model_paths) - model_path = sorted_paths[load_epoch] - return model_path + Output: + 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt' + """ + return get_verified_path(model_folder, load_epoch) -def get_detector_path(model_folder: str, load_epoch: int): - detector_paths = glob.glob(f"{model_folder}/train/detector-snapshot*") - sorted_paths = sort_paths(detector_paths) - detector_path = sorted_paths[load_epoch] - return detector_path +def get_evaluation_folder( + train_fraction: float, shuffle: int, model_prefix: str, test_cfg: dict +) -> str: + """Returns the evaluation folder path given the ff parameters: + train_faction, shuffle, model_prefix, and test_cfg. + Args: + train_fraction: fraction of the dataset assigned for training + shuffle: shuffle id + model_prefix: keep as default (included for backwards compatibility); default value is "" + test_cfg: contents of the config file in a dict -def sort_paths(paths: list): - sorted_paths = sorted( - paths, key=lambda i: int(os.path.basename(i).split("-")[-1][:-3]) + Returns: + evaluation_folder: the path of the evaluation folder + + Example: + Input: + train_fraction = 0.95 + shuffle = 1 + model_prefix = "" + test_cfg = dict from auxiliaryfunctions.read_cfg(configpath) + Output: + 'proj_name/evaluation-results/iteration-0/behaviordate-trainset95shuffle1' + """ + evaluation_folder = os.path.join( + test_cfg["project_path"], + str( + auxiliaryfunctions.get_evaluation_folder( + train_fraction, shuffle, test_cfg, modelprefix=model_prefix + ) + ), ) - return sorted_paths + return evaluation_folder def build_predictions_df( @@ -128,7 +458,7 @@ def build_predictions_df( names=["scorer", "bodyparts", "coords"], ) else: - # Multi animal prediction dataframe + # Multi-animal prediction dataframe index = pd.MultiIndex.from_product( [ [dlc_scorer], @@ -175,8 +505,8 @@ def get_paths( } -def get_results_filename(evaluation_folder, dlc_scorer, dlc_scorer_legacy, model_path): - results_filename = get_result_filename( - evaluation_folder, dlc_scorer, dlc_scorer_legacy, model_path - ) - return results_filename +# def get_detector_path(model_folder: str, load_epoch: int): +# detector_paths = glob.glob(f"{model_folder}/train/detector-snapshot*") +# sorted_paths = sort_paths(detector_paths) +# detector_path = sorted_paths[load_epoch] +# return detector_path diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index c60c767053..b2f82aeff9 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -1,10 +1,19 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import abc import os -import abc import numpy as np import pandas as pd import torch - from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( read_image_shape_fast, ) @@ -155,11 +164,10 @@ def df2generic(proj_root, df, image_id_offset=0): def create_folder(path_to_folder): """Creates all folders contained in the path. - Parameters - ---------- - path_to_folder : str - Path to the folder that should be created - """ + + Args: + path_to_folder: Path to the folder that should be created + """ if not os.path.exists(path_to_folder): os.makedirs(path_to_folder) @@ -179,11 +187,11 @@ def fix_seeds(seed: int): def is_seq_of(seq, expected_type, seq_type=None): """Check whether it is a sequence of some type. Args: - seq (Sequence): The sequence to be checked. - expected_type (type): Expected type of sequence items. - seq_type (type, optional): Expected sequence type. + seq: The sequence to be checked. + expected_type: Expected type of sequence items. + seq_type: Expected sequence type. Returns: - bool: Whether the sequence is valid. + Whether the sequence is valid. """ if seq_type is None: exp_seq_type = abc.Sequence diff --git a/tests/test_dekr_predictor.py b/tests/test_dekr_predictor.py new file mode 100644 index 0000000000..915524ef45 --- /dev/null +++ b/tests/test_dekr_predictor.py @@ -0,0 +1,24 @@ +import torch +import pytest +import deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor as dlc_pep_models_predictors_dekr_predictor + +def test_DEKRPredictor(): + predictor = dlc_pep_models_predictors_dekr_predictor.DEKRPredictor(num_animals=2) + outputs = ( + torch.randn(1, 18, 64, 64), # example heatmap + torch.randn(1, 34, 64, 64), # example offsets + ) + scale_factors = (1.0, 0.5) + + try: + poses_with_scores = predictor.forward(outputs, scale_factors) + except Exception as e: + pytest.fail(f"DEKRPredictor forward pass raised an exception: {e}") + + assert poses_with_scores.shape == (1, 2, 17, 3) + + assert torch.all(poses_with_scores[:, :, :, 2] >= 0) + assert torch.all(poses_with_scores[:, :, :, 2] <= 1) + + + \ No newline at end of file diff --git a/tests/test_pose_estimation_pytorch_solvers_inference.py b/tests/test_pose_estimation_pytorch_solvers_inference.py new file mode 100644 index 0000000000..c18c4ae836 --- /dev/null +++ b/tests/test_pose_estimation_pytorch_solvers_inference.py @@ -0,0 +1,79 @@ +import pytest +import numpy as np +import pandas as pd +import torch +from torch import nn +import deeplabcut.pose_estimation_pytorch.solvers.inference as dlc_pose_estimation_pytorch_solvers_inference + + +# Sample test data +cfg = { + 'location_refinement': True, + 'locref_stdev': 0.1, + 'pcutoff': 0.5 +} +output = (torch.randn(2, 10, 64, 64), torch.randn(2, 10, 64, 64)) +stride = (8, 8) +prediction = pd.DataFrame( + { + ("scorer1", "likelihood", "bodypart1"): [0.8, 0.9], + ("scorer1", "x", "bodypart1"): [1.0, 2.0], + ("scorer1", "y", "bodypart1"): [3.0, 4.0], + } +) +target = pd.DataFrame( + { + ("scorer2", "likelihood", "bodypart1"): [0.8, 0.9], + ("scorer2", "x", "bodypart1"): [1.5, 2.5], + ("scorer2", "y", "bodypart1"): [3.5, 4.5], + } +) +bodyparts = [("bodypart1",)] + +def test_multi_pose_predict(): + scmap = np.random.rand(64, 64, 10) + locref = np.random.rand(64, 64, 10, 2) + stride = (8, 8) + num_outputs = 5 + pose = dlc_pose_estimation_pytorch_solvers_inference.multi_pose_predict(scmap, locref, stride, num_outputs) + assert isinstance(pose, np.ndarray) + assert pose.shape == (10, 15) + +def test_get_prediction_invalid_output(): + # Test get_prediction function with invalid output + with pytest.raises(Exception): + invalid_output = (torch.randn(2, 10, 64, + + 64),) # Missing locref + dlc_pose_estimation_pytorch_solvers_inferenceget_prediction(cfg, invalid_output, stride) + +def test_get_prediction(): + predictions = dlc_pose_estimation_pytorch_solvers_inference.get_prediction(cfg, output, stride) + assert isinstance(predictions, np.ndarray) + assert predictions.shape == (output[0].shape[0], output[0].shape[1], 3) + + +@pytest.mark.parametrize( + "test_n_top", + [ + (10), + (4), + (15), + (20), + (1) + ]) + + +def test_get_top_values(test_n_top): + """ + Tests if n_tops are actually selected + """ + test_scmap = np.random.rand(5, 64, 64, 6) + batchsize, ny, nx, num_joints = test_scmap.shape + top_vals = dlc_pose_estimation_pytorch_solvers_inference.get_top_values(test_scmap,test_n_top) + assert len(top_vals[0]) == test_n_top + + + + + diff --git a/tests/test_pose_estimation_pytorch_solvers_utils.py b/tests/test_pose_estimation_pytorch_solvers_utils.py new file mode 100644 index 0000000000..e90086b8e4 --- /dev/null +++ b/tests/test_pose_estimation_pytorch_solvers_utils.py @@ -0,0 +1,279 @@ +import pytest +import deeplabcut.pose_estimation_pytorch.solvers.utils as deeplabcut_pytorch_pose_utils +import deeplabcut.utils.auxiliaryfunctions as deeplabcut_utils_auxiliary_functions + +test_data = [ + ([ + "/path/to/snapshot-100.pt", + "/path/to/snapshot-10.pt", + "/path/to/snapshot-5.pt", + "/path/to/snapshot-50.pt", + "/path/to/snapshot5-50.pt", + "/path/to/snapshot1-00.pt", + ], [ + "/path/to/snapshot-100.pt", "/path/to/snapshot-10.pt", + "/path/to/snapshot-5.pt", "/path/to/snapshot-50.pt" + ]), + ([ + "\\path\\to\\snapshot-100.pt", + "\\path\\to\\snapshot-10.pt", + "\\path\\to\\snapshot-5.pt", + "\\path\\to\\snapshot-50.pt", + "\\path\\to\\snapshot5-50.pt", + "\\path\\to\\snapshot1-00.pt", + ], [ + "\\path\\to\\snapshot-100.pt", "\\path\\to\\snapshot-10.pt", + "\\path\\to\\snapshot-5.pt", "\\path\\to\\snapshot-50.pt" + ]), + ([ + "\path\to\snapshot-100.pt", + "\path\to\snapshot-10.pt", + "\path\to\snapshot-5.pt", + "\path\to\snapshot-50.pt", + "\path\to\snapshot5-50.pt", + "\path\to\snapshot1-00.pt", + ], [ + "\path\to\snapshot-100.pt", "\path\to\snapshot-10.pt", + "\path\to\snapshot-5.pt", "\path\to\snapshot-50.pt" + ]), + ([ + "C:\\path\\to\\snapshot-100.pt", + "C:\\path\\to\\snapshot-10.pt", + "C:\\path\\to\\snapshot-5.pt", + "C:\\path\\to\\snapshot-50.pt", + "C:\\path\\to\\snapshot5-50.pt", + "C:\\path\\to\\snapshot1-00.pt", + ], [ + "C:\\path\\to\\snapshot-100.pt", "C:\\path\\to\\snapshot-10.pt", + "C:\\path\\to\\snapshot-5.pt", "C:\\path\\to\\snapshot-50.pt" + ]), + ([ + "C:\path\to\snapshot-100.pt", + "C:\path\to\snapshot-10.pt", + "C:\path\to\snapshot-5.pt", + "C:\path\to\snapshot-50.pt", + "C:\path\to\snapshot5-50.pt", + "C:\path\to\snapshot1-00.pt", + ], [ + "C:\path\to\snapshot-100.pt", "C:\path\to\snapshot-10.pt", + "C:\path\to\snapshot-5.pt", "C:\path\to\snapshot-50.pt" + ]), +] + + +@pytest.mark.parametrize("paths,expected_verified_paths", test_data) +def test_verify_paths_model(paths, expected_verified_paths): + with pytest.warns(): + verified_paths = deeplabcut_pytorch_pose_utils.verify_paths(paths) + assert verified_paths == expected_verified_paths + +test_data = [ + ([ + "/path/to/snapshot-100.pt", + "/path/to/snapshot-10.pt", + "/path/to/snapshot-5.pt", + "/path/to/snapshot-50.pt", + "/path/to/snapshot5-50.pt", + "/path/to/snapshot1-00.pt", + ], [ + "/path/to/snapshot-5.pt", "/path/to/snapshot-10.pt", + "/path/to/snapshot-50.pt", "/path/to/snapshot-100.pt" + ]), + ([ + "\\path\\to\\snapshot-100.pt", + "\\path\\to\\snapshot-10.pt", + "\\path\\to\\snapshot-5.pt", + "\\path\\to\\snapshot-50.pt", + "\\path\\to\\snapshot5-50.pt", + "\\path\\to\\snapshot1-00.pt", + ], [ + "\\path\\to\\snapshot-5.pt", "\\path\\to\\snapshot-10.pt", + "\\path\\to\\snapshot-50.pt", "\\path\\to\\snapshot-100.pt" + ]), + ([ + "\path\to\snapshot-100.pt", + "\path\to\snapshot-10.pt", + "\path\to\snapshot-5.pt", + "\path\to\snapshot-50.pt", + "\path\to\snapshot5-50.pt", + "\path\to\snapshot1-00.pt", + ], [ + "\path\to\snapshot-5.pt", "\path\to\snapshot-10.pt", + "\path\to\snapshot-50.pt", "\path\to\snapshot-100.pt" + ]), + ([ + "C:\\path\\to\\snapshot-100.pt", + "C:\\path\\to\\snapshot-10.pt", + "C:\\path\\to\\snapshot-5.pt", + "C:\\path\\to\\snapshot-50.pt", + "C:\\path\\to\\snapshot5-50.pt", + "C:\\path\\to\\snapshot1-00.pt", + ], [ + "C:\\path\\to\\snapshot-5.pt", "C:\\path\\to\\snapshot-10.pt", + "C:\\path\\to\\snapshot-50.pt", "C:\\path\\to\\snapshot-100.pt" + ]), + ([ + "C:\path\to\snapshot-100.pt", + "C:\path\to\snapshot-10.pt", + "C:\path\to\snapshot-5.pt", + "C:\path\to\snapshot-50.pt", + "C:\path\to\snapshot5-50.pt", + "C:\path\to\snapshot1-00.pt", + ], [ + "C:\path\to\snapshot-5.pt", "C:\path\to\snapshot-10.pt", + "C:\path\to\snapshot-50.pt", "C:\path\to\snapshot-100.pt" + ]), +] + + +@pytest.mark.parametrize("paths,expected_sorted_paths", test_data) +def test_sort_model_paths(paths, expected_sorted_paths): + with pytest.warns(): + sorted_paths = deeplabcut_pytorch_pose_utils.sort_paths(paths) + assert sorted_paths == expected_sorted_paths + + +test_data = [([ + "/path/to/detector-snapshot-100.pt", + "/path/to/detector-snapshot-10.pt", + "/path/to/detector-snapshot-5.pt", + "/path/to/detector-snapshot-50.pt", + "/path/to/detector-snapshot5-50.pt", + "/path/to/snapshot1-00.pt", +], [ + "/path/to/detector-snapshot-100.pt", "/path/to/detector-snapshot-10.pt", + "/path/to/detector-snapshot-5.pt", "/path/to/detector-snapshot-50.pt" +]), + ([ + "\\path\\to\\detector-snapshot-100.pt", + "\\path\\to\\detector-snapshot-10.pt", + "\\path\\to\\detector-snapshot-5.pt", + "\\path\\to\\detector-snapshot-50.pt", + "\\path\\to\\detector-snapshot5-50.pt", + "\\path\\to\\detector-snapshot1-00.pt", + ], [ + "\\path\\to\\detector-snapshot-100.pt", + "\\path\\to\\detector-snapshot-10.pt", + "\\path\\to\\detector-snapshot-5.pt", + "\\path\\to\\detector-snapshot-50.pt" + ]), + ([ + "\path\to\detector-snapshot-100.pt", + "\path\to\detector-snapshot-10.pt", + "\path\to\detector-snapshot-5.pt", + "\path\to\detector-snapshot-50.pt", + "\path\to\detector-snapshot5-50.pt", + "\path\to\snapshot1-00.pt", + ], [ + "\path\to\detector-snapshot-100.pt", + "\path\to\detector-snapshot-10.pt", + "\path\to\detector-snapshot-5.pt", + "\path\to\detector-snapshot-50.pt" + ]), + ([ + "C:\\path\\to\\detector-snapshot-100.pt", + "C:\\path\\to\\detector-snapshot-10.pt", + "C:\\path\\to\\detector-snapshot-5.pt", + "C:\\path\\to\\detector-snapshot-50.pt", + "C:\\path\\to\\detector-snapshot5-50.pt", + "C:\\path\\to\\detector-snapshot1-00.pt", + ], [ + "C:\\path\\to\\detector-snapshot-100.pt", + "C:\\path\\to\\detector-snapshot-10.pt", + "C:\\path\\to\\detector-snapshot-5.pt", + "C:\\path\\to\\detector-snapshot-50.pt" + ]), + ([ + "C:\path\to\detector-snapshot-100.pt", + "C:\path\to\detector-snapshot-10.pt", + "C:\path\to\detector-snapshot-5.pt", + "C:\path\to\detector-snapshot-50.pt", + "C:\path\to\detector-snapshot5-50.pt", + "C:\path\to\snapshot1-00.pt", + ], [ + "C:\path\to\detector-snapshot-100.pt", + "C:\path\to\detector-snapshot-10.pt", + "C:\path\to\detector-snapshot-5.pt", + "C:\path\to\detector-snapshot-50.pt" + ])] + + +@pytest.mark.parametrize("paths,expected_verified_paths", test_data) +def test_verify_paths_detector(paths, expected_verified_paths): + with pytest.warns(): + verified_paths = deeplabcut_pytorch_pose_utils.verify_paths( + paths, r"^(.*)?detector-snapshot-(\d+)\.pt$") + assert verified_paths == expected_verified_paths + + +test_data = [ + ([ + "/path/to/detector-snapshot-100.pt", + "/path/to/detector-snapshot-10.pt", + "/path/to/detector-snapshot-5.pt", + "/path/to/detector-snapshot-50.pt", + "/path/to/detector-snapshot5-50.pt", + "/path/to/snapshot1-00.pt", + ], [ + "/path/to/detector-snapshot-5.pt", "/path/to/detector-snapshot-10.pt", + "/path/to/detector-snapshot-50.pt", "/path/to/detector-snapshot-100.pt" + ]), + ([ + "\\path\\to\\detector-snapshot-100.pt", + "\\path\\to\\detector-snapshot-10.pt", + "\\path\\to\\detector-snapshot-5.pt", + "\\path\\to\\detector-snapshot-50.pt", + "\\path\\to\\detector-snapshot5-50.pt", + "\\path\\to\\detector-snapshot1-00.pt", + ], [ + "\\path\\to\\detector-snapshot-5.pt", + "\\path\\to\\detector-snapshot-10.pt", + "\\path\\to\\detector-snapshot-50.pt", + "\\path\\to\\detector-snapshot-100.pt" + ]), + ([ + "\path\to\detector-snapshot-100.pt", + "\path\to\detector-snapshot-10.pt", + "\path\to\detector-snapshot-5.pt", + "\path\to\detector-snapshot-50.pt", + "\path\to\detector-snapshot5-50.pt", + "\path\to\snapshot1-00.pt", + ], [ + "\path\to\detector-snapshot-5.pt", "\path\to\detector-snapshot-10.pt", + "\path\to\detector-snapshot-50.pt", "\path\to\detector-snapshot-100.pt" + ]), + ([ + "C:\\path\\to\\detector-snapshot-100.pt", + "C:\\path\\to\\detector-snapshot-10.pt", + "C:\\path\\to\\detector-snapshot-5.pt", + "C:\\path\\to\\detector-snapshot-50.pt", + "C:\\path\\to\\detector-snapshot5-50.pt", + "C:\\path\\to\\detector-snapshot1-00.pt", + ], [ + "C:\\path\\to\\detector-snapshot-5.pt", + "C:\\path\\to\\detector-snapshot-10.pt", + "C:\\path\\to\\detector-snapshot-50.pt", + "C:\\path\\to\\detector-snapshot-100.pt" + ]), + ([ + "C:\path\to\detector-snapshot-100.pt", + "C:\path\to\detector-snapshot-10.pt", + "C:\path\to\detector-snapshot-5.pt", + "C:\path\to\detector-snapshot-50.pt", + "C:\path\to\detector-snapshot5-50.pt", + "C:\path\to\snapshot1-00.pt", + ], [ + "C:\path\to\detector-snapshot-5.pt", + "C:\path\to\detector-snapshot-10.pt", + "C:\path\to\detector-snapshot-50.pt", + "C:\path\to\detector-snapshot-100.pt" + ]) +] + + +@pytest.mark.parametrize("paths,expected_sorted_paths", test_data) +def test_sort_detector_paths(paths, expected_sorted_paths): + with pytest.warns(): + sorted_paths = deeplabcut_pytorch_pose_utils.sort_paths( + paths, r"^(.*)?detector-snapshot-(\d+)\.pt$") + assert sorted_paths == expected_sorted_paths From 47febf9a90d6037d247cb2cd861cfe660e651cc5 Mon Sep 17 00:00:00 2001 From: Anna Teruel-Sanchis <78961609+anna-teruel@users.noreply.github.com> Date: Fri, 4 Aug 2023 13:25:32 +0200 Subject: [PATCH 040/293] docstrings and tests (AI residency contribution) * docstrings and tests * deleted duplicated code * erase unnecessary comment * in my previous commit i deleted an extra line --- .../pose_estimation_pytorch/data/base.py | 28 +- .../data/cocoproject.py | 69 ++++- .../pose_estimation_pytorch/data/dataset.py | 150 ++++++++++- .../data/dlcproject.py | 99 ++++--- .../models/criterion.py | 230 +++++++++++----- .../models/detectors/__init__.py | 4 +- .../models/detectors/base.py | 51 +++- .../models/detectors/fasterRCNN.py | 78 +++++- .../models/heads/__init__.py | 6 +- .../models/heads/base.py | 1 - .../models/heads/dekr_heads.py | 246 +++++++++++++++--- .../models/heads/simple_head.py | 115 ++++++-- .../pose_estimation_pytorch/models/model.py | 64 +++-- .../models/target_generators/__init__.py | 2 +- .../models/target_generators/base.py | 7 +- .../models/target_generators/dekr_targets.py | 82 ++++-- .../target_generators/gaussian_targets.py | 127 ++++++--- .../target_generators/plateau_targets.py | 132 +++++++--- .../post_processing/__init__.py | 2 +- .../match_predictions_to_gt.py | 96 +++++-- .../solvers/schedulers.py | 52 ++++ .../tests/test_match_predictions_to_gt.py | 126 +++++++++ .../tests/test_plateau_targets.py | 203 +++++++++++++++ .../tests/test_schedulers.py | 88 +++++++ 24 files changed, 1743 insertions(+), 315 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 7456b83b1f..96ffbcfda2 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -3,24 +3,46 @@ class BaseProject(ABC): """ - TODO + Base class for project configuration. + + This class defines the basic structure and methods that a project configuration should have. + Subclasses should implement the abstract method `convert2dict()` to convert the project configuration to a dictionary. """ def __init__(self): pass @abstractmethod - def convert2dict(self): + def convert2dict(self) -> dict: + """Summary: + Not yet implemented. + Abstract method to convert the project configuration to a dictionary. + + Raises: + NotImplementedError: This method must be implemented in the derived classes. + """ raise NotImplementedError @staticmethod def annotation2key(annotation): + """Summary: + Convert the annotation to a key. + + Args: + annotation: The annotation to be converted. + + Returns: + annotation: the project configuration as a dictionary. + """ return annotation class BaseDataset(ABC): """ - TODO + Base class for datasets. + + This class defines the basic structure for datasets and serves as a superclass for future implementations. + Subclasses should implement specific functionalities for their datasets. """ pass diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoproject.py b/deeplabcut/pose_estimation_pytorch/data/cocoproject.py index e16e9f0b8d..84d8cebb55 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoproject.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoproject.py @@ -1,24 +1,48 @@ +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +import json import os -import pickle from typing import List -import numpy as np -import pandas as pd -import json + from .base import BaseProject class COCOProject(BaseProject): """ - TODO + Definition of the class object COCOProject. """ def __init__( self, - proj_root, + proj_root: str, shuffle: int = 0, image_id_offset: int = 0, keys_to_load: List[str] = ["images", "annotations"], ): + """Summary: + Constructor of the COCOProject. + Loads the data + + Args: + proj_root: root directory path of the COCO project. + shuffle: shuffle value used to select a specific shuffle of train and test JSON files. Defaults to 0. + image_id_offset: the offset value to be added to image IDs. Defaults to 0. + keys_to_load: list of strings specifying the keys to load from the JSON files. Defaults to ["images", "annotations"]. + + Returns: + None + + Examples: + project = COCOProject( proj_root = 'path/to/project/', shuffle = 1, image_id_offset = 1000, keys_to_load = ["images", "annotations"] ) + """ super().__init__() self.proj_root = proj_root self.keys_to_load = keys_to_load @@ -35,18 +59,49 @@ def __init__( ) def _load_json(self, json_fn): + """Summary: + Load a JSON file from the annotations directory. + + Args: + json_fn: filename of JSON file to load + + Returns: + json_obj: JSON object loaded from the file + + Examples: + Check https://docs.trainingdata.io/v1.0/Export%20Format/COCO/ to see + examples of how a json file looks like. + """ path = os.path.join(self.proj_root, "annotations", json_fn) with open(path, "r") as f: json_obj = json.load(f) return json_obj def load_split(self): - """ + """Summary: We expected that coco project has train test split in train test json already + + Args: + None + + Return: + None """ pass def convert2dict(self, mode: str = "train"): + """Summary: + Convert data from JSON objecy to dictionary. + + Args: + mode: indicates which JSON object to convert. Defaults to "train". + + Returns: + None + + Examples: + mode = 'test' + """ json_obj = getattr(self, f"{mode}_json_obj") for image in self.images: diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 2ada713beb..fd5fb3884c 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -11,6 +11,20 @@ import os +import albumentations as A +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +import os + import albumentations as A import cv2 import numpy as np @@ -19,6 +33,9 @@ from deeplabcut.utils.auxiliaryfunctions import get_model_folder, read_plainconfig from torch.utils.data import Dataset +from deeplabcut.utils.auxiliaryfunctions import get_model_folder, read_plainconfig +from torch.utils.data import Dataset + from .base import BaseDataset from .dlcproject import DLCProject @@ -31,6 +48,19 @@ class PoseDataset(Dataset, BaseDataset): def __init__( self, project: DLCProject, transform: object = None, mode: str = "train" ): + """Summary: + Constructor of the PoseDataset. + Loads the data + + Args: + project: see class Project (wrapper for DLC original project class) + transform: augmentation/normalization pipeline + mode: 'train' or 'test' + this parameter which dataframe parse from the Project (df_tran or df_test) + + Returns: + None + """ super().__init__() self.transform = transform self.project = project @@ -66,14 +96,49 @@ def __init__( assert self.length == len(self.project.image_path2image_id.keys()) def __len__(self): + """Summary: + Get the length of the dataset + + Args: + None + + Returns: + Number of samples in the dataset + """ return self.length def _calc_area_from_keypoints(self, keypoints: np.ndarray) -> np.ndarray: + """Summary: + Calculate the area from keypoints + + Args: + keypoints (np.ndarray): array of keypoints + + Returns: + np.ndarray: array containing the computed areas based on the keypoints + """ w = keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1) h = keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1) return w * h def _keypoint_in_boundary(self, keypoint: list, shape: tuple): + """Summary: + Check if a keypoint lies inside the given shape. + + Args: + keypoint: [x,y] coordinates of the keypoints + shape: tuple representing the shape of the boundary (height, width) + + Returns: + Whether a keypoint lies inside the given shape + + Example: + input: + keypoint = [100, 50] + shape = (200, 300) + output: + _keypoint_in_boundary(keypoint, shape) = True + """ return ( (keypoint[0] > 0) and (keypoint[1] > 0) @@ -82,6 +147,24 @@ def _keypoint_in_boundary(self, keypoint: list, shape: tuple): ) def __getitem__(self, index: int) -> dict: + """Summary: + Gets the item at the specified index from the dataset. + + Args: + index: ordered number of the items in the dataset + + Returns: + dict: corresponding to the image annotations, with keys: + - image: image tensor + - original_size: original size of the image before applying transforms + useful to convert the predictions/ground truth back to + the input space + - annotations: + - keypoints: array of keypoints, invisible keypoints appear as (-1,-1) + - area: array of animals area in this image + - ids: array containing the individuals IDs associated with each annotation. + - path + """ # load images try: image_file = self.dataframe.index[index] @@ -259,9 +342,27 @@ def __getitem__(self, index: int) -> dict: class CroppedDataset(Dataset, BaseDataset): + """ + Definition of the class object CroppedDataset: dataset for cropped images and keypoints + """ + def __init__( self, project: DLCProject, transform: object = None, mode: str = "train" ): + """Summary: + Constructor of the CroppedDataset class. + Loads the data + + Args: + project: DLC original project class + transform: transformation function that takes keypoints as inputs + and returns a transformed image with corresponding keypoints. + Defaults to None. + mode: 'train' or 'test'. Defaults to "train". + + Returns: + None: + """ super().__init__() self.transform = transform self.project = project @@ -307,13 +408,19 @@ def __init__( keypoint_params=A.KeypointParams(format="xy", remove_invisible=False), bbox_params=A.BboxParams(format="coco"), ) - - # We must drop na because self.project.images doesn't contain imgaes with no - # labels so it can produce an indexnotfound error - # length is stored here to avoid repeating the computation + self.length = len(self.annotations) def __len__(self): + """Summary: + Get the length of the dataset + + Args: + None + + Returns: + Number of samples in the dataset + """ return self.length def _keypoint_in_boundary(self, keypoint: list, shape: tuple): @@ -345,6 +452,15 @@ def _compute_anno(self): """Summary: Compute annotations for the dataset + Args: + None + + Returns: + annotations: list of annotations containing information about keypoints and image paths. + """ + """Summary: + Compute annotations for the dataset + Args: None @@ -375,11 +491,37 @@ def _compute_anno(self): return annotations def _calc_area_from_keypoints(self, keypoints: np.ndarray) -> np.ndarray: + """Summary: + Calculate the area from keypoints + + Args: + keypoints: array of keypoints + + Returns: + Array containing the computed areas based on the keypoints + """ w = keypoints[:, 0].max(axis=0) - keypoints[:, 0].min(axis=0) h = keypoints[:, 1].max(axis=0) - keypoints[:, 1].min(axis=0) return w * h def __getitem__(self, index: int) -> dict: + """Summary: + Get the item at the specified index from the dataset. + + Args: + index: ordered number of the item in the dataset + + Returns: + dict: dictionary containing following information: + - image: tensor representing the cropped image + - original_size:tuple representing the original size of the image + before applying transforms. + - annotations: dictionary containing annotation information. + - keypoints: array of keypoints associated with the cropped image + - area: array of animal's area in the image + - ids: individual ids associated with the animals + - path + """ # load images ann = self.annotations[index] image_file = ann["image_path"] diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py index 03e58360e1..8fb4356cd9 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py @@ -1,29 +1,22 @@ import os import pickle -from typing import List +from typing import List, Tuple +import deeplabcut import numpy as np import pandas as pd -import deeplabcut -from .base import BaseProject -from ..utils import df2generic - +from deeplabcut.pose_estimation_pytorch.utils import ( + df2generic +) +from deeplabcut.pose_estimation_pytorch.data.base import ( + BaseProject +) class DLCProject(BaseProject): """ Wrapper around the project containing information about the data, the actual annotations and the configs - - Methods: - - convert2dict : convert the annotations dataframe into a coco format dict of annotations - - _init_annotation_image_correspondance: binds the image paths to corresponding annotations - ensures there is no indexing offsets between images and annotations when - going through the dataset - - load_split : split the annotation dataframe into train and test dataframes - based on project's split - - annotation2keypoints : convert the coco annotations into array of keypoints - also returns the array of the keypoints' visibility """ def __init__( @@ -33,6 +26,21 @@ def __init__( image_id_offset: int = 0, keys_to_load: List[str] = ["images", "annotations"], ): + """Summary: + Constructor of the DLCProject class. + Loads the data + + Args: + proj_root: project path + shuffle: shuffle index for the project. Defaults to 0. + image_id_offset: offset value for image ids. Defaults to 0. + keys_to_load: list of keys to load from the dataset. + Defaults to ["images", "annotations"]. + + Return: + None + """ + super().__init__() self.proj_root = proj_root self.shuffle = shuffle @@ -62,15 +70,17 @@ def __init__( self.df_test = self.df_test[~self.df_test.index.duplicated(keep="first")] def convert2dict(self, mode: str = "train"): - """ + """Summary: + Convert the annotations dataframe into coco format dictionary of annotations - Parameters - ---------- - mode + Args: + mode: mode indicating whether to use 'train' or 'test' data. Defaults to "train". - Returns - ------- + Raises: + AttributeError: if the specified mode (train or test) does not exist. + Returns: + None """ try: self.dataframe = getattr(self, f"df_{mode}") @@ -85,11 +95,22 @@ def convert2dict(self, mode: str = "train"): for key in self.keys_to_load: setattr(self, key, data[key]) - print("The data has been loaded!") + print("The data has been converted!") def _init_annotation_image_correspondance(self, data: dict): - """data should be a COCO like dictionary of the pose dataset""" + """Summary: + Binds the image paths to corresponding annotations and ensures there is no indexing + offsets between images and annotations when going through the dataset + + Args: + data: dictionary containing annotations in COCO-like format + + Returns: + None + Examples: + data = {"images": [...], "annotations": [...]} + """ # Path to id correspondence self.image_path2image_id = {} for i, image in enumerate(data["images"]): @@ -108,11 +129,14 @@ def _init_annotation_image_correspondance(self, data: dict): return def load_split(self): - """ + """Summary: + Split the annotation dataframe into train and test dataframes based on project's split - Returns - ------- + Args: + None + Return: + None """ with open(self.path_dlc_doc, "rb") as f: meta = pickle.load(f) @@ -128,20 +152,15 @@ def load_split(self): self.df_train = self.dlc_df.loc[train_images] @staticmethod - def annotation2keypoints(annotation): - """ - TODO - This function was copied from modelzoo project (transformation to coco format) - Parameters - ---------- - annotation: dict of annotations - - Returns - ------- - keypoints: list - paired keypoints - undef_ids: array - 0 means this keypoints is undefined, 1 means it is + def annotation2keypoints(annotation: dict) -> Tuple[list, np.array]: + """Summary: + Convert the coco annotations into array of keypoints also returns the array of the keypoints' visibility + Args: + annotation: dictionary containing coco-like annotations + + Returns: + keypoints: paired keypoints + undef_ids: array where 0 means the keypoint is undefined. 1 means it is defined. """ x = annotation["keypoints"][::3] y = annotation["keypoints"][1::3] diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index d08281cf7f..e702ed7ec4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -1,19 +1,54 @@ -import torch -import torch.nn as nn +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from typing import Tuple -from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry +import torch +import torch.nn as nn +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg LOSSES = Registry("losses", build_func=build_from_cfg) class WeightedMSELoss(nn.MSELoss): - def __init__(self): + """ + Weighted Mean Squared Error (MSE) Loss. + + This loss computes the Mean Squared Error between the prediction and target tensors, + but it also incorporates weights to adjust the contribution of each element in the loss + calculation. The loss is computed element-wise, and elements with a weight of 0 (masked items) + are excluded from the loss calculation. + """ + + def __init__(self) -> None: + """ + Constructor of the class WeightedMSELoss + """ super(WeightedMSELoss, self).__init__() self.mse_loss = nn.MSELoss(reduction="none") - def __call__(self, prediction, target, weights=1): + def __call__( + self, prediction: torch.Tensor, target: torch.Tensor, weights: torch.Tensor = 1 + ) -> torch.Tensor: + """Summary: + Compute the weighted Mean Squared Error loss. + + Args: + prediction: predicted tensor + target: target tensor + weights: weights for each element in the loss calculation. Defaults to 1. + + Returns: + Weighted Mean Squared Error Loss. + """ loss_item = self.mse_loss(prediction, target) loss_item_weighted = loss_item * weights @@ -24,11 +59,36 @@ def __call__(self, prediction, target, weights=1): class WeightedHuberLoss(nn.HuberLoss): - def __init__(self): + """ + Weighted Huber Loss. + + This loss computes the Huber loss between the prediction and target tensors, + but it also incorporates weights to adjust the contribution of each element in the loss + calculation. The loss is computed element-wise, and elements with a weight of 0 are + excluded from the loss calculation. + """ + + def __init__(self) -> None: + """Summary: + Constructor of the WeightedHuberLoss class. + """ super(WeightedHuberLoss, self).__init__() self.huber_loss = nn.HuberLoss(reduction="none") - def __call__(self, prediction, target, weights=1): + def __call__( + self, prediction: torch.Tensor, target: torch.Tensor, weights: int = 1 + ) -> torch.Tensor: + """Summary: + Compute the weighted Huber loss. + + Args: + prediction: predicted tensor + target: target tensor + weights: Weights for each element in the loss calculation. Defaults to 1. + + Returns: + Weighted Huber loss. + """ loss_item = self.huber_loss(prediction, target) loss_item_weighted = loss_item * weights @@ -39,11 +99,36 @@ def __call__(self, prediction, target, weights=1): class WeightedBCELoss(nn.BCEWithLogitsLoss): - def __init__(self): + """ + Weighted Binary Cross Entropy (BCE) Loss. + + This loss computes the Binary Cross Entropy loss between the prediction and target tensors, + but it also incorporates weights to adjust the contribution of each element in the loss + calculation. The loss is computed element-wise, and elements with a weight of 0 are + excluded from the loss calculation. + """ + + def __init__(self) -> None: + """Summary: + Constructor of the WeightedBCELoss. + """ super(WeightedBCELoss, self).__init__() self.BCELoss = nn.BCEWithLogitsLoss(reduction="none") - def __call__(self, prediction, target, weights=1): + def __call__( + self, prediction: torch.Tensor, target: torch.Tensor, weights: int = 1 + ) -> torch.Tensor: + """Summary: + Compute the weighted Binary Cross Entropy loss. + + Args: + prediction: _description_ + target: _description_ + weights: _description_. Defaults to 1. + + Returns: + Weighted Binary Cross Entropy loss. + """ loss_item = self.BCELoss(prediction, target) loss_item_weighted = loss_item * weights @@ -55,24 +140,31 @@ def __call__(self, prediction, target, weights=1): @LOSSES.register_module class PoseLoss(nn.Module): + """ + Pose Lose Function. + + This loss function computes the weighted sum of heatmap and locref loss for keypoint detection and + localization, respectively. The locref loss can be either Mean Squared Error (MSE) or Huber Loss, + depending on the locref_huber_loss flag. + """ + def __init__( self, loss_weight_locref: float = 0.1, locref_huber_loss: bool = False, apply_sigmoid: bool = False, - ): - """ + ) -> None: + """Summary: + Constructter of the PoseLoss class. - Parameters - ---------- - loss_weight_locref: float - Weight for loss_locref part - (parsed from the pose_cfg.yaml from the dlc_models folder) - locref_huber_loss: bool - If `True` uses torch.nn.HuberLoss for locref - (default is False) - apply_sigmoid : whether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) + Args: + loss_weight_locref: weight for loss_locref part (parsed from the pose_cfg.yaml from the dlc_models folder) + locref_huber_loss: if True uses torch.nn.HuberLoss for locref (default is False). + apply_sigmoid: whether to apply sigmoid to the heatmap predictions should be true + for MSE, false for BCE (since it already applies it by itself) + Returns: + None. """ super(PoseLoss, self).__init__() if locref_huber_loss: @@ -84,26 +176,34 @@ def __init__( self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() - def forward(self, prediction: Tuple[torch.Tensor], target: dict) -> dict: - """ + def forward(self, prediction: tuple, target: dict) -> tuple: + """Summary: + Forward pass of the Pose Loss function. - Parameters - ---------- - prediction: tuple of Tensors `(heatmaps, locref)` of size `(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)` - Predicted heatmap and locref - target: dict = { - 'heatmaps': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'heatmaps_ignored': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]) + Args: + prediction: a tuple containing the predicted heatmap and locref of size + '(heatmaps, locref)' of size '(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)' + target: dictionary containing the target tensors, including 'heatmaps', + 'locref_maps', 'locref_masks', and 'weights' (optional, default is None). + { + 'heatmaps': (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'heatmaps_ignored': (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]) weights for the heatmaps - 'locref_maps': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'locref_masks': torch.Tensor (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), - } - Returns - ------- - losses_dict: dict of the different unweighted loss components, keys: - - 'total_loss' - - 'heatmap_loss' - - 'locref_loss' + 'locref_maps': (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'locref_masks': (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + } + Returns: + A tuple containing the total_loss, heatmap_loss and locref_loss. + + Examples: + prediction = (predicted_heatmaps, predicted_locref) + target = { + 'heatmaps': torch.tensor([batch_size, num_keypoints, h, w]), + 'locref_maps': torch.tensor([batch_size, 2 * num_keypoints, h, w]), + 'locref_masks': torch.tensor([batch_size, 2 * num_keypoints, h, w]), + 'weights': torch.tensor([batch_size, num_keypoints]) + } + total_loss, heatmap_loss, locref_loss = criterion(prediction, target) """ heatmaps, locref = prediction if self.apply_sigmoid: @@ -130,41 +230,43 @@ def forward(self, prediction: Tuple[torch.Tensor], target: dict) -> dict: @LOSSES.register_module class HeatmapOnlyLoss(nn.Module): - def __init__(self, apply_sigmoid: bool = False): - """ + """ + Heatmap-Only Loss Function. + + This loss function computes the weighted Binary Cross Entropy (BCE) loss for heatmap predictions. + """ + + def __init__(self, apply_sigmoid: bool = False) -> None: + """Summary: + Constructor for the HeatmapOnlyLoss class. - Parameters - ---------- - loss_weight_locref: float - Weight for loss_locref part - (parsed from the pose_cfg.yaml from the dlc_models folder) - locref_huber_loss: bool - If `True` uses torch.nn.HuberLoss for locref - (default is False) - apply_sigmoid : whether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) + Args: + apply_sigmoid: whether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) + Return: + None """ super(HeatmapOnlyLoss, self).__init__() self.heatmap_criterion = WeightedBCELoss() self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() - def forward(self, prediction: Tuple[torch.Tensor], target: dict) -> dict: - """ + def forward(self, prediction: tuple, target) -> torch.Tensor: + """Summary: + Forward pass of the Heatmap_Only Loss function. - Parameters - ---------- - prediction: tuple of Tensors `(heatmaps, locref)` of size `(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)` - Predicted heatmap and locref - target: dict = { - 'heatmaps': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'heatmaps_ignored': torch.Tensor (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]) - weights for the heatmaps - } - Returns - ------- - losses_dict: dict of the different unweighted loss components, keys: - - 'total_loss' + Args: + prediction: tuple containing the predicted heatmap and locref of size + (batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)` + target: dictionary containing the target tensors: { + 'heatmaps': (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'locref_maps': (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'locref_masks': (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'weights': (optional, default is None) + } + + Returns: + heatmap_loss: the computed heatmap loss. """ heatmaps = prediction[0] if self.apply_sigmoid: diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py index c7b794fc4d..a26c4d62bb 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py @@ -1,2 +1,2 @@ -from .base import DETECTORS, BaseDetector -from .fasterRCNN import FasterRCNN +from deeplabcut.pose_estimation_pytorch.models.detectors.base import DETECTORS, BaseDetector +from deeplabcut.pose_estimation_pytorch.models.detectors.fasterRCNN import FasterRCNN diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index 736f4bdb3a..4146de24c9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -1,4 +1,15 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from abc import ABC, abstractmethod + import torch import torch.nn as nn from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg @@ -7,18 +18,50 @@ class BaseDetector(ABC, nn.Module): - def __init__(self): + """ + Definition of the class BaseDetector object. + This is an abstract class defining the common structure and inference for detectors. + """ + + def __init__(self) -> None: super().__init__() @abstractmethod - def forward(self, x): + def forward(self, x: torch.Tensor) -> None: + """Summary: + Forward pass of the detector + + Args: + x: input tensor representing the image + + Returns: + See base class. + """ pass @abstractmethod - def get_target(self, annotations): + def get_target(self, annotations) -> None: + """Summary: + Get the target for training the detector + + Args: + annotations: annotations containing keypoints, bounding boxes, etc. + + Returns: + None + """ pass - def _init_weights(self, pretrained): + def _init_weights(self, pretrained: bool) -> None: + """Summary: + Initialize weights for the detector + + Args: + pretrained: whether to use pretrained weights. + + Returns: + None + """ if not pretrained: pass else: diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index 12794b023c..2b3dfbe165 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -1,16 +1,38 @@ -import torch from typing import List + +import torch import torchvision -from torchvision.models.detection.faster_rcnn import FastRCNNPredictor from torchvision.models.detection import FasterRCNN_ResNet50_FPN_Weights +from torchvision.models.detection.faster_rcnn import FastRCNNPredictor + from .base import DETECTORS, BaseDetector @DETECTORS.register_module class FasterRCNN(BaseDetector): + """ + Definition of the class object FasterRCNN. + Faster Region-based Convolutional Neural Network (R-CNN) is a popular object detection model + that builds upn the R-CNN framework. + + I. A. Siradjuddin, Reynaldi and A. Muntasa, "Faster Region-based Convolutional Neural Network + for Mask Face Detection," 2021 5th International Conference on Informatics and Computational + Sciences (ICICoS), Semarang, Indonesia, 2021, pp. 282-286, doi: 10.1109/ICICoS53627.2021.9651744. + """ + def __init__( self, ): + """Summary: + Constructor of the FasterRCNN object. + Loads the data. + + Args: + None + + Return: + None + """ super().__init__() self.model = torchvision.models.detection.fasterrcnn_resnet50_fpn( weights=FasterRCNN_ResNet50_FPN_Weights.COCO_V1 @@ -20,18 +42,56 @@ def __init__( self.model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes) - def forward(self, x, targets=None): - return self.model(x, targets) + def forward(self, x: torch.Tensor, targets: dict = None) -> torch.Tensor: + """Summary: + Forward pass of the Faster R-CNN + + Args: + x: input tensor to the detector + targets: dictionary containing target information for training. + Defaults to None. - def get_target(self, annotations): + Returns: + Output tensor from the detector. If targets are provided, returns + a tuple of losses (classification and regression). + If targets are not provided, returns a tensor with predicted bounding + boxes and associated scores. """ + return self.model(x, targets) + + def get_target(self, annotations: dict) -> List[dict]: + """Summary: Returns target in a format FasterRCNN can handle + Args: - annotations : dict of annotations, must contain the keys 'area', 'labels', - 'is_crowd', 'image_id', 'boxes' + annotations: dict of annotations, must contain the keys: + area: tensor containing area information for each annotation. + labels: tensor containing class labels for each annotation. + is_crowd: tensor indicating if each annotation is a crowd (1) or not (0). + image_id: tensor containing image ids for each annotation + boxes: tensor containing bounding box information for each annotation + + Returns: + res: list of dictionaries, each representing target information for a single annotation. + Each dictionary contains the following keys: + 'area' + 'labels' + 'image_id' + 'is_crowd' + 'boxes' - Output: - list of the target dictionaries (not the same serialisation for batches as default pytorch does) + Examples: + input: + annotations = {"area": torch.Tensor([100, 200]), + "labels": torch.Tensor([1, 2]), + "is_crowd": torch.Tensor([0, 1]), + "image_id": torch.Tensor([1, 1]), + "boxes": torch.Tensor([[10, 20, 30, 40], [50, 60, 70, 80]])} + output: + res = [{'area': tensor([100.]), 'labels': tensor([1]), 'image_id': tensor([1]), 'is_crowd': tensor([0]), + 'boxes': tensor([[10., 20., 40., 60.]])}, + {'area': tensor([200.]), 'labels': tensor([2]), 'image_id': tensor([1]), 'is_crowd': tensor([1]), 'boxes': + tensor([[50., 60., 70., 80.]])}] """ res = [] for i, _ in enumerate(annotations["image_id"]): diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py index 42a67771f5..2348d33cc8 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py @@ -1,3 +1,3 @@ -from .base import HEADS -from .simple_head import SimpleHead -from .dekr_heads import HeatmapDEKRHead, OffsetDEKRHead +from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS +from deeplabcut.pose_estimation_pytorch.models.heads.dekr_heads import HeatmapDEKRHead, OffsetDEKRHead +from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import SimpleHead diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index 3e1fc38ac9..f5078eddf0 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -2,7 +2,6 @@ import torch import torch.nn as nn - from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg HEADS = Registry("heads", build_func=build_from_cfg) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py index 2679f9ef5f..4ad894b1f3 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py @@ -1,9 +1,23 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from typing import Tuple + import torch import torch.nn as nn from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS +from deeplabcut.pose_estimation_pytorch.models.modules import (AdaptBlock, + BasicBlock) from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import BLOCKS -from deeplabcut.pose_estimation_pytorch.models.modules import BasicBlock, AdaptBlock + from .base import BaseHead @@ -22,12 +36,33 @@ class HeatmapDEKRHead(BaseHead): def __init__( self, - channels, - num_blocks, - dilation_rate, - final_conv_kernel, + channels: Tuple[int], + num_blocks: int, + dilation_rate: int, + final_conv_kernel: int, block=BasicBlock, - ): + ) -> None: + """Summary: + Constructor of the HeatmapDEKRHead. + Loads the data. + + Args: + channels: tuple containing the number of channels for the head. + num_blocks: number of blocks in the head + dilation_rate: dilation rate for the head + final_conv_kernel: kernel size for the final convolution + block: type of block to use in the head. Defaults to BasicBlock. + + Returns: + None + + Examples: + channels = (64,128,17) + num_blocks = 3 + dilation_rate = 2 + final_conv_kernel = 3 + block = BasicBlock + """ super().__init__() self.bn_momentum = 0.1 self.inp_channels = channels[0] @@ -47,15 +82,39 @@ def __init__( dilation_rate, ) - def _make_transition_for_head(self, inplanes, outplanes): + def _make_transition_for_head(self, in_channels: int, out_channels: int) -> nn.Sequential: + """Summary: + Construct the transition layer for the head. + + Args: + in_channels: number of input channels + out_channels: number of output channels + + Returns: + Transition layer consisting of Conv2d, BatchNorm2d, and ReLU + """ transition_layer = [ - nn.Conv2d(inplanes, outplanes, 1, 1, 0, bias=False), - nn.BatchNorm2d(outplanes), + nn.Conv2d(in_channels, out_channels, 1, 1, 0, bias=False), + nn.BatchNorm2d(out_channels), nn.ReLU(True), ] return nn.Sequential(*transition_layer) - def _make_heatmap_head(self, block, num_blocks, num_channels, dilation_rate): + def _make_heatmap_head( + self, block: nn.Module, num_blocks: int, num_channels: int, dilation_rate: int + ) -> nn.ModuleList: + """Summary: + Construct the heatmap head + + Args: + block: type of block to use in the head. + num_blocks: number of num_blocks in the head. + num_channels: number of input channels for the head. + dilation_rate: dilation rate for the head. + + Returns: + List of modules representing the heatmap head layers. + """ heatmap_head_layers = [] feature_conv = self._make_layer( @@ -74,25 +133,47 @@ def _make_heatmap_head(self, block, num_blocks, num_channels, dilation_rate): return nn.ModuleList(heatmap_head_layers) - def _make_layer(self, block, inplanes, planes, blocks, stride=1, dilation=1): + def _make_layer( + self, + block: nn.Module, + in_channels: int, + out_channels: int, + num_blocks: int, + stride: int = 1, + dilation: int = 1, + ) -> nn.Sequential: + """Summary: + Construct a layer in the head. + + Args: + block: type of block to use in the head. + in_channels: number of input channels for the layer. + out_channels: number of output channels for the layer. + num_blocks: number of num_blocks in the layer. + stride: stride for the convolutional layer. Defaults to 1. + dilation: dilation rate for the convolutional layer. Defaults to 1. + + Returns: + Sequential layer containing the specified num_blocks. + """ downsample = None - if stride != 1 or inplanes != planes * block.expansion: + if stride != 1 or in_channels != out_channels * block.expansion: downsample = nn.Sequential( nn.Conv2d( - inplanes, - planes * block.expansion, + in_channels, + out_channels * block.expansion, kernel_size=1, stride=stride, bias=False, ), - nn.BatchNorm2d(planes * block.expansion, momentum=self.bn_momentum), + nn.BatchNorm2d(out_channels * block.expansion, momentum=self.bn_momentum), ) layers = [] - layers.append(block(inplanes, planes, stride, downsample, dilation=dilation)) - inplanes = planes * block.expansion - for _ in range(1, blocks): - layers.append(block(inplanes, planes, dilation=dilation)) + layers.append(block(in_channels, out_channels, stride, downsample, dilation=dilation)) + in_channels = out_channels * block.expansion + for _ in range(1, num_blocks): + layers.append(block(in_channels, out_channels, dilation=dilation)) return nn.Sequential(*layers) @@ -117,13 +198,27 @@ class OffsetDEKRHead(BaseHead): def __init__( self, - channels, - num_offset_per_kpt, - num_blocks, - dilation_rate, - final_conv_kernel, + channels: Tuple[int], + num_offset_per_kpt: int, + num_blocks: int, + dilation_rate: int, + final_conv_kernel: int, block=AdaptBlock, - ): + ) -> None: + """Summary: + Constructor of the OffsetDEKRHead. + Loads the data. + + Args: + channels: tuple containing the number of input, offset, and output channels. + num_offset_per_kpt: number of offset values per keypoint. + num_blocks: number of num_blocks in the head. + dilation_rate: dilation rate for convolutional layers. + final_conv_kernel: kernel size for the final convolution. + block: type of block to use in the head. Defaults to AdaptBlock. + + Return: None + """ super().__init__() self.inp_channels = channels[0] self.num_joints = channels[2] @@ -153,43 +248,97 @@ def __init__( dilation_rate=self.dilation_rate, ) - def _make_layer(self, block, inplanes, planes, blocks, stride=1, dilation=1): + def _make_layer( + self, + block: nn.Module, + in_channels: int, + out_channels: int, + num_blocks: int, + stride: int = 1, + dilation: int = 1, + ) -> nn.Sequential: + """Summary: + Create a sequential layer with the specified block and number of num_blocks. + + Args: + block: block type to use in the layer. + in_channels: number of input channels. + out_channels: number of output channels. + num_blocks: number of blocks to be stacked in the layer. + stride: stride for the first block. Defaults to 1. + dilation: dilation rate for the blocks. Defaults to 1. + + Returns: + A sequential layer containing stacked num_blocks. + + Examples: + input: + block=BasicBlock + in_channels=64 + out_channels=128 + num_blocks=3 + stride=1 + dilation=1 + """ downsample = None - if stride != 1 or inplanes != planes * block.expansion: + if stride != 1 or in_channels != out_channels * block.expansion: downsample = nn.Sequential( nn.Conv2d( - inplanes, - planes * block.expansion, + in_channels, + out_channels * block.expansion, kernel_size=1, stride=stride, bias=False, ), - nn.BatchNorm2d(planes * block.expansion, momentum=self.bn_momentum), + nn.BatchNorm2d(out_channels * block.expansion, momentum=self.bn_momentum), ) layers = [] - layers.append(block(inplanes, planes, stride, downsample, dilation=dilation)) - inplanes = planes * block.expansion - for _ in range(1, blocks): - layers.append(block(inplanes, planes, dilation=dilation)) + layers.append(block(in_channels, out_channels, stride, downsample, dilation=dilation)) + in_channels = out_channels * block.expansion + for _ in range(1, num_blocks): + layers.append(block(in_channels, out_channels, dilation=dilation)) return nn.Sequential(*layers) - def _make_transition_for_head(self, inplanes, outplanes): + def _make_transition_for_head(self, in_channels: int, out_channels: int) -> nn.Sequential: + """Summary: + Create a transition layer for the head. + + Args: + in_channels: number of input channels + out_channels: number of output channels + + Returns: + Sequential layer containing the transition operations. + """ transition_layer = [ - nn.Conv2d(inplanes, outplanes, 1, 1, 0, bias=False), - nn.BatchNorm2d(outplanes), + nn.Conv2d(in_channels, out_channels, 1, 1, 0, bias=False), + nn.BatchNorm2d(out_channels), nn.ReLU(True), ] return nn.Sequential(*transition_layer) def _make_separete_regression_head( self, - block, - num_blocks, - num_channels_per_kpt, - dilation_rate, - ): + block: nn.Module, + num_blocks: int, + num_channels_per_kpt: int, + dilation_rate: int, + ) -> tuple: + """Summary: + + Args: + block: type of block to use in the head + num_blocks: number of blocks in the regression head + num_channels_per_kpt: number of channels per keypoint + dilation_rate: dilation rate for the regression head + + Returns: + A tuple containing two ModuleList objects. + The first ModuleList contains the feature convolution layers for each keypoint, + and the second ModuleList contains the final offset convolution layers. + """ offset_feature_layers = [] offset_final_layer = [] @@ -214,7 +363,18 @@ def _make_separete_regression_head( return nn.ModuleList(offset_feature_layers), nn.ModuleList(offset_final_layer) - def forward(self, x): + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Summary: + Perform forward pass through the OffsetDEKRHead. + + Args: + x: input tensor to the head. + + Returns: + offset: Computed offsets from the center corresponding to each keypoint. + The tensor will have the shape (N, num_joints * 2, H, W), where N is the batch size, + num_joints is the number of keypoints, and H and W are the height and width of the output tensor. + """ final_offset = [] offset_feature = self.transition_offset(x) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index f7bd97aed0..7db39f27f5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -1,22 +1,46 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + import torch import torch.nn as nn - +from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS from einops import rearrange from timm.layers import trunc_normal_ -from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS from .base import BaseHead @HEADS.register_module class SimpleHead(BaseHead): """ - Deconvolutional head to predict maps from the extracted features + Deconvolutional head to predict maps from the extracted features. + This class implements a simple deconvolutional head to predict maps from the extracted features. """ def __init__( self, channels: list, kernel_size: list, strides: list, pretrained: str = None - ): + ) -> None: + """Summary + Constructor of the SimpleHead object. + Loads the data. + + Args: + channels: list containing the number of input and output channels for each deconvolutional layer. + kernel_size: list containing the kernel size for each deconvolutional layer. + strides: list containing the stride for each deconvolutional layer. + pretrained: path to a pretrained model checkpoint. Defaults to None. Defaults to None. + + Returns: + None + """ super().__init__() self.kernel_size = kernel_size self.strides = strides @@ -38,13 +62,36 @@ def __init__( self._init_weights(pretrained) - def _make_layer(self, input_channels, output_channels, kernel_size, stride): + def _make_layer( + self, in_channels: int, out_channels: int, kernel_size: int, stride: int + ) -> torch.nn.ConvTranspose2d: + """Summary: + Helper function to create a deconvolutional layer. + + Args: + in_channels: number of input channels + out_channels: number of output channels + kernel_size: size of the deconvolutional kernel + stride: stride for the covolution operation + + Returns: + upsample_layer: the deconvolutional layer. + """ upsample_layer = nn.ConvTranspose2d( - input_channels, output_channels, kernel_size, stride=stride + in_channels, out_channels, kernel_size, stride=stride ) return upsample_layer - def forward(self, x): + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Summary: + Forward pass of the SimpleHead object. + + Args: + x: input tensor + + Returns: + out: output tensor + """ out = self.model(x) return out @@ -52,15 +99,35 @@ def forward(self, x): @HEADS.register_module class TransformerHead(BaseHead): + """ + Transformer Head module to predict heatmaps using a transformer-based approach + """ + def __init__( self, - dim, - hidden_heatmap_dim, - heatmap_dim, - apply_multi, - heatmap_size, - apply_init, + dim: int, + hidden_heatmap_dim: int, + heatmap_dim: int, + apply_multi: bool, + heatmap_size: tuple, + apply_init: bool, ): + """Summary: + Given the output of a transformer neck, this head applies an mlp head to compute the heatmaps + Args: + dim: Dimension of the input features. + hidden_heatmap_dim: Dimension of the hidden features in the MLP head. + heatmap_dim: Dimension of the output heatmaps. + apply_multi: If True, apply a multi-layer perceptron (MLP) with LayerNorm + to generate heatmaps. If False, directly apply a single linear + layer for heatmap prediction. + heatmap_size: Tuple (height, width) representing the size of the output + heatmaps. + apply_init: If True, apply weight initialization to the module's layers. + + Returns: + None + """ super().__init__() self.mlp_head = ( nn.Sequential( @@ -77,7 +144,16 @@ def __init__( if apply_init: self.apply(self._init_weights) - def _init_weights(self, m): + def _init_weights(self, m: nn.Module) -> None: + """Summary + Custom weight initialization for linear and layer normalization layers. + + Args: + m: module to initialize + + Returns: + None + """ if isinstance(m, nn.Linear): trunc_normal_(m.weight, std=0.02) if isinstance(m, nn.Linear) and m.bias is not None: @@ -86,7 +162,16 @@ def _init_weights(self, m): nn.init.constant_(m.bias, 0) nn.init.constant_(m.weight, 1.0) - def forward(self, x): + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Summary: + Forward pass of the TransformerHead class + + Args: + x: input tensor + + Returns: + x: output tensor containing predicted heatmaps + """ x = self.mlp_head(x) x = rearrange( x, diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 3e5ae3080a..1f08c6369c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -1,11 +1,22 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +from typing import List, Tuple + import numpy as np import torch -from typing import Tuple +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict from torch import nn -from typing import List -from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator class PoseModel(nn.Module): @@ -21,7 +32,22 @@ def __init__( target_generator: BaseGenerator, neck: torch.nn.Module = None, stride: int = 8, - ): + ) -> None: + """Summary + Constructor of the PoseModel. + Loads the data. + + Args: + cfg: configuration dictionary for the model. + backbone: backbone network architecture. + heads: list of head modules, one per keypoint. + target_generator: target generator for model training + neck: neck network architecture (default is None). Defaults to None. + stride: stride used in the model. Defaults to 8. + + Return: + None + """ super().__init__() self.backbone = backbone self.backbone.activate_batch_norm( @@ -36,15 +62,14 @@ def __init__( self.sigmoid = nn.Sigmoid() def forward(self, x: torch.Tensor) -> List[torch.Tensor]: - """ - TODO - Parameters - ---------- - x: input images - - Returns - ------- - outputs : list of outputs, one output per head + """Summary: + Forward pass of the PoseModel. + + Args: + x: input images + + Returns: + List of output, one output per head. """ if x.dim() == 3: x = x[None, :] @@ -62,17 +87,18 @@ def get_target( annotations: dict, prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], - ): - """_summary_ + ) -> dict: + """Summary: + Get targets for model training. Args: - annotations (dict): dict of annotations - prediction (Tuple[torch.Tensor, torch.Tensor]): output of the model + annotations: dictionary of annotations + prediction: output of the model (used here to compute the scaling factor of the model) - image_size (Tuple[int, int]): image_size, used here to compute the scaling factor of the model + image_size: image_size, used here to compute the scaling factor of the model Returns: - targets : dict of the targets needed for model training + targets: dict of the targets needed for model training """ return self.target_generator(annotations, prediction, image_size) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py index cbfa9c62ae..2d3691cbec 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py @@ -1,4 +1,4 @@ -from .base import BaseGenerator, TARGET_GENERATORS +from .base import TARGET_GENERATORS, BaseGenerator from .dekr_targets import DEKRGenerator from .gaussian_targets import GaussianGenerator from .plateau_targets import PlateauGenerator diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py index de4e961d75..9c66ee9193 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -1,13 +1,16 @@ from abc import ABC, abstractmethod -import torch.nn as nn + import torch +import torch.nn as nn from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - TARGET_GENERATORS = Registry("target_generators", build_func=build_from_cfg) class BaseGenerator(ABC, nn.Module): + """ + Given the ground truth anotation generates the corresponding maps for training the model + """ def __init__(self): super().__init__() self.batch_norm_on = False diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index c85ab8c431..8e3876ce4c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -1,10 +1,21 @@ -import numpy as np +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + from typing import Tuple -import torch +import numpy as np +import torch from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - BaseGenerator, TARGET_GENERATORS, + BaseGenerator, ) @@ -18,9 +29,27 @@ class DEKRGenerator(BaseGenerator): CVPR 2021 Code based on: - https://github.com/HRNet/DEKR""" + https://github.com/HRNet/DEKR + """ def __init__(self, num_joints: int, pos_dist_thresh: int, bg_weight: float = 0.1): + """Summary: + Constructor of the DEKRGenerator class. + Loads the data. + + Args: + num_joints: number of keypoints + pos_dist_thresh: 3*std of the gaussian + bg_weight:background weight. Defaults to 0.1. + + Returns: + None + + Examples: + num_joints = 6 + pos_dist_thresh = 17 + bg_weight = 0.1 (default) + """ super().__init__() self.num_joints = num_joints @@ -30,6 +59,20 @@ def __init__(self, num_joints: int, pos_dist_thresh: int, bg_weight: float = 0.1 self.num_joints_with_center = self.num_joints + 1 def get_heat_val(self, sigma: float, x: float, y: float, x0: float, y0: float): + """Summary: + Calculates the corresponding heat value of point (x,y) given the heat distribution centered + at (x0,y0) and spread value of sigma. + + Args: + sigma: controls the spread or width of the heat distribution + x: x coord of a point on the image grid + y: y coord of a point on the image grid + x0: x center coordinate of the heat distribution + y0: y center coordinate of the heat distribution + + Returns: + g: calculated heat value represents the intensity of the heat at a given position + """ g = np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma**2)) return g @@ -40,25 +83,32 @@ def forward( prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], ): - """ - - Parameters - ---------- - annotations: dict, each entry should begin with the shape batch_size - prediction: output of model, format could depend on the model, only used to compute output resolution - image_size: size of image (only one tuple since for batch training all images should have the same size) - - Returns - ------- - #TODO locref is a bad name here and should be 'offset to center', but for code's simplicity it + """Summary + Given the annotations and predictions of your keypoints, this function returns the targets, + a dictionary containing the heatmaps, locref_maps and locref_masks. + Args: + annotations: each entry should begin with the shape batch_size + prediction: output of model, format could depend on the model, only used to compute output resolution + image_size:size of image (only one tuple since for batch training all images should have the same size) + + Returns: + #TODO locref is a bad name here and should be 'offset to center', but for code's simplicity it is easier to use the same keys as for the SingleAnimal target generators - targets : dict of the taregts, keys: + targets, keys: 'heatmaps' : heatmaps 'heatmaps_ignored': weights to apply to the heatmaps for loss computation 'locref_maps' : offset maps 'locref_masks' : weights to apply to the offset maps for loss computation + Examples: + input: + annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} + prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + image_size = (256, 256) + output: + targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} """ + batch_size, _, output_h, output_w = prediction[0].shape output_res = output_h, output_w stride_y, stride_x = image_size[0] / output_h, image_size[1] / output_w diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py index d498028ad7..8598929969 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py @@ -1,10 +1,21 @@ -import numpy as np +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + from typing import Tuple -import torch +import numpy as np +import torch from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - BaseGenerator, TARGET_GENERATORS, + BaseGenerator, ) @@ -16,6 +27,24 @@ class GaussianGenerator(BaseGenerator): """ def __init__(self, locref_stdev: float, num_joints: int, pos_dist_thresh: int): + """Summary: + Constructor of the GaussianGenerator class. + Loads the data. + + Args: + locref_stdev: scaling factor + num_joints: number of keypoints + pos_dist_thresh: 3*std of the gaussian + + Return: + None + + Examples: + input: + locref_stdev = 7.2801, default value in pytorch config + num_joints = 6 + po_dist_thresh = 17, default value in pytorch config + """ super().__init__() self.locref_scale = 1.0 / locref_stdev @@ -32,22 +61,30 @@ def forward( prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], ): + """Summary: + Given the annotations and predictions of your keypoints, this function returns the targets, + a dictionary containing the heatmaps, locref_maps and locref_masks. + + Args: + annotations: each entry should begin with the shape batch_size + prediction: output of model format could depend on the model, only used to compute output resolution + image_size: size of image (only one tuple since for batch training all images should have the same size) + + Returns: + targets: dict of the taregts, keys: + 'heatmaps' : heatmaps + 'locref_maps' : locref maps + 'locref_masks' : weights to apply to the locref maps for loss computation + + Examples: + input: + annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} + prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + image_size = (256, 256) + output: + targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} """ - Parameters - ---------- - annotations: dict, each entry should begin with the shape batch_size - prediction: output of model, format could depend on the model, only used to compute output resolution - image_size: size of image (only one tuple since for batch training all images should have the same size) - - Returns - ------- - targets : dict of the taregts, keys: - 'heatmaps' : heatmaps - 'locref_maps' : locref maps - 'locref_masks' : weights to apply to the locref maps for loss computation - - """ # stride = cfg['stride'] # Apparently, there is no stride in the cfg # stride = scale_factors # TODO just test batch_size, _, height, width = prediction[0].shape @@ -92,7 +129,28 @@ def forward( @TARGET_GENERATORS.register_module class GaussianWithoutLocref(BaseGenerator): - def __init__(self, num_joints, pos_dist_thresh): + """ + Generate plateau heatmaps from ground truth keypoints in order + to train baseline deeplabcut model (ResNet + Deconv) + """ + + def __init__(self, num_joints: int, pos_dist_thresh: int): + """Summary: + Constructor of the GaussianWithoutLocref class. + Loads the data. + + Args: + num_joints: number of keypoints + pos_dist_thresh: 3*std of the gaussian + + Returns: + None + + Examples: + input: + num_joints = 6 + po_dist_thresh = 17, default value in pytorch config + """ super().__init__() self.num_joints = num_joints @@ -100,17 +158,28 @@ def __init__(self, num_joints, pos_dist_thresh): self.dist_thresh_sq = self.dist_thresh**2 self.std = 2 * self.dist_thresh / 3 - def forward(self, annotations, prediction, image_size): - """ - - Parameters - ---------- - annotations : dict of annoations which should all be tensors of first dimension batch_size - prediction: model's output - image_size : size of input images - - Returns - ------- + def forward( + self, + annotations: dict, + prediction: Tuple[torch.Tensor, torch.Tensor], + image_size: Tuple[int, int], + ): + """Summary: + Given the annotations and predictions of your keypoints, this function returns the targets, + a dictionary containing the heatmaps, locref_maps and locref_masks. + + Args: + annotations: dict of annoations which should all be tensors of first dimension batch_size + prediction: model's output + image_size: size of input images + + Returns: + input: + annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} + prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + image_size = (256, 256) + output: + targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} """ batch_size, _, height, width = prediction[0].shape diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py index ad57f85751..d32a149f07 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py @@ -1,21 +1,50 @@ -import numpy as np -import torch +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + from typing import Tuple +import numpy as np +import torch from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - BaseGenerator, TARGET_GENERATORS, + BaseGenerator, ) @TARGET_GENERATORS.register_module class PlateauGenerator(BaseGenerator): """ - Generate gaussian heatmaps and locref targets from ground truth keypoints in order + Generate plateau heatmaps and locref targets from ground truth keypoints in order to train baseline deeplabcut model (ResNet + Deconv) """ def __init__(self, locref_stdev: float, num_joints: int, pos_dist_thresh: int): + """Summary: + Constructor of the PlateauGenerator class. + Loads the data. + + Args: + locref_stdev: scaling factor + num_joints: number of keypoints + pos_dist_thresh: radius plateau on the heatmap + + Returns: + None + + Examples: + input: + locref_stdev = 7.2801, default value in pytorch config + num_joints = 6 + pos_dist_thresh = 17, default value in pytorch config + """ super().__init__() self.locref_scale = 1.0 / locref_stdev @@ -29,21 +58,30 @@ def forward( prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], ): + """Summary: + Given the annotations and predictions of your keypoints, this function returns the targets, + a dictionary containing the heatmaps, locref_maps and locref_masks. + + Args: + annotations: annoations. Should be tensors of first dimension batch_size + prediction: model's output + image_size: size of input images (only one tuple since for batch training all images should have the same size) + + Returns: + targets: keys: + 'heatmaps': heatmaps + 'locref_maps': locref maps + 'locref:masks': weights to apply to the locref maps for loss computation + + Examples: + input: + annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} + prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + image_size = (256, 256) + output: + targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} """ - Parameters - ---------- - annotations : dict of annoations which should all be tensors of first dimension batch_size - prediction: model's output - image_size : size of input images - - Returns - ------- - targets : dict of the taregts, keys: - 'heatmaps' : heatmaps - 'locref_maps' : locref maps - 'locref_masks' : weights to apply to the locref maps for loss computation - """ batch_size, _, height, width = prediction[0].shape stride_y, stride_x = image_size[0] / height, image_size[1] / width coords = annotations["keypoints"].cpu().numpy() @@ -87,26 +125,62 @@ def forward( @TARGET_GENERATORS.register_module class PlateauWithoutLocref(BaseGenerator): - def __init__(self, num_joints, pos_dist_thresh): + """ + Generate plateau heatmaps from ground truth keypoints in order + to train baseline deeplabcut model (ResNet + Deconv) + """ + + def __init__(self, num_joints: int, pos_dist_thresh: int): + """Summary: + Constructurer of the PlateauWithoutLocref class. + Loads the data + + Args: + num_joints: number of keypoints + pos_dist_thresh: radius plateau on the heatmap + + Returns: + None + + Examples: + input: + num_joints = 6 + pos_dist_thresh = 17 + """ super().__init__() self.num_joints = num_joints self.dist_thresh = float(pos_dist_thresh) self.dist_thresh_sq = self.dist_thresh**2 - def forward(self, annotations, prediction, image_size): + def forward( + self, + annotations: dict, + prediction: Tuple[torch.Tensor, torch.Tensor], + image_size: Tuple[int, int], + ): + """Summary: + Given the annotations and predictions of your keypoints, this function returns the targets, + a dictionary containing the heatmaps. + + Args: + annotations: annoations which should all be tensors of first dimension batch_size + prediction: model's output + image_size: size of input images (only one tuple since for batch training all images should have the same size) + + Returns: + targets: key: + 'heatmaps' + + Examples: + input: + annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} + prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + image_size = (256, 256) + output: + targets = {'heatmap':scmap} """ - Parameters - ---------- - annotations : dict of annoations which should all be tensors of first dimension batch_size - prediction: model's output - image_size : size of input images - - Returns - ------- - - """ batch_size, _, height, width = prediction[0].shape stride_y, stride_x = image_size[0] / height, image_size[1] / width coords = annotations["keypoints"].cpu().numpy() diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py index 194cd6a7e1..cb2fe2fc2a 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py @@ -1,4 +1,4 @@ from deeplabcut.pose_estimation_pytorch.post_processing.match_predictions_to_gt import ( - rmse_match_prediction_to_gt, oks_match_prediction_to_gt, + rmse_match_prediction_to_gt, ) diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index e1263f379d..4ecfed2eeb 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -1,3 +1,14 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + import numpy as np from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( calc_object_keypoint_similarity, @@ -9,19 +20,28 @@ def rmse_match_prediction_to_gt( pred_kpts: np.ndarray, gt_kpts: np.ndarray, ) -> np.ndarray: - """ - Hungarian algorithm predicted individuals to ground truth ones, using rmse - - Arguments - --------- - pred_kpts: (num_animals, num_keypoints, 3) - gt_kpts: (num_animals, num_keypoints(+1 if with center), 2) + """Summary: + Hungarian algorithm predicted individuals to ground truth ones, using root mean squared error (rmse). The function provides a way to + match predicted individuals to ground truth individuals based on the rmse distance between their corresponding + keypoints. This algorithm is used to find the optimal matching, taking into account the potential missing animal. + + Raises: + ValueError: if `gt_kpts.shape != pred_kpts.shape` + + Args: + pred_kpts: predicted keypoints for each animal. The shape of the array is (num_animals, num_keypoints, 3): + num_animals: number of animals + num_keypoints: number of keypoints + 3: (x,y,score) coordinates of each keypoint + gt_kpts: ground truth keypoints for each animal. The shape of the array is (num_animals, num_keypoints(+1 if with center), 2): + num_animals: number of animals + num_keypoints: number of keypoints + 2: (x,y) coordinates of each keypoint + individual_names: names of individuals - Output - ------ - row_ind: array of the individuals indexes for prediction + Returns: + col_ind (np.array): array of the individuals indices for prediction """ - num_animals, num_keypoints, _ = pred_kpts.shape if num_keypoints + 1 == gt_kpts.shape[1]: gt_kpts_without_ctr = gt_kpts[:, :-1, :].copy() @@ -52,19 +72,32 @@ def rmse_match_prediction_to_gt( def oks_match_prediction_to_gt( pred_kpts: np.array, gt_kpts: np.array, individual_names: list -): - """ - Hungarian algorithm predicted individuals to ground truth ones, using oks - - Arguments - --------- - pred_kpts: (num_animals, num_keypoints, 3) - gt_kpts: (num_animals, num_keypoints(+1 if with center), 2) +) -> np.array: + """Summary: + Hungarian algorithm predicted individuals to ground truth ones, using object keypoint similarity (oks). + Oks measures the accuracy of predicted keypoints compared to ground truth keypoints. + More information about oks can be found in cocodataset (https://cocodataset.org/#keypoints-eval). + + Args: + pred_kpts: Predicted keypoints for each animal. The shape of the array is (num_animals, num_keypoints, 3): + num_animals: Number of animals. + num_keypoints: Number of keypoints. + 3: (x, y, score) coordinates of each keypoint. + gt_kpts: Ground truth keypoints for each animal. The shape of the array is (num_animals, num_keypoints(+1 if with center), 2): + num_animals: Number of animals. + num_keypoints: Number of keypoints. individual_names: names of individuals - Output - ------ - row_ind: array of the individuals indexes for prediction + Returns: + col_ind: Array of the individual indexes for prediction. + + Examples: + input: + pred_kpts = np.array(...) + gt_kpts = np.array(...) + individual_names = [...] + output: + col_ind = np.array([...]) """ num_animals, num_keypoints, _ = pred_kpts.shape @@ -107,7 +140,24 @@ def oks_match_prediction_to_gt( return col_ind -def extend_col_ind(col_ind, num_animals): +def extend_col_ind(col_ind: np.array, num_animals: int) -> np.array: + """Summary: + Extends the column indices of a 1D array, col_ind, by adding any missing column indices from 0 to num_animals-1. + + Args: + col_ind: 1D array of column indices + num_animals: total number of animals + + Returns: + extended_array: extended 1D array of column indices + + Examples: + input: + col_ind = + num_animals = 5 + output: + extended_array = + """ existing_cols = set(col_ind) # Convert the array to a set for faster lookup missing_cols = [num for num in range(num_animals) if num not in existing_cols] extended_array = np.concatenate((col_ind, missing_cols)).astype(int) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py b/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py index 11679a5901..52b2ca1832 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py @@ -1,15 +1,67 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + from torch.optim.lr_scheduler import _LRScheduler class LRListScheduler(_LRScheduler): + """ + Definition of the class object Scheduler. + You can achieve increased performance and faster training by using a learning rate that changes + during training. A scheduler makes the learning rate adaptative. Given a list of learning rates + and milestones modifies the learning rate accordingly during training + """ + def __init__( self, optimizer, last_epoch=-1, verbose=False, milestones=[10], lr_list=[0.001] ): + """Summary: + Constructor of the LRListScheduler. + Loads the data. + + Args: + optimizer: optimizer used for learning. + last_epoch: where to start the scheduler. Defaults to -1, starts from beggining. + verbose: prints model summary. Defaults to False. + milestones: number of epochs. Defaults to [10]. + lr_list: learning rate list. Defaults to [0.001]. + + Returns: + None + + Examples: + input: + last_epoch = -1 + verbose = False + milestones = [10, 30, 40] + lr_list = [[0.00001],[0.000005],[0.000001]] + """ self.milestones = milestones self.lr_list = lr_list super().__init__(optimizer, last_epoch, verbose) def get_lr(self): + """Summary: + Given a milestones, get the corresponding learning rate. + + Args: + LRListScheduler + + Returns: + lr: learning rate value + + Examples: + input: LRListScheduler object + output: learning rate (lr) = [0.001] + """ if self.last_epoch not in self.milestones: return [group["lr"] for group in self.optimizer.param_groups] return [lr for lr in self.lr_list[self.milestones.index(self.last_epoch)]] diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py new file mode 100644 index 0000000000..5ca38d52ea --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py @@ -0,0 +1,126 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +from typing import List, Tuple + +import numpy as np +import pytest +import torch + +import deeplabcut.pose_estimation_pytorch.post_processing.match_predictions_to_gt as deeplabcut_torch_match_predictions_gt + + +@pytest.fixture +def animals_and_keypoints_invalid(): + """Summary: + Fixture with invalid pred_kpts and gt_kpts shapes that will raise ValueErrors. + + Returns: + tuple containing: + predicted keypoints(pred_kpts), of shape num_animals, num_keypoints, (x,y,score) + ground truth keypoints (gt_kpts), of shape num_animals, num_keypoints, (x,y) + individual names (indv_names) + """ + gt_kpts = np.random.rand(6, 6, 2) #num animals, num keypoints, (x,y) + pred_kpts = np.random.rand(6, 8, 3) #num animals, num keypoints, (x,y,score) + indv_names = ["indv1", "indv2"] + return pred_kpts, gt_kpts, indv_names + +@pytest.fixture +def animals_and_keypoints(): + """Summary: + Fixture with pred_kpts, gt_kpts shapes and indv_names. + + Returns: + tuple containing: + predicted keypoints(pred_kpts), of shape num_animals, num_keypoints, (x,y,score) + ground truth keypoints (gt_kpts), of shape num_animals, num_keypoints, (x,y) + individual names (indv_names) + """ + gt_kpts = np.random.rand(6, 6, 2) #num animals, num keypoints, (x,y) + + #adding score value because the shape of pred_kpts should be (6,6,3) + score = np.full((gt_kpts.shape[0], gt_kpts.shape[1], 1), 0.5) + pred_kpts = np.concatenate((gt_kpts, score), axis=2) + np.random.shuffle(pred_kpts) #shuffle predicted keypoints + + indv_names = ["indv1", "indv2"] + return pred_kpts, gt_kpts, indv_names + +def test_invalid_rmse(animals_and_keypoints_invalid:tuple)->None: + """Summary: + Tets if an invalid output really returns a ValueError in the rmse function. + + Args: + animals_and_keypoints_invalid (tuple): containing predicted keypoints (pred_kpts), + ground truth keypoints (gt_kpts) and individual names (indv_names). + """ + pred_kpts, gt_kpts, indv_names = animals_and_keypoints_invalid + + with pytest.raises(ValueError): + deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + +def test_invalid_oks(animals_and_keypoints_invalid:tuple)->None: + """Summary: + Test if an invalid output really returns a ValueError in the oks function. + + Args: + animals_and_keypoints_invalid (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) + and individual names (indv_names) + """ + pred_kpts, gt_kpts, indv_names = animals_and_keypoints_invalid + + with pytest.raises(ValueError): + deeplabcut_torch_match_predictions_gt.oks_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + +def test_rmse_match_predictions_to_gt(animals_and_keypoints:tuple, num_animals:int=6)->None: + """Summary: + Test if rmse_match_prediction_to_gt function returns the expected shape output. + + Args: + animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) + and individual names (indv_names) + """ + pred_kpts, gt_kpts, indv_names = animals_and_keypoints + + col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + assert isinstance(col_ind, np.ndarray) + assert col_ind.shape == (num_animals,) + +def test_oks_match_predictions_to_gt(animals_and_keypoints:tuple, num_animals:int=6)->None: + """Summary: + Test if oks_match_predictions_to_gt function returns the expected shape output. + + Args: + animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) + and individual names (indv_names) + """ + pred_kpts, gt_kpts, indv_names = animals_and_keypoints + + col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + assert isinstance(col_ind, np.ndarray) + assert col_ind.shape == (num_animals,) + +def test_extend_col_ind(animals_and_keypoints:tuple, num_animals:int=6)->None: + """Summary: + Test if the column indices have the expected shape. + + Args: + animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) + and individual names (indv_names) + """ + pred_kpts, gt_kpts, indv_names = animals_and_keypoints + + col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + extended_array = deeplabcut_torch_match_predictions_gt.extend_col_ind(col_ind, num_animals) + assert extended_array.shape == (num_animals,) + + diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py new file mode 100644 index 0000000000..b91780f5c0 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py @@ -0,0 +1,203 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +from typing import List, Tuple + +import deeplabcut.pose_estimation_pytorch.models.target_generators.plateau_targets as deeplabcut_torch_plateau_targets +import numpy as np +import pytest +import torch + + +def get_target( + batch_size: int, + num_animals: int, + num_joints: int, + image_size: Tuple[int, int], + locref_stdev: float, + pos_dist_thresh: int, +): + """Summary + Getting the target generator for certain annotations, predictions and image size. + + Args: + batch_size (int): number of images + num_animals (int): number of animals + num_joints (int): number of bodyparts + image_size (tuple): image size in pixels + locref_stdev (float): scaling factor + pos_dist_thresh (int): radius plateau on the heatmap + + Returns: + target_output (dict): containing the heatmaps, locref_maps and locref_masks. + annotations (dict): containing input keypoint annotations. + + Examples: + input: + batch_size = 1 + num_animals = 1 + num_joints = 6 + image_size = (256,256) + locref_stdev = 7.2801 + pos_dist_thresh = 17 + output: + + """ + annotations = { + "keypoints": torch.randint( + 1, min(image_size), (batch_size, num_animals, num_joints, 2) + ) + } # 2 for x,y coords + prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + generator = deeplabcut_torch_plateau_targets.PlateauGenerator( + locref_stdev, num_joints, pos_dist_thresh + ) + + targets_output = generator(annotations, prediction, image_size) + return targets_output, annotations + + +data = [(1, 1, 10, (256, 256), 7.2801, 17)] + + +@pytest.mark.parametrize( + "batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh", + data, +) +def test_expected_output( + batch_size: int, + num_animals: int, + num_joints: int, + image_size: Tuple[int, int], + locref_stdev: float, + pos_dist_thresh: int, +): + """Summary: + Testing if plateau_targets.py returns the expected output. We take a target generator from + get_target function. Given a sequence of random numbers for batch_size, num_animals etc., we assert if + it returns the expected heatmaps and locrefmaps, as well as checking if the output has the expected shape. + + Args: + batch_size (int): number of images + num_animals (int): number of animals + num_joints (int): number of bodyparts + image_size (tuple): image size in pixels + locref_stdev (float): scaling factor + pos_dist_thresh (int): radius plateau on heatmap + + Returns: + None + + Examples: + input: + batch_size = 1 + num_animals = 1 + num_joints = 6 + image_size = (256,256) + locref_stdev = 7.2801 + pos_dist_thresh = 17 + """ + targets_output, annotations = get_target( + batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh + ) + + assert "heatmaps" in targets_output + assert "locref_maps" in targets_output + assert "locref_masks" in targets_output + + assert targets_output["heatmaps"].shape == ( + batch_size, + num_joints, + image_size[0], + image_size[1], + ) # heatmaps score output + assert targets_output["locref_masks"].shape == ( + batch_size, + num_joints * 2, + image_size[0], + image_size[1], + ) + assert targets_output["locref_maps"].shape == ( + batch_size, + num_joints * 2, + image_size[0], + image_size[1], + ) + + +data = [(1, 1, 10, (256, 256), 7.2801, 17)] + + +@pytest.mark.parametrize( + "batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh", + data, +) +def test_single_animal( + batch_size: int, + num_animals: int, + num_joints: int, + image_size: Tuple[int, int], + locref_stdev: float, + pos_dist_thresh: int, +): + """Summary + Testing, for single animals experiments (num_animals=1) if the distance between the expected keypoints + and the annotations keypoints is smaller than the radius plateau. + + 'argmax' function returns the indices of the max values of all elements in the input tensor. + If there are multiple maximal values, such as in our case because it's a plateau, then the + indices of the first maximal value are returned. From this tensor we exctact x,y coords + and then concatenate these new tensors along a new dimension. Then, we assert if the distance between + each x,y element in annotations and predicted keypoints is smaller or equal to the 'pos_dist_thresh', + which represents the radius of the plateau heatmap. + + Args: + batch_size (int): number of images + num_animals (int): number of animals + num_joints (int): number of bodyparts + image_size (tuple): image size in pixels + locref_stdev (float): scaling factor + pos_dist_thresh (int): radius plateau on heatmap + + Returns: + None + + Examples: + input: + batch_size = 1 + num_animals = 1 + num_joints = 6 + image_size = (256,256) + locref_stdev = 7.2801 + pos_dist_thresh = 17 + """ + targets_output, annotations = get_target( + batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh + ) + + targets_output = torch.tensor( + targets_output["heatmaps"].reshape(1, 10, image_size[0] * image_size[1]) + ) # converting from dict to tensor. 'argmax' works on tensors. + + plt_max = torch.argmax(targets_output, dim=2) + # get unraveled coords + x = plt_max % image_size[1] + y = plt_max // image_size[1] + + predict_kp = torch.stack((x, y), dim=-1) + + predict_kp = predict_kp.float() + + annotations["keypoints"] = torch.squeeze(annotations["keypoints"], dim=1) + annotations["keypoints"] = annotations["keypoints"].float() + + dist = torch.norm(annotations["keypoints"] - predict_kp, p=2, dim=-1) + assert (dist <= pos_dist_thresh).all() diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py b/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py new file mode 100644 index 0000000000..4731fa4ca9 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py @@ -0,0 +1,88 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +import random + +import deeplabcut.pose_estimation_pytorch.solvers.schedulers as deeplabcut_torch_schedulers +import pytest +import torch +from torch.optim import SGD + + +def generate_random_lr_list(num_floats: int): + """Summary: + Generate list of lists including random numbers. + + Args: + num_floats: number of floats we want to include in our list + + Returns: + ran_list: random list of sorted numbers, being first number bigger than the last + + Examples: + input: num_float = 2 + output: [[0.96420871896179], [0.3917365732012833]] + """ + ran_list = [] + for i in range(num_floats): + random_floats = [random.random()] + ran_list.append(random_floats) + return sorted(ran_list, reverse=True) + + +milestones = random.sample(range(0, 999), 2) +milestones.sort() +data = [([10, 430], [[0.05], [0.005]]), (milestones, generate_random_lr_list(2))] +# tetsing for default values in pytorch_config and also for random values with pytest parametrize + + +@pytest.mark.parametrize("milestones, lr_list", data) +def test_scheduler(milestones, lr_list): + """Summary: + Testing shedulers.py. + Given a list of milestones and a list of learning rates, this function tests + if the length of each list is the same. Furthermore, it will assess if + the current learning rate (output from the function we are testing) is a float + and corresponds to the expected learning rate given the milestones. + + Args: + milestones: list of epochs indices (number of epochs) + lr_list: learning rates list + + Returns: + None + + Examples: + input: + milestones = [10,25,50] + lr_list = [[0.00001],[0.000005],[0.000001]] + """ + + assert len(milestones) == len(lr_list) + + optimizer = torch.optim.SGD([torch.randn(2, 2)], lr=0.01) + lrlistscheduler = deeplabcut_torch_schedulers.LRListScheduler( + optimizer, milestones=milestones, lr_list=lr_list + ) + + index_rng = range(milestones[0], milestones[1]) + for i in range((milestones[-1]) + 1): + if i < milestones[0]: + expected_lr = [0.01] + elif i in index_rng: + expected_lr = lr_list[0] + else: + expected_lr = lr_list[1] + + current_lr = lrlistscheduler.get_lr()[0] + assert lrlistscheduler.get_lr() == expected_lr + assert isinstance(current_lr, float) + lrlistscheduler.step() From 2554273247f14334ca6f3da653abfd46e56532e8 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Mon, 7 Aug 2023 17:37:51 +0200 Subject: [PATCH 041/293] fixed bugs, docstrings and typing for DLC PyTorch --- .../apis/analyze_videos.py | 13 ++- .../pose_estimation_pytorch/apis/evaluate.py | 10 +- .../pose_estimation_pytorch/apis/inference.py | 13 ++- .../pose_estimation_pytorch/apis/train.py | 14 ++- .../pose_estimation_pytorch/data/dataset.py | 32 +----- .../models/criterion.py | 66 ++++++++----- .../models/detectors/fasterRCNN.py | 6 +- .../models/heads/base.py | 10 ++ .../models/heads/dekr_heads.py | 38 ++++--- .../pose_estimation_pytorch/models/model.py | 6 +- .../models/modules/conv_block.py | 43 +++++--- .../models/modules/conv_module.py | 42 +++++--- .../models/necks/transformer.py | 10 +- .../models/predictors/dekr_predictor.py | 6 +- .../models/predictors/single_predictor.py | 12 ++- .../models/target_generators/__init__.py | 27 ++++- .../models/target_generators/base.py | 14 ++- .../models/target_generators/dekr_targets.py | 10 +- .../target_generators/gaussian_targets.py | 4 +- .../target_generators/plateau_targets.py | 4 +- .../pose_estimation_pytorch/solvers/base.py | 20 ++-- .../solvers/inference.py | 9 +- .../tests/test_get_predictions.py | 26 ++--- .../tests/test_match_predictions_to_gt.py | 99 +++++++++++-------- 24 files changed, 326 insertions(+), 208 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 748497c9bd..82924173bb 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -76,8 +76,6 @@ def video_inference( colormode: RGB or BGR method: 'td' (Top Down) or 'bu' (Bottom Up) detector: Detector for top down approach - top_down_predictor: Makes predictions from the cropped keypoints coordinates and - the detected bbox max_num_animals: max number of animals num_keypoints: number of keypoints frames_resized: Whether the frame are resized for inference or not @@ -463,14 +461,14 @@ def analyze_videos( PyTorch config as a default transform: Optional custom transforms to apply to the video overwrite: Overwrite any existing videos - auto_track: By default, tracking and stitching are automatically performed, + auto_track: By default, tracking and stitching are automatically performed, producing the final h5 data file. This is equivalent to the behavior for single-animal projects. If ``False``, one must run ``convert_detections2tracklets`` and ``stitch_tracklets`` afterwards, in order to obtain the h5 file. identity_only: sub-call for auto_track. If ``True`` and animal identity was - learned by the model, assembly and tracking rely exclusively on identity + learned by the model, assembly and tracking rely exclusively on identity prediction. Returns: @@ -507,7 +505,9 @@ def analyze_videos( num_keypoints = len(auxiliaryfunctions.get_bodyparts(project.cfg)) # Read the inference configuration, load the model - pytorch_config = auxiliaryfunctions.read_plainconfig(model_folder / "train" / "pytorch_config.yaml") + pytorch_config = auxiliaryfunctions.read_plainconfig( + model_folder / "train" / "pytorch_config.yaml" + ) pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" pose_cfg = auxiliaryfunctions.read_config(pose_cfg_path) method = pytorch_config.get("method", "bu") @@ -572,7 +572,6 @@ def analyze_videos( colormode=pytorch_config.get("colormode", "RGB"), method=method, detector=detector, - top_down_predictor=top_down_predictor, max_num_animals=max_num_animals, num_keypoints=num_keypoints, frames_resized=frames_resized_with_transform, @@ -617,7 +616,7 @@ def analyze_videos( shuffle, trainingsetindex, overwrite=False, - identity_only=identity_only + identity_only=identity_only, ) stitch_tracklets( config, str(video), videotype, shuffle, trainingsetindex diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index ef1dda4b0e..069623b579 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -111,7 +111,9 @@ def evaluate_snapshot( bodyparts = auxiliaryfunctions.get_bodyparts(cfg) max_individuals = len(individuals) num_joints = len(bodyparts) - pytorch_config = auxiliaryfunctions.read_plainconfig(os.path.join(modelfolder, "train", "pytorch_config.yaml")) + pytorch_config = auxiliaryfunctions.read_plainconfig( + os.path.join(modelfolder, "train", "pytorch_config.yaml") + ) method = pytorch_config.get("method", "bu") if method not in ["bu", "td"]: raise ValueError( @@ -224,7 +226,7 @@ def evaluate_snapshot( df_all_predictions = df_all_predictions.reindex(project.dlc_df.index) output_filename = Path(results_filename) - output_filename.parent.mkdir(exist_ok=True) + output_filename.parent.mkdir(parents=True, exist_ok=True) df_all_predictions.to_hdf(str(output_filename), "df_with_missing") @@ -269,13 +271,13 @@ def evaluate_network( batch_size: the batch size to use for evaluation Examples: - If you want to evaluate without plotting predicitons with shuffle set to 1. + If you want to evaluate on shuffle 1 without plotting predictions. >>> deeplabcut.evaluate_network( '/analysis/project/reaching-task/config.yaml', shuffles=[1], ) - If you want to plot and evaluate with shuffle set to 0 and 1. + If you want to evaluate shuffles 0 and 1 and plot the predictions. >>> deeplabcut.evaluate_network( '/analysis/project/reaching-task/config.yaml', diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index e6808a3650..b1764f616b 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -1,4 +1,14 @@ -from typing import Dict, List, Optional, Tuple, Union +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from typing import List, Optional, Tuple, Union import numpy as np import torch @@ -303,7 +313,6 @@ def inference( images=item["image"], max_num_animals=max_individuals, num_keypoints=num_keypoints, - device=device, resize_object=resize_object, ) else: diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 45843f492d..9c81346623 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import argparse import os from typing import Optional, Union @@ -71,7 +81,9 @@ def train_network( modelprefix=modelprefix, ), ) - pytorch_config = auxiliaryfunctions.read_plainconfig(os.path.join(modelfolder, "train", "pytorch_config.yaml")) + pytorch_config = auxiliaryfunctions.read_plainconfig( + os.path.join(modelfolder, "train", "pytorch_config.yaml") + ) update_config_parameters(pytorch_config=pytorch_config, **kwargs) if transform is None: print("No transform specified... using default") diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index fd5fb3884c..2b7de13928 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -8,21 +8,6 @@ # # Licensed under GNU Lesser General Public License v3.0 # - -import os - -import albumentations as A -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# - import os import albumentations as A @@ -120,7 +105,7 @@ def _calc_area_from_keypoints(self, keypoints: np.ndarray) -> np.ndarray: w = keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1) h = keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1) return w * h - + def _keypoint_in_boundary(self, keypoint: list, shape: tuple): """Summary: Check if a keypoint lies inside the given shape. @@ -232,7 +217,7 @@ def __getitem__(self, index: int) -> dict: # Sometimes bbox coords are larger than the image because of the margin bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) - bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - np.spacing(0.) + bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - np.spacing(0.0) bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) else: bboxes = np.zeros((0, 4)) @@ -408,7 +393,7 @@ def __init__( keypoint_params=A.KeypointParams(format="xy", remove_invisible=False), bbox_params=A.BboxParams(format="coco"), ) - + self.length = len(self.annotations) def __len__(self): @@ -422,7 +407,7 @@ def __len__(self): Number of samples in the dataset """ return self.length - + def _keypoint_in_boundary(self, keypoint: list, shape: tuple): """Summary: Check if a keypoint lies inside the given shape. @@ -452,15 +437,6 @@ def _compute_anno(self): """Summary: Compute annotations for the dataset - Args: - None - - Returns: - annotations: list of annotations containing information about keypoints and image paths. - """ - """Summary: - Compute annotations for the dataset - Args: None diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index e702ed7ec4..d043298544 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -9,7 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # -from typing import Tuple +from typing import Dict, Tuple, Union import torch import torch.nn as nn @@ -36,7 +36,10 @@ def __init__(self) -> None: self.mse_loss = nn.MSELoss(reduction="none") def __call__( - self, prediction: torch.Tensor, target: torch.Tensor, weights: torch.Tensor = 1 + self, + prediction: torch.Tensor, + target: torch.Tensor, + weights: Union[float, torch.Tensor] = 1, ) -> torch.Tensor: """Summary: Compute the weighted Mean Squared Error loss. @@ -44,7 +47,8 @@ def __call__( Args: prediction: predicted tensor target: target tensor - weights: weights for each element in the loss calculation. Defaults to 1. + weights: weights for each element in the loss calculation. If a float, + weights all elements by that value. Defaults to 1. Returns: Weighted Mean Squared Error Loss. @@ -76,7 +80,10 @@ def __init__(self) -> None: self.huber_loss = nn.HuberLoss(reduction="none") def __call__( - self, prediction: torch.Tensor, target: torch.Tensor, weights: int = 1 + self, + prediction: torch.Tensor, + target: torch.Tensor, + weights: Union[float, torch.Tensor] = 1, ) -> torch.Tensor: """Summary: Compute the weighted Huber loss. @@ -84,7 +91,8 @@ def __call__( Args: prediction: predicted tensor target: target tensor - weights: Weights for each element in the loss calculation. Defaults to 1. + weights: weights for each element in the loss calculation. If a float, + weights all elements by that value. Defaults to 1. Returns: Weighted Huber loss. @@ -116,7 +124,10 @@ def __init__(self) -> None: self.BCELoss = nn.BCEWithLogitsLoss(reduction="none") def __call__( - self, prediction: torch.Tensor, target: torch.Tensor, weights: int = 1 + self, + prediction: torch.Tensor, + target: torch.Tensor, + weights: Union[float, torch.Tensor] = 1, ) -> torch.Tensor: """Summary: Compute the weighted Binary Cross Entropy loss. @@ -155,13 +166,13 @@ def __init__( apply_sigmoid: bool = False, ) -> None: """Summary: - Constructter of the PoseLoss class. + Constructor of the PoseLoss class. Args: loss_weight_locref: weight for loss_locref part (parsed from the pose_cfg.yaml from the dlc_models folder) locref_huber_loss: if True uses torch.nn.HuberLoss for locref (default is False). apply_sigmoid: whether to apply sigmoid to the heatmap predictions should be true - for MSE, false for BCE (since it already applies it by itself) + for MSE, false for BCE (since it already applies it by itself) Returns: None. @@ -176,34 +187,35 @@ def __init__( self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() - def forward(self, prediction: tuple, target: dict) -> tuple: + def forward( + self, prediction: Tuple[torch.Tensor, torch.Tensor], target: Dict + ) -> Dict: """Summary: Forward pass of the Pose Loss function. Args: prediction: a tuple containing the predicted heatmap and locref of size '(heatmaps, locref)' of size '(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)' - target: dictionary containing the target tensors, including 'heatmaps', - 'locref_maps', 'locref_masks', and 'weights' (optional, default is None). + target: dictionary containing the target tensors, including 'heatmaps', 'heatmaps_ignored' + (optional, default is None), 'locref_maps', 'locref_masks'. { - 'heatmaps': (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'heatmaps_ignored': (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]) - weights for the heatmaps - 'locref_maps': (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'locref_masks': (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), + 'heatmaps': (batch_size, number_body_parts, w, h), + 'heatmaps_ignored': heatmap weights (batch_size, number_body_parts, h, w) + 'locref_maps': (batch_size, 2 * number_body_parts, h, w), + 'locref_masks': (batch_size, 2 * number_body_parts, h, w), } Returns: - A tuple containing the total_loss, heatmap_loss and locref_loss. + dict of the different unweighted loss components, with keys 'total_loss', 'heatmap_loss' and 'locref_loss' Examples: prediction = (predicted_heatmaps, predicted_locref) target = { 'heatmaps': torch.tensor([batch_size, num_keypoints, h, w]), + 'heatmaps_ignored': torch.tensor([batch_size, num_keypoints]), 'locref_maps': torch.tensor([batch_size, 2 * num_keypoints, h, w]), 'locref_masks': torch.tensor([batch_size, 2 * num_keypoints, h, w]), - 'weights': torch.tensor([batch_size, num_keypoints]) } - total_loss, heatmap_loss, locref_loss = criterion(prediction, target) + losses = criterion(prediction, target) """ heatmaps, locref = prediction if self.apply_sigmoid: @@ -251,22 +263,22 @@ def __init__(self, apply_sigmoid: bool = False) -> None: self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() - def forward(self, prediction: tuple, target) -> torch.Tensor: + def forward( + self, prediction: Tuple[torch.Tensor, torch.Tensor], target: Dict + ) -> Dict: """Summary: Forward pass of the Heatmap_Only Loss function. Args: - prediction: tuple containing the predicted heatmap and locref of size - (batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)` + prediction: tuple of Tensors `(heatmaps, locref)` of size + `(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)` target: dictionary containing the target tensors: { - 'heatmaps': (batch_size x number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'locref_maps': (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'locref_masks': (batch_size x 2 * number_body_parts x heatmap_size[0] x heatmap_size[1]), - 'weights': (optional, default is None) + 'heatmaps': (batch_size, number_body_parts, h, w), + 'heatmaps_ignored': weights for the heatmap of size (batch_size, number_body_parts, h, w) } Returns: - heatmap_loss: the computed heatmap loss. + dict with a single 'total_loss' key """ heatmaps = prediction[0] if self.apply_sigmoid: diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index 2b3dfbe165..c7f73ea0c9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -15,9 +15,9 @@ class FasterRCNN(BaseDetector): Faster Region-based Convolutional Neural Network (R-CNN) is a popular object detection model that builds upn the R-CNN framework. - I. A. Siradjuddin, Reynaldi and A. Muntasa, "Faster Region-based Convolutional Neural Network - for Mask Face Detection," 2021 5th International Conference on Informatics and Computational - Sciences (ICICoS), Semarang, Indonesia, 2021, pp. 282-286, doi: 10.1109/ICICoS53627.2021.9651744. + Ren, Shaoqing, Kaiming He, Ross Girshick, and Jian Sun. "Faster r-cnn: Towards + real-time object detection with region proposal networks." Advances in neural + information processing systems 28 (2015). """ def __init__( diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index f5078eddf0..b4a2b7c9cb 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from abc import ABC, abstractmethod import torch diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py index 4ad894b1f3..e316f50f07 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py @@ -13,12 +13,8 @@ import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS -from deeplabcut.pose_estimation_pytorch.models.modules import (AdaptBlock, - BasicBlock) -from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import BLOCKS - -from .base import BaseHead +from deeplabcut.pose_estimation_pytorch.models.heads.base import BaseHead, HEADS +from deeplabcut.pose_estimation_pytorch.models.modules import AdaptBlock, BasicBlock @HEADS.register_module @@ -82,7 +78,9 @@ def __init__( dilation_rate, ) - def _make_transition_for_head(self, in_channels: int, out_channels: int) -> nn.Sequential: + def _make_transition_for_head( + self, in_channels: int, out_channels: int + ) -> nn.Sequential: """Summary: Construct the transition layer for the head. @@ -108,7 +106,7 @@ def _make_heatmap_head( Args: block: type of block to use in the head. - num_blocks: number of num_blocks in the head. + num_blocks: number of blocks in the head. num_channels: number of input channels for the head. dilation_rate: dilation rate for the head. @@ -149,7 +147,7 @@ def _make_layer( block: type of block to use in the head. in_channels: number of input channels for the layer. out_channels: number of output channels for the layer. - num_blocks: number of num_blocks in the layer. + num_blocks: number of blocks in the layer. stride: stride for the convolutional layer. Defaults to 1. dilation: dilation rate for the convolutional layer. Defaults to 1. @@ -166,11 +164,15 @@ def _make_layer( stride=stride, bias=False, ), - nn.BatchNorm2d(out_channels * block.expansion, momentum=self.bn_momentum), + nn.BatchNorm2d( + out_channels * block.expansion, momentum=self.bn_momentum + ), ) layers = [] - layers.append(block(in_channels, out_channels, stride, downsample, dilation=dilation)) + layers.append( + block(in_channels, out_channels, stride, downsample, dilation=dilation) + ) in_channels = out_channels * block.expansion for _ in range(1, num_blocks): layers.append(block(in_channels, out_channels, dilation=dilation)) @@ -212,7 +214,7 @@ def __init__( Args: channels: tuple containing the number of input, offset, and output channels. num_offset_per_kpt: number of offset values per keypoint. - num_blocks: number of num_blocks in the head. + num_blocks: number of blocks in the head. dilation_rate: dilation rate for convolutional layers. final_conv_kernel: kernel size for the final convolution. block: type of block to use in the head. Defaults to AdaptBlock. @@ -290,18 +292,24 @@ def _make_layer( stride=stride, bias=False, ), - nn.BatchNorm2d(out_channels * block.expansion, momentum=self.bn_momentum), + nn.BatchNorm2d( + out_channels * block.expansion, momentum=self.bn_momentum + ), ) layers = [] - layers.append(block(in_channels, out_channels, stride, downsample, dilation=dilation)) + layers.append( + block(in_channels, out_channels, stride, downsample, dilation=dilation) + ) in_channels = out_channels * block.expansion for _ in range(1, num_blocks): layers.append(block(in_channels, out_channels, dilation=dilation)) return nn.Sequential(*layers) - def _make_transition_for_head(self, in_channels: int, out_channels: int) -> nn.Sequential: + def _make_transition_for_head( + self, in_channels: int, out_channels: int + ) -> nn.Sequential: """Summary: Create a transition layer for the head. diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 1f08c6369c..157540cc18 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -11,12 +11,10 @@ from typing import List, Tuple -import numpy as np import torch +import torch.nn as nn + from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator -from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps -from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict -from torch import nn class PoseModel(nn.Module): diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py index 929f2a8808..eb93711d53 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py @@ -1,10 +1,14 @@ -# ------------------------------------------------------------------------------ -# Copyright (c) Microsoft -# Licensed under the MIT License. -# The code is based on HigherHRNet-Human-Pose-Estimation. -# (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) -# Modified by Zigang Geng (zigang@mail.ustc.edu.cn). -# ------------------------------------------------------------------------------ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +# The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main from abc import ABC, abstractmethod from typing import Optional @@ -12,8 +16,7 @@ import torch.nn as nn import torchvision.ops as ops -from deeplabcut.pose_estimation_pytorch.registry import (Registry, - build_from_cfg) +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg BLOCKS = Registry("blocks", build_func=build_from_cfg) @@ -79,7 +82,14 @@ class BasicBlock(BaseBlock): expansion = 1 - def __init__(self, in_channels: int, out_channels: int, stride: int = 1, downsample: Optional[nn.Module] = None, dilation: int = 1): + def __init__( + self, + in_channels: int, + out_channels: int, + stride: int = 1, + downsample: Optional[nn.Module] = None, + dilation: int = 1, + ): super(BasicBlock, self).__init__() self.conv1 = nn.Conv2d( in_channels, @@ -151,7 +161,14 @@ class Bottleneck(BaseBlock): expansion = 4 - def __init__(self, in_channels: int, out_channels: int, stride: int = 1, downsample: Optional[nn.Module] = None, dilation: int = 1): + def __init__( + self, + in_channels: int, + out_channels: int, + stride: int = 1, + downsample: Optional[nn.Module] = None, + dilation: int = 1, + ): super(Bottleneck, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum) @@ -168,7 +185,9 @@ def __init__(self, in_channels: int, out_channels: int, stride: int = 1, downsam self.conv3 = nn.Conv2d( out_channels, out_channels * self.expansion, kernel_size=1, bias=False ) - self.bn3 = nn.BatchNorm2d(out_channels * self.expansion, momentum=self.bn_momentum) + self.bn3 = nn.BatchNorm2d( + out_channels * self.expansion, momentum=self.bn_momentum + ) self.relu = nn.ReLU(inplace=True) self.downsample = downsample self.stride = stride diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py index 101fc3df05..9e32116def 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py @@ -1,15 +1,17 @@ -# ------------------------------------------------------------------------------ -# Copyright (c) Microsoft -# Licensed under the MIT License. -# The code is based on HigherHRNet-Human-Pose-Estimation. -# (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) -# Modified by Zigang Geng (zigang@mail.ustc.edu.cn). -# ------------------------------------------------------------------------------ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +# The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main import logging -import os -from typing import List, Optional +from typing import List -import torch import torch.nn as nn from deeplabcut.pose_estimation_pytorch.models.modules import BasicBlock @@ -61,7 +63,12 @@ def __init__( self.relu = nn.ReLU(True) def _check_branches( - self, num_branches: int, block: BasicBlock, num_blocks: int, num_inchannels: int, num_channels: int + self, + num_branches: int, + block: BasicBlock, + num_blocks: int, + num_inchannels: int, + num_channels: int, ): if num_branches != len(num_blocks): error_msg = "NUM_BRANCHES({}) <> NUM_BLOCKS({})".format( @@ -84,7 +91,14 @@ def _check_branches( logger.error(error_msg) raise ValueError(error_msg) - def _make_one_branch(self, branch_index: int, block: BasicBlock, num_blocks: int, num_channels: int, stride: int = 1) -> nn.Sequential: + def _make_one_branch( + self, + branch_index: int, + block: BasicBlock, + num_blocks: int, + num_channels: int, + stride: int = 1, + ) -> nn.Sequential: downsample = None if ( stride != 1 @@ -121,7 +135,9 @@ def _make_one_branch(self, branch_index: int, block: BasicBlock, num_blocks: int return nn.Sequential(*layers) - def _make_branches(self, num_branches: int, block: BasicBlock, num_blocks: int, num_channels: int) -> nn.ModuleList: + def _make_branches( + self, num_branches: int, block: BasicBlock, num_blocks: int, num_channels: int + ) -> nn.ModuleList: branches = [] for i in range(num_branches): diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py index 2a29d6dd9b..346e8e150c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py @@ -14,9 +14,11 @@ from einops import rearrange, repeat from timm.layers import trunc_normal_ -from .base import NECKS -from .layers import TransformerLayer -from .utils import make_sine_position_embedding +from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS +from deeplabcut.pose_estimation_pytorch.models.necks.layers import TransformerLayer +from deeplabcut.pose_estimation_pytorch.models.necks.utils import ( + make_sine_position_embedding, +) MIN_NUM_PATCHES = 16 BN_MOMENTUM = 0.1 @@ -228,7 +230,7 @@ def _init_weights(self, m: torch.nn.Module): torch.nn.init.constant_(m.bias, 0) torch.nn.init.constant_(m.weight, 1.0) - def forward(self, feature: torch.Tensor, mask = None) -> torch.Tensor: + def forward(self, feature: torch.Tensor, mask=None) -> torch.Tensor: """Forward pass through the Transformer neck. Args: diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 37f720f105..2b05f201f4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -82,7 +82,11 @@ def __init__( self.use_heatmap = use_heatmap self.max_absorb_distance = 75 - def forward(self, outputs: Tuple, scale_factors: Tuple[float, float]): + def forward( + self, + outputs: Tuple[torch.Tensor, torch.Tensor], + scale_factors: Tuple[float, float], + ) -> torch.Tensor: """Forward pass of DEKRPredictor. Args: diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index fb1a3dc755..ec43fe084e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -71,7 +71,9 @@ def __init__( self.sigmoid = torch.nn.Sigmoid() def forward( - self, output: Tuple[torch.Tensor, torch.Tensor], scale_factors + self, + output: Tuple[torch.Tensor, torch.Tensor], + scale_factors: Tuple[float, float], ) -> torch.Tensor: """Forward pass of SinglePredictor. Gets predictions from model output. @@ -214,7 +216,9 @@ def __init__(self, num_animals: int, apply_sigmoid: bool = True): self.sigmoid = torch.nn.Sigmoid() def forward( - self, output: Tuple[torch.Tensor, torch.Tensor], scale_factors + self, + output: Tuple[torch.Tensor, torch.Tensor], + scale_factors: Tuple[float, float], ) -> torch.Tensor: """Forward pass of HeatmapOnlyPredictor. Computes predictions from the trained model output. @@ -266,7 +270,9 @@ def get_top_values( Y, X = heatmap_top // nx, heatmap_top % nx return Y, X - def get_pose_prediction(self, heatmap: torch.Tensor, scale_factors): + def get_pose_prediction( + self, heatmap: torch.Tensor, scale_factors: Tuple[float, float] + ) -> torch.Tensor: """Get the pose prediction from heatmaps. Args: diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py index 2d3691cbec..7da5e4af20 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py @@ -1,4 +1,23 @@ -from .base import TARGET_GENERATORS, BaseGenerator -from .dekr_targets import DEKRGenerator -from .gaussian_targets import GaussianGenerator -from .plateau_targets import PlateauGenerator +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( + TARGET_GENERATORS, + BaseGenerator, +) +from deeplabcut.pose_estimation_pytorch.models.target_generators.dekr_targets import ( + DEKRGenerator, +) +from deeplabcut.pose_estimation_pytorch.models.target_generators.gaussian_targets import ( + GaussianGenerator, +) +from deeplabcut.pose_estimation_pytorch.models.target_generators.plateau_targets import ( + PlateauGenerator, +) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py index 9c66ee9193..54ac2c57f4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -1,6 +1,15 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from abc import ABC, abstractmethod -import torch import torch.nn as nn from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg @@ -9,8 +18,9 @@ class BaseGenerator(ABC, nn.Module): """ - Given the ground truth anotation generates the corresponding maps for training the model + Given the ground truth annotation generates the corresponding maps for training the model """ + def __init__(self): super().__init__() self.batch_norm_on = False diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index 8e3876ce4c..2eced58cc9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -58,7 +58,9 @@ def __init__(self, num_joints: int, pos_dist_thresh: int, bg_weight: float = 0.1 self.num_joints_with_center = self.num_joints + 1 - def get_heat_val(self, sigma: float, x: float, y: float, x0: float, y0: float): + def get_heat_val( + self, sigma: float, x: float, y: float, x0: float, y0: float + ) -> float: """Summary: Calculates the corresponding heat value of point (x,y) given the heat distribution centered at (x0,y0) and spread value of sigma. @@ -82,7 +84,7 @@ def forward( annotations: dict, prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], - ): + ) -> dict: """Summary Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. @@ -228,9 +230,7 @@ def forward( ) weight_map[ b, idx * 2 + 1, pos_y, pos_x - ] = 1.0 / np.sqrt( - area[b, person_id] - ) + ] = 1.0 / np.sqrt(area[b, person_id]) area_map[b, pos_y, pos_x] = area[b, person_id] hms_list[1][hms_list[1] == 2] = self.bg_weight diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py index 8598929969..15a759b7ff 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py @@ -60,7 +60,7 @@ def forward( annotations: dict, prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], - ): + ) -> dict: """Summary: Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. @@ -163,7 +163,7 @@ def forward( annotations: dict, prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], - ): + ) -> dict: """Summary: Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py index d32a149f07..57628bc8b8 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py @@ -57,7 +57,7 @@ def forward( annotations: dict, prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], - ): + ) -> dict: """Summary: Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. @@ -158,7 +158,7 @@ def forward( annotations: dict, prediction: Tuple[torch.Tensor, torch.Tensor], image_size: Tuple[int, int], - ): + ) -> dict: """Summary: Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps. diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index f852c1c922..bee7eb6147 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -13,15 +13,15 @@ from collections import defaultdict from typing import Dict, Optional, Tuple +import numpy as np +import torch + import deeplabcut.pose_estimation_pytorch.data.dataset as deeplabcut_pose_estimation_pytorch_data_dataset import deeplabcut.pose_estimation_pytorch.models.model as deeplabcut_pose_estimation_pytorch_models_model import deeplabcut.pose_estimation_pytorch.models.predictors as deeplabcut_pose_estimation_pytorch_models_predictors import deeplabcut.pose_estimation_pytorch.solvers.inference as deeplabcut_pose_estimation_pytorch_solvers_inference -import numpy as np -import torch - -from ..registry import Registry, build_from_cfg -from .utils import * +import deeplabcut.pose_estimation_pytorch.solvers.utils as solver_utils +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg SOLVERS = Registry("solvers", build_func=build_from_cfg) @@ -69,7 +69,7 @@ def __init__( Notes/TODO: Read stride from config file """ - + if cfg is None: raise ValueError("") self.model = model @@ -102,7 +102,6 @@ def fit( *, epochs: int = 10000, ) -> None: - """Train model for the specified number of steps. Args: @@ -122,7 +121,7 @@ def fit( solver.fit(train_loader, valid_loader, epochs=50) """ - model_folder = get_model_folder( + model_folder = solver_utils.get_model_folder( train_fraction, shuffle, model_prefix, train_loader.dataset.cfg ) @@ -159,7 +158,6 @@ def epoch( mode: str = "train", step: Optional[int] = None, ) -> float: - """Facilitates training over an epoch. Returns the loss over the batches. Args: @@ -211,12 +209,11 @@ def epoch( @abstractmethod def step(self, batch: Tuple[torch.Tensor, torch.Tensor], *args) -> dict: raise NotImplementedError - + @torch.no_grad() def inference( self, dataset: deeplabcut_pose_estimation_pytorch_data_dataset.PoseDataset ) -> np.ndarray: - """Run inference on the given dataset and obtain predicted poses. Args: @@ -250,7 +247,6 @@ class BottomUpSolver(Solver): def step( self, batch: Tuple[torch.Tensor, torch.Tensor], mode: str = "train" ) -> dict: - """Perform a single epoch gradient update or validation step. Args: diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index a943fe04cf..bad410a363 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -13,15 +13,18 @@ import numpy as np import pandas as pd +import torch +import torch.nn as nn + from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( Assembly, evaluate_assembly, ) -from torch import nn -#DEPRECATED + +# TODO: DEPRECATED def get_prediction( - cfg: dict, output: Tuple[np.ndarray, np.ndarray], stride: int = 8 + cfg: dict, output: Tuple[torch.Tensor, torch.Tensor], stride: int = 8 ) -> np.ndarray: """Generates pose predictions from the model outputwhich is a tuple given by (heatmaps,location refinement fields)). diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py index 9b1e1e71f6..831009739b 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py @@ -1,21 +1,17 @@ from itertools import product -import deeplabcut import pytest -import torch +from torchvision.transforms import Resize as TorchResize + from deeplabcut.generate_training_dataset.make_pytorch_config import * -from deeplabcut.pose_estimation_pytorch.apis import inference_utils, utils +from deeplabcut.pose_estimation_pytorch.apis import inference, utils from deeplabcut.pose_estimation_pytorch.default_config import * from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS, BaseDetector -from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.models.predictors import ( PREDICTORS, BasePredictor, ) -from deeplabcut.pose_estimation_pytorch.tests.test_utils import ( - write_config -) -from deeplabcut.utils import auxiliaryfunctions +from deeplabcut.pose_estimation_pytorch.tests.test_utils import write_config # Check implemented net types single_nets = [ @@ -34,6 +30,7 @@ params_bu = single + multi + @pytest.mark.parametrize("net_type, multianimal", params_bu) def test_get_predictions_bottom_up( net_type: str, @@ -65,7 +62,7 @@ def test_get_predictions_bottom_up( # get predictions with torch.no_grad(): - output = inference_utils.get_predictions_bottom_up(model, predictor, images) + output = inference.get_predictions_bottom_up(model, predictor, images) # Generate test tensor with expected output shape test = torch.randint(1, 12, (batch_size, num_animals, num_keypoints, 3)) @@ -73,7 +70,6 @@ def test_get_predictions_bottom_up( assert test.shape == output.shape -# Doesn't work yet since top down doesn't fully work @pytest.mark.parametrize("net_type, multianimal", multi_td) def test_get_predicitons_top_down( net_type: str, @@ -112,18 +108,22 @@ def test_get_predicitons_top_down( {"type": "TopDownPredictor", "format_bbox": "xyxy"} ) pose_predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config["predictor"])) + detector.eval() + model.eval() + pose_predictor.eval() + top_down_predictor.eval() # get predictions with torch.no_grad(): - output = inference_utils.get_predictions_top_down( + output = inference.get_predictions_top_down( detector, - top_down_predictor, model, pose_predictor, + top_down_predictor, images, num_animals, num_keypoints, - device="cpu", + TorchResize((256, 256)), ) # Generate test tensor with expected output shape diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py index 5ca38d52ea..b3b8a8825f 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py @@ -9,11 +9,8 @@ # Licensed under GNU Lesser General Public License v3.0 # -from typing import List, Tuple - import numpy as np import pytest -import torch import deeplabcut.pose_estimation_pytorch.post_processing.match_predictions_to_gt as deeplabcut_torch_match_predictions_gt @@ -21,106 +18,126 @@ @pytest.fixture def animals_and_keypoints_invalid(): """Summary: - Fixture with invalid pred_kpts and gt_kpts shapes that will raise ValueErrors. + Fixture with invalid pred_kpts and gt_kpts shapes that will raise ValueErrors. Returns: - tuple containing: + tuple containing: predicted keypoints(pred_kpts), of shape num_animals, num_keypoints, (x,y,score) ground truth keypoints (gt_kpts), of shape num_animals, num_keypoints, (x,y) individual names (indv_names) - """ - gt_kpts = np.random.rand(6, 6, 2) #num animals, num keypoints, (x,y) - pred_kpts = np.random.rand(6, 8, 3) #num animals, num keypoints, (x,y,score) + """ + gt_kpts = np.random.rand(6, 6, 2) # num animals, num keypoints, (x,y) + pred_kpts = np.random.rand(6, 8, 3) # num animals, num keypoints, (x,y,score) indv_names = ["indv1", "indv2"] return pred_kpts, gt_kpts, indv_names + @pytest.fixture def animals_and_keypoints(): """Summary: Fixture with pred_kpts, gt_kpts shapes and indv_names. - + Returns: - tuple containing: + tuple containing: predicted keypoints(pred_kpts), of shape num_animals, num_keypoints, (x,y,score) ground truth keypoints (gt_kpts), of shape num_animals, num_keypoints, (x,y) individual names (indv_names) - """ - gt_kpts = np.random.rand(6, 6, 2) #num animals, num keypoints, (x,y) + """ + gt_kpts = np.random.rand(6, 6, 2) # num animals, num keypoints, (x,y) - #adding score value because the shape of pred_kpts should be (6,6,3) + # adding score value because the shape of pred_kpts should be (6,6,3) score = np.full((gt_kpts.shape[0], gt_kpts.shape[1], 1), 0.5) pred_kpts = np.concatenate((gt_kpts, score), axis=2) - np.random.shuffle(pred_kpts) #shuffle predicted keypoints + np.random.shuffle(pred_kpts) # shuffle predicted keypoints indv_names = ["indv1", "indv2"] return pred_kpts, gt_kpts, indv_names -def test_invalid_rmse(animals_and_keypoints_invalid:tuple)->None: + +def test_invalid_rmse(animals_and_keypoints_invalid: tuple) -> None: """Summary: - Tets if an invalid output really returns a ValueError in the rmse function. + Tets if an invalid output really returns a ValueError in the rmse function. Args: - animals_and_keypoints_invalid (tuple): containing predicted keypoints (pred_kpts), + animals_and_keypoints_invalid (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) and individual names (indv_names). - """ + """ pred_kpts, gt_kpts, indv_names = animals_and_keypoints_invalid with pytest.raises(ValueError): - deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt( + pred_kpts, gt_kpts + ) + -def test_invalid_oks(animals_and_keypoints_invalid:tuple)->None: - """Summary: - Test if an invalid output really returns a ValueError in the oks function. +def test_invalid_oks(animals_and_keypoints_invalid: tuple) -> None: + """Summary: + Test if an invalid output really returns a ValueError in the oks function. Args: animals_and_keypoints_invalid (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) and individual names (indv_names) - """ + """ pred_kpts, gt_kpts, indv_names = animals_and_keypoints_invalid with pytest.raises(ValueError): - deeplabcut_torch_match_predictions_gt.oks_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + deeplabcut_torch_match_predictions_gt.oks_match_prediction_to_gt( + pred_kpts, gt_kpts, indv_names + ) + -def test_rmse_match_predictions_to_gt(animals_and_keypoints:tuple, num_animals:int=6)->None: +def test_rmse_match_predictions_to_gt( + animals_and_keypoints: tuple, num_animals: int = 6 +) -> None: """Summary: - Test if rmse_match_prediction_to_gt function returns the expected shape output. + Test if rmse_match_prediction_to_gt function returns the expected shape output. Args: animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) and individual names (indv_names) """ pred_kpts, gt_kpts, indv_names = animals_and_keypoints - - col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + + col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt( + pred_kpts, gt_kpts + ) assert isinstance(col_ind, np.ndarray) assert col_ind.shape == (num_animals,) -def test_oks_match_predictions_to_gt(animals_and_keypoints:tuple, num_animals:int=6)->None: - """Summary: + +def test_oks_match_predictions_to_gt( + animals_and_keypoints: tuple, num_animals: int = 6 +) -> None: + """Summary: Test if oks_match_predictions_to_gt function returns the expected shape output. Args: animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) and individual names (indv_names) - """ + """ pred_kpts, gt_kpts, indv_names = animals_and_keypoints - - col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) + + col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt( + pred_kpts, gt_kpts + ) assert isinstance(col_ind, np.ndarray) assert col_ind.shape == (num_animals,) -def test_extend_col_ind(animals_and_keypoints:tuple, num_animals:int=6)->None: + +def test_extend_col_ind(animals_and_keypoints: tuple, num_animals: int = 6) -> None: """Summary: - Test if the column indices have the expected shape. + Test if the column indices have the expected shape. Args: animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts) and individual names (indv_names) - """ + """ pred_kpts, gt_kpts, indv_names = animals_and_keypoints - - col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts,gt_kpts,indv_names) - extended_array = deeplabcut_torch_match_predictions_gt.extend_col_ind(col_ind, num_animals) - assert extended_array.shape == (num_animals,) - + col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt( + pred_kpts, gt_kpts + ) + extended_array = deeplabcut_torch_match_predictions_gt.extend_col_ind( + col_ind, num_animals + ) + assert extended_array.shape == (num_animals,) From 52477ac5a32dc15b94013599e603dbf6fe7f1e80 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 11 Aug 2023 12:55:30 +0200 Subject: [PATCH 042/293] Implement unique bodypart detection --------- Co-authored-by: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Co-authored-by: Niels Poulsen Co-authored-by: QuentinJGMace <95310069+quentinjgmace@users.noreply.github.com> --- .../make_pytorch_config.py | 59 +++++++ .../apis/analyze_videos.py | 165 ++++++++++++++---- .../pose_estimation_pytorch/apis/config.yaml | 2 + .../pose_estimation_pytorch/apis/evaluate.py | 14 +- .../pose_estimation_pytorch/apis/inference.py | 69 ++++++-- .../pose_estimation_pytorch/data/dataset.py | 43 +++-- .../data/dlcproject.py | 9 +- .../models/criterion.py | 53 +++++- .../models/detectors/__init__.py | 5 +- .../models/heads/__init__.py | 5 +- .../models/heads/dekr_heads.py | 1 + .../pose_estimation_pytorch/models/model.py | 32 +++- .../models/modules/__init__.py | 10 +- .../models/predictors/__init__.py | 17 +- .../models/predictors/base.py | 12 +- .../models/predictors/dekr_predictor.py | 52 +++++- .../models/predictors/single_predictor.py | 12 +- .../models/predictors/top_down_prediction.py | 9 +- .../match_predictions_to_gt.py | 2 +- .../solvers/inference.py | 66 +++++-- .../solvers/top_down.py | 3 - .../pose_estimation_pytorch/solvers/utils.py | 55 +++++- .../tests/test_dataset.py | 44 +++-- .../tests/test_get_predictions.py | 4 +- deeplabcut/pose_estimation_pytorch/utils.py | 4 +- deeplabcut/utils/auxiliaryfunctions.py | 28 ++- deeplabcut/utils/visualization.py | 50 +++++- .../openfield-Pranav-2018-10-30/config.yaml | 26 ++- tests/test_dekr_predictor.py | 9 +- 29 files changed, 675 insertions(+), 185 deletions(-) diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 16ce97b35b..347ae0edd1 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -1,3 +1,4 @@ +from typing import List import torch from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions @@ -89,6 +90,10 @@ def make_pytorch_config( bodyparts = auxiliaryfunctions.get_bodyparts(project_config) num_joints = len(bodyparts) + unique_bpts = auxiliaryfunctions.get_unique_bodyparts(project_config) + num_unique_bpts = len(unique_bpts) + compute_unique_bpts = num_unique_bpts > 0 + pytorch_config = config_template pytorch_config["device"] = "cuda" if torch.cuda.is_available() else "cpu" pytorch_config["method"] = "bu" @@ -123,6 +128,14 @@ def make_pytorch_config( pytorch_config["model"]["heads"] = make_dekr_head_cfg( num_joints, backbone_type, num_offset_per_kpt ) + if compute_unique_bpts: + pytorch_config["model"]["heads"] += make_unique_bpts_head_cfg( + num_unique_bpts, backbone_type + ) + pytorch_config["model"]["pose_model"]["num_unique_bodyparts"] = len( + unique_bpts + ) + pytorch_config["criterion"]["unique_bodyparts"] = True pytorch_config["model"]["target_generator"] = { "type": "DEKRGenerator", "num_joints": num_joints, @@ -132,10 +145,15 @@ def make_pytorch_config( pytorch_config["predictor"] = { "type": "DEKRPredictor", "num_animals": num_animals, + "unique_bodyparts": compute_unique_bpts, } pytorch_config["with_center"] = True elif "token_pose" in net_type: + if compute_unique_bpts: + raise NotImplementedError( + "Unique body parts are currently not handled by top down models" + ) pytorch_config["method"] = "td" version = net_type.split("_")[-1] backbone_type = "hrnet_" + version @@ -200,6 +218,47 @@ def make_single_head_cfg(num_joints: int, net_type: str): return head_configs +def make_unique_bpts_head_cfg(num_unique_bpts: int, backbone_type: str) -> List[dict]: + """Creates a deconvolutional head to predict unique bodyparts + + Args: + num_unique_bpts: number of unique bodyparts + backbone_type: type of the backbone + + Raises: + NotImplementedError if unique bodyparts are not implemented for backbone_type + + Returns: + The configs for the unique bodyparts heatmap and locref heads + """ + head_configs = [] + + if backbone_type == "hrnet_w32": + # Only one deconvolutional layer since hrnet stride is 1/4 + heatmap_heag_cfg = { + "type": "SimpleHead", + "channels": [480, num_unique_bpts], + "kernel_size": [2, 2], + "strides": [2, 2], + } + head_configs.append(heatmap_heag_cfg) + + locref_head_cfg = { + "type": "SimpleHead", + "channels": [480, 2 * num_unique_bpts], + "kernel_size": [2, 2], + "strides": [2, 2], + } + head_configs.append(locref_head_cfg) + + else: + raise NotImplementedError( + f"Unique bodyparts prediction is not implemented yet for backbone {backbone_type}" + ) + + return head_configs + + def make_dekr_head_cfg(num_joints: int, backbone_type: str, num_offset_per_kpt: int): head_configs = [] heatmap_heag_cfg, offset_head_cfg = {}, {} diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 82924173bb..8be2c0caa5 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -60,7 +60,7 @@ def video_inference( max_num_animals: Optional[int] = 1, num_keypoints: Optional[int] = 1, frames_resized: Optional[bool] = False, -) -> np.ndarray: +) -> Tuple[np.ndarray, np.ndarray]: """ TODO: This should be refactored to use the `inference` code with a `video dataset` @@ -81,8 +81,9 @@ def video_inference( frames_resized: Whether the frame are resized for inference or not Returns: - for each frame in the video, a numpy array containing the output of the - predictor for the frame + array of shape (num_frames, max_num_animals, num_keypoints, 3) for pose predictions + empty array if unique bodyparts are not handled by the model, or array of shape + (num_frames, num_unique_bodyparts, 3) for unique bodypart pose predictions """ if method.lower() == "bu": @@ -121,7 +122,7 @@ def bottom_up_video_inference( transform: Optional[A.Compose] = None, colormode: Optional[str] = "RGB", frames_resized: Optional[bool] = False, -) -> np.ndarray: +) -> Tuple[np.ndarray, np.ndarray]: """Does batched inference for top down over a video Args: @@ -135,7 +136,8 @@ def bottom_up_video_inference( frames_resized: Whether the frames were resized or not. Defaults to False. Returns: - List of pose predictions, each element has shape (max_num_animals, num_keypoints, 3) + array of shape (num_frames, max_num_animals, num_keypoints, 3) for pose predictions + array of shape (num_frames, num_unique_bodyparts, 3) for unique bodypart pose predictions """ if device is None: device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") @@ -157,6 +159,7 @@ def bottom_up_video_inference( pbar = tqdm(total=n_frames, file=sys.stdout) predictions = [] + all_unique_predictions = [] frame = video_reader.read_frame() original_size = frame.shape transformed_size = original_size @@ -183,7 +186,7 @@ def bottom_up_video_inference( batch = torch.tensor( batch_frames, device=device, dtype=torch.float ).permute(0, 3, 1, 2) - batched_predictions = get_predictions_bottom_up( + batched_predictions, unique_predictions = get_predictions_bottom_up( model=model, predictor=predictor, images=batch, @@ -202,6 +205,21 @@ def bottom_up_video_inference( + resizing_factor[0] / 2 ) predictions.append(frame_pred) + if unique_predictions is not None: + for frame_unique in unique_predictions: + if frames_resized: + resizing_factor = ( + original_size[0] / transformed_size[0] + ), (original_size[1] / transformed_size[1]) + frame_unique[:, :, 0] = ( + frame_unique[:, :, 0] * resizing_factor[1] + + resizing_factor[1] / 2 + ) + frame_unique[:, :, 1] = ( + frame_unique[:, :, 1] * resizing_factor[0] + + resizing_factor[0] / 2 + ) + all_unique_predictions.append(frame_unique) frame = video_reader.read_frame() batch_ind += 1 @@ -210,7 +228,7 @@ def bottom_up_video_inference( pbar.close() - return np.array(predictions) + return np.array(predictions), np.array(all_unique_predictions) def top_down_video_inference( @@ -225,7 +243,7 @@ def top_down_video_inference( max_num_animals: Optional[int] = 1, num_keypoints: Optional[int] = 1, frames_resized: Optional[bool] = False, -) -> np.ndarray: +) -> Tuple[np.ndarray, np.ndarray]: """Does batched inference for top down over a video Args: @@ -242,7 +260,8 @@ def top_down_video_inference( frames_resized: Whether the frames were resized or not. Defaults to False. Returns: - tensor of pose predictions, shape (num_frames, max_num_animals, num_keypoints, 3) + array of shape (num_frames, max_num_animals, num_keypoints, 3) for pose predictions + empty array for unique bodyparts (not handled by top down) """ if device is None: device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") @@ -257,7 +276,7 @@ def top_down_video_inference( ) print(f"Loading {video_path}") video_reader = VideoReader(str(video_path)) - n_frames = video_reader.get_n_frames() + n_frames = video_reader.get_n_frames(robust=False) vid_w, vid_h = video_reader.dimensions print( f"Video metadata: \n" @@ -267,7 +286,6 @@ def top_down_video_inference( ) pbar = tqdm(total=n_frames, file=sys.stdout) - predictions = [] frame = video_reader.read_frame() original_size = frame.shape transformed_size = original_size @@ -282,7 +300,6 @@ def top_down_video_inference( ) detections_list = [] - detections = torch.zeros((n_frames, max_num_animals, 4)) with torch.no_grad(): while frame is not None: if frame.dtype != np.uint8: @@ -318,10 +335,11 @@ def top_down_video_inference( detections_list.append(detect) pbar.close() - + detections = torch.stack(detections_list) print("Detections are done, moving to estimating poses...") - detections = torch.stack(detections_list) + # update n_frames to have robust value + n_frames = len(detections) # Pose estimation batch_ind_pose = 0 # Index of the current pose img in batch @@ -330,7 +348,7 @@ def top_down_video_inference( ) # TODO 256 hardcoded # This array stores (image_idx, animal_idx, bbox_coords) - # To be able to go back to it from bacthed cropepd images + # To be able to go back to it from batched cropped images batch_image_infos = torch.zeros((batch_size, 6)) cropped_predictions = torch.full( (n_frames, max_num_animals, num_keypoints, 3), -1.0 @@ -373,7 +391,8 @@ def top_down_video_inference( 256 / model_outputs[0].shape[2], 256 / model_outputs[0].shape[3], ) - pose_predictions = predictor(model_outputs, scale_factors) + pose_pred_dict = predictor(model_outputs, scale_factors) + pose_predictions = pose_pred_dict["poses"] for idx, prediction in enumerate(pose_predictions): cropped_predictions[ batch_image_infos[idx, 0].detach().int(), @@ -395,7 +414,8 @@ def top_down_video_inference( 256 / model_outputs[0].shape[2], 256 / model_outputs[0].shape[3], ) - pose_predictions = predictor(model_outputs, scale_factors) + pose_pred_dict = predictor(model_outputs, scale_factors) + pose_predictions = pose_pred_dict["poses"] for idx, prediction in enumerate(pose_predictions): if batch_image_infos[idx, 0] != -1.0: cropped_predictions[ @@ -403,11 +423,10 @@ def top_down_video_inference( batch_image_infos[idx, 1].detach().int(), ] = prediction[0] pbar.close() - predictions = top_down_predictor(detections, cropped_predictions) - + pred_dict = top_down_predictor(detections, cropped_predictions) + predictions = pred_dict["poses"] print("Keypoints coordinate prediction done !") - - return predictions.detach().cpu().numpy() + return predictions.detach().cpu().numpy(), np.array([]) def analyze_videos( @@ -501,7 +520,7 @@ def analyze_videos( modelprefix=modelprefix, ) # Get general project parameters - max_num_animals = len(project.cfg.get("individuals", ["single"])) + max_num_animals = len(project.cfg.get("individuals", ["animal"])) num_keypoints = len(auxiliaryfunctions.get_bodyparts(project.cfg)) # Read the inference configuration, load the model @@ -509,7 +528,7 @@ def analyze_videos( model_folder / "train" / "pytorch_config.yaml" ) pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" - pose_cfg = auxiliaryfunctions.read_config(pose_cfg_path) + pose_cfg = auxiliaryfunctions.read_plainconfig(pose_cfg_path) method = pytorch_config.get("method", "bu") # Get model parameters @@ -518,7 +537,7 @@ def analyze_videos( if batchsize is None: batchsize = pytorch_config.get("batch_size", 1) pose_cfg["batch_size"] = batchsize - individuals = project.cfg.get("individuals", ["single"]) + individuals = project.cfg.get("individuals", ["animal"]) # Get data processing parameters # if images are resized for inference, @@ -527,17 +546,20 @@ def analyze_videos( # Load model, predictor model = build_pose_model(pytorch_config["model"], pose_cfg) - model.load_state_dict(torch.load(model_path)["model_state_dict"]) + try: + model.load_state_dict(torch.load(model_path)["model_state_dict"]) + except KeyError: + model.load_state_dict(torch.load(model_path)) predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) detector = None top_down_predictor = None if method.lower() == "td": detector_path = _get_detector_path(model_folder, snapshotindex, project.cfg) detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) - detector.load_state_dict(torch.load(detector_path)["detector_state_dict"]) - top_down_predictor = PREDICTORS.build( - {"type": "TopDownPredictor", "format_bbox": "xyxy"} - ) + try: + detector.load_state_dict(torch.load(detector_path)["detector_state_dict"]) + except KeyError: + detector.load_state_dict(torch.load(detector_path)) # Load inference if transform is None: @@ -562,7 +584,7 @@ def analyze_videos( print(f"Video already analyzed at {output_pkl}!") else: runtime = [time.time()] - predictions = video_inference( + predictions, unique_predictions = video_inference( model=model, predictor=predictor, video_path=video, @@ -589,6 +611,56 @@ def analyze_videos( runtime=(runtime[0], runtime[1]), video=VideoReader(str(video)), ) + + coordinate_labels = ["x", "y", "likelihood"] + if len(individuals) > 1: + print("Extracting ", len(individuals), "instances per bodypart") + # first has empty suffix for backwards compatibility + individual_suffixes = [str(s + 1) for s in range(len(individuals))] + individual_suffixes[0] = "" + coordinate_labels = [ + coord_label + s + for s in individual_suffixes + for coord_label in coordinate_labels + ] + + results_df_index = pd.MultiIndex.from_product( + [ + [dlc_scorer], + auxiliaryfunctions.get_bodyparts(project.cfg), + coordinate_labels, + ], + names=["scorer", "bodyparts", "coords"], + ) + df = pd.DataFrame( + predictions.reshape((len(predictions), -1)), + columns=results_df_index, + index=range(len(predictions)), + ) + if unique_predictions.size: + coordinate_labels_unique = ["x", "y", "likelihood"] + results_unique_df_index = pd.MultiIndex.from_product( + [ + [dlc_scorer], + auxiliaryfunctions.get_unique_bodyparts(project.cfg), + coordinate_labels_unique, + ], + names=["scorer", "bodyparts", "coords"], + ) + df_u = pd.DataFrame( + unique_predictions.reshape((len(unique_predictions), -1)), + columns=results_unique_df_index, + index=range(len(unique_predictions)), + ) + df = df.join(df_u, how="outer") + + df.to_hdf( + str(output_h5), + "df_with_missing", + format="table", + mode="w", + ) + results.append((str(video), df)) output_data = _generate_output_data(pose_cfg, predictions) _ = auxfun_multianimal.SaveFullMultiAnimalData( output_data, metadata, str(output_h5) @@ -606,20 +678,39 @@ def analyze_videos( ass = np.concatenate((prediction, extra_column), axis=-1) assemblies[i] = ass + if unique_predictions.size: + assemblies["single"] = {} + for i, unique_prediction in enumerate(unique_predictions): + extra_column = np.full( + (unique_prediction.shape[1], 1), + -1.0, + dtype=np.float32, + ) + ass = np.concatenate( + (unique_prediction[0], extra_column), axis=-1 + ) + assemblies["single"][i] = ass + with open(output_ass, "wb") as handle: pickle.dump(assemblies, handle, protocol=pickle.HIGHEST_PROTOCOL) if auto_track: convert_detections2tracklets( - config, - str(video), - videotype, - shuffle, - trainingsetindex, + config=config, + videos=str(video), + videotype=videotype, + shuffle=shuffle, + trainingsetindex=trainingsetindex, overwrite=False, identity_only=identity_only, + destfolder=destfolder, ) stitch_tracklets( - config, str(video), videotype, shuffle, trainingsetindex + config, + [str(video)], + videotype, + shuffle, + trainingsetindex, + destfolder=destfolder, ) else: @@ -747,7 +838,7 @@ def _get_detector_path(model_folder: Path, snapshot_index: int, config: dict) -> def _generate_output_data( pose_config: dict, - predictions: List[np.ndarray], + predictions: np.ndarray, ) -> dict: output = { "metadata": { diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml index fae20770b4..b1ea00567c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -4,6 +4,7 @@ colormode: 'RGB' criterion: locref_huber_loss: true loss_weight_locref: 0.02 + unique_bodyparts: false type: PoseLoss cropped_data: # Used only for Top-Down approach covering: true @@ -36,6 +37,7 @@ model: type: ResNet pose_model: stride: 8 + num_unique_bodyparts : 0 target_generator: locref_stdev: 7.2801 num_joints: -1 diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 069623b579..74d9cff0a5 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -30,6 +30,7 @@ get_paths, get_results_filename, get_snapshots, + build_entire_pred_df, ) from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.visualization import plot_evaluation_results @@ -50,7 +51,7 @@ def ensure_multianimal_df_format(df_predictions: pd.DataFrame) -> pd.DataFrame: df_predictions_ma.columns.get_level_values("individuals").unique().tolist() except KeyError: new_cols = pd.MultiIndex.from_tuples( - [(col[0], "single", col[1], col[2]) for col in df_predictions_ma.columns], + [(col[0], "animal", col[1], col[2]) for col in df_predictions_ma.columns], names=["scorer", "individuals", "bodyparts", "coords"], ) df_predictions_ma.columns = new_cols @@ -107,8 +108,9 @@ def evaluate_snapshot( modelprefix=modelprefix, ), ) - individuals = cfg.get("individuals", ["single"]) + individuals = cfg.get("individuals", ["animal"]) bodyparts = auxiliaryfunctions.get_bodyparts(cfg) + unique_bodyparts = auxiliaryfunctions.get_unique_bodyparts(cfg) max_individuals = len(individuals) num_joints = len(bodyparts) pytorch_config = auxiliaryfunctions.read_plainconfig( @@ -164,7 +166,7 @@ def evaluate_snapshot( dataset, batch_size=batch_size, shuffle=False ) target_df = dataset.dataframe.copy() - predictions = inference( + predictions, unique_poses = inference( dataloader=dataloader, model=model, predictor=predictor, @@ -176,12 +178,14 @@ def evaluate_snapshot( images_resized_with_transform=images_resized_with_transform, detector=detector, ) - df_predictions = build_predictions_df( + df_predictions = build_entire_pred_df( dlc_scorer=names["dlc_scorer"], individuals=individuals, bodyparts=bodyparts, df_index=target_df.index, predictions=predictions.reshape(target_df.index.shape[0], -1), + unique_bodyparts=unique_bodyparts, + unique_predictions=unique_poses.reshape(target_df.index.shape[0], -1), ) df_mode_predictions.append(df_predictions) @@ -202,6 +206,7 @@ def evaluate_snapshot( else: plot_mode = "bodypart" + plot_unique_bodyparts = len(unique_bodyparts) > 0 plot_evaluation_results( df_combined=df_combined, project_root=cfg["project_path"], @@ -209,6 +214,7 @@ def evaluate_snapshot( model_name=names["dlc_scorer"], output_folder=folder_name, in_train_set=mode == "train", + plot_unique_bodyparts=plot_unique_bodyparts, mode=plot_mode, colormap=cfg["colormap"], dot_size=cfg["dotsize"], diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index b1764f616b..449ec5aef7 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -23,7 +23,7 @@ def get_predictions_bottom_up( model: PoseModel, predictor: BasePredictor, images: torch.Tensor -) -> np.array: +) -> Tuple[np.array, Optional[np.ndarray]]: """Gets the predicted coordinates tensor for a bottom_up approach Model and images should already be on the same device @@ -35,7 +35,9 @@ def get_predictions_bottom_up( shape (batch_size, 3, height, width) Returns: - np.array: predictions tensor of shape (batch_size, num_animals, num_keypoints, 3) + array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions + None if there are no unique bodyparts, otherwise array of shape (batch_size, num_keypoints, 3) + for unique bodypart predictions """ output = model(images) shape_image = images.shape @@ -43,8 +45,13 @@ def get_predictions_bottom_up( shape_image[2] / output[0].shape[2], shape_image[3] / output[0].shape[3], ) - predictions = predictor(output, scale_factor) - return predictions.cpu().numpy() + pred_dict = predictor(output, scale_factor) + predictions = pred_dict["poses"] + unique_bodyparts = pred_dict.get("unique_bodyparts", None) + if unique_bodyparts is not None: + return predictions.cpu().numpy(), unique_bodyparts["poses"].cpu().numpy() + else: + return predictions.cpu().numpy(), None def get_predictions_top_down( @@ -56,7 +63,7 @@ def get_predictions_top_down( max_num_animals: int, num_keypoints: int, resize_object: TorchResize, -) -> np.array: +) -> Tuple[np.array, Optional[np.ndarray]]: """ TODO probably quite bad design, most arguments could be stored somewhere else Gets the predicted coordinates tensor for a bottom_up approach @@ -76,7 +83,9 @@ def get_predictions_top_down( device (Union[torch.device, str]): device everything should be on Returns: - np.array: predictions tensor of shape (batch_size, num_animals, num_keypoints, 3) + array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions + None as unique bodyparts is currently not supported by top down but still returned by the function + for coherence over the repo """ batch_size = images.shape[0] output_detector = detector(images) @@ -104,11 +113,13 @@ def get_predictions_top_down( cropped_image.shape[3] / heatmaps[0].shape[3], ) - cropped_kpts = predictor(heatmaps, scale_factors_cropped) + pred_dict = predictor(heatmaps, scale_factors_cropped) + cropped_kpts = pred_dict["poses"] cropped_kpts_total[b, j, :] = cropped_kpts[0, 0] - final_predictions = top_down_predictor(boxes, cropped_kpts_total) - return final_predictions.cpu().numpy() + final_pred_dict = top_down_predictor(boxes, cropped_kpts_total) + final_predictions = final_pred_dict["poses"] + return final_predictions.cpu().numpy(), None def get_detections_batch( @@ -169,7 +180,8 @@ def get_pose_batch( # Predictor always returns num_animals as 2nd dimension even for single animal ones # Hence the slicing - poses = predictor(outputs, scale_factors_cropped)[:, 0] + pred_dict = predictor(outputs, scale_factors_cropped) + poses = pred_dict["poses"][:, 0] return poses @@ -209,8 +221,6 @@ def resize_batch_predictions( image_shape: Tuple[int, int], ) -> None: """ - TODO shifting error when padding - Converts keypoint coordinates to their values in the original image. Call if the image was resized during the image augmentation pipeline. @@ -245,7 +255,7 @@ def inference( align_predictions_to_ground_truth: bool, images_resized_with_transform: bool, detector: Optional[BaseDetector] = None, -) -> np.ndarray: +) -> Tuple[np.ndarray, Optional[np.ndarray]]: """ Runs inference for a pose estimation model. @@ -264,7 +274,9 @@ def inference( detector: None when `method="bu"`. The detector to use when `method="td"`. Returns: - shape (num_images, individual, keypoints, 3): the predicted keypoints + array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions + None if there are no unique bodyparts, otherwise array of shape (batch_size, num_keypoints, 3) + for unique bodypart predictions """ if method.lower() == "td": if detector is None: @@ -290,6 +302,10 @@ def inference( top_down_predictor = None resize_object = None + if hasattr(predictor, "unique_bodyparts"): + compute_unique_bpts = predictor.unique_bodyparts + else: + compute_unique_bpts = False if method == "td": top_down_predictor = PREDICTORS.build( {"type": "TopDownPredictor", "format_bbox": "xyxy"} @@ -300,12 +316,14 @@ def inference( resize_object = TorchResize((256, 256)) # TODO hardcoded 256 predicted_poses = [] + unique_poses = [] with torch.no_grad(): for item in dataloader: item["image"] = item["image"].to(device) image_shape = item["image"].shape # b, c, w, h if method == "td": - predictions = get_predictions_top_down( + # TODO unique_bodyparts not supported by top down, it is None here + predictions, unique_pred = get_predictions_top_down( detector=detector, model=model, predictor=predictor, @@ -316,7 +334,7 @@ def inference( resize_object=resize_object, ) else: - predictions = get_predictions_bottom_up( + predictions, unique_pred = get_predictions_bottom_up( model=model, predictor=predictor, images=item["image"], @@ -338,11 +356,28 @@ def inference( original_sizes=original_sizes.cpu().numpy(), image_shape=(image_shape[2], image_shape[3]), ) + if compute_unique_bpts: + resize_batch_predictions( + predictions=unique_pred, + original_sizes=original_sizes.cpu().numpy(), + image_shape=(image_shape[2], image_shape[3]), + ) predicted_poses.append(predictions) + if compute_unique_bpts: + unique_poses.append(unique_pred) if len(predicted_poses) > 0: predicted_poses = np.concatenate(predicted_poses, axis=0) else: predicted_poses = np.zeros((0, max_individuals, num_keypoints, 3)) - return predicted_poses + if compute_unique_bpts: + num_unique_bpts = unique_poses[0].shape[2] + if len(unique_poses) > 0: + unique_poses = np.concatenate(unique_poses, axis=0) + else: + unique_poses = np.zeros((0, 1, num_unique_bpts, 3)) + else: + unique_poses = None + + return predicted_poses, unique_poses diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 2b7de13928..0714d7069f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -54,6 +54,9 @@ def __init__( self.bodyparts = auxiliaryfunctions.get_bodyparts(self.cfg) self.num_joints = len(self.bodyparts) + self.unique_bpts = auxiliaryfunctions.get_unique_bodyparts(self.cfg) + self.num_unique_bpts = len(self.unique_bpts) + self.shuffle = self.project.shuffle self.project.convert2dict(mode) self.dataframe = self.project.dataframe @@ -70,7 +73,7 @@ def __init__( pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") pytorch_cfg = read_plainconfig(pytorch_config_path) self.with_center = pytorch_cfg.get("with_center", False) - self.individuals = self.cfg.get("individuals", ["single"]) + self.individuals = self.cfg.get("individuals", ["animal"]) self.individual_to_idx = {} for i, indiv in enumerate(self.individuals): self.individual_to_idx[indiv] = i @@ -179,6 +182,8 @@ def __getitem__(self, index: int) -> dict: num_keypoints_returned = self.num_joints + 1 bodyparts += ["_center_"] + unique_bpts_kpts = np.zeros((self.num_unique_bpts, 3)) + bbox_list = [] bbox_label_list = [] labels = np.zeros((self.max_num_animals), dtype=np.int64) @@ -190,6 +195,12 @@ def __getitem__(self, index: int) -> dict: _keypoints, _undef_ids = self.project.annotation2keypoints(_annotation) _keypoints = np.array(_keypoints) + # Filter out the unique bodyparts + if _annotation["individual"] == "single": + unique_bpts_kpts[:, :2] = _keypoints + unique_bpts_kpts[:, 2] = _undef_ids + continue + ids[i] = self.individual_to_idx[_annotation["individual"]] if self.with_center: @@ -224,17 +235,19 @@ def __getitem__(self, index: int) -> dict: bbox_labels = np.zeros((0,)) # Needs two be 2 dimensional for albumentations - keypoints = keypoints.reshape((-1, 3)) + all_keypoints = np.concatenate( + (keypoints.reshape((-1, 3)), unique_bpts_kpts), axis=0 + ) if self.transform: class_labels = [ f"individual{i}_{bpt}" for i in range(self.max_num_animals) for bpt in bodyparts - ] + ] + [f"unique_{bpt}" for bpt in self.unique_bpts] transformed = self.transform( image=image, - keypoints=keypoints[:, :2], + keypoints=all_keypoints[:, :2], bboxes=bboxes, class_labels=class_labels, bbox_labels=bbox_labels, @@ -252,7 +265,7 @@ def __getitem__(self, index: int) -> dict: # Discard keypoints that are undefined undef_class_labels = [ - class_labels[i] for i, kpt in enumerate(keypoints) if kpt[2] == 0 + class_labels[i] for i, kpt in enumerate(all_keypoints) if kpt[2] == 0 ] for label in undef_class_labels: new_index = transformed["class_labels"].index(label) @@ -260,7 +273,7 @@ def __getitem__(self, index: int) -> dict: else: transformed = { - "keypoints": keypoints[:, :2], + "keypoints": all_keypoints[:, :2], "image": image, } @@ -268,12 +281,15 @@ def __getitem__(self, index: int) -> dict: 2, 0, 1 ) # channels first - assert len(transformed["keypoints"]) == len(keypoints) - keypoints = ( - np.array(transformed["keypoints"]) - .reshape((n_annotations, num_keypoints_returned, 2)) - .astype(float) - ) + assert len(transformed["keypoints"]) == len(all_keypoints) + keypoints = np.array(transformed["keypoints"]).astype(float) + if self.num_unique_bpts > 0: + keypoints = keypoints[: -self.num_unique_bpts] + keypoints = keypoints.reshape((self.max_num_animals, num_keypoints_returned, 2)) + + unique_bpts_kpts = np.array( + transformed["keypoints"][-self.num_unique_bpts :] + ).astype(float) # Pad bboxes and labels to always have shape (num_animals, 4) # If we want the original index of the bboxes, we can use the bbox labels @@ -316,6 +332,7 @@ def __getitem__(self, index: int) -> dict: ] = original_size # In order to convert back the keypoints to their original space res["annotations"] = {} res["annotations"]["keypoints"] = keypoints + res["annotations"]["unique_kpts"] = unique_bpts_kpts res["annotations"]["area"] = area res["annotations"]["ids"] = ids res["annotations"]["boxes"] = bbox_tensor @@ -372,7 +389,7 @@ def __init__( pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") pytorch_cfg = read_plainconfig(pytorch_config_path) self.with_center = pytorch_cfg.get("with_center", False) - self.individuals = self.cfg.get("individuals", ["single"]) + self.individuals = self.cfg.get("individuals", ["animal"]) self.individual_to_idx = {} for i, indiv in enumerate(self.individuals): self.individual_to_idx[indiv] = i diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py index 8fb4356cd9..324bb2a9db 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py @@ -6,12 +6,9 @@ import numpy as np import pandas as pd -from deeplabcut.pose_estimation_pytorch.utils import ( - df2generic -) -from deeplabcut.pose_estimation_pytorch.data.base import ( - BaseProject -) +from deeplabcut.pose_estimation_pytorch.utils import df2generic +from deeplabcut.pose_estimation_pytorch.data.base import BaseProject + class DLCProject(BaseProject): """ diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py index d043298544..621801e16d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterion.py @@ -164,6 +164,7 @@ def __init__( loss_weight_locref: float = 0.1, locref_huber_loss: bool = False, apply_sigmoid: bool = False, + unique_bodyparts: bool = False, ) -> None: """Summary: Constructor of the PoseLoss class. @@ -173,6 +174,7 @@ def __init__( locref_huber_loss: if True uses torch.nn.HuberLoss for locref (default is False). apply_sigmoid: whether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) + unique_bodyparts : Is there a unique bodyparts head attached to the model Returns: None. @@ -186,6 +188,7 @@ def __init__( self.heatmap_criterion = WeightedBCELoss() self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() + self.unique_bodyparts = unique_bodyparts def forward( self, prediction: Tuple[torch.Tensor, torch.Tensor], target: Dict @@ -217,7 +220,23 @@ def forward( } losses = criterion(prediction, target) """ - heatmaps, locref = prediction + unique_htmp_loss, unique_locref_loss = 0.0, 0.0 + if self.unique_bodyparts: + heatmaps, locref = prediction[:2] + unique_heatmaps, unique_locref = prediction[2:] + if self.apply_sigmoid: + unique_heatmaps = self.sigmoid(unique_heatmaps) + + unique_htmp_loss = self.heatmap_criterion( + unique_heatmaps, target["unique_heatmaps"], 1.0 + ) + unique_locref_loss = self.locref_criterion( + unique_locref, + target["unique_locref_maps"], + target["unique_locref_masks"], + ) + else: + heatmaps, locref = prediction if self.apply_sigmoid: heatmap_loss = self.heatmap_criterion( self.sigmoid(heatmaps), @@ -232,12 +251,27 @@ def forward( locref_loss = self.locref_criterion( locref, target["locref_maps"], target["locref_masks"] ) - total_loss = locref_loss * self.loss_weight_locref + heatmap_loss - return { - "total_loss": total_loss, - "heatmap_loss": heatmap_loss, - "locref_loss": locref_loss, - } + total_loss = ( + locref_loss * self.loss_weight_locref + + heatmap_loss + + unique_locref_loss * self.loss_weight_locref + + unique_htmp_loss + ) + + if self.unique_bodyparts: + return { + "total_loss": total_loss, + "heatmap_loss": heatmap_loss, + "locref_loss": locref_loss, + "unique_heatmap_loss": unique_htmp_loss, + "unique_locref_loss": unique_locref_loss, + } + else: + return { + "total_loss": total_loss, + "heatmap_loss": heatmap_loss, + "locref_loss": locref_loss, + } @LOSSES.register_module @@ -248,7 +282,7 @@ class HeatmapOnlyLoss(nn.Module): This loss function computes the weighted Binary Cross Entropy (BCE) loss for heatmap predictions. """ - def __init__(self, apply_sigmoid: bool = False) -> None: + def __init__(self, apply_sigmoid: bool = False, unique_bodyparts: bool = False): """Summary: Constructor for the HeatmapOnlyLoss class. @@ -263,6 +297,9 @@ def __init__(self, apply_sigmoid: bool = False) -> None: self.apply_sigmoid = apply_sigmoid self.sigmoid = nn.Sigmoid() + # Unused for now since no model supporting unique_bodyparts use this loss + self.unique_bodyparts = unique_bodyparts + def forward( self, prediction: Tuple[torch.Tensor, torch.Tensor], target: Dict ) -> Dict: diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py index a26c4d62bb..8bdb8a5830 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py @@ -1,2 +1,5 @@ -from deeplabcut.pose_estimation_pytorch.models.detectors.base import DETECTORS, BaseDetector +from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( + DETECTORS, + BaseDetector, +) from deeplabcut.pose_estimation_pytorch.models.detectors.fasterRCNN import FasterRCNN diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py index 2348d33cc8..307e4900b9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py @@ -1,3 +1,6 @@ from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS -from deeplabcut.pose_estimation_pytorch.models.heads.dekr_heads import HeatmapDEKRHead, OffsetDEKRHead +from deeplabcut.pose_estimation_pytorch.models.heads.dekr_heads import ( + HeatmapDEKRHead, + OffsetDEKRHead, +) from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import SimpleHead diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py index e316f50f07..5c9d20aec3 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py @@ -15,6 +15,7 @@ from deeplabcut.pose_estimation_pytorch.models.heads.base import BaseHead, HEADS from deeplabcut.pose_estimation_pytorch.models.modules import AdaptBlock, BasicBlock +from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import BLOCKS @HEADS.register_module diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 157540cc18..edf14a1a07 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -11,10 +11,17 @@ from typing import List, Tuple +import numpy as np import torch -import torch.nn as nn - from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator +from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps +from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict +from torch import nn +from typing import List +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator +from deeplabcut.pose_estimation_pytorch.models.target_generators.gaussian_targets import ( + GaussianGenerator, +) class PoseModel(nn.Module): @@ -30,6 +37,7 @@ def __init__( target_generator: BaseGenerator, neck: torch.nn.Module = None, stride: int = 8, + num_unique_bodyparts: int = 0, ) -> None: """Summary Constructor of the PoseModel. @@ -57,6 +65,14 @@ def __init__( self.stride = stride self.cfg = cfg self.target_generator = target_generator + + self.num_unique_bodyparts = num_unique_bodyparts + self.compute_unique_bpts = num_unique_bodyparts > 0 + self.unique_bpts_target_gen = GaussianGenerator( + locref_stdev=7.2801, + num_joints=self.num_unique_bodyparts, + pos_dist_thresh=17, + ) self.sigmoid = nn.Sigmoid() def forward(self, x: torch.Tensor) -> List[torch.Tensor]: @@ -99,4 +115,14 @@ def get_target( targets: dict of the targets needed for model training """ - return self.target_generator(annotations, prediction, image_size) + targets_dict = self.target_generator(annotations, prediction, image_size) + if self.compute_unique_bpts: + unique_anno = {"keypoints": annotations["unique_kpts"][:, None, :]} + unique_targets = self.unique_bpts_target_gen( + unique_anno, prediction[-2:], image_size + ) + + for key in unique_targets: + targets_dict["unique_" + key] = unique_targets[key] + + return targets_dict diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py index c9e41e3769..3f5a4bdaa1 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py @@ -5,5 +5,11 @@ # (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) # ------------------------------------------------------------------------------ -from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import AdaptBlock, BasicBlock, Bottleneck -from deeplabcut.pose_estimation_pytorch.models.modules.conv_module import HighResolutionModule +from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import ( + AdaptBlock, + BasicBlock, + Bottleneck, +) +from deeplabcut.pose_estimation_pytorch.models.modules.conv_module import ( + HighResolutionModule, +) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index 95f7ec3a5d..23369a5880 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -8,7 +8,16 @@ # # Licensed under GNU Lesser General Public License v3.0 # -from deeplabcut.pose_estimation_pytorch.models.predictors.base import PREDICTORS, BasePredictor -from deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor import DEKRPredictor -from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import SinglePredictor -from deeplabcut.pose_estimation_pytorch.models.predictors.top_down_prediction import TopDownPredictor +from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( + PREDICTORS, + BasePredictor, +) +from deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor import ( + DEKRPredictor, +) +from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import ( + SinglePredictor, +) +from deeplabcut.pose_estimation_pytorch.models.predictors.top_down_prediction import ( + TopDownPredictor, +) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index 24b4fe428d..d8bf290285 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -10,7 +10,9 @@ # from abc import ABC, abstractmethod +from typing import Tuple +import torch from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg from torch import nn @@ -22,7 +24,7 @@ class BasePredictor(ABC, nn.Module): This class is an abstract base class (ABC) for defining predictors used in the DeepLabCut Toolbox. All predictor classes should inherit from this base class and implement the forward method. - Regresses keypoint coordinates from model's output maps + Regresses keypoint coordinates from model's output maps Attributes: num_animals: Number of animals in the project. Should be set in subclasses. @@ -45,14 +47,18 @@ def __init__(self): self.num_animals = None @abstractmethod - def forward(self, outputs): + def forward( + self, outputs: Tuple[torch.Tensor, ...], scale_factors: Tuple[float, float] + ) -> dict: """Abstract method for the forward pass of the Predictor. Args: outputs: Output tensors from previous layers. + scale_factors: Scale factors for the poses. Returns: - Tensor: Output tensor. + A dictionary containing a "poses" key with the output tensor as value, and + optionally a "unique_bodyparts" with the unique bodyparts tensor as value. Raises: NotImplementedError: This method must be implemented in subclasses. diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 2b05f201f4..6795af1e6b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -16,6 +16,9 @@ PREDICTORS, BasePredictor, ) +from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import ( + SinglePredictor, +) @PREDICTORS.register_module @@ -62,6 +65,7 @@ def __init__( detection_threshold: float = 0.01, apply_sigmoid: bool = True, use_heatmap: bool = True, + unique_bodyparts: bool = False, ): """Initializes the DEKRPredictor class. @@ -80,13 +84,21 @@ def __init__( self.detection_threshold = detection_threshold self.apply_sigmoid = apply_sigmoid self.use_heatmap = use_heatmap + self.unique_bodyparts = unique_bodyparts + if self.unique_bodyparts: + self.unique_predictor = SinglePredictor( + num_animals=1, + location_refinement=True, + locref_stdev=7.8201, + apply_sigmoid=False, + ) self.max_absorb_distance = 75 def forward( self, - outputs: Tuple[torch.Tensor, torch.Tensor], + outputs: Tuple[torch.Tensor, ...], scale_factors: Tuple[float, float], - ) -> torch.Tensor: + ) -> dict: """Forward pass of DEKRPredictor. Args: @@ -94,18 +106,23 @@ def forward( scale_factors: Scale factors for the poses. Returns: - Poses with scores. + A dictionary containing a "poses" key with the output tensor as value, and + optionally a "unique_bodyparts" with the unique bodyparts tensor as value. Example: # Assuming you have 'outputs' (heatmaps and offsets) and 'scale_factors' for poses poses_with_scores = predictor.forward(outputs, scale_factors) - - Notes: - TODO: implement confidence scores for each keypoints """ - heatmaps, offsets = outputs - if self.apply_sigmoid: + if self.unique_bodyparts: + heatmaps, offsets, unique_heatmaps, unique_locref = outputs + else: + heatmaps, offsets = outputs + if self.apply_sigmoid and not self.unique_bodyparts: heatmaps = torch.nn.Sigmoid()(heatmaps) + elif self.apply_sigmoid: + heatmaps = torch.nn.Sigmoid()(heatmaps) + unique_heatmaps = torch.nn.Sigmoid()(unique_heatmaps) + posemap = self.offset_to_pose(offsets) batch_size, num_joints_with_center, h, w = heatmaps.shape @@ -136,7 +153,24 @@ def forward( poses_w_scores = torch.cat([poses, ctr_score], dim=3) # self.pose_nms(heatmaps, poses_w_scores) - return poses_w_scores + if self.unique_bodyparts: + # Super trick to compute scale factor without knowing original image size + scale_factors_unique = ( + scale_factors[0] * h / unique_heatmaps.shape[2], + scale_factors[0] * w / unique_heatmaps.shape[3], + ) + unique_poses = self.unique_predictor( + [unique_heatmaps, unique_locref], scale_factors_unique + ) + + return { + "poses": poses_w_scores, + "unique_bodyparts": unique_poses, + } + + return { + "poses": poses_w_scores, + } def get_locations( self, height: int, width: int, device: torch.device diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index ec43fe084e..06b6055370 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -74,7 +74,7 @@ def forward( self, output: Tuple[torch.Tensor, torch.Tensor], scale_factors: Tuple[float, float], - ) -> torch.Tensor: + ) -> dict: """Forward pass of SinglePredictor. Gets predictions from model output. Args: @@ -85,7 +85,7 @@ def forward( scale_factors: Scale factors for the poses. Returns: - Poses with scores. + A dictionary containing a "poses" key with the output tensor as value. Example: >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) @@ -106,7 +106,7 @@ def forward( poses = self.get_pose_prediction( heatmaps, locrefs * self.locref_stdev, scale_factors ) - return poses + return {"poses": poses} def get_top_values( self, heatmap: torch.Tensor @@ -219,7 +219,7 @@ def forward( self, output: Tuple[torch.Tensor, torch.Tensor], scale_factors: Tuple[float, float], - ) -> torch.Tensor: + ) -> dict: """Forward pass of HeatmapOnlyPredictor. Computes predictions from the trained model output. Args: @@ -230,7 +230,7 @@ def forward( scale_factors: Scale factors for the poses. Returns: - Poses with scores. + A dictionary containing a "poses" key with the output tensor as value. Example: >>> predictor = HeatmapOnlyPredictor(num_animals=1, apply_sigmoid=True) @@ -244,7 +244,7 @@ def forward( heatmaps = heatmaps.permute(0, 2, 3, 1) poses = self.get_pose_prediction(heatmaps, scale_factors) - return poses + return {"poses": poses} def get_top_values( self, heatmap: torch.Tensor diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py index efcb76458c..62241b20a0 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py @@ -46,9 +46,7 @@ def _convert_bbox_to_coco(self, bboxes: torch.Tensor) -> torch.Tensor: return coco_bboxes - def forward( - self, bboxes: torch.Tensor, keypoints_cropped: torch.Tensor - ) -> torch.Tensor: + def forward(self, bboxes: torch.Tensor, keypoints_cropped: torch.Tensor) -> dict: """Computes keypoints coordinates in the original image given predicted bbox and predicted keypoints coordinates inside the bbox cropped image. @@ -57,7 +55,8 @@ def forward( keypoints_cropped: Keypoints with the shape (batch_size, max_num_animals, num_joints, 3) Returns: - Keypoints tensor of the shape: (batch_size, max_num_animals, num_joints, 3) + A dictionary containing a "poses" key with a keypoints tensor of the shape: + (batch_size, max_num_animals, num_joints, 3) as value. """ if self.format_bbox != "coco": bboxes = self._convert_bbox_to_coco(bboxes) @@ -74,4 +73,4 @@ def forward( new_kpts[:, :, :, 0] = scales_x * new_kpts[:, :, :, 0] + x_corners new_kpts[:, :, :, 1] = scales_y * new_kpts[:, :, :, 1] + y_corners - return new_kpts + return {"poses": new_kpts} diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 4ecfed2eeb..c80ab6a25e 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -25,7 +25,7 @@ def rmse_match_prediction_to_gt( match predicted individuals to ground truth individuals based on the rmse distance between their corresponding keypoints. This algorithm is used to find the optimal matching, taking into account the potential missing animal. - Raises: + Raises: ValueError: if `gt_kpts.shape != pred_kpts.shape` Args: diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index bad410a363..d952d02f59 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -9,7 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Optional import numpy as np import pandas as pd @@ -24,7 +24,7 @@ # TODO: DEPRECATED def get_prediction( - cfg: dict, output: Tuple[torch.Tensor, torch.Tensor], stride: int = 8 + cfg: dict, output: Tuple[np.ndarray, np.ndarray], stride: int = 8 ) -> np.ndarray: """Generates pose predictions from the model outputwhich is a tuple given by (heatmaps,location refinement fields)). @@ -38,7 +38,7 @@ def get_prediction( keypoint occurs at a particular location locref: location refinement fields that predict offsets to mitigate quantization errors due to downsampled score maps - stride: window stride; defaults to 8 + stride: window stride; defaults to 8, Optional Returns: Array of poses @@ -223,19 +223,33 @@ def get_oks( mask = prediction[scorer_pred].xs("likelihood", level=2, axis=1) >= pcutoff # Convert predictions to DLC assemblies - assemblies_pred_raw = conv_df_to_assemblies(prediction[scorer_pred]) - assemblies_gt_raw = conv_df_to_assemblies(target[scorer_target]) + assemblies_pred_raw, unique_pred_raw = conv_df_to_assemblies( + prediction[scorer_pred] + ) + assemblies_gt_raw, unique_gt_raw = conv_df_to_assemblies(target[scorer_target]) - assemblies_pred_masked = conv_df_to_assemblies(prediction[scorer_pred][mask]) - assemblies_gt_masked = conv_df_to_assemblies(target[scorer_target][mask]) + assemblies_pred_masked, unique_pred_masked = conv_df_to_assemblies( + prediction[scorer_pred][mask] + ) + assemblies_gt_masked, unique_gt_masked = conv_df_to_assemblies( + target[scorer_target][mask] + ) - oks_raw = evaluate_assembly( + oks_assemblies_raw = evaluate_assembly( assemblies_pred_raw, assemblies_gt_raw, oks_sigma, margin=margin, symmetric_kpts=symmetric_kpts, ) + if unique_pred_raw is not None and unique_gt_raw is not None: + oks_unique_raw = evaluate_assembly( + unique_pred_raw, + unique_gt_raw, + oks_sigma, + margin=margin, + symmetric_kpts=symmetric_kpts, + ) oks_pcutoff = evaluate_assembly( assemblies_pred_masked, @@ -244,22 +258,43 @@ def get_oks( margin=margin, symmetric_kpts=symmetric_kpts, ) + if unique_pred_masked is not None and unique_gt_masked is not None: + oks_unique_masked = evaluate_assembly( + unique_pred_masked, + unique_gt_masked, + oks_sigma, + margin=margin, + symmetric_kpts=symmetric_kpts, + ) - return oks_raw, oks_pcutoff + return oks_assemblies_raw, oks_pcutoff -def conv_df_to_assemblies(df: pd.DataFrame) -> dict: - """Convert a dataframe to an assemblies dictionary. +def conv_df_to_assemblies(df: pd.DataFrame) -> Tuple[dict, Optional[dict]]: + """ + Convert a dataframe to an assemblies dictionary Args: df : dataframe of coordinates/predictions, df is expected to have a multi_index of shape (num_animals, num_keypoints, 2 or 3) - Returns: assemblies: dictionary of the assemblies of keypoints + if there are unique bodyparts, a dictionary containing unique bodyparts """ - assemblies = {} + individuals = df.columns.get_level_values(0) + df_bodyparts = df.loc[:, individuals != "single"] + assemblies = _df_to_dict(df_bodyparts) + + unique_keypoints = None + if "single" in individuals: + df_unique = df.loc[:, individuals == "single"] + unique_keypoints = _df_to_dict(df_unique) + + return assemblies, unique_keypoints + +def _df_to_dict(df: pd.DataFrame) -> dict: + data = {} num_animals = len(df.columns.get_level_values(0).unique()) num_kpts = len(df.columns.get_level_values(1).unique()) for image_path in df.index: @@ -272,8 +307,9 @@ def conv_df_to_assemblies(df: pd.DataFrame) -> dict: if len(ass): kpt_lst.append(ass) - assemblies[image_path] = kpt_lst - return assemblies + data[image_path] = kpt_lst + + return data # DEPRECATED diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py index a1b5fe8519..06e1367289 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py @@ -189,7 +189,6 @@ def epoch_detector( mode: str = "train", step: Optional[int] = None, ) -> float: - if mode not in ["train", "eval"]: raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") to_mode_detector = getattr(self.detector, mode) @@ -233,7 +232,6 @@ def epoch_pose( mode: str = "train", step: Optional[int] = None, ) -> float: - if mode not in ["train", "eval"]: raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") to_mode_pose = getattr(self.model, mode) @@ -315,7 +313,6 @@ def step_detector(self, batch: dict, mode: str = "train") -> float: return 0.0 def step_pose(self, batch: dict, mode: str = "train") -> dict: - if mode not in ["train", "eval"]: raise ValueError( f"Solver must be in train or eval mode, but {mode} was found." diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/solvers/utils.py index b8b5a82157..510bd49650 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/utils.py @@ -14,7 +14,7 @@ import re import warnings from pathlib import Path -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Optional import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions @@ -141,7 +141,8 @@ def get_detector_path(model_folder: str, load_epoch: int) -> str: Output: 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/detector-snapshot-10.pt' """ - return get_verified_path(model_folder, load_epoch, mode = "detector") + return get_verified_path(model_folder, load_epoch, mode="detector") + def get_dlc_scorer( train_fraction: float, @@ -208,9 +209,7 @@ def get_snapshots(model_folder: Path) -> List[str]: return sorted(snapshots, key=lambda s: int(s.split("-")[-1])) -def get_verified_path( - directory_path: str, load_epoch: int, mode: str = "model" -) -> str: +def get_verified_path(directory_path: str, load_epoch: int, mode: str = "model") -> str: """Helper function for the get_model_path and get_detector_path functions. Verifies the directories and returns the specific directory given the parameters: @@ -250,7 +249,9 @@ def get_verified_path( # Assigns the proper prefix and paths given the verification mode: for either model paths or detector paths if mode == "detector": mode_prefix = "detector-" - directory_paths = glob.glob(os.path.join(directory_path,"train",f"{mode_prefix}snapshot*")) + directory_paths = glob.glob( + os.path.join(directory_path, "train", f"{mode_prefix}snapshot*") + ) # else: # directory_paths = glob.glob(f"{directory_path}/train/snapshot*") @@ -472,6 +473,48 @@ def build_predictions_df( return pd.DataFrame(predictions, columns=index, index=df_index) +def build_entire_pred_df( + dlc_scorer: str, + individuals: List[str], + bodyparts: List[str], + df_index: pd.Index, + predictions: np.ndarray, + unique_bodyparts: List[str], + unique_predictions: Optional[np.ndarray], +) -> pd.DataFrame: + num_individuals = len(individuals) + if num_individuals == 1 or len(unique_bodyparts) == 0 or unique_predictions is None: + return build_predictions_df( + dlc_scorer, + individuals, + bodyparts, + df_index, + predictions, + ) + + animals_df = build_predictions_df( + dlc_scorer, + individuals, + bodyparts, + df_index, + predictions, + ) + unique_df = build_predictions_df( + dlc_scorer, + ["single"], + unique_bodyparts, + df_index, + unique_predictions, + ) + new_cols = pd.MultiIndex.from_tuples( + [(col[0], "single", col[1], col[2]) for col in unique_df.columns], + names=["scorer", "individuals", "bodyparts", "coords"], + ) + unique_df.columns = new_cols + predictions_df = animals_df.merge(unique_df, left_index=True, right_index=True) + return predictions_df + + def get_paths( train_fraction: float = 0.95, shuffle: int = 0, diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py b/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py index b057e68032..64513dcb73 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py @@ -1,15 +1,21 @@ -import albumentations as A -import numpy as np import os -from torch.utils.data import DataLoader -import pytest import random +from pathlib import Path + +import albumentations as A +import pytest +from torch.utils.data import DataLoader import deeplabcut.pose_estimation_pytorch as dlc import deeplabcut.utils.auxiliaryfunctions as dlc_auxfun +from deeplabcut.generate_training_dataset import create_training_dataset def _get_dataset(path, transform, mode="train"): + project_root = Path(path) + if not (project_root / "training-datasets").exists(): + create_training_dataset(config=str(project_root / "config.yaml")) + dlc_project = dlc.DLCProject(path, shuffle=1) dataset = dlc.PoseDataset(dlc_project, transform=transform, mode=mode) return dataset @@ -35,10 +41,17 @@ def test_iter_all_dataset_no_transform(batch_size): transform = None dataset = _get_openfield_dataset(transform=transform) dataloader = DataLoader(dataset, batch_size=batch_size) - key_set = set(["image", "original_size", "annotations"]) - anno_key_set = set( - ["keypoints", "area", "ids", "boxes", "image_id", "is_crowd", "labels"] - ) + key_set = {"image", "original_size", "annotations"} + anno_key_set = { + "keypoints", + "area", + "ids", + "boxes", + "image_id", + "is_crowd", + "labels", + "unique_kpts", + } max_num_animals = dataset.max_num_animals num_keypoints = dataset.num_joints @@ -103,10 +116,17 @@ def test_iter_all_augmented_dataset(batch_size, x_size, y_size, exageration): ) dataset = _get_openfield_dataset(transform=transform) dataloader = DataLoader(dataset, batch_size=batch_size) - key_set = set(["image", "original_size", "annotations"]) - anno_key_set = set( - ["keypoints", "area", "ids", "boxes", "image_id", "is_crowd", "labels"] - ) + key_set = {"image", "original_size", "annotations"} + anno_key_set = { + "keypoints", + "area", + "ids", + "boxes", + "image_id", + "is_crowd", + "labels", + "unique_kpts", + } max_num_animals = dataset.max_num_animals num_keypoints = dataset.num_joints diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py index 831009739b..c0b81a336c 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py @@ -62,7 +62,7 @@ def test_get_predictions_bottom_up( # get predictions with torch.no_grad(): - output = inference.get_predictions_bottom_up(model, predictor, images) + output, _ = inference.get_predictions_bottom_up(model, predictor, images) # Generate test tensor with expected output shape test = torch.randint(1, 12, (batch_size, num_animals, num_keypoints, 3)) @@ -115,7 +115,7 @@ def test_get_predicitons_top_down( # get predictions with torch.no_grad(): - output = inference.get_predictions_top_down( + output, _ = inference.get_predictions_top_down( detector, model, pose_predictor, diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index b2f82aeff9..6f8718a4e4 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -28,7 +28,7 @@ def df2generic(proj_root, df, image_id_offset=0): individuals = df.columns.get_level_values("individuals").unique().tolist() except KeyError: new_cols = pd.MultiIndex.from_tuples( - [(col[0], "single", col[1], col[2]) for col in df.columns], + [(col[0], "animal", col[1], col[2]) for col in df.columns], names=["scorer", "individuals", "bodyparts", "coords"], ) df.columns = new_cols @@ -167,7 +167,7 @@ def create_folder(path_to_folder): Args: path_to_folder: Path to the folder that should be created - """ + """ if not os.path.exists(path_to_folder): os.makedirs(path_to_folder) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 0923cbf2dd..6200558274 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -275,18 +275,35 @@ def get_bodyparts(cfg: dict) -> typing.List[str]: Args: cfg: a project configuration file - Returns: all bodyparts listed in the project + Returns: bodyparts listed in the project (does not include the unique_bodyparts entry) """ if cfg.get("multianimalproject", False): ( _, - unique_bodyparts, + _, multianimal_bodyparts, ) = auxfun_multianimal.extractindividualsandbodyparts(cfg) - return multianimal_bodyparts + unique_bodyparts + return multianimal_bodyparts return cfg["bodyparts"] +def get_unique_bodyparts(cfg : dict) -> typing.List[str]: + """ + Args: + cfg: a project configuration file + + Returns: all unique bodyparts listed in the project + """ + if cfg.get("multianimalproject", False): + ( + _, + unique_bodyparts, + _, + ) = auxfun_multianimal.extractindividualsandbodyparts(cfg) + return unique_bodyparts + + return [] + def write_config_3d(configname, cfg): """ @@ -397,10 +414,11 @@ def get_list_of_videos( if isinstance(videotype, str): videotype = [videotype] - + if videotype is None: + videotype = auxfun_videos.SUPPORTED_VIDEOS # filter list of videos videos = [ - v + v for v in videos if os.path.isfile(v) and any(v.endswith(ext) for ext in videotype) diff --git a/deeplabcut/utils/visualization.py b/deeplabcut/utils/visualization.py index c7b36dbade..f0e3739113 100644 --- a/deeplabcut/utils/visualization.py +++ b/deeplabcut/utils/visualization.py @@ -388,6 +388,7 @@ def plot_evaluation_results( model_name: str, output_folder: str, in_train_set: bool, + plot_unique_bodyparts: bool = False, mode: str = "bodypart", colormap: str = "rainbow", dot_size: int = 12, @@ -408,6 +409,7 @@ def plot_evaluation_results( model_name: the name of the model for predictions in df_combined output_folder: the name of the folder where images should be saved in_train_set: whether df_combined is for train set images + plot_unique_bodyparts: whether we should plot unique bodyparts mode: one of {"bodypart", "individual"}. Determines the keypoint color grouping colormap: the colormap to use for keypoints dot_size: the dot size to use for keypoints @@ -419,15 +421,37 @@ def plot_evaluation_results( image_path = Path(project_root) / data_folder / video / image frame = auxfun_videos.imread(str(image_path), mode="skimage") - individuals = len(row.index.levels[1]) - bodyparts = len(row.index.levels[2]) - df_gt = row[scorer] - df_predictions = row[model_name] + row_multi = row.loc[ + (slice(None), row.index.get_level_values("individuals") != "single") + ] + individuals = len(row_multi.index.get_level_values("individuals").unique()) + bodyparts = len(row_multi.index.get_level_values("bodyparts").unique()) + df_gt = row_multi[scorer] + df_predictions = row_multi[model_name] # Shape (num_individuals, num_bodyparts, xy) ground_truth = df_gt.to_numpy().reshape((individuals, bodyparts, 2)) predictions = df_predictions.to_numpy().reshape((individuals, bodyparts, 3)) + if plot_unique_bodyparts: + row_unique = row.loc[ + (slice(None), row.index.get_level_values("individuals") == "single") + ] + unique_individuals = 1 + unique_bodyparts = len( + row_unique.index.get_level_values("bodyparts").unique() + ) + unique_ground_truth = ( + row_unique[scorer] + .to_numpy() + .reshape((unique_individuals, unique_bodyparts, 2)) + ) + unique_predictions = ( + row_unique[model_name] + .to_numpy() + .reshape((unique_individuals, unique_bodyparts, 3)) + ) + fig, ax = create_minimal_figure() h, w, _ = np.shape(frame) fig.set_size_inches(w / 100, h / 100) @@ -436,11 +460,11 @@ def plot_evaluation_results( ax.invert_yaxis() if mode == "bodypart": - colors = get_cmap(bodyparts, name=colormap) + colors = get_cmap(bodyparts + unique_bodyparts, name=colormap) predictions = predictions.swapaxes(0, 1) ground_truth = ground_truth.swapaxes(0, 1) elif mode == "individual": - colors = get_cmap(individuals, name=colormap) + colors = get_cmap(individuals + 1, name=colormap) else: colors = [] @@ -455,6 +479,20 @@ def plot_evaluation_results( p_cutoff, ax=ax, ) + if plot_unique_bodyparts: + unique_predictions = unique_predictions.swapaxes(0, 1) + unique_ground_truth = unique_ground_truth.swapaxes(0, 1) + ax = make_multianimal_labeled_image( + frame, + unique_ground_truth, + unique_predictions[:, :, :2], + unique_predictions[:, :, 2:], + colors, + dot_size, + alpha_value, + p_cutoff, + ax=ax, + ) save_labeled_frame( fig, diff --git a/examples/openfield-Pranav-2018-10-30/config.yaml b/examples/openfield-Pranav-2018-10-30/config.yaml index 64c2ce17b2..ece5b4a5e7 100644 --- a/examples/openfield-Pranav-2018-10-30/config.yaml +++ b/examples/openfield-Pranav-2018-10-30/config.yaml @@ -1,12 +1,15 @@ -# Project definitions (do not edit) + # Project definitions (do not edit) Task: openfield scorer: Pranav date: Oct30 +multianimalproject: +identity: -# Project path (change when moving around) -project_path: WILL BE AUTOMATICALLY UPDATED BY DEMO CODE + # Project path (change when moving around) +project_path: + /Users/niels/Documents/upamathis/repos/DLCdev/examples/openfield-Pranav-2018-10-30 -# Annotation data set configuration (and individual video cropping parameters) + # Annotation data set configuration (and individual video cropping parameters) video_sets: WILL BE AUTOMATICALLY UPDATED BY DEMO CODE: crop: 0, 640, 0, 480 @@ -16,33 +19,38 @@ bodyparts: - rightear - tailbase + + # Fraction of video to start/stop when extracting frames for labeling/refinement start: 0 stop: 1 numframes2pick: 20 -# Plotting configuration + # Plotting configuration +skeleton: [] +skeleton_color: black pcutoff: 0.4 dotsize: 8 alphavalue: 0.7 colormap: jet -# Training,Evaluation and Analysis configuration + # Training,Evaluation and Analysis configuration TrainingFraction: - 0.95 iteration: 0 default_net_type: resnet_50 +default_augmenter: imgaug snapshotindex: -1 batch_size: 4 -# Cropping Parameters (for analysis and outlier frame detection) + # Cropping Parameters (for analysis and outlier frame detection) cropping: false -#if cropping is true for analysis, then set the values here: + #if cropping is true for analysis, then set the values here: x1: 0 x2: 640 y1: 277 y2: 624 -# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) + # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) corner2move2: - 50 - 50 diff --git a/tests/test_dekr_predictor.py b/tests/test_dekr_predictor.py index 915524ef45..5328e2bf3c 100644 --- a/tests/test_dekr_predictor.py +++ b/tests/test_dekr_predictor.py @@ -2,16 +2,18 @@ import pytest import deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor as dlc_pep_models_predictors_dekr_predictor + def test_DEKRPredictor(): predictor = dlc_pep_models_predictors_dekr_predictor.DEKRPredictor(num_animals=2) outputs = ( - torch.randn(1, 18, 64, 64), # example heatmap + torch.randn(1, 18, 64, 64), # example heatmap torch.randn(1, 34, 64, 64), # example offsets ) scale_factors = (1.0, 0.5) try: - poses_with_scores = predictor.forward(outputs, scale_factors) + pose_dict = predictor.forward(outputs, scale_factors) + poses_with_scores = pose_dict["poses"] except Exception as e: pytest.fail(f"DEKRPredictor forward pass raised an exception: {e}") @@ -19,6 +21,3 @@ def test_DEKRPredictor(): assert torch.all(poses_with_scores[:, :, :, 2] >= 0) assert torch.all(poses_with_scores[:, :, :, 2] <= 1) - - - \ No newline at end of file From 0a4cc9206cef18467df0f462cf502b0a6592aa58 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 15 Aug 2023 10:54:14 +0200 Subject: [PATCH 043/293] Implement DEKR keypoint scores * added different options to get DEKR keypoint scores. save evaluation results to csv. * added keypoint_score_type to pytorch config --- .../make_pytorch_config.py | 1 + .../pose_estimation_pytorch/apis/evaluate.py | 53 ++++++++++++- .../models/predictors/dekr_predictor.py | 75 ++++++++++++++----- .../solvers/inference.py | 50 ++++++------- 4 files changed, 130 insertions(+), 49 deletions(-) diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 347ae0edd1..402838d666 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -146,6 +146,7 @@ def make_pytorch_config( "type": "DEKRPredictor", "num_animals": num_animals, "unique_bodyparts": compute_unique_bpts, + "keypoint_score_type": "combined", } pytorch_config["with_center"] = True diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 74d9cff0a5..892e27fe9d 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -159,6 +159,13 @@ def evaluate_snapshot( torch.load(names["detector_path"])["detector_state_dict"] ) + pcutoff = project.cfg.get("pcutoff") + scores = { + "Training epochs": int(names["dlc_scorer"].split("_")[-1]), + "%Training dataset": train_fraction, + "Shuffle number": shuffle, + "pcutoff": pcutoff, + } df_mode_predictions: List[pd.DataFrame] = [] for mode in ["train", "test"]: dataset = dlc.PoseDataset(project, transform=transform, mode=mode) @@ -222,9 +229,9 @@ def evaluate_snapshot( p_cutoff=cfg["pcutoff"], ) - if show_errors: - scores = get_scores(pose_cfg, df_predictions, target_df) - print(f"Mode {mode} scores:", scores) + mode_scores = get_scores(df_predictions, target_df, pcutoff) + for k, v in mode_scores.items(): + scores[f"{mode} {k}"] = round(v, 2) # Create the output dataframe df_all_predictions = pd.concat(df_mode_predictions, axis=0) @@ -236,6 +243,13 @@ def evaluate_snapshot( df_all_predictions.to_hdf(str(output_filename), "df_with_missing") + df_scores = pd.DataFrame([scores]).set_index( + ["Training epochs", "%Training dataset", "Shuffle number", "pcutoff"] + ) + scores_filepath = Path(results_filename).with_suffix(".csv") + scores_filepath = scores_filepath.with_stem(scores_filepath.stem + "-results") + save_evaluation_results(df_scores, scores_filepath, show_errors, pcutoff) + def evaluate_network( config: str, @@ -341,6 +355,39 @@ def evaluate_network( ) +def save_evaluation_results( + df_scores: pd.DataFrame, + scores_path: Path, + print_results: bool, + pcutoff: float, +) -> None: + """ + Saves the evaluation results to a CSV file. Adds the evaluation results for the + model to the combined results file, or creates it if it does not yet exist. + + Args: + df_scores: the scores dataframe for a snapshot + scores_path: the path where the model scores CSV should be saved + print_results: whether to print evaluation results to the console + pcutoff: the pcutoff used to get the evaluation results + """ + if print_results: + print(f"Evaluation results for {scores_path.name} (pcutoff: {pcutoff}):") + print(df_scores.iloc[0]) + + # Save scores file + df_scores.to_csv(scores_path) + + # Update combined results + combined_scores_path = scores_path.parent.parent / "CombinedEvaluation-results.csv" + if combined_scores_path.exists(): + df_existing_results = pd.read_csv(combined_scores_path, index_col=[0, 1, 2, 3]) + df_scores = df_scores.combine_first(df_existing_results) + + df_scores = df_scores.sort_index() + df_scores.to_csv(combined_scores_path) + + if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--config", type=str) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 6795af1e6b..28a8923762 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -39,13 +39,21 @@ class DEKRPredictor(BasePredictor): num_animals (int): Number of animals in the project. detection_threshold (float, optional): Threshold for detection. Defaults to 0.01. apply_sigmoid (bool, optional): Apply sigmoid to heatmaps. Defaults to True. - use_heatmap (bool, optional): Use heatmap. Defaults to True. + use_heatmap (bool, optional): Use heatmap to refine keypoint predictions. Defaults to True. + keypoint_score_type (str): Type of score to compute for keypoints. "heatmap" applies the heatmap + score to each keypoint. "center" applies the score of the center of each individual to + all of its keypoints. "combined" multiplies the score of the heatmap and individual + center for each keypoint. Attributes: num_animals (int): Number of animals in the project. detection_threshold (float): Threshold for detection. apply_sigmoid (bool): Apply sigmoid to heatmaps. use_heatmap (bool): Use heatmap. + keypoint_score_type (str): Type of score to compute for keypoints. "heatmap" applies the heatmap + score to each keypoint. "center" applies the score of the center of each individual to + all of its keypoints. "combined" multiplies the score of the heatmap and individual + center for each keypoint. Example: # Create a DEKRPredictor instance with 2 animals. @@ -66,14 +74,20 @@ def __init__( apply_sigmoid: bool = True, use_heatmap: bool = True, unique_bodyparts: bool = False, + keypoint_score_type: str = "combined", ): """Initializes the DEKRPredictor class. Args: num_animals: Number of animals in the project. - detection_threshold: Threshold for detection. Defaults to 0.01. - apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. - use_heatmap: Use heatmap. Defaults to True. + detection_threshold: Threshold for detection + apply_sigmoid: Apply sigmoid to heatmaps + use_heatmap: Use heatmap to refine the keypoint predictions. + unique_bodyparts: Whether the model predicts unique bodyparts. + keypoint_score_type: Type of score to compute for keypoints. "heatmap" + applies the heatmap score to each keypoint. "center" applies the score + of the center of each individual to all of its keypoints. "combined" + multiplies the score of the heatmap and individual for each keypoint. Returns: None @@ -92,6 +106,12 @@ def __init__( locref_stdev=7.8201, apply_sigmoid=False, ) + + self.keypoint_score_type = keypoint_score_type + if self.keypoint_score_type not in ("heatmap", "center", "combined"): + raise ValueError(f"Unknown keypoint score type: {self.keypoint_score_type}") + + # TODO: Set as in HRNet/DEKR configs. Define as a constant. self.max_absorb_distance = 75 def forward( @@ -118,10 +138,10 @@ def forward( else: heatmaps, offsets = outputs if self.apply_sigmoid and not self.unique_bodyparts: - heatmaps = torch.nn.Sigmoid()(heatmaps) + heatmaps = torch.nn.Sigmoid()(heatmaps) # TODO: OPTIMIZE elif self.apply_sigmoid: - heatmaps = torch.nn.Sigmoid()(heatmaps) - unique_heatmaps = torch.nn.Sigmoid()(unique_heatmaps) + heatmaps = torch.nn.Sigmoid()(heatmaps) # TODO: OPTIMIZE + unique_heatmaps = torch.nn.Sigmoid()(unique_heatmaps) # TODO: OPTIMIZE posemap = self.offset_to_pose(offsets) @@ -129,19 +149,37 @@ def forward( num_joints = num_joints_with_center - 1 center_heatmaps = heatmaps[:, -1] - pose_ind, scores = self.get_top_values(center_heatmaps) + pose_ind, ctr_scores = self.get_top_values(center_heatmaps) posemap = posemap.permute(0, 2, 3, 1).view(batch_size, h * w, -1, 2) poses = torch.zeros(batch_size, pose_ind.shape[1], num_joints, 2).to( - scores.device + ctr_scores.device ) for i in range(batch_size): pose = posemap[i, pose_ind[i]] poses[i] = pose - poses = self._update_pose_with_heatmaps(poses, heatmaps[:, :-1]) + if self.use_heatmap: + poses = self._update_pose_with_heatmaps(poses, heatmaps[:, :-1]) - ctr_score = scores[:, :, None].expand(batch_size, -1, num_joints)[:, :, :, None] + if self.keypoint_score_type == "center": + score = ( + ctr_scores.unsqueeze(-1) + .expand(batch_size, -1, num_joints) + .unsqueeze(-1) + ) + elif self.keypoint_score_type == "heatmap": + score = self.get_heat_value(poses, heatmaps).unsqueeze(-1) + elif self.keypoint_score_type == "combined": + center_score = ( + ctr_scores.unsqueeze(-1) + .expand(batch_size, -1, num_joints) + .unsqueeze(-1) + ) + htmp_score = self.get_heat_value(poses, heatmaps).unsqueeze(-1) + score = center_score * htmp_score + else: + raise ValueError(f"Unknown keypoint score type: {self.keypoint_score_type}") poses[:, :, :, 0] = ( poses[:, :, :, 0] * scale_factors[1] + 0.5 * scale_factors[1] @@ -150,7 +188,7 @@ def forward( poses[:, :, :, 1] * scale_factors[0] + 0.5 * scale_factors[0] ) - poses_w_scores = torch.cat([poses, ctr_score], dim=3) + poses_w_scores = torch.cat([poses, score], dim=3) # self.pose_nms(heatmaps, poses_w_scores) if self.unique_bodyparts: @@ -339,7 +377,7 @@ def get_heat_value( """Get heat values for pose coordinates and heatmaps. Args: - pose_coords: Pose coordinates tensor (batch_size, num_people, num_joints, 2) + pose_coords: Pose coordinates tensor (batch_size, num_animals, num_joints, 2) heatmaps: Heatmaps tensor (batch_size, 1+num_joints, h, w). Returns: @@ -354,13 +392,12 @@ def get_heat_value( 2, 3 ) # (batch_size, num_joints, h*w) - # Predicted poses based on the offset can be outsied of the image - y = torch.clamp(torch.floor(pose_coords[:, :, :, 1]), 0, h - 1).long() + # Predicted poses based on the offset can be outside of the image x = torch.clamp(torch.floor(pose_coords[:, :, :, 0]), 0, w - 1).long() - - heatvals = torch.gather(heatmaps_nocenter, 2, y * w + x) - - return heatvals + y = torch.clamp(torch.floor(pose_coords[:, :, :, 1]), 0, h - 1).long() + keypoint_poses = (y * w + x).mT # (batch, num_joints, num_individuals) + heatscores = torch.gather(heatmaps_nocenter, 2, keypoint_poses) + return heatscores.mT # (batch, num_individuals, num_joints) def pose_nms(self, heatmaps: torch.Tensor, poses: torch.Tensor): """Non-Maximum Suppression (NMS) for regressed poses. diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index d952d02f59..b352539a05 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -70,9 +70,9 @@ def get_prediction( def get_scores( - cfg: Dict, prediction: pd.DataFrame, target: pd.DataFrame, + pcutoff: Optional[float] = None, bodyparts: List[str] = None, ) -> Dict: """Computes for the different scores given the grount truth and the predictions. @@ -83,10 +83,10 @@ def get_scores( OKS mAR (Mean Average Recall) Args: - cfg: config file in a dictionary prediction: prediction df, should already be matched to ground truth using - Hungarian Algorithm (Ref: https://brilliant.org/wiki/hungarian-matching/) + Hungarian Algorithm (Ref: https://brilliant.org/wiki/hungarian-matching/) target: ground truth dataframe + pcutoff: the value used to compute the pcutoff scores bodyparts: names of the bodyparts. Defaults to None. Returns: @@ -94,39 +94,35 @@ def get_scores( ['rmse', 'rmse_pcutoff', 'mAP', 'mAR', 'mAP_pcutoff', 'mAR_pcutoff'] Examples: - >>> # Define the cfg dictionary, prediction, and target DataFrames - >>> cfg = {'pcutoff': 0.5} + >>> # Define the p-cutoff, prediction, and target DataFrames + >>> pcutoff = 0.5 >>> prediction = pd.DataFrame(...) # Your DataFrame here >>> target = pd.DataFrame(...) # Your DataFrame here >>> # Compute the scores - >>> scores = get_scores(cfg, prediction, target) + >>> scores = get_scores(prediction, target, pcutoff) >>> print(scores) { 'rmse': 0.156, 'rmse_pcutoff': 0.115, - 'mAP': 0.842, - 'mAR': 0.745, - 'mAP_pcutoff': 0.913, - 'mAR_pcutoff': 0.825 + 'mAP': 84.2, + 'mAR': 74.5, + 'mAP_pcutoff': 91.3, + 'mAR_pcutoff': 82.5 } # Sample output scores """ - if cfg.get("pcutoff"): - pcutoff = cfg["pcutoff"] - rmse, rmse_p = get_rmse(prediction, target, pcutoff, bodyparts=bodyparts) - oks, oks_p = get_oks(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) - else: - rmse, rmse_p = get_rmse(prediction, target, bodyparts=bodyparts) - oks, oks_p = get_oks(prediction, target, bodyparts=bodyparts) - - scores = {} - scores["rmse"] = np.nanmean(rmse) - scores["rmse_pcutoff"] = np.nanmean(rmse_p) - scores["mAP"] = oks["mAP"] - scores["mAR"] = oks["mAR"] - scores["mAP_pcutoff"] = oks_p["mAP"] - scores["mAR_pcutoff"] = oks_p["mAR"] - - return scores + if pcutoff is None: + pcutoff = -1 + + rmse, rmse_p = get_rmse(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) + oks, oks_p = get_oks(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) + return { + "rmse": np.nanmean(rmse), + "rmse_pcutoff": np.nanmean(rmse_p), + "mAP": 100 * oks["mAP"], + "mAR": 100 * oks["mAR"], + "mAP_pcutoff": 100 * oks_p["mAP"], + "mAR_pcutoff": 100 * oks_p["mAR"], + } def get_rmse( From 40fb428737c4cd8abdeaca662cbe856e6d5ad83b Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 24 Aug 2023 21:10:22 +0200 Subject: [PATCH 044/293] Implement missing DLC features * implemented logging training to file * improved PyTorch DLC documentation * added base logger * updated default configuration. fixed bug in evaluate * fixed bugs in visualization and dataset * updated default learning rates --- .../make_pytorch_config.py | 41 +++-- .../pose_estimation_pytorch/apis/config.yaml | 16 +- .../pose_estimation_pytorch/apis/evaluate.py | 5 +- .../pose_estimation_pytorch/apis/train.py | 24 ++- .../pose_estimation_pytorch/data/dataset.py | 40 ++--- .../pose_estimation_pytorch/default_config.py | 14 +- .../pose_estimation_pytorch/solvers/base.py | 21 ++- .../pose_estimation_pytorch/solvers/logger.py | 79 ++++++-- .../solvers/top_down.py | 37 ++-- deeplabcut/utils/visualization.py | 6 +- docs/pytorch_dlc.md | 170 +++++++++++++++++- 11 files changed, 351 insertions(+), 102 deletions(-) diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 402838d666..cd86879091 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -57,6 +57,11 @@ def make_pytorch_config( - dekr_w32 - dekr_w48 + Multi Animal top-down models: + - token_pose_w18 + - token_pose_w32 + - token_pose_w48 + """ single_animal_nets = [ @@ -165,6 +170,8 @@ def make_pytorch_config( "pad_height_divisor": 32, } pytorch_config["detector"] = make_detector_cfg() + pytorch_config["detector_max_epochs"] = 500 + pytorch_config["detector_save_epochs"] = 100 pytorch_config["model"] = make_token_pose_model_cfg( num_joints, backbone_type ) @@ -321,7 +328,7 @@ def make_token_pose_model_cfg(num_joints, backbone_type): "heatmap_dim": 4096, "apply_multi": True, "heatmap_size": [64, 64], - "apply_init": False, + "apply_init": True, } ) @@ -336,24 +343,16 @@ def make_token_pose_model_cfg(num_joints, backbone_type): def make_detector_cfg(): - detector_cfg = {} - - detector_cfg["detector_model"] = { - "type": "FasterRCNN", + return { + "detector_model": { + "type": "FasterRCNN", + }, + "detector_optimizer": { + "type": "AdamW", + "params": {"lr": 1e-4}, + }, + "detector_scheduler": { + "type": "LRListScheduler", + "params": {"milestones": [90], "lr_list": [[1e-5]]}, + }, } - - detector_cfg["detector_optimizer"] = { - "type": "SGD", - "params": {"lr": 0.01}, - } - - detector_cfg["detector_scheduler"] = { - "type": "LRListScheduler", - "params": {"milestones": [10, 430], "lr_list": [[0.05], [0.005]]}, - } - - detector_cfg["detector_max_epochs"] = 500 - - detector_cfg["detector_save_epochs"] = 100 - - return detector_cfg diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml index b1ea00567c..4496edbf26 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -29,8 +29,8 @@ data: - 1.25 translation: 40 device: cuda:0 -display_iters: 1000 -epochs: 1000 +display_iters: 50 +epochs: 200 model: backbone: pretrained: https://download.pytorch.org/models/resnet50-19c8e357.pth @@ -45,8 +45,8 @@ model: type: PlateauGenerator optimizer: params: - lr: 0.01 - type: SGD + lr: 0.0001 + type: AdamW pos_dist_thresh: 17 predictor: location_refinement: true @@ -57,11 +57,11 @@ save_epochs: 50 scheduler: params: lr_list: - - - 0.05 - - - 0.005 + - - 0.00001 + - - 0.000001 milestones: - - 10 - - 430 + - 90 + - 120 type: LRListScheduler seed: 42 solver: diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 892e27fe9d..eb6b2edd9e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -185,6 +185,9 @@ def evaluate_snapshot( images_resized_with_transform=images_resized_with_transform, detector=detector, ) + if unique_poses is not None: + unique_poses = unique_poses.reshape(target_df.index.shape[0], -1) + df_predictions = build_entire_pred_df( dlc_scorer=names["dlc_scorer"], individuals=individuals, @@ -192,7 +195,7 @@ def evaluate_snapshot( df_index=target_df.index, predictions=predictions.reshape(target_df.index.shape[0], -1), unique_bodyparts=unique_bodyparts, - unique_predictions=unique_poses.reshape(target_df.index.shape[0], -1), + unique_predictions=unique_poses, ) df_mode_predictions.append(df_predictions) diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 9c81346623..66a450e8fa 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -9,10 +9,14 @@ # Licensed under GNU Lesser General Public License v3.0 # import argparse +import logging import os +from pathlib import Path from typing import Optional, Union import albumentations as A +from torch.utils.data import DataLoader + import deeplabcut.pose_estimation_pytorch as dlc from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import ( @@ -21,7 +25,10 @@ update_config_parameters, ) from deeplabcut.pose_estimation_pytorch.solvers.base import Solver -from torch.utils.data import DataLoader +from deeplabcut.pose_estimation_pytorch.solvers.logger import ( + setup_file_logging, + destroy_file_logging, +) def train_network( @@ -33,7 +40,7 @@ def train_network( modelprefix: str = "", snapshot_path: Optional[str] = "", detector_path: Optional[str] = "", - **kwargs + **kwargs, ) -> Solver: """Trains a network for a project @@ -81,12 +88,15 @@ def train_network( modelprefix=modelprefix, ), ) + log_path = Path(modelfolder) / "train" / "log.txt" + setup_file_logging(log_path) + pytorch_config = auxiliaryfunctions.read_plainconfig( os.path.join(modelfolder, "train", "pytorch_config.yaml") ) update_config_parameters(pytorch_config=pytorch_config, **kwargs) if transform is None: - print("No transform specified... using default") + logging.info("No transform specified... using default") transform = build_transforms(dict(pytorch_config["data"]), augment_bbox=True) batch_size = pytorch_config["batch_size"] @@ -109,14 +119,14 @@ def train_network( solver = build_solver(pytorch_config, snapshot_path, detector_path) if pytorch_config.get("method", "bu").lower() == "td": if transform_cropped is None: - print( + logging.info( "No transform passed to augment cropped images, using default augmentations" ) transform_cropped = build_transforms( pytorch_config["cropped_data"], augment_bbox=False ) - detector_epochs = pytorch_config["detector"].get("detector_max_epochs", epochs) + detector_epochs = pytorch_config.get("detector_max_epochs", epochs) train_cropped_dataset = dlc.CroppedDataset( project_train, transform=transform_cropped, mode="train" ) @@ -126,7 +136,6 @@ def train_network( train_cropped_dataloader = DataLoader( train_cropped_dataset, batch_size=batch_size, shuffle=True ) - valid_cropped_dataloader = DataLoader( valid_cropped_dataset, batch_size=batch_size, shuffle=False ) @@ -151,9 +160,12 @@ def train_network( model_prefix=modelprefix, ) else: + destroy_file_logging() raise ValueError( "Method not supported, should be either 'bu' (Bottom Up) or 'td' (Top Down)" ) + + destroy_file_logging() return solver diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 0714d7069f..db51462db8 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -283,13 +283,14 @@ def __getitem__(self, index: int) -> dict: assert len(transformed["keypoints"]) == len(all_keypoints) keypoints = np.array(transformed["keypoints"]).astype(float) + unique_kpts = np.array([], dtype=float) if self.num_unique_bpts > 0: keypoints = keypoints[: -self.num_unique_bpts] - keypoints = keypoints.reshape((self.max_num_animals, num_keypoints_returned, 2)) + unique_kpts = np.array( + transformed["keypoints"][-self.num_unique_bpts :] + ).astype(float) - unique_bpts_kpts = np.array( - transformed["keypoints"][-self.num_unique_bpts :] - ).astype(float) + keypoints = keypoints.reshape((self.max_num_animals, num_keypoints_returned, 2)) # Pad bboxes and labels to always have shape (num_animals, 4) # If we want the original index of the bboxes, we can use the bbox labels @@ -324,23 +325,20 @@ def __getitem__(self, index: int) -> dict: np.nan_to_num(keypoints, copy=False, nan=-1) area = self._calc_area_from_keypoints(keypoints) - - res = {} - res["image"] = image - res[ - "original_size" - ] = original_size # In order to convert back the keypoints to their original space - res["annotations"] = {} - res["annotations"]["keypoints"] = keypoints - res["annotations"]["unique_kpts"] = unique_bpts_kpts - res["annotations"]["area"] = area - res["annotations"]["ids"] = ids - res["annotations"]["boxes"] = bbox_tensor - res["annotations"]["image_id"] = image_id - res["annotations"]["is_crowd"] = is_crowd - res["annotations"]["labels"] = labels - - return res + return { + "image": image, + "original_size": original_size, + "annotations": { + "keypoints": keypoints, + "unique_kpts": unique_kpts, + "area": area, + "ids": ids, + "boxes": bbox_tensor, + "image_id": image_id, + "is_crowd": is_crowd, + "labels": labels, + }, + } class CroppedDataset(Dataset, BaseDataset): diff --git a/deeplabcut/pose_estimation_pytorch/default_config.py b/deeplabcut/pose_estimation_pytorch/default_config.py index aa74784068..67e3b2892f 100644 --- a/deeplabcut/pose_estimation_pytorch/default_config.py +++ b/deeplabcut/pose_estimation_pytorch/default_config.py @@ -46,15 +46,21 @@ } pytorch_cfg_template["optimizer"] = { - "type": "SGD", + "type": "AdamW", "params": { - "lr": 0.01, + "lr": 1e-4, }, } pytorch_cfg_template["scheduler"] = { "type": "LRListScheduler", - "params": {"milestones": [10, 430], "lr_list": [[0.05], [0.005]]}, + "params": { + "milestones": [ + 90, + 120, + ], + "lr_list": [[1e-5], [1e-6]], + }, } pytorch_cfg_template["predictor"] = { @@ -75,7 +81,7 @@ pytorch_cfg_template["pos_dist_thresh"] = 17 pytorch_cfg_template["with_center"] = False pytorch_cfg_template["batch_size"] = 1 -pytorch_cfg_template["epochs"] = 1000 +pytorch_cfg_template["epochs"] = 200 if __name__ == "__main__": import yaml diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py index bee7eb6147..49a39270e8 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/base.py @@ -8,7 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # - +import logging from abc import ABC, abstractmethod from collections import defaultdict from typing import Dict, Optional, Tuple @@ -22,6 +22,7 @@ import deeplabcut.pose_estimation_pytorch.solvers.inference as deeplabcut_pose_estimation_pytorch_solvers_inference import deeplabcut.pose_estimation_pytorch.solvers.utils as solver_utils from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg +from deeplabcut.pose_estimation_pytorch.solvers.logger import BaseLogger SOLVERS = Registry("solvers", build_func=build_from_cfg) @@ -42,7 +43,7 @@ def __init__( device: str = "cpu", snapshot_path: Optional[str] = "", scheduler: torch.optim.lr_scheduler = None, - logger: Optional = None, + logger: Optional[BaseLogger] = None, ): """Constructor of the Solver class. @@ -129,11 +130,13 @@ def fit( train_loss = self.epoch(train_loader, mode="train", step=i + 1) if self.scheduler: self.scheduler.step() - print(f"Training for epoch {i + 1} done, starting eval on validation data") + logging.info( + f"Training for epoch {i + 1} done, starting eval on validation data" + ) valid_loss = self.epoch(valid_loader, mode="eval", step=i + 1) if (i + 1) % self.cfg["save_epochs"] == 0: - print(f"Finished epoch {i + 1}; saving model") + logging.info(f"Finished epoch {i + 1}; saving model") torch.save( { "model_state_dict": self.model.state_dict(), @@ -145,7 +148,7 @@ def fit( f"{model_folder}/train/snapshot-{i + 1}.pt", ) - print( + logging.info( f"Epoch {i + 1}/{epochs}, " f"train loss {float(train_loss):.5f}, " f"valid loss {float(valid_loss):.5f}, " @@ -190,10 +193,12 @@ def epoch( metrics[key].append(losses_dict[key]) if (i + 1) % self.cfg["display_iters"] == 0: - print( - f"Number of iterations : {i+1}, loss : {losses_dict['total_loss']:.5f}, lr : {self.optimizer.param_groups[0]['lr']}" + logging.info( + f"Number of iterations: {i+1}, " + f"loss: {losses_dict['total_loss']:.5f}, " + f"lr: {self.optimizer.param_groups[0]['lr']}" ) - epoch_loss = np.mean(epoch_loss) + epoch_loss = np.mean(epoch_loss).item() self.history[f"{mode}_loss"].append(epoch_loss) if self.logger: diff --git a/deeplabcut/pose_estimation_pytorch/solvers/logger.py b/deeplabcut/pose_estimation_pytorch/solvers/logger.py index b2723e444f..5839725c70 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/logger.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/logger.py @@ -8,21 +8,82 @@ # # Licensed under GNU Lesser General Public License v3.0 # +import logging +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Optional -from typing import Optional +import wandb as wb import deeplabcut.pose_estimation_pytorch.registry as deeplabcut_pose_estimation_pytorch_registry -import wandb as wb from deeplabcut.pose_estimation_pytorch.models.model import PoseModel + LOGGER = deeplabcut_pose_estimation_pytorch_registry.Registry( - "single_animal_solver", + "loggers", build_func=deeplabcut_pose_estimation_pytorch_registry.build_from_cfg, ) +def setup_file_logging(filepath: Path) -> None: + """ + Sets up logging to a file + + Args: + filepath: the path where logs should be saved + """ + logging.basicConfig( + filename=filepath, + filemode="a", + datefmt="%Y-%m-%d %H:%M:%S", + level=logging.INFO, + format="%(asctime)-15s %(message)s", + ) + console_logger = logging.StreamHandler() + console_logger.setLevel(logging.INFO) + root = logging.getLogger("") + root.addHandler(console_logger) + + +def destroy_file_logging() -> None: + """Resets the logging module to log everything to the console""" + root = logging.getLogger() + handlers = [h for h in root.handlers] + for handler in handlers: + root.removeHandler(handler) + console_logger = logging.StreamHandler() + console_logger.setLevel(logging.INFO) + root.addHandler(console_logger) + + +class BaseLogger(ABC): + """Base class for logging training runs""" + + @abstractmethod + def log_config(self, config: dict = None) -> None: + """Logs the configuration data for a training run + + Args: + config: the training configuration used for the run + """ + + @abstractmethod + def log(self, key: str, value: Any, step: Optional[int] = None) -> None: + """Logs data from a training run + + Args: + key: The name of the logged value. + value: Data to log. + step: The global step in processing. Defaults to None. + """ + + @abstractmethod + def save(self) -> None: + """Saves the current training logs""" + + @LOGGER.register_module -class WandbLogger: +class WandbLogger(BaseLogger): """Wandb logger to track experiments and log data. Refer to: https://docs.wandb.ai/guides for more information on wandb. @@ -55,13 +116,11 @@ def __init__( raise ValueError("Specify the model to track!") self.run.watch(model) - def log( - self, key: str = None, value: str = None, step: Optional[int] = None - ) -> None: + def log(self, key: str, value: Any, step: Optional[int] = None) -> None: """Logs data from runs, such as scalars, images, video, histograms, plots, and tables. Args: - key The name of the logged value. + key: The name of the logged value. value: Data to log. step: The global step in processing. Defaults to None. @@ -70,9 +129,9 @@ def log( logger.log(key="loss", value=0.123, step=100) """ - if key is None or value is None: + if value is None: raise ValueError( - f"Nothing to log. Key: {key} and value: {value} expected to be scalar, table or image." + f"Nothing to log. Value ({value}) expected to be scalar, table or image." ) self.run.log({key: value}, step=step) diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py index 06e1367289..03b81bb8e0 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py @@ -8,7 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # - +import logging from collections import defaultdict from typing import Dict, Optional, Tuple @@ -107,12 +107,12 @@ def fit( ) if self.detector_scheduler: self.detector_scheduler.step() - print(f"Training the detector for epoch {i + 1} done") + logging.info(f"Training the detector for epoch {i + 1} done") # TODO no eval pass for the detector since fasterRCNN can't return a loss in eval mode - if (i + 1) % self.cfg["detector"].get("detector_save_epochs", 1) == 0: - print(f"Finished epoch {i + 1}; saving detector") + if (i + 1) % self.cfg.get("detector_save_epochs", 10) == 0: + logging.info(f"Finished epoch {i + 1}; saving detector") torch.save( { "detector_state_dict": self.detector.state_dict(), @@ -122,17 +122,17 @@ def fit( }, f"{model_folder}/train/detector-snapshot-{i + 1}.pt", ) - print( + logging.info( f"Epoch {i + 1}/{detector_epochs}, " f"train detector loss {train_detector_loss:.5f}" ) - if detector_epochs % self.cfg["detector"].get("detector_save_epochs", 1) != 0: + if detector_epochs % self.cfg.get("detector_save_epochs", 10) != 0: torch.save( self.detector.state_dict(), f"{model_folder}/train/detector-snapshot-{epochs}.pt", ) - print(f"Finished epoch {detector_epochs}; saving model") + logging.info(f"Finished epoch {detector_epochs}; saving model") for i in range(self.starting_epoch, epochs): train_pose_loss = self.epoch_pose( @@ -141,8 +141,9 @@ def fit( if self.scheduler: self.scheduler.step() - print( - f"Training the pose estimator for epoch {i + 1} done, starting eval on validation data" + logging.info( + f"Training the pose estimator for epoch {i + 1} done, starting eval on " + f"validation data" ) valid_pose_loss = self.epoch_pose( @@ -150,7 +151,7 @@ def fit( ) if (i + 1) % self.cfg["save_epochs"] == 0: - print(f"Finished epoch {i + 1}; saving pose model") + logging.info(f"Finished epoch {i + 1}; saving pose model") torch.save( { "model_state_dict": self.model.state_dict(), @@ -162,14 +163,14 @@ def fit( f"{model_folder}/train/snapshot-{i + 1}.pt", ) - print( + logging.info( f"Epoch {i + 1}/{epochs}, " f"train pose loss {train_pose_loss:.5f}, " f"valid pose loss {valid_pose_loss:.5f}" ) if epochs % self.cfg["save_epochs"] != 0: - print(f"Finished epoch {epochs}; saving model") + logging.info(f"Finished epoch {epochs}; saving model") torch.save( self.model.state_dict(), f"{model_folder}/train/pose-snapshot-{epochs}.pt", @@ -208,8 +209,10 @@ def epoch_detector( # break if (i + 1) % self.cfg["display_iters"] == 0: - print( - f"Number of iterations for detector: {i+1}, loss : {np.mean(metrics['detector_loss']):.5f}, lr : {self.optimizer.param_groups[0]['lr']}" + logging.info( + f"Number of iterations for detector: {i+1}, " + f"loss: {np.mean(metrics['detector_loss']):.5f}, " + f"lr: {self.detector_optimizer.param_groups[0]['lr']}" ) epoch_detector_loss = np.mean(epoch_detector_loss) @@ -252,8 +255,10 @@ def epoch_pose( # break if (i + 1) % self.cfg["display_iters"] == 0: - print( - f"Number of iterations for pose: {i+1}, loss : {np.mean(metrics['pose_total_loss']):.5f}, lr : {self.optimizer.param_groups[0]['lr']}" + logging.info( + f"Number of iterations for pose: {i+1}, " + f"loss: {np.mean(metrics['pose_total_loss']):.5f}, " + f"lr: {self.optimizer.param_groups[0]['lr']}" ) epoch_pose_loss = np.mean(epoch_pose_loss) diff --git a/deeplabcut/utils/visualization.py b/deeplabcut/utils/visualization.py index f0e3739113..e780b60638 100644 --- a/deeplabcut/utils/visualization.py +++ b/deeplabcut/utils/visualization.py @@ -460,7 +460,11 @@ def plot_evaluation_results( ax.invert_yaxis() if mode == "bodypart": - colors = get_cmap(bodyparts + unique_bodyparts, name=colormap) + num_colors = bodyparts + if plot_unique_bodyparts: + num_colors += unique_bodyparts + + colors = get_cmap(num_colors, name=colormap) predictions = predictions.swapaxes(0, 1) ground_truth = ground_truth.swapaxes(0, 1) elif mode == "individual": diff --git a/docs/pytorch_dlc.md b/docs/pytorch_dlc.md index 5cb32c61bd..175db891ce 100644 --- a/docs/pytorch_dlc.md +++ b/docs/pytorch_dlc.md @@ -1,9 +1,167 @@ -# Pytorch DLC API +# DeepLabCut: PyTorch API + +## Modules + - [data](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/project.py#L7): -The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. - - `Project` class provides train and test splitting and converts dataset to required format. For intance, to [COCO]() format. - - `PoseTrainDataset` class is a [torch.utils.Dataset](https://pytorch.org/docs/stable/data.html) class, which converts raw images and keypoints to a tensor dataset for training and evaluation. +The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch +dataset creation and test/train splitting. + - `Project` class provides train and test splitting and converts dataset to required + format. For intance, to [COCO]() format. + - `PoseTrainDataset` class is a [torch.utils.Dataset](https://pytorch.org/docs/stable/data.html) class, which converts raw + images and keypoints to a tensor dataset for training and evaluation. - [models](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): -The `deeplabcut.pose_estimations_pytorch.models` package contains all related to building a model with `backbone`, `neck` (optional) and `head`. -- [train_module](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. +The `deeplabcut.pose_estimations_pytorch.models` package contains all related to +building a model with `backbone`, `neck` (optional) and `head`. +- [train_module](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): +The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model +training and validation. + +## API + +The PyTorch implementation of DeepLabCut is very similar to the Tensorflow multi-animal +implementation: the same steps need to be followed, just with slightly different API +calls (and different model names). + +Up until it's time to create the training dataset, there are no changes to the way a +PyTorch or Tensorflow project should be created. + +### Creating a Training Dataset + +To create a training dataset for a DeepLabCut PyTorch model, simply call: +```python +import deeplabcut +deeplabcut.create_training_dataset( + path_config_file, + net_type="dekr_32", +) +``` + +This will create folders for the training dataset in the same way as the Tensorflow +version, with an addition configuration file in the `train` folder: +`pytorch_config.yaml`. This is the file that can be edited to modify the model +architecture or training parameters. + +There are currently two "families" of models implemented in PyTorch: DEKR (Geng, Zigang, +et al. "Bottom-up human pose estimation via disentangled keypoint regression." +Proceedings of the IEEE/CVF conference on computer vision and pattern recognition. +2021.) and Tokenpose (Li, Yanjie, et al. "Tokenpose: Learning keypoint tokens for human +pose estimation." Proceedings of the IEEE/CVF International conference on computer +vision. 2021.). The choices of `net_type` that will create PyTorch training sets are: +- `"dekr_16"` +- `"dekr_32"` +- `"dekr_48"` +- `"token_pose_w16"` +- `"token_pose_w32"` +- `"token_pose_w48"` + +Note that Tokenpose models cannot currently be used with projects that contain unique +keypoints. + +### Training the network +Training a PyTorch model is done in a very similar manner as a tensorflow model, though +currently the PyTorch API needs to be called directly: +```python +import deeplabcut.pose_estimation_pytorch.apis as api +api.train_network(config_path, shuffle=1, trainingsetindex=0) +``` + +**Parameters** +``` +config : path to the yaml config file of the project +shuffle : index of the shuffle we want to train on +trainingsetindex : training set index +transform: Augmentation pipeline for the images + if None, the augmentation pipeline is built from config files + Advice if you want to use custom transformations: + Keep in mind that in order for transfer learning to be efficient, your + data statistical distribution should resemble the one used to pretrain your backbone + In most cases (e.g backbone was pretrained on ImageNet), that means it should be Normalized with + A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) +transform_cropped: Augmentation pipeline for the cropped images around animals + if None, the augmentation pipeline is built from config files + Advice if you want to use custom transformations: + Keep in mind that in order for transfer learning to be efficient, your + data statistical distribution should resemble the one used to pretrain your backbone + In most cases (e.g backbone was pretrained on ImageNet), that means it should be Normalized with + A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) +modelprefix: directory containing the deeplabcut configuration files to use + to train the network (and where snapshots will be saved). By default, they + are assumed to exist in the project folder. +snapshot_path: if resuming training, used to specify the snapshot from which to resume +detector_path: if resuming training of a top down model, used to specify the detector snapshot from + which to resume +**kwargs : could be any entry of the pytorch_config dictionary. Examples are + to see the full list see the pytorch_cfg.yaml file in your project folder +``` + +### Evaluating the network +As for training, the main difference is the need to call the API directly. +```python +import deeplabcut.pose_estimation_pytorch.apis as api +api.evaluate_network(config_path, shuffle=1, trainingsetindex="all") +``` + +**Parameters** +``` +config: path to the project's config file +shuffles: Iterable of integers specifying the shuffle indices to evaluate. +trainingsetindex: Integer specifying which training set fraction to use. + Evaluates all fractions if set to "all" +snapshotindex: index (starting at 0) of the snapshot we want to load. To + evaluate the last one, use -1. To evaluate all snapshots, use "all". For + example if we have 3 models saved + - snapshot-0.pt + - snapshot-50.pt + - snapshot-100.pt + and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None, + the snapshotindex is loaded from the project configuration. +plotting: Plots the predictions on the train and test images. If provided it must + be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting + to ``True`` defaults as ``"bodypart"`` for multi-animal projects. +show_errors: display train and test errors. +transform: transformation pipeline for evaluation + ** Should normalise the data the same way it was normalised during training ** +modelprefix: directory containing the deeplabcut models to use when evaluating + the network. By default, they are assumed to exist in the project folder. +batch_size: the batch size to use for evaluation +``` + +### Analyzing novel videos +One big difference between the PyTorch and Tensorflow implementations comes in the way +animal assembly happens (for multi-animal models). While in Tensorflow, assembly was a +separate step that needed to be done from the keypoints, in the PyTorch version it's +integrated directly into the models. From an API standpoint, that does not change much. + +Again, the PyTorch API needs to be invoked directly (it also has the `auto_track` +option). +```python +import deeplabcut.pose_estimation_pytorch.apis as api +api.analyze_videos(config_path, ["/fullpath/project/videos/test.mp4"], videotype=".mp4") +``` + +The PyTorch detections need to be converted to tracklets using the PyTorch API, but then +the original tracklet stitching can be used. +```python +import deeplabcut +import deeplabcut.pose_estimation_pytorch.apis as api +api.convert_detections2tracklets( + config_path, + videos=['/fullpath/project/videos/test.mp4'], + videotype=".mp4", +) +deeplabcut.stitch_tracklets( + config_path, + videos=['/fullpath/project/videos/test.mp4'], + videotype=".mp4", +) +``` +Creating labeled videos can then be called in exactly the same way as before. +```python +import deeplabcut +deeplabcut.create_labeled_video( + config_path, + videos=['/fullpath/project/videos/test.mp4'], + videotype=".mp4", +) +``` From bbdb245daeb72015dd596956808a899fc78df136 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 30 Aug 2023 10:34:44 +0200 Subject: [PATCH 045/293] bug fixes incorrect mAP computation et al. --- deeplabcut/pose_estimation_pytorch/apis/evaluate.py | 3 +-- deeplabcut/pose_estimation_pytorch/apis/inference.py | 4 ++-- .../pose_estimation_pytorch/solvers/inference.py | 10 +++------- deeplabcut/utils/visualization.py | 9 ++++++++- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index eb6b2edd9e..8696417b49 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -25,7 +25,6 @@ from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS from deeplabcut.pose_estimation_pytorch.solvers.inference import get_scores from deeplabcut.pose_estimation_pytorch.solvers.utils import ( - build_predictions_df, get_model_folder, get_paths, get_results_filename, @@ -206,7 +205,7 @@ def evaluate_snapshot( f"{names['evaluation_folder']}/" f"LabeledImages_{names['dlc_scorer']}_{snapshot_name}" ) - auxiliaryfunctions.attempttomakefolder(folder_name) + auxiliaryfunctions.attempt_to_make_folder(folder_name) df_combined = df_predictions_ma.merge( target_df, left_index=True, right_index=True ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 449ec5aef7..e6ad14cdc0 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -8,7 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple import numpy as np import torch @@ -80,7 +80,7 @@ def get_predictions_top_down( shape (batch_size, 3, height, width) max_num_animals (int) : maximum number of animals to predict num_keypoints (int) : number of keypoints per animal in the dataset - device (Union[torch.device, str]): device everything should be on + resize_object: a torch resize transform to resize the cropped images Returns: array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/solvers/inference.py index b352539a05..d30292b480 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/solvers/inference.py @@ -227,10 +227,6 @@ def get_oks( assemblies_pred_masked, unique_pred_masked = conv_df_to_assemblies( prediction[scorer_pred][mask] ) - assemblies_gt_masked, unique_gt_masked = conv_df_to_assemblies( - target[scorer_target][mask] - ) - oks_assemblies_raw = evaluate_assembly( assemblies_pred_raw, assemblies_gt_raw, @@ -249,15 +245,15 @@ def get_oks( oks_pcutoff = evaluate_assembly( assemblies_pred_masked, - assemblies_gt_masked, + assemblies_gt_raw, oks_sigma, margin=margin, symmetric_kpts=symmetric_kpts, ) - if unique_pred_masked is not None and unique_gt_masked is not None: + if unique_pred_masked is not None and unique_gt_raw is not None: oks_unique_masked = evaluate_assembly( unique_pred_masked, - unique_gt_masked, + unique_gt_raw, oks_sigma, margin=margin, symmetric_kpts=symmetric_kpts, diff --git a/deeplabcut/utils/visualization.py b/deeplabcut/utils/visualization.py index e780b60638..99e8c1ff29 100644 --- a/deeplabcut/utils/visualization.py +++ b/deeplabcut/utils/visualization.py @@ -417,7 +417,14 @@ def plot_evaluation_results( p_cutoff: the p-cutoff for "confident" keypoints """ for row_index, row in df_combined.iterrows(): - data_folder, video, image = row_index + if isinstance(row_index, str): + image_rel_path = Path(row_index) + data_folder = image_rel_path.parent.parent.name + video = image_rel_path.parent.name + image = image_rel_path.name + else: + data_folder, video, image = row_index + image_path = Path(project_root) / data_folder / video / image frame = auxfun_videos.imread(str(image_path), mode="skimage") From 6d014ae78a1a741017c2a3cd78e0778c6a8b6c18 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 12 Sep 2023 17:36:58 +0200 Subject: [PATCH 046/293] Fix wrong locref_stdev in dekr_predictor.py --- .../pose_estimation_pytorch/models/predictors/dekr_predictor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 28a8923762..692109e737 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -103,7 +103,7 @@ def __init__( self.unique_predictor = SinglePredictor( num_animals=1, location_refinement=True, - locref_stdev=7.8201, + locref_stdev=7.2801, apply_sigmoid=False, ) From 4ecaa4de100c3b7d0aee898397231f85ee6bbb7d Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 6 Sep 2023 17:26:23 +0200 Subject: [PATCH 047/293] added option to run top-down inference with ground truth bboxes --- .../pose_estimation_pytorch/apis/evaluate.py | 1 + .../pose_estimation_pytorch/apis/inference.py | 46 +++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 8696417b49..5986a1d5f9 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -183,6 +183,7 @@ def evaluate_snapshot( align_predictions_to_ground_truth=True, images_resized_with_transform=images_resized_with_transform, detector=detector, + use_ground_truth_bboxes=False, ) if unique_poses is not None: unique_poses = unique_poses.reshape(target_df.index.shape[0], -1) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index e6ad14cdc0..b63c27d741 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -12,13 +12,15 @@ import numpy as np import torch +from torchvision.ops import box_convert +from torchvision.transforms import Resize as TorchResize + from deeplabcut.pose_estimation_pytorch.models import PREDICTORS, PoseModel from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.post_processing import ( rmse_match_prediction_to_gt, ) -from torchvision.transforms import Resize as TorchResize def get_predictions_bottom_up( @@ -63,6 +65,7 @@ def get_predictions_top_down( max_num_animals: int, num_keypoints: int, resize_object: TorchResize, + ground_truth_bboxes: Optional[torch.Tensor] = None, ) -> Tuple[np.array, Optional[np.ndarray]]: """ TODO probably quite bad design, most arguments could be stored somewhere else @@ -73,14 +76,18 @@ def get_predictions_top_down( Args: detector (BaseDetector): detector used to detect bboxes, should be in eval mode model (PoseModel): pose model - predictor (BasePredictor): predictor used to regress keypoints coordinates and scores in the cropped images + predictor (BasePredictor): predictor used to regress keypoints coordinates and + scores in the cropped images top_down_predictor (BasePredictor): Given the bboxes and the cropped keypoints coordinates, outputs the regressed keypoints - images (torch.Tensor): input images (should already be normalised and formatted if needed), - shape (batch_size, 3, height, width) + images (torch.Tensor): input images (should already be normalised and formatted + if needed), shape (batch_size, 3, height, width) max_num_animals (int) : maximum number of animals to predict num_keypoints (int) : number of keypoints per animal in the dataset resize_object: a torch resize transform to resize the cropped images + ground_truth_bboxes: if defined, the detector is ignored and the predicted + bboxes are taken from this list. If defined, must be of shape (batch_size, + max_num_animals, xyxy). Returns: array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions @@ -88,13 +95,17 @@ def get_predictions_top_down( for coherence over the repo """ batch_size = images.shape[0] - output_detector = detector(images) - boxes = torch.zeros((batch_size, max_num_animals, 4)) - for b, item in enumerate(output_detector): - boxes[b][: min(max_num_animals, len(item["boxes"]))] = item["boxes"][ - :max_num_animals - ] # Boxes should be sorted by scores, only keep the maximum number allowed + if ground_truth_bboxes is not None: + boxes = ground_truth_bboxes + else: + output_detector = detector(images) + boxes = torch.zeros((batch_size, max_num_animals, 4)) + for b, item in enumerate(output_detector): + boxes[b][: min(max_num_animals, len(item["boxes"]))] = item["boxes"][ + :max_num_animals + ] # Boxes should be sorted by scores, only keep the maximum number allowed + boxes = boxes.int() cropped_kpts_total = torch.full( (batch_size, max_num_animals, num_keypoints, 3), -1.0 @@ -255,6 +266,7 @@ def inference( align_predictions_to_ground_truth: bool, images_resized_with_transform: bool, detector: Optional[BaseDetector] = None, + use_ground_truth_bboxes: bool = False, ) -> Tuple[np.ndarray, Optional[np.ndarray]]: """ Runs inference for a pose estimation model. @@ -272,6 +284,8 @@ def inference( truth individual i) images_resized_with_transform: whether the image is resized by the transform detector: None when `method="bu"`. The detector to use when `method="td"`. + use_ground_truth_bboxes: For top-down models, whether to make pose predictions + using ground truth bbox annotations (which the dataset must contain). Returns: array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions @@ -284,6 +298,7 @@ def inference( f"A detector must be provided when running inference for a top-down " f"pose estimator!" ) + detector.eval() detector.to(device) elif method.lower() == "bu": @@ -323,6 +338,16 @@ def inference( image_shape = item["image"].shape # b, c, w, h if method == "td": # TODO unique_bodyparts not supported by top down, it is None here + gt_bboxes = None + if use_ground_truth_bboxes: + boxes_xywh = item.get("annotations", {}).get("boxes") + if boxes_xywh is None: + raise ValueError( + f"Using ground truth bboxes for inference, but there are none defined" + ) + gt_bboxes = box_convert(boxes_xywh.reshape(-1, 4), "xywh", "xyxy") + gt_bboxes = gt_bboxes.reshape(boxes_xywh.shape) + predictions, unique_pred = get_predictions_top_down( detector=detector, model=model, @@ -332,6 +357,7 @@ def inference( max_num_animals=max_individuals, num_keypoints=num_keypoints, resize_object=resize_object, + ground_truth_bboxes=gt_bboxes, ) else: predictions, unique_pred = get_predictions_bottom_up( From 217c19105c7e6e315a645e2cce54b283abe981a3 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 20 Sep 2023 15:50:47 +0200 Subject: [PATCH 048/293] Fix spelling Co-authored-by: Jessy Lauer <30733203+jeylau@users.noreply.github.com> --- deeplabcut/pose_estimation_pytorch/apis/inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index b63c27d741..135a411a79 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -80,7 +80,7 @@ def get_predictions_top_down( scores in the cropped images top_down_predictor (BasePredictor): Given the bboxes and the cropped keypoints coordinates, outputs the regressed keypoints - images (torch.Tensor): input images (should already be normalised and formatted + images (torch.Tensor): input images (should already be normalized and formatted if needed), shape (batch_size, 3, height, width) max_num_animals (int) : maximum number of animals to predict num_keypoints (int) : number of keypoints per animal in the dataset From 16bd6fad64fec1fa463b9cc7088df956dc595e93 Mon Sep 17 00:00:00 2001 From: Anastasiia Filippova Date: Fri, 27 Oct 2023 20:10:41 +0200 Subject: [PATCH 049/293] Dataset refactoring Co-authored-by: Alexander Mathis Co-authored-by: Niels Poulsen --- .../pose_estimation_pytorch/__init__.py | 6 +- .../pose_estimation_pytorch/data/base.py | 169 +++- .../data/cocoloader.py | 102 +++ .../data/cocoproject.py | 115 --- .../pose_estimation_pytorch/data/dataset.py | 819 +++++++----------- .../pose_estimation_pytorch/data/dlcloader.py | 172 ++++ .../data/dlcproject.py | 172 ---- .../pose_estimation_pytorch/data/helper.py | 86 ++ .../pose_estimation_pytorch/data/utils.py | 522 +++++++++++ .../tests/test_data_helper.py | 64 ++ deeplabcut/pose_estimation_pytorch/utils.py | 105 ++- 11 files changed, 1469 insertions(+), 863 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/data/cocoloader.py delete mode 100644 deeplabcut/pose_estimation_pytorch/data/cocoproject.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/dlcloader.py delete mode 100644 deeplabcut/pose_estimation_pytorch/data/dlcproject.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/helper.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index 7c51953b58..baf34f74bc 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -1,5 +1,7 @@ -from deeplabcut.pose_estimation_pytorch.data.dlcproject import DLCProject -from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset, CroppedDataset +from deeplabcut.pose_estimation_pytorch.data.base import Loader +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset, PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader +from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader from deeplabcut.pose_estimation_pytorch.utils import fix_seeds from deeplabcut.pose_estimation_pytorch.apis import ( analyze_videos, diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 96ffbcfda2..23f86ad4bc 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -1,48 +1,169 @@ -from abc import ABC, abstractmethod +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations +from abc import ABC +from abc import abstractmethod -class BaseProject(ABC): +import albumentations as A +import numpy as np + +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters +from deeplabcut.utils.auxiliaryfunctions import get_bodyparts +from deeplabcut.utils.auxiliaryfunctions import get_unique_bodyparts + + +class Loader(ABC): """ - Base class for project configuration. + Abstract class that represents a blueprint for loading and processing dataset information. - This class defines the basic structure and methods that a project configuration should have. - Subclasses should implement the abstract method `convert2dict()` to convert the project configuration to a dictionary. + Methods: + load_data(mode: str = 'train') -> dict: + Abstract method to convert the project configuration to a standard COCO format. + create_dataset(images: dict = None, annotations: dict = None, transform: object = None, mode: str = "train", task: str = "BU") -> PoseDataset: + Creates and returns a PoseDataset given a set of images, annotations, and other parameters. + _get_all_bboxes(images, annotations, method: str = 'gt') -> dict: + Retrieves all bounding boxes based on the specified method. + _get_dataset_parameters(*args, **kwargs) -> dict: + Returns a dictionary containing dataset parameters derived from the configuration. """ - def __init__(self): - pass + def __init__(self, project_root: str) -> None: + self.project_root = project_root @abstractmethod - def convert2dict(self) -> dict: - """Summary: - Not yet implemented. - Abstract method to convert the project configuration to a dictionary. + def load_data(self, mode: str = "train") -> dict: + """Abstract method to convert the project configuration to a standard coco format. Raises: NotImplementedError: This method must be implemented in the derived classes. """ raise NotImplementedError + def create_dataset( + self, + transform: A.BaseCompose | None = None, + mode: str = "train", + task: str = "BU", + ) -> PoseDataset: + """ + Creates a PoseDataset based on provided arguments. + + Args: + transform: Transformation to be applied on dataset. Defaults to None. + mode: Mode in which dataset is to be used (e.g., 'train', 'test'). Defaults to 'train'. + task: Task for which the dataset is being used. Defaults to 'BU'. + + Returns: + PoseDataset: An instance of the PoseDataset class. + + Raises: + Any exception raised by `_get_dataset_parameters` or `load_data` methods. + """ + parameters = self._get_dataset_parameters() + data = self.load_data(mode) + data["annotations"] = self.filter_annotations(data["annotations"]) + dataset = PoseDataset( + images=data["images"], + annotations=data["annotations"], + transform=transform, + mode=mode, + task=task, + parameters=parameters, + ) + return dataset + + def _get_dataset_parameters(self, *args, **kwargs) -> PoseDatasetParameters: + """ TODO: _get_dataset_parameters should be an abstract method + Retrieves dataset parameters based on the instance's configuration. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + An instance of the PoseDatasetParameters with the parameters set. + """ + return PoseDatasetParameters( + bodyparts=get_bodyparts(self.cfg), + unique_bpts=get_unique_bodyparts(self.cfg), + individuals=self.cfg.get("individuals", ["animal"]), + with_center_keypoints=self.cfg.get("with_center", False), + color_mode=self.cfg.get("color_mode", "RGB"), + cropped_image_size=self.cfg.get("output_size", (256, 256)), + ) + @staticmethod - def annotation2key(annotation): - """Summary: - Convert the annotation to a key. + def filter_annotations(annotations: list[dict]) -> list[dict]: + """Filters annotations based on the keypoints, removing empty annotations Args: - annotation: The annotation to be converted. + annotations: A list of annotations. Returns: - annotation: the project configuration as a dictionary. + list: A list of filtered annotations. """ - return annotation + filtered_annotations = [] + for annotation in annotations: + keypoints = annotation["keypoints"].reshape(-1, 3) + annotation["bbox"].reshape(-1, 4) + if np.all(keypoints[:, :2] <= 0): + continue + filtered_annotations.append(annotation) + return annotations -class BaseDataset(ABC): - """ - Base class for datasets. + @staticmethod + def _get_all_bboxes(images, annotations, method: str = "gt"): + """ TODO: Nastya method of bbox computation (detection bbox, seg. mask, ...) + Retrieves all bounding boxes based on the given method. - This class defines the basic structure for datasets and serves as a superclass for future implementations. - Subclasses should implement specific functionalities for their datasets. - """ + Args: + images: A list of images. + annotations: A list of annotations corresponding to images. + method (str, optional): Method to use for retrieving bounding boxes. Defaults to 'gt'. + - 'gt': Ground truth bounding boxes. + - 'detection bbox': Bounding boxes from detection. + - 'keypoints': Bounding boxes from keypoints. + - 'segmentation mask': Bounding boxes from segmentation masks. + + Returns: + list: Updated annotations based on the given method. + + Raises: + ValueError: If 'bbox' is not found in annotation when method is 'gt'. + ValueError: If method is not one of 'gt', 'detection bbox', 'keypoints', or 'segmentation mask'. + """ + + if not method: + return annotations + + elif method == "gt": + for annotation in annotations: + if "bbox" not in annotation: + # or do something else? + raise ValueError( + "Bounding box not found in annotation, please chose another method", + ) + return annotations + + elif method == "detection bbox": + return annotations + + elif method == "keypoints": + return annotations + + elif method == "segmentation mask": + return annotations - pass + else: + raise ValueError(f"Unknown method: {method}") diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py new file mode 100644 index 0000000000..dcaaecdbb2 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py @@ -0,0 +1,102 @@ +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import json +import os +from dataclasses import dataclass + +from deeplabcut.pose_estimation_pytorch.data.base import Loader + + +@dataclass +class COCOLoader(Loader): + """ + Attributes: + project_root: root directory path of the COCO project. + train_json_filename: the name of the json file containing the train annotations + test_json_filename: the name of the json file containing the train annotations. + None if there is no test set. + + Examples: + loader = COCOLoader( + project_root='/path/to/project/', + train_json_filename="train.json", + test_json_filename="test.json", + ) + """ + project_root: str + train_json_filename: str = "train.json" + test_json_filename: str | None = "test.json" + + def __post_init__(self) -> None: + self.train_json = self._load_json(self.project_root, self.train_json_filename) + self.test_json = None + if self.test_json_filename: + self.test_json = self._load_json(self.project_root, self.test_json_filename) + + # TODO: change when _get_dataset_parameters is abstract + self.cfg = {} + + @staticmethod + def _load_json(project_root: str, filename: str) -> dict: + """Load a JSON file from the annotations directory. + + Args: + filename: filename of JSON file to load + + Returns: + json_obj: JSON object loaded from the file + + Raises: + FileNotFoundError if the file does not exist + ValueError if the object stored in the file is not a dict + + Examples: + Check https://docs.trainingdata.io/v1.0/Export%20Format/COCO/ to see + examples of how a json file looks like. + """ + json_path = os.path.join(project_root, "annotations", filename) + if not os.path.exists(json_path): + raise FileNotFoundError(f"File {json_path} does not exist.") + + with open(json_path, "r") as f: + json_obj = json.load(f) + + if not isinstance(json_obj, dict): + raise ValueError("COCO datasets need to be saved in JSON Objects") + + return json_obj + + def load_data(self, mode: str = "train") -> dict: + """Convert data from JSON object to dictionary. + Args: + mode: indicates which JSON object to convert. Defaults to "train". + + Returns: + the train or test data + """ + # todo: add validation + if mode == "train": + json_obj = self.train_json + elif mode == "test": + json_obj = self.test_json + else: + raise AttributeError(f"Unknown mode: {mode}") + + for image in json_obj["images"]: + image_path = image["file_name"] + image["file_name"] = os.path.join( + self.project_root, + "images", + image_path, + ) + + return json_obj diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoproject.py b/deeplabcut/pose_estimation_pytorch/data/cocoproject.py deleted file mode 100644 index 84d8cebb55..0000000000 --- a/deeplabcut/pose_estimation_pytorch/data/cocoproject.py +++ /dev/null @@ -1,115 +0,0 @@ -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# - -import json -import os -from typing import List - -from .base import BaseProject - - -class COCOProject(BaseProject): - """ - Definition of the class object COCOProject. - """ - - def __init__( - self, - proj_root: str, - shuffle: int = 0, - image_id_offset: int = 0, - keys_to_load: List[str] = ["images", "annotations"], - ): - """Summary: - Constructor of the COCOProject. - Loads the data - - Args: - proj_root: root directory path of the COCO project. - shuffle: shuffle value used to select a specific shuffle of train and test JSON files. Defaults to 0. - image_id_offset: the offset value to be added to image IDs. Defaults to 0. - keys_to_load: list of strings specifying the keys to load from the JSON files. Defaults to ["images", "annotations"]. - - Returns: - None - - Examples: - project = COCOProject( proj_root = 'path/to/project/', shuffle = 1, image_id_offset = 1000, keys_to_load = ["images", "annotations"] ) - """ - super().__init__() - self.proj_root = proj_root - self.keys_to_load = keys_to_load - - self.train_json_obj = ( - self._load_json("train.json") - if shuffle is None - else self._load_json(f"train_shuffle{shuffle}.json") - ) - self.test_json_obj = ( - self._load_json("test.json") - if shuffle is None - else self._load_json(f"test_shuffle{shuffle}.json") - ) - - def _load_json(self, json_fn): - """Summary: - Load a JSON file from the annotations directory. - - Args: - json_fn: filename of JSON file to load - - Returns: - json_obj: JSON object loaded from the file - - Examples: - Check https://docs.trainingdata.io/v1.0/Export%20Format/COCO/ to see - examples of how a json file looks like. - """ - path = os.path.join(self.proj_root, "annotations", json_fn) - with open(path, "r") as f: - json_obj = json.load(f) - return json_obj - - def load_split(self): - """Summary: - We expected that coco project has train test split in train test json already - - Args: - None - - Return: - None - """ - pass - - def convert2dict(self, mode: str = "train"): - """Summary: - Convert data from JSON objecy to dictionary. - - Args: - mode: indicates which JSON object to convert. Defaults to "train". - - Returns: - None - - Examples: - mode = 'test' - """ - json_obj = getattr(self, f"{mode}_json_obj") - - for image in self.images: - image_path = image["file_name"] - # if os.sep not in image_path: - # assuming the file_name is mmpose style, i.e. only the image name is stored - # so we need to add back absolute path - image["file_name"] = os.path.join(self.proj_root, "images", image_path) - - for key in self.keys_to_load: - setattr(self, key, json_obj[key]) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index db51462db8..30495dba46 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -8,134 +8,115 @@ # # Licensed under GNU Lesser General Public License v3.0 # -import os +from __future__ import annotations + +from dataclasses import dataclass import albumentations as A import cv2 import numpy as np import torch -from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions -from deeplabcut.utils.auxiliaryfunctions import get_model_folder, read_plainconfig -from torch.utils.data import Dataset - -from deeplabcut.utils.auxiliaryfunctions import get_model_folder, read_plainconfig from torch.utils.data import Dataset -from .base import BaseDataset -from .dlcproject import DLCProject - - -class PoseDataset(Dataset, BaseDataset): - """ - Dataset for pose estimation +from deeplabcut.pose_estimation_pytorch.data.utils import ( + _crop_image_keypoints, + _crop_and_pad_image_torch, +) +from deeplabcut.pose_estimation_pytorch.data.utils import _extract_keypoints_and_bboxes +from deeplabcut.pose_estimation_pytorch.data.utils import apply_transform +from deeplabcut.pose_estimation_pytorch.data.utils import map_id_to_annotations +from deeplabcut.pose_estimation_pytorch.data.utils import map_image_path_to_id + + +@dataclass(frozen=True) +class PoseDatasetParameters: + """Parameters for a pose dataset + + Attributes: + bodyparts: the names of bodyparts in the dataset + unique_bpts: the names of unique bodyparts, or an empty list + individuals: the names of individuals + with_center_keypoints: whether to compute center keypoints for individuals + color_mode: {"RGB", "BGR"} the mode to load images in """ - def __init__( - self, project: DLCProject, transform: object = None, mode: str = "train" - ): - """Summary: - Constructor of the PoseDataset. - Loads the data + bodyparts: list[str] + unique_bpts: list[str] + individuals: list[str] + with_center_keypoints: bool = False + color_mode: str = "RGB" + cropped_image_size: tuple[int, int] | None = None - Args: - project: see class Project (wrapper for DLC original project class) - transform: augmentation/normalization pipeline - mode: 'train' or 'test' - this parameter which dataframe parse from the Project (df_tran or df_test) + @property + def num_joints(self) -> int: + return len(self.bodyparts) - Returns: - None - """ - super().__init__() - self.transform = transform - self.project = project - self.cfg = self.project.cfg - - self.bodyparts = auxiliaryfunctions.get_bodyparts(self.cfg) - self.num_joints = len(self.bodyparts) - - self.unique_bpts = auxiliaryfunctions.get_unique_bodyparts(self.cfg) - self.num_unique_bpts = len(self.unique_bpts) - - self.shuffle = self.project.shuffle - self.project.convert2dict(mode) - self.dataframe = self.project.dataframe - - modelfolder = os.path.join( - self.project.proj_root, - get_model_folder( - self.cfg["TrainingFraction"][0], - self.shuffle, - self.cfg, - "", - ), - ) - pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - pytorch_cfg = read_plainconfig(pytorch_config_path) - self.with_center = pytorch_cfg.get("with_center", False) - self.individuals = self.cfg.get("individuals", ["animal"]) - self.individual_to_idx = {} - for i, indiv in enumerate(self.individuals): - self.individual_to_idx[indiv] = i - self.max_num_animals = len(self.individuals) - self.color_mode = pytorch_cfg.get("colormode", "RGB") - - self.length = self.dataframe.shape[0] - assert self.length == len(self.project.image_path2image_id.keys()) + @property + def num_unique_bpts(self) -> int: + return len(self.unique_bpts) - def __len__(self): - """Summary: - Get the length of the dataset + @property + def max_num_animals(self) -> int: + return len(self.individuals) - Args: - None - Returns: - Number of samples in the dataset - """ - return self.length +@dataclass +class PoseDataset(Dataset): + """A pose dataset""" + images: list[dict[str, str]] + annotations: list[dict] + parameters: PoseDatasetParameters + transform: A.BaseCompose | None = None + mode: str = "train" + task: str = "BU" - def _calc_area_from_keypoints(self, keypoints: np.ndarray) -> np.ndarray: - """Summary: - Calculate the area from keypoints + def __post_init__(self): + self.image_path_id_map = map_image_path_to_id(self.images) + self.annotation_idx_map = map_id_to_annotations(self.annotations) - Args: - keypoints (np.ndarray): array of keypoints + def __len__(self): + return len(self.images) if self.task == "BU" else len(self.annotations) - Returns: - np.ndarray: array containing the computed areas based on the keypoints + def _get_raw_item(self, index: int) -> tuple[str, list[dict], int]: """ - w = keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1) - h = keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1) - return w * h - - def _keypoint_in_boundary(self, keypoint: list, shape: tuple): - """Summary: - Check if a keypoint lies inside the given shape. + Retrieve the image path and annotations for the specified index. Args: - keypoint: [x,y] coordinates of the keypoints - shape: tuple representing the shape of the boundary (height, width) + index (int): The index of the item to retrieve. Returns: - Whether a keypoint lies inside the given shape - - Example: - input: - keypoint = [100, 50] - shape = (200, 300) - output: - _keypoint_in_boundary(keypoint, shape) = True + tuple[str, list]: A tuple containing the image path and annotations. + + Note: + This method is used by the __getitem__ method to fetch raw data from the dataset storage. + If `self.crop` is True, it returns the image path and a list with a single annotation. + Otherwise, it returns the image path and a list of annotations for all instances in the image. """ - return ( - (keypoint[0] > 0) - and (keypoint[1] > 0) - and (keypoint[0] < shape[1]) - and (keypoint[1] < shape[0]) - ) + image_path = self.images[index]["file_name"] + image_id = self.image_path_id_map[image_path] + annotations = [ + self.annotations[annotations_id] + for annotations_id in self.annotation_idx_map[image_id] + ] + return image_path, annotations, image_id + + def _get_raw_item_crop(self, index: int) -> tuple[str, list[dict], int]: + annotations = self.annotations[index] + image_id = annotations["image_id"] + annotations = [annotations] + + image_id2path = { + image_id: image_path + for ( + image_path, + image_id, + ) in self.image_path_id_map.items() + } + + return image_id2path[image_id], annotations, image_id def __getitem__(self, index: int) -> dict: - """Summary: + """ Gets the item at the specified index from the dataset. Args: @@ -143,460 +124,270 @@ def __getitem__(self, index: int) -> dict: Returns: dict: corresponding to the image annotations, with keys: - - image: image tensor - - original_size: original size of the image before applying transforms - useful to convert the predictions/ground truth back to - the input space - - annotations: - - keypoints: array of keypoints, invisible keypoints appear as (-1,-1) - - area: array of animals area in this image - - ids: array containing the individuals IDs associated with each annotation. - - path + { + "image": image tensor of shape (c, h, w), + "image_id": the ID of the image, + "path": the filepath to the image, + "original_size": the original (h, w) size before transforms + "offsets": the (x, y) offsets to apply to the keypoints in TD mode + "scales": the (x, y) scales to apply to the keypoints in TD mode + "annotations": { + "keypoints": array of keypoints, invisible keypoints appear as (-1,-1) + "keypoints_unique": the unique keypoints, if there are any + "area": array of animals area in this image + "boxes": the bounding boxes in this image + "is_crowd": is_crowd annotations + "labels": category_id annotations for boxes + }, + } """ - # load images - try: - image_file = self.dataframe.index[index] - if isinstance(image_file, tuple): - image_file = os.path.join(self.cfg["project_path"], *image_file) - else: - image_file = os.path.join(self.cfg["project_path"], image_file) - except: - print(len(self.project.images)) - print(index) - - image = cv2.imread(image_file) - if self.color_mode == "RGB": - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - original_size = image.shape - - # load annotations - image_id = self.project.image_path2image_id[image_file] - n_annotations = len(self.project.id2annotations_idx[image_id]) - - bodyparts = [bpt for bpt in self.bodyparts] - if not self.with_center: - keypoints = np.zeros((self.max_num_animals, self.num_joints, 3)) - num_keypoints_returned = self.num_joints - else: - keypoints = np.zeros((self.max_num_animals, self.num_joints + 1, 3)) - num_keypoints_returned = self.num_joints + 1 - bodyparts += ["_center_"] - - unique_bpts_kpts = np.zeros((self.num_unique_bpts, 3)) - - bbox_list = [] - bbox_label_list = [] - labels = np.zeros((self.max_num_animals), dtype=np.int64) - is_crowd = np.zeros((self.max_num_animals), dtype=np.int64) - ids = np.full((self.max_num_animals), -1, dtype=np.int64) - image_id = index - for i, annotation_idx in enumerate(self.project.id2annotations_idx[image_id]): - _annotation = self.project.annotations[annotation_idx] - _keypoints, _undef_ids = self.project.annotation2keypoints(_annotation) - _keypoints = np.array(_keypoints) - - # Filter out the unique bodyparts - if _annotation["individual"] == "single": - unique_bpts_kpts[:, :2] = _keypoints - unique_bpts_kpts[:, 2] = _undef_ids - continue - - ids[i] = self.individual_to_idx[_annotation["individual"]] - - if self.with_center: - keypoints[i, :-1, :2] = _keypoints - keypoints[i, :-1, 2] = _undef_ids - - else: - keypoints[i, :, :2] = _keypoints - keypoints[i, :, 2] = _undef_ids - - # If bbox has width and height > 0, add - annotation_bbox = np.array(_annotation["bbox"]) - if 0 < annotation_bbox[2] and 0 < annotation_bbox[3]: - bbox_list.append(annotation_bbox) - bbox_label_list.append(i) - - is_crowd[i] = _annotation["iscrowd"] - labels[i] = _annotation["category_id"] - - if len(bbox_list) > 0: - h, w, _ = image.shape - bboxes = np.stack(bbox_list, axis=0) - bbox_labels = np.array(bbox_label_list) - - # Sometimes bbox coords are larger than the image because of the margin - bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) - bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) - bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - np.spacing(0.0) - bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) - else: - bboxes = np.zeros((0, 4)) - bbox_labels = np.zeros((0,)) - - # Needs two be 2 dimensional for albumentations - all_keypoints = np.concatenate( - (keypoints.reshape((-1, 3)), unique_bpts_kpts), axis=0 + image_path, annotations, image_id = self._get_data_based_on_task(index) + image, original_size = self._load_image(image_path) + ( + keypoints, + keypoints_unique, + bboxes, + annotations_merged, + ) = self.extract_keypoints_and_bboxes( + annotations, + image, ) + offsets = np.zeros((self.parameters.max_num_animals, 2)) + scales = (1, 1) + if self.task == "TD": + if self.parameters.cropped_image_size is None: + raise ValueError( + "You must specify a cropped image size for top-down models", + ) + if len(bboxes) > 1: + raise ValueError( + "There can only be one bbox per item in TD datasets, found " + f"{bboxes} for {index} (image {image_path})" + ) + bboxes = bboxes.astype(int) - if self.transform: - class_labels = [ - f"individual{i}_{bpt}" - for i in range(self.max_num_animals) - for bpt in bodyparts - ] + [f"unique_{bpt}" for bpt in self.unique_bpts] - transformed = self.transform( - image=image, - keypoints=all_keypoints[:, :2], - bboxes=bboxes, - class_labels=class_labels, - bbox_labels=bbox_labels, - ) - bboxes = transformed["bboxes"] - - # Discard keypoints that aren't in the frame anymore - shape_transformed = transformed["image"].shape - transformed["keypoints"] = [ - keypoint - if self._keypoint_in_boundary(keypoint, shape_transformed) - else (-1, -1) - for keypoint in transformed["keypoints"] - ] - - # Discard keypoints that are undefined - undef_class_labels = [ - class_labels[i] for i, kpt in enumerate(all_keypoints) if kpt[2] == 0 - ] - for label in undef_class_labels: - new_index = transformed["class_labels"].index(label) - transformed["keypoints"][new_index] = (-1, -1) - - else: - transformed = { - "keypoints": all_keypoints[:, :2], - "image": image, - } - - image = torch.tensor(transformed["image"], dtype=torch.float).permute( - 2, 0, 1 - ) # channels first - - assert len(transformed["keypoints"]) == len(all_keypoints) - keypoints = np.array(transformed["keypoints"]).astype(float) - unique_kpts = np.array([], dtype=float) - if self.num_unique_bpts > 0: - keypoints = keypoints[: -self.num_unique_bpts] - unique_kpts = np.array( - transformed["keypoints"][-self.num_unique_bpts :] - ).astype(float) - - keypoints = keypoints.reshape((self.max_num_animals, num_keypoints_returned, 2)) - - # Pad bboxes and labels to always have shape (num_animals, 4) - # If we want the original index of the bboxes, we can use the bbox labels - bbox_tensor = torch.tensor(bboxes, dtype=torch.float) - if len(bbox_tensor) < self.max_num_animals: - missing_animals = self.max_num_animals - len(bbox_tensor) - bbox_tensor = torch.cat( - [bbox_tensor, torch.zeros((missing_animals, 4))], - dim=0, + # TODO: The following code should be replaced by a numpy version + image, offsets, scales = _crop_and_pad_image_torch( + image, bboxes[0], "xywh", self.parameters.cropped_image_size[0] ) + keypoints[:, :, 0] = (keypoints[:, :, 0] - offsets[0]) / scales[0] + keypoints[:, :, 1] = (keypoints[:, :, 1] - offsets[1]) / scales[1] + + # coords = [ + # (bboxes[0][0], bboxes[0][0] + bboxes[0][2]), # bboxes=xywh + # (bboxes[0][1], bboxes[0][1] + bboxes[0][3]), + # ] + # image, keypoints, offsets, scales = self.crop( + # image, + # keypoints, + # coords, + # self.parameters.cropped_image_size, + # ) + bboxes = [] # No more bounding boxes as we cropped around them + + transformed = self.apply_transform_all_keypoints( + image, keypoints, keypoints_unique, bboxes, + ) + keypoints = transformed["keypoints"] + + if self.parameters.with_center_keypoints: + keypoints = self.add_center_keypoints(keypoints) + + item = self._prepare_final_data_dict( + transformed["image"], + keypoints, + transformed["keypoints_unique"], + original_size, + image_path, + bboxes, + image_id, + annotations_merged, + offsets, + scales, + ) + return item + + def _prepare_final_data_dict( + self, + image: np.ndarray, + keypoints: np.ndarray, + keypoints_unique: np.ndarray, + original_size: tuple[int, int], + image_path: str, + bboxes: np.array, + image_id: int, + annotations_merged: dict, + offsets: tuple[int, int], + scales: tuple[float, float], + ) -> dict: + area = self.calc_area_from_keypoints(keypoints) + image = torch.tensor(image, dtype=torch.float).permute(2, 0, 1) + keypoints = torch.tensor(keypoints, dtype=torch.float) + keypoints_unique = torch.tensor(keypoints_unique, dtype=torch.float) + bboxes = torch.tensor(bboxes, dtype=torch.float) - # TODO Quite ugly - # - # Center keypoint needs to be computed after transformation because - # it should depend on visible keypoints only (which may change after augmentation) - if self.with_center: - try: - keypoints[:, -1, :] = ( - keypoints[:, :-1, :][~np.any(keypoints[:, :-1, :] == -1, axis=2)] - .reshape(n_annotations, -1, 2) - .mean(axis=1) - ) - except ValueError: - # For at least one annotation every keypoint is out of the frame - for i in range(keypoints.shape[0]): - try: - keypoints[i, -1, :] = keypoints[i, :-1, :][ - ~np.any(keypoints[i, :-1, :] == -1, axis=1) - ].mean(axis=0) - except ValueError: - keypoints[i, -1, :] = np.array([-1, -1]) - - np.nan_to_num(keypoints, copy=False, nan=-1) - area = self._calc_area_from_keypoints(keypoints) return { "image": image, + "image_id": image_id, + "path": image_path, "original_size": original_size, + "offsets": offsets, + "scales": scales, "annotations": { "keypoints": keypoints, - "unique_kpts": unique_kpts, + "keypoints_unique": keypoints_unique, "area": area, - "ids": ids, - "boxes": bbox_tensor, - "image_id": image_id, - "is_crowd": is_crowd, - "labels": labels, + "boxes": bboxes, + "is_crowd": annotations_merged["iscrowd"], + "labels": annotations_merged["category_id"], }, } + def _load_image(self, image_path): + image = cv2.imread(image_path) + if self.parameters.color_mode == "RGB": + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + return image, image.shape -class CroppedDataset(Dataset, BaseDataset): - """ - Definition of the class object CroppedDataset: dataset for cropped images and keypoints - """ - - def __init__( - self, project: DLCProject, transform: object = None, mode: str = "train" - ): - """Summary: - Constructor of the CroppedDataset class. - Loads the data - - Args: - project: DLC original project class - transform: transformation function that takes keypoints as inputs - and returns a transformed image with corresponding keypoints. - Defaults to None. - mode: 'train' or 'test'. Defaults to "train". - - Returns: - None: + def _get_data_based_on_task(self, index: int) -> tuple[str, dict, int]: """ - super().__init__() - self.transform = transform - self.project = project - self.cfg = self.project.cfg - self.bodyparts = auxiliaryfunctions.get_bodyparts(self.cfg) - self.num_joints = len(self.bodyparts) - self.shuffle = self.project.shuffle - self.project.convert2dict(mode) - self.dataframe = self.project.dataframe - - self.annotations = self._compute_anno() - - modelfolder = os.path.join( - self.project.proj_root, - get_model_folder( - self.cfg["TrainingFraction"][0], - self.shuffle, - self.cfg, - "", - ), - ) - pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - pytorch_cfg = read_plainconfig(pytorch_config_path) - self.with_center = pytorch_cfg.get("with_center", False) - self.individuals = self.cfg.get("individuals", ["animal"]) - self.individual_to_idx = {} - for i, indiv in enumerate(self.individuals): - self.individual_to_idx[indiv] = i - self.max_num_animals = len(self.individuals) - self.color_mode = pytorch_cfg.get("colormode", "RGB") - - self.input_size = 256, 256 # (h, w) #TODO make that depend on pytorch config - self.crop = A.Compose( - [ - A.RandomCropNearBBox( - max_part_shift=0.0, - cropping_box_key="animal_bbox", - always_apply=True, - p=1.0, - ), - A.Resize(*self.input_size), - ], - keypoint_params=A.KeypointParams(format="xy", remove_invisible=False), - bbox_params=A.BboxParams(format="coco"), - ) + Retrieve data based on the specified task. - self.length = len(self.annotations) + For the 'TD' (top-down pose estimation) task: + - Provides a cropped image and its annotations. + - The shape of annotations['keypoints'] is (1, num_joints, 2). - def __len__(self): - """Summary: - Get the length of the dataset + For 'BU' and 'DT' tasks: + - Provides the full, non-cropped image and its annotations. + - The shape of annotations['keypoints'] is (max_num_animals, num_joints, 2). Args: - None + index: Index of the item in the dataset. Returns: - Number of samples in the dataset + tuple: Tuple containing the image path, annotations, and image ID. """ - return self.length + if self.task == "TD": + return self._get_raw_item_crop(index) + elif self.task in ["BU", "DT"]: + return self._get_raw_item(index) + raise ValueError( + f"Unknown task: {self.task}. " 'Task should be one of: "BU", "TD", "DT"', + ) - def _keypoint_in_boundary(self, keypoint: list, shape: tuple): - """Summary: - Check if a keypoint lies inside the given shape. + def apply_transform_all_keypoints( + self, + image: np.ndarray, + keypoints: np.ndarray, + keypoints_unique: np.ndarray, + bboxes: np.ndarray, + ) -> dict[str, np.ndarray]: + """ Transforms the image using this class's transform Args: - keypoint: [x,y] coordinates of the keypoints - shape: tuple representing the shape of the boundary (height, width) + image: the image to transform + keypoints: an array of shape (num_individuals, num_joints, 3) containing + the keypoints in the image + keypoints_unique: an array of shape (num_unique_bodyparts, 3) containing + the unique keypoints in the image + bboxes: the bounding boxes in the image Returns: - Whether a keypoint lies inside the given shape - - Example: - input: - keypoint = [100, 50] - shape = (200, 300) - output: - _keypoint_in_boundary(keypoint, shape) = True + the augmented image, keypoints and bboxes, in format + { + "image": (h, w, c), + "keypoints": (num_individuals, num_joints, 3), + "keypoints_unique": (num_unique_bodyparts, 3), + "bboxes": (4,), + } """ - return ( - (keypoint[0] > 0) - and (keypoint[1] > 0) - and (keypoint[0] < shape[1]) - and (keypoint[1] < shape[0]) + class_labels = [ + f"individual{i}_{bpt}" + for i in range(self.parameters.max_num_animals) + for bpt in self.parameters.bodyparts + ] + [f'unique_{bpt}' for bpt in self.parameters.unique_bpts] + + all_keypoints = keypoints.reshape(-1, 3) + if self.parameters.num_unique_bpts > 0: + all_keypoints = np.concatenate([all_keypoints, keypoints_unique], axis=0) + + transformed = apply_transform( + self.transform, + image, + all_keypoints, + bboxes, + class_labels=class_labels, ) + if self.parameters.num_unique_bpts > 0: + keypoints = transformed["keypoints"][:-self.parameters.num_unique_bpts] + keypoints = keypoints.reshape(*keypoints.shape) + keypoints_unique = transformed["keypoints"][-self.parameters.num_unique_bpts:] + keypoints_unique = keypoints_unique.reshape(self.parameters.num_unique_bpts, 3) + else: + keypoints = transformed["keypoints"].reshape(*keypoints.shape) + keypoints_unique = np.zeros((0,)) - def _compute_anno(self): - """Summary: - Compute annotations for the dataset - - Args: - None + transformed["keypoints"] = keypoints + transformed["keypoints_unique"] = keypoints_unique + return transformed - Returns: - annotations: list of annotations containing information about keypoints and image paths. + @staticmethod + def calc_area_from_keypoints(keypoints: np.ndarray) -> np.ndarray: """ - annotations = [] - df_length = self.dataframe.shape[0] - - for index in range(df_length): - image_path = self.dataframe.index[index] - if isinstance(image_path, tuple): - image_path = os.path.join(self.cfg["project_path"], *image_path) - else: - image_path = os.path.join(self.cfg["project_path"], image_path) - - image_id = self.project.image_path2image_id[image_path] - annotations_ids = self.project.id2annotations_idx[image_id] - for ann_idx in annotations_ids: - ann = self.project.annotations[ann_idx] - if ( - ann["bbox"] == 0.0 - ).all(): # I think we don't want unnanotated cropped images - continue - ann["image_path"] = image_path - annotations.append(ann) - - return annotations - - def _calc_area_from_keypoints(self, keypoints: np.ndarray) -> np.ndarray: - """Summary: Calculate the area from keypoints Args: - keypoints: array of keypoints + keypoints (np.ndarray): array of keypoints Returns: - Array containing the computed areas based on the keypoints + np.ndarray: array containing the computed areas based on the keypoints """ - w = keypoints[:, 0].max(axis=0) - keypoints[:, 0].min(axis=0) - h = keypoints[:, 1].max(axis=0) - keypoints[:, 1].min(axis=0) - return w * h - - def __getitem__(self, index: int) -> dict: - """Summary: - Get the item at the specified index from the dataset. + return (keypoints.max(axis=1) - keypoints.min(axis=1)).prod(axis=-1) + + @staticmethod + def crop( + image: np.ndarray, + keypoints, + coords: tuple[tuple[int, int], tuple[int, int]], + output_size: tuple[int, int], + ) -> tuple[np.ndarray, np.ndarray, tuple[int, int], tuple[int, int]]: + """ + Crop the image based on a given bounding box and resize it to the desired output size. Args: - index: ordered number of the item in the dataset + image: the image to transform + keypoints: an array of shape (num_individuals, num_joints, 3) containing + the keypoints in the image + coords: A bounding box defined as ((x_center, y_center), (width, height)). + output_size: Desired size for the output cropped, padded and resized image. Returns: - dict: dictionary containing following information: - - image: tensor representing the cropped image - - original_size:tuple representing the original size of the image - before applying transforms. - - annotations: dictionary containing annotation information. - - keypoints: array of keypoints associated with the cropped image - - area: array of animal's area in the image - - ids: individual ids associated with the animals - - path + Cropped (and possibly padded) and resized image. + Offsets used for cropping. + Padding sizes. + Scale factor used to resize the image. """ - # load images - ann = self.annotations[index] - image_file = ann["image_path"] - - image = cv2.imread(image_file) - if self.color_mode == "RGB": - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - original_size = image.shape - - if not self.with_center: - keypoints = np.zeros((self.num_joints, 3)) - num_keypoints_returned = self.num_joints - else: - keypoints = np.zeros((self.num_joints + 1, 3)) - num_keypoints_returned = self.num_joints + 1 + return _crop_image_keypoints(image, keypoints, coords, output_size) - _keypoints, _undef_ids = self.project.annotation2keypoints(ann) - if self.with_center: - keypoints[:-1, :2] = np.array(_keypoints) - keypoints[:-1, 2] = _undef_ids - else: - keypoints[:, :2] = np.array(_keypoints) - keypoints[:, 2] = _undef_ids - animal_id = self.individual_to_idx[ann["individual"]] - - crop_box = ann["bbox"].copy() - crop_box[2] += crop_box[0] - crop_box[3] += crop_box[1] - cropped = self.crop( - image=image, keypoints=keypoints[:, :2], bboxes=[], animal_bbox=crop_box - ) - image = cropped["image"] - keypoints = [ - (-1, -1) if (keypoints[i, 2] == 0) else keypoint - for i, keypoint in enumerate(cropped["keypoints"]) - ] + @staticmethod + def extract_keypoints_and_bboxes( + annotations: list[dict], + image: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, list]]: + """ + Args: + annotations: COCO-style annotations + image: from which extract keypoints and bounding boxes for - if self.transform: - transformed = self.transform(image=image, keypoints=keypoints) - shape_transformed = transformed["image"].shape - transformed["keypoints"] = [ - (-1, -1) - if not self._keypoint_in_boundary(keypoint, shape_transformed) - else keypoint - for i, keypoint in enumerate(transformed["keypoints"]) - ] - else: - transformed = {} - transformed["keypoints"] = keypoints - transformed["image"] = image - - image = torch.tensor(transformed["image"], dtype=torch.float).permute( - 2, 0, 1 - ) # channels first - - assert len(transformed["keypoints"]) == len(keypoints) - keypoints = np.array(transformed["keypoints"]).astype(float) - - # Center keypoint needs to be computed after transformation because - # it should depend on visible keypoints only (which may change after augmentation) - if self.with_center: - try: - keypoints[-1, :] = np.nanmean( - keypoints[:-1, :][~np.any(keypoints[:-1, :] == -1, axis=1)].reshape( - -1, 2 - ), - axis=0, - ) - except ValueError: - keypoints[-1, :] = np.array([-1, -1]) - np.nan_to_num(keypoints, copy=False, nan=-1) - area = self._calc_area_from_keypoints(keypoints) - - # Animal_idx is always the first dimension even is there is only one animal - # This convention is the one adopted int this whole repository - res = {} - res["image"] = image - res[ - "original_size" - ] = original_size # In order to convert back the keypoints to their original space - res["annotations"] = {} - res["annotations"]["keypoints"] = keypoints[None, :] - res["annotations"]["area"] = np.array(area)[None] - res["annotations"]["ids"] = np.array(animal_id)[None] - res["annotations"]["path"] = image_file - - return res + Returns: + keypoints, unique_keypoints, bboxes in xywh format, annotations_merged + """ + return _extract_keypoints_and_bboxes(annotations, image) + + @staticmethod + def add_center_keypoints(keypoints: np.ndarray) -> np.ndarray: + """ Adds a keypoint in the mean of each individual""" + center_keypoints = keypoints.copy() + center_keypoints[center_keypoints == -1] = np.nan + center_keypoints = np.nanmean(keypoints, axis=1) + return np.concatenate((keypoints, center_keypoints[:, None, :]), axis=1) diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py new file mode 100644 index 0000000000..b3d31eb630 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import os +import pickle +from dataclasses import dataclass + +import numpy as np +import pandas as pd + +import deeplabcut +from deeplabcut.pose_estimation_pytorch.data.base import Loader +from deeplabcut.pose_estimation_pytorch.data.helper import CombinedPropertyMeta +from deeplabcut.pose_estimation_pytorch.utils import df_to_generic +from deeplabcut.utils.auxiliaryfunctions import get_model_folder + + +@dataclass +class DLCLoader(Loader, metaclass=CombinedPropertyMeta): + """A Loader for DeepLabCut projects""" + project_root: str + shuffle: int = 0 + image_id_offset: int = 0 + + properties = { + "cfg": ( + deeplabcut.auxiliaryfunctions.read_config, + lambda self: os.path.join(self.project_root, "config.yaml"), + ), + "model_folder": ( + lambda x: os.path.join( + x[0], + get_model_folder(x[1], x[2], x[3]), + ), + lambda self: ( + self.project_root, + self.cfg["TrainingFraction"][0], + self.shuffle, + self.cfg, + ), + ), + "_datasets_folder": ( + lambda x: os.path.join( + x[0], + deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(x[1]), + ), + lambda self: (self.project_root, self.cfg), + ), + "_path_dlc_data": ( + lambda x: os.path.join(x[0], f"CollectedData_{x[1]}.h5"), + lambda self: (self._datasets_folder, self.cfg["scorer"]), + ), + "_path_dlc_doc": ( + lambda x: os.path.join( + x[0], + f"Documentation_data-{x[1]}_{x[2]}shuffle{x[3]}.pickle", + ), + lambda self: ( + self._datasets_folder, + self.cfg["Task"], + int( + 100 * self.cfg["TrainingFraction"][0], + ), + self.shuffle, + ), + ), + } + + def __post_init__(self): + self.split, self.df_dlc, self.df_train, self.df_test = self._load_dlc_data() + + def _load_dlc_data(self): + split = self._load_split(self._path_dlc_doc) + df_dlc = pd.read_hdf(self._path_dlc_data) + df_train, df_test = self.split_data(df_dlc, split) + df_dlc, df_train, df_test = self.drop_duplicates(df_dlc, df_train, df_test) + + return split, df_dlc, df_train, df_test + + @staticmethod + def drop_duplicates(dlc_df, df_train, df_test): + dlc_df = dlc_df[dlc_df.index.duplicated(keep="first")] + df_train = df_train[ + ~df_train.index.duplicated( + keep="first", + ) + ] + if df_test is not None: + df_test = df_test[ + df_test.index.duplicated( + keep="first", + ) + ] + return dlc_df, df_train, df_test + + def load_data(self, mode: str = "train") -> dict: + """ Loads DeepLabCut data into COCO-style annotations + + This function reads data from h5 file, split the data and returns it in + COCO-like format + + Args: + mode: mode indicating whether to use 'train' or 'test' data. Defaults to "train". + + Raises: + AttributeError: if the specified mode (train or test) does not exist. + + Returns: + the coco-style annotations + """ + if mode == "train": + data_dlc_format = self.df_train + elif mode == "test": + data_dlc_format = self.df_test + # to do: add validation + else: + raise AttributeError(f"Unknown mode: {mode}") + + data = df_to_generic( + self.project_root, + data_dlc_format, + self.image_id_offset, + ) + annotations_with_bbox = self._get_all_bboxes( + data["images"], + data["annotations"], + ) + data["annotations"] = annotations_with_bbox + + return data + + @staticmethod + def _load_split(path_dlc_doc: str) -> dict[str, list[int]]: + """Summary: + Split the annotation dataframe into train and test dataframes based on project's split + that is downloaded from the project's directory + + Args: + path_dlc_doc: the path to the DLC documentation file + + Return: + the {"train": [train_ids], "test": [test_ids]} data split + """ + with open(path_dlc_doc, "rb") as f: + meta = pickle.load(f) + + train_ids = [int(i) for i in meta[1]] + test_ids = [int(i) for i in meta[2]] + + return {"train": train_ids, "test": test_ids} + + @staticmethod + def split_data( + dlc_df: pd.DataFrame, split: dict[str, list[int]] + ) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + Splits a DeepLabCut DataFrame into train/test dataframes + + Args: + dlc_df: the dataframe containing the labeled data + split: the train/test indices + + Returns: + df_train, df_test + """ + df_test = None + train_images = dlc_df.index[split["train"]] + if len(split["test"]) != 0: + test_images = dlc_df.index[split["test"]] + df_test = dlc_df.loc[test_images] + df_train = dlc_df.loc[train_images] + + return df_train, df_test diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py deleted file mode 100644 index 324bb2a9db..0000000000 --- a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py +++ /dev/null @@ -1,172 +0,0 @@ -import os -import pickle -from typing import List, Tuple - -import deeplabcut -import numpy as np -import pandas as pd - -from deeplabcut.pose_estimation_pytorch.utils import df2generic -from deeplabcut.pose_estimation_pytorch.data.base import BaseProject - - -class DLCProject(BaseProject): - """ - Wrapper around the project containing information about the data, - the actual annotations and the configs - """ - - def __init__( - self, - proj_root: str, - shuffle: int = 0, - image_id_offset: int = 0, - keys_to_load: List[str] = ["images", "annotations"], - ): - """Summary: - Constructor of the DLCProject class. - Loads the data - - Args: - proj_root: project path - shuffle: shuffle index for the project. Defaults to 0. - image_id_offset: offset value for image ids. Defaults to 0. - keys_to_load: list of keys to load from the dataset. - Defaults to ["images", "annotations"]. - - Return: - None - """ - - super().__init__() - self.proj_root = proj_root - self.shuffle = shuffle - self.keys_to_load = keys_to_load - self.image_id_offset = image_id_offset - config_file = os.path.join(self.proj_root, "config.yaml") - self.cfg = deeplabcut.auxiliaryfunctions.read_config(config_file) - self.task = self.cfg["Task"] - self.scorer = self.cfg["scorer"] - self.datasets_folder = os.path.join( - self.proj_root, - deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(self.cfg), - ) - tr_frac = int(self.cfg["TrainingFraction"][0] * 100) - self.path_dlc_data = os.path.join( - self.datasets_folder, f"CollectedData_{self.scorer}.h5" - ) - self.path_dlc_doc = os.path.join( - self.datasets_folder, - f"Documentation_data-{self.task}_{tr_frac}shuffle{self.shuffle}.pickle", - ) - self.dlc_df = pd.read_hdf(self.path_dlc_data) - self.load_split() - self.dlc_df = self.dlc_df[~self.dlc_df.index.duplicated(keep="first")] - self.df_train = self.df_train[~self.df_train.index.duplicated(keep="first")] - if hasattr(self, "df_test"): - self.df_test = self.df_test[~self.df_test.index.duplicated(keep="first")] - - def convert2dict(self, mode: str = "train"): - """Summary: - Convert the annotations dataframe into coco format dictionary of annotations - - Args: - mode: mode indicating whether to use 'train' or 'test' data. Defaults to "train". - - Raises: - AttributeError: if the specified mode (train or test) does not exist. - - Returns: - None - """ - try: - self.dataframe = getattr(self, f"df_{mode}") - except: - raise AttributeError( - f"PoseDataset doesn't have df_{mode} attr. Do project.train_test_split() first!" - ) - - data = df2generic(self.proj_root, self.dataframe, self.image_id_offset) - - self._init_annotation_image_correspondance(data) - - for key in self.keys_to_load: - setattr(self, key, data[key]) - print("The data has been converted!") - - def _init_annotation_image_correspondance(self, data: dict): - """Summary: - Binds the image paths to corresponding annotations and ensures there is no indexing - offsets between images and annotations when going through the dataset - - Args: - data: dictionary containing annotations in COCO-like format - - Returns: - None - - Examples: - data = {"images": [...], "annotations": [...]} - """ - # Path to id correspondence - self.image_path2image_id = {} - for i, image in enumerate(data["images"]): - image_path = image["file_name"] - self.image_path2image_id[image_path] = image["id"] - - # id to annotations list - self.id2annotations_idx = {} - for i, annotation in enumerate(data["annotations"]): - image_id = annotation["image_id"] - try: - self.id2annotations_idx[image_id].append(i) - except KeyError: - self.id2annotations_idx[image_id] = [i] - - return - - def load_split(self): - """Summary: - Split the annotation dataframe into train and test dataframes based on project's split - - Args: - None - - Return: - None - """ - with open(self.path_dlc_doc, "rb") as f: - meta = pickle.load(f) - - train_ids = meta[1] - test_ids = meta[2] - - train_images = self.dlc_df.index[train_ids] - if len(test_ids) != 0: - test_images = self.dlc_df.index[test_ids] - self.dlc_images = np.hstack([train_images, test_images]) - self.df_test = self.dlc_df.loc[test_images] - self.df_train = self.dlc_df.loc[train_images] - - @staticmethod - def annotation2keypoints(annotation: dict) -> Tuple[list, np.array]: - """Summary: - Convert the coco annotations into array of keypoints also returns the array of the keypoints' visibility - Args: - annotation: dictionary containing coco-like annotations - - Returns: - keypoints: paired keypoints - undef_ids: array where 0 means the keypoint is undefined. 1 means it is defined. - """ - x = annotation["keypoints"][::3] - y = annotation["keypoints"][1::3] - undef_ids = ((x > 0) & (y > 0)).astype(int) - keypoints = [] - - for pair in np.stack([x, y]).T: - if pair[0] != -1: - keypoints.append((pair[0], pair[1])) - else: - keypoints.append((0, 0)) - return keypoints, undef_ids diff --git a/deeplabcut/pose_estimation_pytorch/data/helper.py b/deeplabcut/pose_estimation_pytorch/data/helper.py new file mode 100644 index 0000000000..88cd8c235f --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/helper.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from abc import ABCMeta + + +def cfg_getter(key, default=None): + def _getter(cfg): + return cfg.get(key, default) + return _getter + + +def class_property(func, arg_func): + """ + Decorator to create a class property. + + Parameters: + - func: Callable that represents the logic of the property. + - arg_func: Callable that provides the arguments for `func`. + + Returns: + - A property with the logic encapsulated in `func` and arguments derived from `arg_func`. + """ + def decorator_wrapper(method): + def wrapper(self): + return func(arg_func(self)) + return property(wrapper) + return decorator_wrapper + + +class PropertyMeta(type): + """ + Metaclass for creating class properties in a more organized and systematic manner. + + This metaclass allows a class to define its properties using a simple dictionary + structure (`properties`). The dictionary keys represent the property names, + while the values are tuples containing two callables: + 1. The function that represents the logic of the property. + 2. The function that provides the arguments for the logic function. + + Usage: + class MyClass(metaclass=PropertyMeta): + properties = { + 'property_name': (logic_function, arguments_function), + # ... more properties ... + } + + For each property specified in the `properties` dictionary, the metaclass will + generate a real property that uses the logic from `logic_function` and + arguments from `arguments_function`. + + Attributes: + - properties (dict): Dictionary containing property names as keys and tuples + of (logic_function, arguments_function) as values. + """ + + def __new__(cls, name, bases, attrs): + if 'properties' not in attrs: + raise AttributeError( + f"{name} must define a 'properties' dictionary.", + ) + properties = attrs.get('properties', {}) + for prop_name, (func, arg_func) in properties.items(): + attrs[prop_name] = class_property( + func, arg_func, + )(lambda self: None) + return super().__new__(cls, name, bases, attrs) + + +class CombinedPropertyMeta(ABCMeta, PropertyMeta): + """ + Combined metaclass that integrates the functionalities of both `ABCMeta` and `BasePropertyMeta`. + + This metaclass is useful in scenarios where a class needs to use both abstract methods (from `ABCMeta`) + and the property definition utilities provided by `BasePropertyMeta`. + + By using this metaclass, a class can be both an abstract class (with abstract methods and/or properties) + and can also define properties in the structured manner facilitated by `PropertyMeta`. + + Inherits: + - ABCMeta: Metaclass for base classes that include abstract methods. + - PropertyMeta: Metaclass that facilitates structured property definitions. + + Note: + When defining a class using `CombinedPropertyMeta`, ensure that the class also inherits + from `ABC` to make it compatible with the `ABCMeta` behavior. + """ diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py new file mode 100644 index 0000000000..414b403f22 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -0,0 +1,522 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +from collections import defaultdict +from functools import reduce + +import albumentations as A +import numpy as np +import torch +from torchvision.ops import box_convert +from torchvision.transforms import functional as F + + +def merge_list_of_dicts( + list_of_dicts: list[dict], + keys_to_include: list[str], +) -> dict[str, list]: + """ + Flattens a list of dictionaries into a dictionary with the lists concatenated. + + Args: + list_of_dicts: the dictionaries to merge + keys_to_include: the keys to include in the new dictionary + + Returns: + the merged dictionary + + Examples: + input: + list_of_dicts: [{"id": 0, "num": 1}, {"id": 1, "num": 10}] + keys_to_include: ["id", "num"] + output: + {"id": [0, 1], "num": [1, 10]} + """ + return reduce( + lambda acc, d: { + key: acc.get(key, []) + [value] + for key, value in d.items() + if key in keys_to_include + }, + list_of_dicts, + defaultdict(list), + ) + + +def map_image_path_to_id(images: list[dict]) -> dict[str, int]: + """ + Binds the image paths to their respective IDs. + + Args: + images: List of dictionaries containing image data in COCO-like format. + Each dictionary should have 'file_name' and 'id' keys. + + Returns: + A dictionary mapping image paths to their respective IDs. + + Examples: + images = [{"file_name": "path/to/image1.jpg", "id": 1}, ...] + """ + + return {image["file_name"]: image["id"] for image in images} + + +def map_id_to_annotations(annotations: list[dict]) -> dict[int, list[int]]: + """ + Maps image IDs to their corresponding annotation indices. + + Args: + annotations: List of dictionaries containing annotation data. Each dictionary + should have 'image_id' key. + + Returns: + A dictionary mapping image IDs to lists of corresponding annotation indices. + + Examples: + annotations = [{"image_id": 1, ...}, ...] + """ + + annotation_idx_map = defaultdict(list) + for idx, annotation in enumerate(annotations): + annotation_idx_map[annotation["image_id"]].append(idx) + + return annotation_idx_map + + +def _crop_and_pad_image( + image: np.ndarray, + coords: tuple[tuple[int, int], tuple[int, int]], + output_size: tuple[int, int], +) -> tuple[np.ndarray, tuple[int, int]]: + """ + Crop the image using the given coordinates and pad the larger dimension to change + the aspect ratio. + + Args: + image: Image to crop, of shape (height, width, channels). + coords: Coordinates for cropping as [(xmin, xmax), (ymin, ymax)]. + output_size: The (output_h, output_w) that this cropped image will be resized + to. Used to compute padding to keep aspect ratios. + + Returns: + Cropped (and possibly padded) image + Padding (pad_h, pad_w) + """ + cropped_image = image[ + coords[1][0] : coords[1][1], + coords[0][0] : coords[0][1], + :, + ] + + crop_h, crop_w, c = cropped_image.shape + pad_h, pad_w = 0, 0 + target_ratio_h = output_size[0] / crop_h + target_ratio_w = output_size[1] / crop_w + + if target_ratio_h != target_ratio_w: + if crop_h < crop_w: + # Pad the height + new_h = int(crop_w * output_size[0] / output_size[1]) + pad_h = new_h - crop_h + pad_image = np.zeros((new_h, crop_w, c)) + y_offset = pad_h // 2 + pad_image[y_offset : y_offset + crop_h, :] = cropped_image + else: + # Pad the width + new_w = int(crop_h * output_size[1] / output_size[0]) + pad_w = new_w - crop_w + pad_image = np.zeros((crop_h, new_w, c)) + x_offset = pad_w // 2 + pad_image[:, x_offset : x_offset + crop_w] = cropped_image + else: + pad_image = cropped_image + + return pad_image, (pad_h, pad_w) + + +def _crop_and_pad_keypoints( + keypoints: np.ndarray, + coords: tuple[int, int], + pad_size: tuple[int, int], +): + """ + Adjust the keypoints after cropping and padding. + + Parameters: + keypoints: The original keypoints, typically a 2D array of shape (..., 2). + coords: The (xmin, ymin) crop coordinates used for cropping the image. + pad_size: The padding sizes added to the cropped image, in the format (pad_h, pad_w). + + Returns: + Adjusted keypoints. + """ + keypoints[..., 0] -= coords[0] + keypoints[..., 1] -= coords[1] + keypoints[..., 0] += pad_size[1] // 2 + keypoints[..., 1] += pad_size[0] // 2 + return keypoints + + +def _crop_image_keypoints( + image, + keypoints, + coords, + output_size, +) -> tuple[np.ndarray, np.ndarray, tuple[int, int], tuple[int, int]]: + """TODO: Requires fixing + Crop the image based on a given bounding box and resize it to the desired output + size. Returns offsets and scales to map keypoints in the resized image to + coordinates in the original image: + + x_original = (x_cropped * x_scale) + x_offset + y_original = (y_cropped * y_scale) + y_offset + + Args: + image: Image to crop, of shape (height, width, channels). + coords: Coordinates for cropping as ((xmin, xmax), (ymin, ymax)). + output_size: The (h, w) that the cropped image should be resized to. + + Returns: + Cropped, possibly padded, and resized image. + The position of the keypoints in the cropped, resized image + Offsets used for cropping. + The offsets to map predicted keypoints back to the original image + The scale to map predicted keypoints back to the original image + """ + + cropped_image, pad_size = _crop_and_pad_image( + image, + coords, + output_size, + ) + cropped_keypoints = _crop_and_pad_keypoints( + keypoints, + (coords[0][0], coords[1][0]), + pad_size, + ) + + offsets = (coords[0][0], coords[1][0]) + scales = [ + output_size[0] / cropped_image.shape[0], + output_size[1] / cropped_image.shape[1], + ] + + # TODO: Fix resizing, use OpenCV + cropped_resized_image = np.resize( + cropped_image, + (*output_size, cropped_image.shape[2]), + ) + + cropped_resized_keypoints = np.array( + cropped_keypoints, + ) * np.array(scales + [1]) + + return cropped_resized_image, cropped_resized_keypoints, offsets, scales + + +def _crop_and_pad_image_torch( + image: np.array, + bbox: np.array, + bbox_format: str, + output_size: int, +) -> tuple[np.array, tuple[int, int], tuple[int, int]]: + """TODO: Reimplement this function with numpy and for non-square resize :) + Only works for square cropped bounding boxes. Crops images around bounding boxes + for top-down pose estimation in a MMpose style. Computes offsets so that + coordinates in the original image can be mapped to the cropped one; + + x_cropped = (x - offset_x) / scale_x + x_cropped = (y - offset_y) / scale_y + + Args: + image: (h, w, c) the image to crop + bbox: (4,) the bounding box to crop around + bbox_format: {"xyxy", "xywh", "cxcywh"} the format of the bounding box + output_size: the size to resize the image to + + Returns: + cropped_image, (offset_x, offset_y), (scale_x, scale_y) + """ + image = torch.tensor(image).permute(2, 0, 1) + bbox = torch.tensor(bbox) + if bbox_format != "cxcywh": + bbox = box_convert(bbox.unsqueeze(0), bbox_format, "cxcywh").squeeze() + + c, h, w = image.shape + crop_size = torch.max(bbox[2:]) + + xmin = int( + torch.clip( + bbox[0] - (crop_size / 2), + min=0, + max=w - 1, + ) + .cpu() + .item(), + ) + xmax = int( + torch.clip( + bbox[0] + (crop_size / 2), + min=1, + max=w, + ) + .cpu() + .item(), + ) + ymin = int( + torch.clip( + bbox[1] - (crop_size / 2), + min=0, + max=h - 1, + ) + .cpu() + .item(), + ) + ymax = int( + torch.clip( + bbox[1] + (crop_size / 2), + min=1, + max=h, + ) + .cpu() + .item(), + ) + cropped_image = image[:, ymin:ymax, xmin:xmax] + + crop_h, crop_w = cropped_image.shape[1:3] + pad_size = max(crop_h, crop_w) + + # Pad image if not square + if not crop_h == crop_w: + padded_cropped_image = torch.zeros( + (c, pad_size, pad_size), + dtype=image.dtype, + ) + padded_cropped_image[:, :crop_h, :crop_w] = cropped_image + cropped_image = padded_cropped_image + + scale = pad_size / output_size + offset = (xmin, ymin) + output = F.resize(cropped_image, [output_size, output_size]) + + return output.permute(1, 2, 0).numpy(), offset, (scale, scale) + + +def _compute_crop_bounds( + bboxes: np.ndarray, + image_shape: tuple[int, int, int], +) -> np.ndarray: + """ + Compute the boundaries for cropping an image based on a COCO-format bounding box + and image shape by clipping values so the bounding boxes are entirely in the image. + + Args: + bboxes: COCO-format bounding box of shape (b, xywh) + image_shape: Shape of the image defined as (height, width, channels). + + Returns: + The bounding boxes, clipped to be entirely inside the image + """ + h, w = image_shape[:2] + bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) + bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) + bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - np.spacing(0.0) + bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) + return bboxes + + +def _extract_keypoints_and_bboxes( + annotations: list[dict], + image: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, list]]: + """ + Args: + annotations: COCO-style annotations + image: from which extract keypoints and bounding boxes for + + Returns: + keypoints, unique_keypoints, bboxes in xywh format, annotations_merged + """ + keypoints = [] + unique_keypoints = [] + original_bboxes = [] + + annotations_merged = merge_list_of_dicts( + annotations, + keys_to_include=["category_id", "iscrowd"], + ) + + for i, annotation in enumerate(annotations): + keypoints_individual = _annotation_to_keypoints(annotation) + if annotation["individual"] != "single": + bbox_individual = annotation["bbox"] + original_bboxes.append(bbox_individual) + keypoints.append(keypoints_individual) + else: + unique_keypoints = keypoints_individual + + unique_keypoints = np.array(unique_keypoints) + + keypoints = np.stack(keypoints, axis=0) + + bboxes = np.array( + _compute_crop_bounds(np.stack(original_bboxes, axis=0), image.shape), + ).reshape((-1, 4)) + return keypoints, unique_keypoints, bboxes, annotations_merged + + +def _annotation_to_keypoints(annotation: dict) -> np.array: + """ + Convert the coco annotations into array of keypoints returns the array of the keypoints' visibility + If keypoint is not visible, the value for (x,y) coordinates is set to 0 + Args: + annotation: dictionary containing coco-like annotations with essential `keypoints` field + Returns: + keypoints: np.array where the first two columns are x and y coordinates of the keypoints and the third column is the visibility of the keypoints + """ + keypoints = annotation["keypoints"].reshape(-1, 3) + visibility_mask = keypoints > -1 + keypoints[~visibility_mask] = 0 + keypoints[:, -1] = visibility_mask.all(axis=1) + return keypoints + + +def apply_transform( + transform: A.BaseCompose, + image: np.ndarray, + keypoints: np.ndarray, + bboxes: np.ndarray, + class_labels: list[str], +) -> dict[str, np.ndarray]: + """ + Applies a transformation to the provided image and keypoints. + + Args: + transform: The transformation to apply. + image: The input image to which the transformation will be applied. + keypoints: List of keypoints to be transformed along with the image. Each keypoint + is expected to be a tuple or list with at least three values, + where the third value indicates the class label index. + bboxes: List of bounding boxes to be transformed along with the image. + class_labels: List of class labels corresponding to the keypoints. + + Returns: + transformed: A dictionary containing the transformed image and keypoints. + """ + + if transform: + transformed = _apply_transform( + transform, + image, + keypoints, + bboxes, + class_labels, + ) + transformed["keypoints"] = np.array(transformed["keypoints"]) + shape_transformed = transformed["image"].shape + mask_valid = _check_keypoints_within_bounds( + transformed["keypoints"], + shape_transformed, + ) + transformed["keypoints"][~mask_valid] = -1 + else: + transformed = {"keypoints": keypoints, "image": image} + np.nan_to_num(transformed["keypoints"], copy=False, nan=-1) + + return transformed + + +def _apply_transform( + transform: A.BaseCompose, + image: np.ndarray, + keypoints: np.ndarray, + bboxes: np.ndarray, + class_labels: list[str], +) -> dict[str, np.ndarray]: + """ + Applies a transformation to the provided image and keypoints. + + Args: + image : np.array or similar image data format + The input image to which the transformation will be applied. + + keypoints : list or similar data format + List of keypoints to be transformed along with the image. Each keypoint + is expected to be a tuple or list with at least three values, + where the third value indicates the class label index. + + Returns: + dict + A dictionary containing the transformed image and keypoints. + """ + transformed = transform( + image=image, + keypoints=keypoints, + class_labels=class_labels, + bboxes=bboxes, + bbox_labels=np.arange(len(bboxes)), + ) + transformed = _set_invalid_keypoints_to_neg_one( + transformed, + keypoints, + class_labels, + ) + return transformed + + +def _set_invalid_keypoints_to_neg_one( + transformed: dict[str, list], + keypoints: np.ndarray, + class_labels: list, +) -> dict[str, list]: + """ + Updates keypoints that are out of bounds or undefined to (-1, -1). + + Args: + transformed: A dictionary containing the transformed image and keypoints. + keypoints: Array of keypoints to be transformed along with the image. + class_labels: List of class labels corresponding to the keypoints. + + Returns: + A dictionary containing the transformed image and with masked invalid keypoints. + + """ + undef_class_labels = [ + class_labels[i] for i, kpt in enumerate(keypoints) if kpt[2] == 0 + ] + for label in undef_class_labels: + new_index = transformed["class_labels"].index(label) + transformed["keypoints"][new_index] = (-1, -1) + + return transformed + + +def _check_keypoints_within_bounds(keypoints: np.ndarray, shape: tuple) -> np.ndarray: + """ + Check if each keypoint in an array of keypoints is within given bounds. + Parameters: + - keypoints (np.ndarray): A (N, 2) shaped array where N is the number of keypoints + and each keypoint is represented as [x, y] or [width, height]. + - shape (tuple): A tuple representing the shape or bounds as (height, width). + Returns: + - np.ndarray: A boolean array of shape (N,) where each element corresponds to whether + the respective keypoint is within the bounds. `True` indicates the keypoint + is within bounds, while `False` indicates it's outside. + Example: + >>> are_keypoints_within_bounds(np.array([[5, 5], [15, 15]]), (10, 10)) + array([ True, False]) + """ + return np.all( + (keypoints[..., :2] > 0) + & (keypoints[..., :2] < np.array([shape[1], shape[0]])), + axis=1, + ) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py b/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py new file mode 100644 index 0000000000..4bc96df207 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os + +import numpy as np +import pytest + +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset +from deeplabcut.pose_estimation_pytorch.data.dlcproject import DLCProject +from deeplabcut.pose_estimation_pytorch.data.helper import merge_list_of_dicts + + +@pytest.mark.parametrize('repo_path', ['/home/anastasiia/DLCdev']) +def test_propertymeta_project(repo_path): + project_root = os.path.join( + repo_path, 'examples', 'openfield-Pranav-2018-10-30', + ) + dlc_project = DLCProject(project_root, shuffle=1) + + for prop in dlc_project.properties: + print(prop, getattr(dlc_project, prop)) + + +@pytest.mark.parametrize('repo_path, mode', [('/home/anastasiia/DLCdev', 'train'), ('/home/anastasiia/DLCdev', 'test')]) +def test_propertymeta_dataset(repo_path, mode): + repo_path = '/home/anastasiia/DLCdev' + mode = 'train' + mode = 'train' + project_root = os.path.join( + repo_path, 'examples', 'openfield-Pranav-2018-10-30', + ) + dlc_project = DLCProject(project_root, shuffle=1) + dataset = PoseDataset(dlc_project, mode) + + for prop in dataset.properties: + print(prop, getattr(dataset, prop)) + + +@pytest.mark.parametrize( + 'list_dicts, keys_to_include', [ + ([{'a': 1, 'b': 2}, {'a': 3, 'b': 4}], ['a']), + ( + [ + *[{ + 'keypoints': np.random.randn(27, 3), 'images': np.random.randn( + 256, 192, + ), + }]*10, + ], [*['keypoints', 'images']*10], + ), + ], +) +def test_merge_list_of_dicts(list_dicts, keys_to_include): + result_dict = merge_list_of_dicts(list_dicts, keys_to_include) + expected_result_dict = {} + for dictionary in list_dicts: + for key in dictionary: + if key not in keys_to_include: + continue + else: + if key not in expected_result_dict: + expected_result_dict[key] = [] + expected_result_dict[key].append(dictionary[key]) + assert result_dict == expected_result_dict diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index 6f8718a4e4..1be87846db 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -8,44 +8,64 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + import abc import os import numpy as np import pandas as pd import torch + from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( read_image_shape_fast, ) from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import ( calc_bboxes_from_keypoints, ) - +from deeplabcut.utils.auxiliaryfunctions import read_plainconfig # Shaokai's function -def df2generic(proj_root, df, image_id_offset=0): + + +def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> dict: + """ + Convert a pandas DataFrame containing pose estimation data to a dictionary in COCO format. + + Args: + proj_root (str): The root directory of the project. + df (pd.DataFrame): The DataFrame containing the pose estimation data. + image_id_offset (int, optional): The offset to add to the image IDs. Defaults to 0. + + Returns: + dict: A dictionary in COCO format containing the images, annotations, and categories. + """ try: - individuals = df.columns.get_level_values("individuals").unique().tolist() + individuals = df.columns.get_level_values( + 'individuals', + ).unique().tolist() except KeyError: new_cols = pd.MultiIndex.from_tuples( - [(col[0], "animal", col[1], col[2]) for col in df.columns], - names=["scorer", "individuals", "bodyparts", "coords"], + [(col[0], 'animal', col[1], col[2]) for col in df.columns], + names=['scorer', 'individuals', 'bodyparts', 'coords'], ) df.columns = new_cols - individuals = df.columns.get_level_values("individuals").unique().tolist() + individuals = df.columns.get_level_values( + 'individuals', + ).unique().tolist() unique_bpts = [] - if "single" in individuals: + if 'single' in individuals: unique_bpts.extend( - df.xs("single", level="individuals", axis=1) - .columns.get_level_values("bodyparts") - .unique() + df.xs('single', level='individuals', axis=1) + .columns.get_level_values('bodyparts') + .unique(), ) multi_bpts = ( - df.xs(individuals[0], level="individuals", axis=1) - .columns.get_level_values("bodyparts") + df.xs(individuals[0], level='individuals', axis=1) + .columns.get_level_values('bodyparts') .unique() .tolist() ) @@ -57,15 +77,15 @@ def df2generic(proj_root, df, image_id_offset=0): individual = individuals[0] category = { - "name": individual, - "id": 0, - "supercategory": "animal", + 'name': individual, + 'id': 0, + 'supercategory': 'animal', } - if individual == "single": - category["keypoints"] = unique_bpts + if individual == 'single': + category['keypoints'] = unique_bpts else: - category["keypoints"] = multi_bpts + category['keypoints'] = multi_bpts coco_categories.append(category) @@ -87,13 +107,17 @@ def df2generic(proj_root, df, image_id_offset=0): category_id = 1 # 0 is for background by default try: kpts = ( - data.xs(individual, level="individuals").to_numpy().reshape((-1, 2)) + data.xs(individual, level='individuals').to_numpy().reshape( + (-1, 2), + ) ) except: # somehow there are duplicates. So only use the first occurrence data = data.iloc[0] kpts = ( - data.xs(individual, level="individuals").to_numpy().reshape((-1, 2)) + data.xs(individual, level='individuals').to_numpy().reshape( + (-1, 2), + ) ) keypoints = np.zeros((len(kpts), 3)) @@ -121,15 +145,15 @@ def df2generic(proj_root, df, image_id_offset=0): annotation_id += 1 annotation = { - "image_id": image_id + image_id_offset, - "num_keypoints": num_keypoints, - "keypoints": keypoints, - "id": annotation_id, - "category_id": category_id, - "individual": individual, - "area": area, - "bbox": bbox, - "iscrowd": 0, + 'image_id': image_id + image_id_offset, + 'num_keypoints': num_keypoints, + 'keypoints': keypoints, + 'id': annotation_id, + 'category_id': category_id, + 'individual': individual, + 'area': area, + 'bbox': bbox, + 'iscrowd': 0, } # adds an annotation even if no keypoint is annotated for the current individual @@ -147,17 +171,17 @@ def df2generic(proj_root, df, image_id_offset=0): _, height, width = read_image_shape_fast(image_path) image = { - "file_name": image_path, - "width": width, - "height": height, - "id": image_id + image_id_offset, + 'file_name': image_path, + 'width': width, + 'height': height, + 'id': image_id + image_id_offset, } coco_images.append(image) ret_obj = { - "images": coco_images, - "annotations": coco_annotations, - "categories": coco_categories, + 'images': coco_images, + 'annotations': coco_annotations, + 'categories': coco_categories, } return ret_obj @@ -204,3 +228,12 @@ def is_seq_of(seq, expected_type, seq_type=None): if not isinstance(item, expected_type): return False return True + + +def get_pytorch_config(modelfolder): + pytorch_config_path = os.path.join( + modelfolder, 'train', 'pytorch_config.yaml', + ) + pytorch_cfg = read_plainconfig(pytorch_config_path) + + return pytorch_cfg From cbf21e5babd3f2fc81cc2a19e17e82b4ca02101e Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Fri, 27 Oct 2023 10:38:11 +0200 Subject: [PATCH 050/293] KeypointAwareCrop implementation --- .../pose_estimation_pytorch/apis/utils.py | 22 +++++ .../data/transforms.py | 85 +++++++++++++++++++ .../tests/test_transforms.py | 29 +++++++ 3 files changed, 136 insertions(+) create mode 100644 deeplabcut/pose_estimation_pytorch/data/transforms.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_transforms.py diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index f0ea9873b8..2a98558395 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -2,8 +2,10 @@ from typing import Dict, List, Optional, Union import albumentations as A +import cv2 import torch import yaml +from deeplabcut.pose_estimation_pytorch.data.transforms import KeypointAwareCrop from deeplabcut.pose_estimation_pytorch.models import ( BACKBONES, DETECTORS, @@ -186,6 +188,26 @@ def build_transforms( input_size = aug_cfg.get("resize", False) transforms.append(A.Resize(input_size[0], input_size[1])) + crop_sampling = aug_cfg.get("crop_sampling", False) + if crop_sampling: + # Add smart, keypoint-aware image cropping + transforms.append( + A.PadIfNeeded( + min_height=crop_sampling["height"], + min_width=crop_sampling["width"], + border_mode=cv2.BORDER_CONSTANT, + always_apply=True, + ) + ) + transforms.append( + KeypointAwareCrop( + crop_sampling["width"], + crop_sampling["height"], + crop_sampling["max_shift"], + crop_sampling["method"], + ) + ) + # TODO code again this augmentation to match the symmetric_pair syntax in original dlc # if aug_cfg.get('flipr', False) and aug_cfg.get('symmetric_pair', False): # opt = aug_cfg.get("fliplr", False) diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py new file mode 100644 index 0000000000..e1ec768552 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import numpy as np +from albumentations.augmentations.crops import RandomCrop +from numpy.typing import NDArray +from scipy.spatial.distance import pdist, squareform +from typing import Any + + +class KeypointAwareCrop(RandomCrop): + def __init__( + self, + width: int, + height: int, + max_shift: float = 0.4, + crop_sampling: str = "hybrid", + ): + """ + Args: + width: Crop images down to this maximum width. + height: Crop images down to this maximum height. + max_shift: Maximum allowed shift of the cropping center position + as a fraction of the crop size. + crop_sampling: Crop centers sampling method. Must be either: + "uniform" (randomly over the image), + "keypoints" (randomly over the annotated keypoints), + "density" (weighing preferentially dense regions of keypoints), + "hybrid" (alternating randomly between "uniform" and "density"). + """ + super().__init__(height, width, always_apply=True) + # Clamp to 40% of crop size to ensure that at least + # the center keypoint remains visible after the offset is applied. + self.max_shift = max(0.0, min(max_shift, 0.4)) + if crop_sampling not in ("uniform", "keypoints", "density", "hybrid"): + raise ValueError( + f"Invalid sampling {crop_sampling}. Must be " + f"either 'uniform', 'keypoints', 'density', or 'hybrid." + ) + self.crop_sampling = crop_sampling + + @staticmethod + def calc_n_neighbors(xy: NDArray, radius: float) -> NDArray: + d = pdist(xy, "sqeuclidean") + mat = squareform(d <= radius * radius, checks=False) + return np.sum(mat, axis=0) + + @property + def targets_as_params(self) -> list[str]: + return ["image", "keypoints"] + + def get_params_dependent_on_targets(self, params: dict[str, Any]) -> dict[str, Any]: + img = params["image"] + kpts = params["keypoints"] + shift_factors = np.random.random(2) + shift = self.max_shift * shift_factors * np.array([self.width, self.height]) + sampling = self.crop_sampling + if self.crop_sampling == "hybrid": + sampling = np.random.choice(["uniform", "density"]) + if sampling == "uniform": + center = np.random.random(2) + else: + h, w = img.shape[:2] + kpts = np.asarray(kpts)[:, :2] + kpts = kpts[~np.isnan(kpts).all(axis=1)] + n_kpts = kpts.shape[0] + inds = np.arange(n_kpts) + if sampling == "density": + # Points located close to one another are sampled preferentially + # in order to augment crowded regions. + radius = 0.1 * min(h, w) + n_neighbors = self.calc_n_neighbors(kpts, radius) + # Include keypoints in the count to avoid null probabilities + n_neighbors += 1 + p = n_neighbors / n_neighbors.sum() + else: + p = np.ones_like(inds) / n_kpts + center = kpts[np.random.choice(inds, p=p)] + # Shift the crop center in both dimensions by random amounts + # and normalize to the original image dimensions. + center = (center + shift) / [w, h] + center = np.clip(center, 0, np.nextafter(1, 0)) # Clip to 1 exclusive + return {"h_start": center[1], "w_start": center[0]} + + def get_transform_init_args_names(self) -> tuple[str, ...]: + return "width", "height", "max_shift", "crop_sampling" diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py b/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py new file mode 100644 index 0000000000..6a509f404a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py @@ -0,0 +1,29 @@ +import numpy as np +import pytest +from deeplabcut.pose_estimation_pytorch.data import transforms + + +@pytest.mark.parametrize( + "width, height", + [ + (200, 200), + (300, 300), + (400, 400), + ], +) +def test_keypoint_aware_cropping( + width, + height, +): + fake_image = np.empty((600, 600, 3)) + fake_keypoints = [(i * 100, i * 100, 0, 0) for i in range(1, 6)] + aug = transforms.KeypointAwareCrop( + width=width, height=height, crop_sampling="density" + ) + transformed = aug( + image=fake_image, + keypoints=fake_keypoints, + ) + assert transformed["image"].shape[:2] == (height, width) + # Ensure at least a keypoint is visible in each crop + assert len(transformed["keypoints"]) From fd4bb0f1e6c6d6d585c3dd82ab218ee3d0d2b6ec Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 18 Oct 2023 11:46:22 +0200 Subject: [PATCH 051/293] major model/runner refactor --- .../make_pytorch_config.py | 394 +++++++----- .../pose_estimation_pytorch/__init__.py | 5 +- .../apis/analyze_videos.py | 598 ++++-------------- .../pose_estimation_pytorch/apis/config.yaml | 34 +- .../apis/convert_detections_to_tracklets.py | 4 +- .../pose_estimation_pytorch/apis/evaluate.py | 447 ++++++++----- .../pose_estimation_pytorch/apis/inference.py | 3 +- .../pose_estimation_pytorch/apis/scoring.py | 287 +++++++++ .../pose_estimation_pytorch/apis/train.py | 228 ++++--- .../pose_estimation_pytorch/apis/utils.py | 513 +++++++++------ .../benchmark/profile_HRNetCoAM.py | 6 +- .../pose_estimation_pytorch/data/base.py | 129 +++- .../data/cocoloader.py | 1 + .../pose_estimation_pytorch/data/dataset.py | 116 ++-- .../pose_estimation_pytorch/data/dlcloader.py | 15 +- .../data/dlcproject.py | 0 .../pose_estimation_pytorch/data/helper.py | 11 +- .../data/postprocessor.py | 181 ++++++ .../data/preprocessor.py | 202 ++++++ .../pose_estimation_pytorch/data/utils.py | 146 ++++- .../pose_estimation_pytorch/default_config.py | 147 ++--- .../models/__init__.py | 6 +- .../models/backbones/__init__.py | 4 + .../models/backbones/base.py | 29 +- .../models/backbones/hrnet.py | 82 +-- .../models/backbones/resnet.py | 21 +- .../models/criterion.py | 334 ---------- .../models/criterions/__init__.py | 14 + .../models/criterions/aggregators.py | 31 + .../models/criterions/base.py | 55 ++ .../models/criterions/weighted.py | 102 +++ .../models/detectors/base.py | 40 +- .../models/detectors/fasterRCNN.py | 77 ++- .../models/heads/__init__.py | 10 +- .../models/heads/base.py | 90 ++- .../models/heads/{dekr_heads.py => dekr.py} | 108 ++-- .../models/heads/simple_head.py | 151 ++--- .../models/heads/transformer.py | 93 +++ .../pose_estimation_pytorch/models/model.py | 183 ++++-- .../models/modules/__init__.py | 7 - .../models/modules/conv_block.py | 22 +- .../models/predictors/base.py | 18 +- .../models/predictors/dekr_predictor.py | 77 +-- .../models/predictors/single_predictor.py | 47 +- .../models/predictors/top_down_prediction.py | 6 +- .../models/target_generators/base.py | 51 +- .../models/target_generators/dekr_targets.py | 177 +++--- .../target_generators/gaussian_targets.py | 155 ++--- .../target_generators/plateau_targets.py | 168 ++--- .../pose_estimation_pytorch/models/utils.py | 172 ----- .../match_predictions_to_gt.py | 3 +- .../runners/__init__.py | 15 + .../pose_estimation_pytorch/runners/base.py | 269 ++++++++ .../{solvers => runners}/logger.py | 2 + .../pose_estimation_pytorch/runners/pose.py | 124 ++++ .../{solvers => runners}/schedulers.py | 0 .../inference.py => runners/scoring.py} | 136 +--- .../runners/top_down.py | 135 ++++ .../{solvers => runners}/utils.py | 49 +- .../solvers/__init__.py | 17 - .../pose_estimation_pytorch/solvers/base.py | 297 --------- .../solvers/single_animal.py | 14 - .../solvers/top_down.py | 347 ---------- .../tests/test_data_helper.py | 44 +- .../tests/test_helper.py | 8 +- .../tests/test_plateau_targets.py | 2 +- .../tests/test_pose_model.py | 2 +- .../tests/test_single_animal.py | 155 +++-- .../tests/test_utils.py | 17 +- deeplabcut/pose_estimation_pytorch/utils.py | 91 +-- .../runners/bottum_up.py | 94 +++ 71 files changed, 4061 insertions(+), 3557 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/apis/scoring.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/dlcproject.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/postprocessor.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/preprocessor.py delete mode 100644 deeplabcut/pose_estimation_pytorch/models/criterion.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/criterions/base.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py rename deeplabcut/pose_estimation_pytorch/models/heads/{dekr_heads.py => dekr.py} (83%) create mode 100644 deeplabcut/pose_estimation_pytorch/models/heads/transformer.py delete mode 100644 deeplabcut/pose_estimation_pytorch/models/utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/runners/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/runners/base.py rename deeplabcut/pose_estimation_pytorch/{solvers => runners}/logger.py (98%) create mode 100644 deeplabcut/pose_estimation_pytorch/runners/pose.py rename deeplabcut/pose_estimation_pytorch/{solvers => runners}/schedulers.py (100%) rename deeplabcut/pose_estimation_pytorch/{solvers/inference.py => runners/scoring.py} (63%) create mode 100644 deeplabcut/pose_estimation_pytorch/runners/top_down.py rename deeplabcut/pose_estimation_pytorch/{solvers => runners}/utils.py (94%) delete mode 100644 deeplabcut/pose_estimation_pytorch/solvers/__init__.py delete mode 100644 deeplabcut/pose_estimation_pytorch/solvers/base.py delete mode 100644 deeplabcut/pose_estimation_pytorch/solvers/single_animal.py delete mode 100644 deeplabcut/pose_estimation_pytorch/solvers/top_down.py create mode 100644 tests/pose_estimation_pytorch/runners/bottum_up.py diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index cd86879091..3ce46c7148 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -1,10 +1,21 @@ -from typing import List +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + import torch -from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions + +from deeplabcut.utils import auxiliaryfunctions + BACKBONE_OUT_CHANNELS = { - "resnet-50": 2048, - "resnet-50": 2048, "resnet-50": 2048, "mobilenet_v2_1.0": 1280, "mobilenet_v2_0.75": 1280, @@ -103,9 +114,9 @@ def make_pytorch_config( pytorch_config["device"] = "cuda" if torch.cuda.is_available() else "cpu" pytorch_config["method"] = "bu" if net_type in single_animal_nets: - pytorch_config["model"]["heads"] = make_single_head_cfg(num_joints, net_type) - pytorch_config["model"]["target_generator"]["num_joints"] = num_joints - pytorch_config["predictor"]["num_animals"] = 1 + pytorch_config["model"]["heads"] = { + "bodypart": make_single_head_cfg(num_joints, net_type), + } if "efficientnet" in net_type: raise NotImplementedError("efficientnet config not yet implemented") @@ -115,14 +126,14 @@ def make_pytorch_config( raise NotImplementedError("hrnet config not yet implemented") elif net_type in multi_animal_nets: - num_animals = len(project_config.get("individuals", [0])) + num_individuals = len(project_config.get("individuals", [0])) if "dekr" in net_type: version = net_type.split("_")[-1] backbone_type = "hrnet_" + version num_offset_per_kpt = 15 pytorch_config["data"]["auto_padding"] = { - "min_height": 64, - "min_width": 64, + "min_height": None, + "min_width": None, "pad_width_divisor": 32, "pad_height_divisor": 32, } @@ -130,31 +141,18 @@ def make_pytorch_config( "type": "HRNet", "model_name": "hrnet_" + version, } - pytorch_config["model"]["heads"] = make_dekr_head_cfg( - num_joints, backbone_type, num_offset_per_kpt - ) + pytorch_config["model"]["heads"] = { + "bodypart": make_dekr_head_cfg( + num_individuals, num_joints, backbone_type, num_offset_per_kpt + ), + } + pytorch_config["with_center_keypoints"] = True + if compute_unique_bpts: - pytorch_config["model"]["heads"] += make_unique_bpts_head_cfg( + pytorch_config["model"]["heads"]["unique_bodypart"] = make_unique_bodyparts_head( num_unique_bpts, backbone_type ) - pytorch_config["model"]["pose_model"]["num_unique_bodyparts"] = len( - unique_bpts - ) - pytorch_config["criterion"]["unique_bodyparts"] = True - pytorch_config["model"]["target_generator"] = { - "type": "DEKRGenerator", - "num_joints": num_joints, - "pos_dist_thresh": 17, - } - pytorch_config["predictor"] = { - "type": "DEKRPredictor", - "num_animals": num_animals, - "unique_bodyparts": compute_unique_bpts, - "keypoint_score_type": "combined", - } - - pytorch_config["with_center"] = True elif "token_pose" in net_type: if compute_unique_bpts: raise NotImplementedError( @@ -163,34 +161,21 @@ def make_pytorch_config( pytorch_config["method"] = "td" version = net_type.split("_")[-1] backbone_type = "hrnet_" + version - pytorch_config["data"]["auto_padding"] = { - "min_height": 64, - "min_width": 64, - "pad_width_divisor": 32, - "pad_height_divisor": 32, - } - pytorch_config["detector"] = make_detector_cfg() - pytorch_config["detector_max_epochs"] = 500 - pytorch_config["detector_save_epochs"] = 100 + pytorch_config["data_detector"] = make_detector_data_aug() + pytorch_config["detector"] = make_detector_cfg(num_individuals) pytorch_config["model"] = make_token_pose_model_cfg( num_joints, backbone_type ) - pytorch_config["predictor"] = { - "type": "HeatmapOnlyPredictor", - "num_animals": 1, - } - pytorch_config["criterion"] = {"type": "HeatmapOnlyLoss"} - pytorch_config["solver"] = { - "type": "TopDownSolver", - } - pytorch_config["with_center"] = False + pytorch_config["criterion"] = {"type": "HeatmapOnlyCriterion"} + pytorch_config["runner"] = {"type": "PoseRunner"} + pytorch_config["with_center_keypoints"] = False else: raise NotImplementedError( "Currently no other model than dekr and token_pose are implemented" ) else: - raise ValueError("This net type is not supported by pytorch verison") + raise ValueError("This net type is not supported by DeepLabCut PyTorch") if augmenter_type == None: pytorch_config["data"] = {} @@ -202,35 +187,78 @@ def make_pytorch_config( return pytorch_config -def make_single_head_cfg(num_joints: int, net_type: str): - head_configs = [] - heatmap_heag_cfg, locref_head_cfg = {}, {} - - if "resnet" in net_type: - heatmap_heag_cfg = { - "type": "SimpleHead", - "channels": [2048, 1024, num_joints], - "kernel_size": [2, 2], - "strides": [2, 2], +def make_heatmap_head( + num_joints: int, + heatmap_channels: list[int], + locref_channels: list[int], +) -> dict: + return { + "type": "HeatmapHead", + "predictor": { + "type": "SinglePredictor", + "location_refinement": True, + "locref_stdev": 7.2801, + "num_animals": 1, + }, + "target_generator": { + "type": "PlateauGenerator", + "locref_stdev": 7.2801, + "num_joints": num_joints, + "pos_dist_thresh": 17, + }, + "criterion": { + "heatmap": { + "type": "WeightedBCECriterion", + "weight": 1.0, + }, + "locref": { + "type": "WeightedHuberCriterion", # or WeightedMSECriterion + "weight": 0.03, + } + }, + "heatmap_config": { + "channels": heatmap_channels, + "kernel_size": [2, 2], + "strides": [2, 2], + }, + "locref_config": { + "channels": locref_channels, + "kernel_size": [2, 2], + "strides": [2, 2], + }, } - head_configs.append(heatmap_heag_cfg) - locref_head_cfg = { - "type": "SimpleHead", - "channels": [2048, 1024, 2 * num_joints], - "kernel_size": [2, 2], - "strides": [2, 2], - } - head_configs.append(locref_head_cfg) - return head_configs +def make_single_head_cfg(num_joints: int, net_type: str) -> dict: + """ + Args: + num_joints: the number of keypoints to predict + net_type: the type of neural net to make the head for + + Raises: + NotImplementedError if unique bodyparts are not implemented for backbone_type + + Returns: + the head configuration + """ + if "resnet" in net_type: + return make_heatmap_head( + num_joints, + heatmap_channels=[2048, 1024, num_joints], + locref_channels=[2048, 1024, 2 * num_joints], + ) + raise NotImplementedError( + f"Heads for single animals are not yet implemented with a {net_type} " + f"backbone" + ) -def make_unique_bpts_head_cfg(num_unique_bpts: int, backbone_type: str) -> List[dict]: + +def make_unique_bodyparts_head(num_unique_bodyparts: int, backbone_type: str) -> dict: """Creates a deconvolutional head to predict unique bodyparts Args: - num_unique_bpts: number of unique bodyparts + num_unique_bodyparts: number of unique bodyparts backbone_type: type of the backbone Raises: @@ -239,120 +267,152 @@ def make_unique_bpts_head_cfg(num_unique_bpts: int, backbone_type: str) -> List[ Returns: The configs for the unique bodyparts heatmap and locref heads """ - head_configs = [] - - if backbone_type == "hrnet_w32": + if "hrnet" in backbone_type: # Only one deconvolutional layer since hrnet stride is 1/4 - heatmap_heag_cfg = { - "type": "SimpleHead", - "channels": [480, num_unique_bpts], - "kernel_size": [2, 2], - "strides": [2, 2], - } - head_configs.append(heatmap_heag_cfg) - - locref_head_cfg = { - "type": "SimpleHead", - "channels": [480, 2 * num_unique_bpts], - "kernel_size": [2, 2], - "strides": [2, 2], - } - head_configs.append(locref_head_cfg) - - else: - raise NotImplementedError( - f"Unique bodyparts prediction is not implemented yet for backbone {backbone_type}" + heatmap_in_channels = BACKBONE_OUT_CHANNELS[backbone_type] + head = make_heatmap_head( + num_unique_bodyparts, + heatmap_channels=[heatmap_in_channels, num_unique_bodyparts], + locref_channels=[heatmap_in_channels, 2 * num_unique_bodyparts], ) + head["target_generator"]["label_keypoint_key"] = "keypoints_unique" + return head - return head_configs - - -def make_dekr_head_cfg(num_joints: int, backbone_type: str, num_offset_per_kpt: int): - head_configs = [] - heatmap_heag_cfg, offset_head_cfg = {}, {} + raise NotImplementedError( + f"Unique bodyparts prediction is not implemented yet for backbone {backbone_type}" + ) - heatmap_heag_cfg = { - "type": "HeatmapDEKRHead", - "channels": [ - BACKBONE_OUT_CHANNELS[backbone_type], - 64, - num_joints + 1, - ], # +1 since we need center - "num_blocks": 1, - "dilation_rate": 1, - "final_conv_kernel": 1, - } - head_configs.append(heatmap_heag_cfg) - offset_head_cfg = { - "type": "OffsetDEKRHead", - "channels": [ - BACKBONE_OUT_CHANNELS[backbone_type], - num_offset_per_kpt * num_joints, - num_joints, - ], - "num_offset_per_kpt": num_offset_per_kpt, - "num_blocks": 1, - "dilation_rate": 1, - "final_conv_kernel": 1, +def make_dekr_head_cfg( + num_individuals: int, + num_joints: int, + backbone_type: str, + num_offset_per_kpt: int, +): + return { + "type": "DEKRHead", + "target_generator": { + "type": "DEKRGenerator", + "num_joints": num_joints, + "pos_dist_thresh": 17, + "bg_weight": 0.1, + }, + "criterion": { + "heatmap": { + "type": "WeightedBCECriterion", + "weight": 1, + }, + "offset": { + "type": "WeightedHuberCriterion", # or WeightedMSECriterion + "weight": 0.03, + } + }, + "predictor": { + "type": "DEKRPredictor", + "num_animals": num_individuals, + "keypoint_score_type": "combined", + "max_absorb_distance": 75, + }, + "heatmap_config": { + "channels": [ + BACKBONE_OUT_CHANNELS[backbone_type], + 64, + num_joints + 1, + ], # +1 since we need center + "num_blocks": 1, + "dilation_rate": 1, + "final_conv_kernel": 1, + }, + "offset_config": { + "channels": [ + BACKBONE_OUT_CHANNELS[backbone_type], + num_offset_per_kpt * num_joints, + num_joints, + ], + "num_offset_per_kpt": num_offset_per_kpt, + "num_blocks": 2, + "dilation_rate": 1, + "final_conv_kernel": 1, + }, } - head_configs.append(offset_head_cfg) - - return head_configs def make_token_pose_model_cfg(num_joints, backbone_type): - model_cfg = {} - model_cfg["backbone"] = { - "type": "HRNetTopDown", - "model_name": backbone_type, - } - - model_cfg["neck"] = { - "type": "Transformer", - "feature_size": [64, 64], - "patch_size": [4, 4], - "num_keypoints": num_joints, - "channels": 32, - "dim": 192, - "heads": 8, - "depth": 6, - } - - model_cfg["heads"] = [] - model_cfg["heads"].append( - { - "type": "TransformerHead", + return { + "backbone": { + "type": "HRNet", + "model_name": backbone_type, + "pretrained": True, + "only_high_res": True, + }, + "neck": { + "type": "Transformer", + "feature_size": [64, 64], + "patch_size": [4, 4], + "num_keypoints": num_joints, + "channels": 32, "dim": 192, - "hidden_heatmap_dim": 384, - "heatmap_dim": 4096, - "apply_multi": True, - "heatmap_size": [64, 64], - "apply_init": True, - } - ) - - model_cfg["target_generator"] = { - "type": "PlateauWithoutLocref", - "num_joints": num_joints, - "pos_dist_thresh": 17, + "heads": 8, + "depth": 6, + }, + "heads": { + "bodypart": { + "type": "TransformerHead", + "target_generator": { + "type": "PlateauGenerator", + "generate_locref": False, + "num_joints": num_joints, + "pos_dist_thresh": 17, + }, + "criterion": { + "type": "WeightedBCECriterion", + }, + "predictor": { + "type": "HeatmapOnlyPredictor", + "num_animals": 1, + }, + "dim": 192, + "hidden_heatmap_dim": 384, + "heatmap_dim": 4096, + "apply_multi": True, + "heatmap_size": [64, 64], + "apply_init": True, + }, + }, + "pose_model": {"stride": 4} } - model_cfg["pose_model"] = {"stride": 4} - return model_cfg - -def make_detector_cfg(): +def make_detector_cfg(num_individuals: int): return { - "detector_model": { - "type": "FasterRCNN", - }, - "detector_optimizer": { + "model": {"type": "FasterRCNN"}, + "optimizer": { "type": "AdamW", "params": {"lr": 1e-4}, }, - "detector_scheduler": { + "scheduler": { "type": "LRListScheduler", "params": {"milestones": [90], "lr_list": [[1e-5]]}, }, + "runner": { + "type": "DetectorRunner", + "max_individuals": num_individuals, + }, + "batch_size": 1, + "epochs": 500, + "save_epochs": 100, + "display_iters": 500, + } + + +def make_detector_data_aug() -> dict: + return { + "covering": True, + "gaussian_noise": 12.75, + "hist_eq": True, + "motion_blur": True, + "normalize_images": True, + "rotation": 30, + "scale_jitter": [0.5, 1.25], + "translation": 40, } diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index baf34f74bc..64c6fb11e2 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -1,5 +1,8 @@ from deeplabcut.pose_estimation_pytorch.data.base import Loader -from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset, PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.data.dataset import ( + PoseDataset, + PoseDatasetParameters, +) from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader from deeplabcut.pose_estimation_pytorch.utils import fix_seeds diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 8be2c0caa5..8a880c4d17 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -4,429 +4,114 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + +import copy import pickle -import sys import time from pathlib import Path -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple, Union, Any import albumentations as A -import cv2 -import deeplabcut.pose_estimation_pytorch as dlc import numpy as np import pandas as pd -import torch from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( convert_detections2tracklets, ) -from deeplabcut.pose_estimation_pytorch.apis.inference import ( - get_detections_batch, - get_predictions_bottom_up, -) from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_inference_transform, - build_pose_model, get_detector_snapshots, get_model_snapshots, - videos_in_folder, -) -from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS, BaseDetector -from deeplabcut.pose_estimation_pytorch.models.model import PoseModel -from deeplabcut.pose_estimation_pytorch.models.predictors import ( - PREDICTORS, - BasePredictor, + list_videos_in_folder, + get_runners, ) +from deeplabcut.pose_estimation_pytorch.runners import Runner from deeplabcut.refine_training_dataset.stitch import stitch_tracklets from deeplabcut.utils import VideoReader, auxfun_multianimal, auxiliaryfunctions -from skimage.transform import resize -from skimage.util import img_as_ubyte -from tqdm import tqdm -def video_inference( - model: PoseModel, - predictor: BasePredictor, - video_path: Path, - batch_size: int = 1, - device: Optional[str] = None, - transform: Optional[A.Compose] = None, - colormode: Optional[str] = "RGB", - method: Optional[str] = "bu", - detector: Optional[BaseDetector] = None, - max_num_animals: Optional[int] = 1, - num_keypoints: Optional[int] = 1, - frames_resized: Optional[bool] = False, -) -> Tuple[np.ndarray, np.ndarray]: - """ - TODO: This should be refactored to use the `inference` code with a `video dataset` +class VideoIterator(VideoReader): + """A class to iterate over videos, with possible added context""" - Runs inference on all frames of a video + def __init__( + self, + video_path: str, + context: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(video_path) + self._context = context + self._index = 0 - Args: - model: the model with which to run inference - predictor: the predictor to use alongside the model - video_path: the path to the video onto which inference should be run - batch_size: the batch size with which to run inference - device: the torch device to use to run inference. Dynamic selection if None - transform: the image augmentation transform to use on the video frames, if any - colormode: RGB or BGR - method: 'td' (Top Down) or 'bu' (Bottom Up) - detector: Detector for top down approach - max_num_animals: max number of animals - num_keypoints: number of keypoints - frames_resized: Whether the frame are resized for inference or not + def get_context(self) -> list[dict[str, Any]] | None: + if self._context is None: + return None - Returns: - array of shape (num_frames, max_num_animals, num_keypoints, 3) for pose predictions - empty array if unique bodyparts are not handled by the model, or array of shape - (num_frames, num_unique_bodyparts, 3) for unique bodypart pose predictions - """ + return copy.deepcopy(self._context) - if method.lower() == "bu": - return bottom_up_video_inference( - model, - predictor, - video_path, - batch_size, - device, - transform, - colormode, - frames_resized, - ) - elif method.lower() == "td": - return top_down_video_inference( - detector, - model, - predictor, - video_path, - batch_size, - device, - transform, - colormode, - max_num_animals, - num_keypoints, - frames_resized, - ) + def set_context(self, context: list[dict[str, Any]] | None) -> None: + if context is None: + self._context = None + return + self._context = copy.deepcopy(context) -def bottom_up_video_inference( - model: PoseModel, - predictor: BasePredictor, - video_path: Path, - batch_size: int = 1, - device: Optional[str] = None, - transform: Optional[A.Compose] = None, - colormode: Optional[str] = "RGB", - frames_resized: Optional[bool] = False, -) -> Tuple[np.ndarray, np.ndarray]: - """Does batched inference for top down over a video - - Args: - model: pose_estimator - predictor: predictor to regress the coordinates inside the cropped image from the pose_estimator's outputs - video_path: path to the video/folder of videos - batch_size: batch_size. Defaults to 1. - device: device on which to run inference. Defaults to None. - transform: transform for inference (normalizing, padding...). Defaults to None. - colormode: "BGR" or "RGB. Defaults to "RGB". - frames_resized: Whether the frames were resized or not. Defaults to False. - - Returns: - array of shape (num_frames, max_num_animals, num_keypoints, 3) for pose predictions - array of shape (num_frames, num_unique_bodyparts, 3) for unique bodypart pose predictions - """ - if device is None: - device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + def __iter__(self): + return self - # Set the model to eval mode and put it on the device - model.eval() - model.to(device) + def __next__(self) -> np.ndarray | tuple[str, dict[str, Any]]: + frame = self.read_frame() + if frame is None: + self._index = 0 + self.reset() + raise StopIteration - print(f"Loading {video_path}") - video_reader = VideoReader(str(video_path)) - n_frames = video_reader.get_n_frames() - vid_w, vid_h = video_reader.dimensions - print( - f"Video metadata: \n" - f" n_frames: {n_frames}\n" - f" fps: {video_reader.fps}\n" - f" resolution: w={vid_w}, h={vid_h}\n" - ) + # Otherwise ValueError: At least one stride in the given numpy array is negative, + # and tensors with negative strides are not currently supported. (You can probably + # work around this by making a copy of your array with array.copy().) + frame = frame.copy() + if self._context is None: + self._index += 1 + return frame - pbar = tqdm(total=n_frames, file=sys.stdout) - predictions = [] - all_unique_predictions = [] - frame = video_reader.read_frame() - original_size = frame.shape - transformed_size = original_size - if transform: - # Apply transformation once only to see the shape after transformation - transformed_size = transform(image=frame, keypoints=[])["image"].shape - - batch_ind = 0 # Index of the current img in batch - batch_frames = np.empty((batch_size, transformed_size[0], transformed_size[1], 3)) - - with torch.no_grad(): - while frame is not None: - if frame.dtype != np.uint8: - frame = img_as_ubyte(frame) - - if colormode == "BGR": - frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) - - if transform: - frame = transform(image=frame, keypoints=[])["image"] - - batch_frames[batch_ind] = frame - if batch_ind == batch_size - 1: - batch = torch.tensor( - batch_frames, device=device, dtype=torch.float - ).permute(0, 3, 1, 2) - batched_predictions, unique_predictions = get_predictions_bottom_up( - model=model, - predictor=predictor, - images=batch, - ) - for frame_pred in batched_predictions: - if frames_resized: - resizing_factor = (original_size[0] / transformed_size[0]), ( - original_size[1] / transformed_size[1] - ) - frame_pred[:, :, 0] = ( - frame_pred[:, :, 0] * resizing_factor[1] - + resizing_factor[1] / 2 - ) - frame_pred[:, :, 1] = ( - frame_pred[:, :, 1] * resizing_factor[0] - + resizing_factor[0] / 2 - ) - predictions.append(frame_pred) - if unique_predictions is not None: - for frame_unique in unique_predictions: - if frames_resized: - resizing_factor = ( - original_size[0] / transformed_size[0] - ), (original_size[1] / transformed_size[1]) - frame_unique[:, :, 0] = ( - frame_unique[:, :, 0] * resizing_factor[1] - + resizing_factor[1] / 2 - ) - frame_unique[:, :, 1] = ( - frame_unique[:, :, 1] * resizing_factor[0] - + resizing_factor[0] / 2 - ) - all_unique_predictions.append(frame_unique) - - frame = video_reader.read_frame() - batch_ind += 1 - batch_ind = batch_ind % batch_size - pbar.update(1) - - pbar.close() - - return np.array(predictions), np.array(all_unique_predictions) - - -def top_down_video_inference( - detector: BaseDetector, - model: PoseModel, - predictor: BasePredictor, - video_path: Path, - batch_size: int = 1, - device: Optional[str] = None, - transform: Optional[A.Compose] = None, - colormode: Optional[str] = "RGB", - max_num_animals: Optional[int] = 1, - num_keypoints: Optional[int] = 1, - frames_resized: Optional[bool] = False, -) -> Tuple[np.ndarray, np.ndarray]: - """Does batched inference for top down over a video + context = copy.deepcopy(self._context[self._index]) + self._index += 1 + return frame, context - Args: - detector: detector used to detect animals. - model: pose_estimator - predictor: predictor to regress the coordinates inside the cropped image from the pose_estimator's outputs - video_path: path to the video/folder of videos - batch_size: batch_size. Defaults to 1. - device: device on which to run inference. Defaults to None. - transform: transform for inference (normalizing, padding...). Defaults to None. - colormode: "BGR" or "RGB. Defaults to "RGB". - max_num_animals: Maximum number of animals. Defaults to 1. - num_keypoints: Number of keypoints. Defaults to 1. - frames_resized: Whether the frames were resized or not. Defaults to False. - Returns: - array of shape (num_frames, max_num_animals, num_keypoints, 3) for pose predictions - empty array for unique bodyparts (not handled by top down) - """ - if device is None: - device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") - - # Set the models to eval mode and put it on the device - model.eval() - model.to(device) - detector.eval() - detector.to(device) - top_down_predictor = PREDICTORS.build( - {"type": "TopDownPredictor", "format_bbox": "xyxy"} - ) - print(f"Loading {video_path}") - video_reader = VideoReader(str(video_path)) - n_frames = video_reader.get_n_frames(robust=False) - vid_w, vid_h = video_reader.dimensions +def video_inference( + video_path: str | Path, + task: str, + pose_runner: Runner, + detector_runner: Runner | None = None, +) -> tuple[np.ndarray, np.ndarray | None]: + """Runs inference on a video""" + video = VideoIterator(str(video_path)) + n_frames = video.get_n_frames() + vid_w, vid_h = video.dimensions print( f"Video metadata: \n" f" n_frames: {n_frames}\n" - f" fps: {video_reader.fps}\n" + f" fps: {video.fps}\n" f" resolution: w={vid_w}, h={vid_h}\n" ) - pbar = tqdm(total=n_frames, file=sys.stdout) - frame = video_reader.read_frame() - original_size = frame.shape - transformed_size = original_size - if transform: - # Apply transformation once only to see the shape after transformation - transformed_size = transform(image=frame, keypoints=[])["image"].shape - - # Animal detections - batch_ind_detect = 0 # Index of the current img in batch - batch_detect_frames = np.empty( - (batch_size, transformed_size[0], transformed_size[1], 3) - ) + if task == "TD": + # Get bounding boxes for context + if detector_runner is None: + raise ValueError("Must use a detector for top-down video analysis") - detections_list = [] - with torch.no_grad(): - while frame is not None: - if frame.dtype != np.uint8: - frame = img_as_ubyte(frame) - - if colormode == "BGR": - frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) - - if transform: - frame = transform(image=frame, keypoints=[])["image"] - - batch_detect_frames[batch_ind_detect] = frame - if batch_ind_detect == batch_size - 1: - batch = torch.tensor( - batch_detect_frames, device=device, dtype=torch.float - ).permute(0, 3, 1, 2) - batched_detections = get_detections_batch( - detector, batch, max_num_animals - ) - for detect in batched_detections: - detections_list.append(detect) - - frame = video_reader.read_frame() - batch_ind_detect += 1 - batch_ind_detect = batch_ind_detect % batch_size - pbar.update(1) - if batch_ind_detect != 0: - batch = torch.tensor( - batch_detect_frames, device=device, dtype=torch.float - ).permute(0, 3, 1, 2) - batched_detections = get_detections_batch(detector, batch, max_num_animals) - for detect in batched_detections[:batch_ind_detect]: - detections_list.append(detect) - - pbar.close() - detections = torch.stack(detections_list) - print("Detections are done, moving to estimating poses...") - - # update n_frames to have robust value - n_frames = len(detections) - - # Pose estimation - batch_ind_pose = 0 # Index of the current pose img in batch - batch_pose_frames = torch.empty( - (batch_size, 3, 256, 256), device=device - ) # TODO 256 hardcoded - - # This array stores (image_idx, animal_idx, bbox_coords) - # To be able to go back to it from batched cropped images - batch_image_infos = torch.zeros((batch_size, 6)) - cropped_predictions = torch.full( - (n_frames, max_num_animals, num_keypoints, 3), -1.0 - ) + bbox_predictions = detector_runner.inference(images=video) + video.set_context(bbox_predictions) - video_reader.reset() - frame = video_reader.read_frame() - frame_index = 0 - pbar = tqdm(total=n_frames, file=sys.stdout) - with torch.no_grad(): - while frame is not None: - if frame.dtype != np.uint8: - frame = img_as_ubyte(frame) - - if colormode == "BGR": - frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) - - if transform is not None: - frame = transform(image=frame, keypoints=[])["image"] - - for animal_idx, box in enumerate(detections[frame_index]): - if (box == 0.0).all(): - continue - cropped_image = frame[box[1] : box[3] + 1, box[0] : box[2] + 1, :] - cropped_image = resize( - cropped_image, (256, 256) - ) # TODO: hardcoded for now - cropped_image = torch.tensor(cropped_image.transpose(2, 0, 1)).to( - device - ) - batch_pose_frames[batch_ind_pose] = cropped_image - batch_image_infos[batch_ind_pose, 0] = frame_index - batch_image_infos[batch_ind_pose, 1] = animal_idx - batch_image_infos[batch_ind_pose, 2:] = box - - if batch_ind_pose == batch_size - 1: - model_outputs = model(batch_pose_frames) - # TODO hardcoded for now - scale_factors = ( - 256 / model_outputs[0].shape[2], - 256 / model_outputs[0].shape[3], - ) - pose_pred_dict = predictor(model_outputs, scale_factors) - pose_predictions = pose_pred_dict["poses"] - for idx, prediction in enumerate(pose_predictions): - cropped_predictions[ - batch_image_infos[idx, 0].detach().int(), - batch_image_infos[idx, 1].detach().int(), - ] = prediction[0] - - batch_ind_pose += 1 - batch_ind_pose = batch_ind_pose % batch_size - - frame = video_reader.read_frame() - frame_index += 1 - pbar.update(1) - - # Left cropped images - if batch_ind_pose != 0: - model_outputs = model(batch_pose_frames) - # TODO hardcoded for now - scale_factors = ( - 256 / model_outputs[0].shape[2], - 256 / model_outputs[0].shape[3], - ) - pose_pred_dict = predictor(model_outputs, scale_factors) - pose_predictions = pose_pred_dict["poses"] - for idx, prediction in enumerate(pose_predictions): - if batch_image_infos[idx, 0] != -1.0: - cropped_predictions[ - batch_image_infos[idx, 0].detach().int(), - batch_image_infos[idx, 1].detach().int(), - ] = prediction[0] - pbar.close() - pred_dict = top_down_predictor(detections, cropped_predictions) - predictions = pred_dict["poses"] - print("Keypoints coordinate prediction done !") - return predictions.detach().cpu().numpy(), np.array([]) + predictions = pose_runner.inference(images=video) + poses = np.stack([p["bodyparts"] for p in predictions]) + unique_poses = None + if len(predictions) > 0 and "unique_bodyparts" in predictions[0]: + unique_poses = np.stack([p["unique_bodyparts"] for p in predictions]) + return poses, unique_poses def analyze_videos( @@ -436,8 +121,7 @@ def analyze_videos( shuffle: int = 1, trainingsetindex: int = 0, snapshotindex: Optional[int] = None, - device: Optional[str] = None, # TODO: Keep "device" instead of gputouse - # TODO: save_as_csv + device: Optional[str] = None, destfolder: Optional[str] = None, batchsize: Optional[int] = None, modelprefix: str = "", @@ -445,10 +129,15 @@ def analyze_videos( auto_track: Optional[bool] = True, identity_only: Optional[bool] = False, overwrite: bool = False, - # TODO: other options such as auto_track ) -> List[Tuple[str, pd.DataFrame]]: """Makes prediction based on a trained network. + # TODO: + - allow batch size greater than 1 + - other options such as save_as_csv + - pass detector path or detector runner + - add TQDM to runner + The index of the trained network is specified by parameters in the config file (in particular the variable 'snapshot_index'). @@ -497,31 +186,30 @@ def analyze_videos( _create_output_folder(destfolder) # Load the project configuration - project = dlc.DLCProject( - shuffle=shuffle, - proj_root=str(Path(config).parent), - ) - project.convert2dict(mode="test") - project_path = Path(project.cfg["project_path"]) - train_fraction = project.cfg["TrainingFraction"][trainingsetindex] + cfg = auxiliaryfunctions.read_config(config) + project_path = Path(cfg["project_path"]) + train_fraction = cfg["TrainingFraction"][trainingsetindex] model_folder = project_path / auxiliaryfunctions.get_model_folder( train_fraction, shuffle, - project.cfg, + cfg, modelprefix=modelprefix, ) - model_path = _get_model_path(model_folder, snapshotindex, project.cfg) + model_path = _get_model_path(model_folder, snapshotindex, cfg) model_epochs = int(model_path.stem.split("-")[-1]) dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( - project.cfg, + cfg, shuffle, train_fraction, trainingsiterations=model_epochs, modelprefix=modelprefix, ) # Get general project parameters - max_num_animals = len(project.cfg.get("individuals", ["animal"])) - num_keypoints = len(auxiliaryfunctions.get_bodyparts(project.cfg)) + bodyparts = auxiliaryfunctions.get_bodyparts(cfg) + unique_bodyparts = auxiliaryfunctions.get_unique_bodyparts(cfg) + individuals = cfg.get("individuals", ["animal"]) + max_num_animals = len(individuals) + num_keypoints = len(bodyparts) # Read the inference configuration, load the model pytorch_config = auxiliaryfunctions.read_plainconfig( @@ -529,47 +217,28 @@ def analyze_videos( ) pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" pose_cfg = auxiliaryfunctions.read_plainconfig(pose_cfg_path) - method = pytorch_config.get("method", "bu") - - # Get model parameters - # TODO: Should we get the batch size from the inference pose_cfg? Or have an - # inference pytorch_cfg? - if batchsize is None: - batchsize = pytorch_config.get("batch_size", 1) - pose_cfg["batch_size"] = batchsize - individuals = project.cfg.get("individuals", ["animal"]) - - # Get data processing parameters - # if images are resized for inference, - # need to take that into account to go back to original space - frames_resized_with_transform = pytorch_config["data"].get("resize", False) - - # Load model, predictor - model = build_pose_model(pytorch_config["model"], pose_cfg) - try: - model.load_state_dict(torch.load(model_path)["model_state_dict"]) - except KeyError: - model.load_state_dict(torch.load(model_path)) - predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) - detector = None - top_down_predictor = None - if method.lower() == "td": - detector_path = _get_detector_path(model_folder, snapshotindex, project.cfg) - detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) - try: - detector.load_state_dict(torch.load(detector_path)["detector_state_dict"]) - except KeyError: - detector.load_state_dict(torch.load(detector_path)) - - # Load inference - if transform is None: - print("No transform passed, using default normalisation from config") - transform = build_inference_transform( - pytorch_config["data"], augment_bbox=False - ) + method = pytorch_config.get("method", "BU").upper() + + if device is not None: + pytorch_config["device"] = device + + detector_path = None + if method == "TD": + # TODO: Choose which detector to use + detector_path = _get_detector_path(model_folder, -1, cfg) + + print(f"Analyzing videos with {model_path}") + pose_runner, detector_runner = get_runners( + pytorch_config=pytorch_config, + snapshot_path=str(model_path), + with_unique_bodyparts=(len(unique_bodyparts) > 0), + transform=transform, + detector_path=detector_path, + detector_transform=None, + ) # Reading video and init variables - videos = videos_in_folder(videos, videotype) + videos = list_videos_in_folder(videos, videotype) results = [] for video in videos: if destfolder is None: @@ -585,26 +254,17 @@ def analyze_videos( else: runtime = [time.time()] predictions, unique_predictions = video_inference( - model=model, - predictor=predictor, video_path=video, - batch_size=batchsize, - device=device, - transform=transform, - colormode=pytorch_config.get("colormode", "RGB"), - method=method, - detector=detector, - max_num_animals=max_num_animals, - num_keypoints=num_keypoints, - frames_resized=frames_resized_with_transform, + pose_runner=pose_runner, + task=method, + detector_runner=detector_runner, ) runtime.append(time.time()) print(f"Inference is done for {video}! Saving results...") metadata = _generate_metadata( - config=project.cfg, - pose_config=pose_cfg, - pytorch_pose_config=pytorch_config, + cfg=cfg, + pytorch_config=pytorch_config, dlc_scorer=dlc_scorer, train_fraction=train_fraction, batch_size=batchsize, @@ -627,7 +287,7 @@ def analyze_videos( results_df_index = pd.MultiIndex.from_product( [ [dlc_scorer], - auxiliaryfunctions.get_bodyparts(project.cfg), + auxiliaryfunctions.get_bodyparts(cfg), coordinate_labels, ], names=["scorer", "bodyparts", "coords"], @@ -637,12 +297,12 @@ def analyze_videos( columns=results_df_index, index=range(len(predictions)), ) - if unique_predictions.size: + if unique_predictions is not None: coordinate_labels_unique = ["x", "y", "likelihood"] results_unique_df_index = pd.MultiIndex.from_product( [ [dlc_scorer], - auxiliaryfunctions.get_unique_bodyparts(project.cfg), + auxiliaryfunctions.get_unique_bodyparts(cfg), coordinate_labels_unique, ], names=["scorer", "bodyparts", "coords"], @@ -666,7 +326,7 @@ def analyze_videos( output_data, metadata, str(output_h5) ) - if project.cfg["multianimalproject"] and len(individuals) > 1: + if cfg["multianimalproject"] and len(individuals) > 1: output_ass = output_path / f"{output_prefix}_assemblies.pickle" assemblies = {} for i, prediction in enumerate(predictions): @@ -678,7 +338,7 @@ def analyze_videos( ass = np.concatenate((prediction, extra_column), axis=-1) assemblies[i] = ass - if unique_predictions.size: + if unique_predictions is not None: assemblies["single"] = {} for i, unique_prediction in enumerate(unique_predictions): extra_column = np.full( @@ -750,9 +410,8 @@ def _create_output_folder(output_folder: Optional[Path]) -> None: def _generate_metadata( - config: dict, - pose_config: dict, - pytorch_pose_config: dict, + cfg: dict, + pytorch_config: dict, dlc_scorer: str, train_fraction: int, batch_size: int, @@ -760,13 +419,13 @@ def _generate_metadata( video: VideoReader, ) -> dict: w, h = video.dimensions - cropping = config.get("cropping", False) + cropping = cfg.get("cropping", False) if cropping: cropping_parameters = [ - config["x1"], - config["x2"], - config["y1"], - config["y2"], + cfg["x1"], + cfg["x2"], + cfg["y1"], + cfg["y2"], ] else: cropping_parameters = [0, w, 0, h] @@ -776,13 +435,12 @@ def _generate_metadata( "stop": runtime[1], "run_duration": runtime[1] - runtime[0], "Scorer": dlc_scorer, - "DLC-model-config file": pose_config, - "DLC-model-pytorch-config file": pytorch_pose_config, + "pytorch-config": pytorch_config, "fps": video.fps, "batch_size": batch_size, "frame_dimensions": (w, h), "nframes": video.get_n_frames(), - "iteration (active-learning)": config["iteration"], + "iteration (active-learning)": cfg["iteration"], "training set fraction": train_fraction, "cropping": cropping, "cropping_parameters": cropping_parameters, @@ -813,7 +471,11 @@ def _get_model_path(model_folder: Path, snapshot_index: int, config: dict) -> Pa return trained_models[snapshot_index] -def _get_detector_path(model_folder: Path, snapshot_index: int, config: dict) -> Path: +def _get_detector_path( + model_folder: Path, + snapshot_index: int | str, + config: dict | None, +) -> Path: trained_models = get_detector_snapshots(model_folder / "train") if snapshot_index is None: diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml index 4496edbf26..f045ea6dd0 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -1,22 +1,6 @@ batch_size: 1 -cfg_path: /data/quentin/datasets/daniel3mouse/config.yaml +cfg_path: /project/config.yaml colormode: 'RGB' -criterion: - locref_huber_loss: true - loss_weight_locref: 0.02 - unique_bodyparts: false - type: PoseLoss -cropped_data: # Used only for Top-Down approach - covering: true - gaussian_noise: 12.75 - hist_eq: true - motion_blur: true - normalize_images: true - rotation: 30 - scale_jitter: - - 0.5 - - 1.25 - translation: 40 data: covering: true gaussian_noise: 12.75 @@ -37,22 +21,10 @@ model: type: ResNet pose_model: stride: 8 - num_unique_bodyparts : 0 - target_generator: - locref_stdev: 7.2801 - num_joints: -1 - pos_dist_thresh: 17 - type: PlateauGenerator optimizer: params: lr: 0.0001 type: AdamW -pos_dist_thresh: 17 -predictor: - location_refinement: true - locref_stdev: 7.2801 - num_animals: -1 - type: SinglePredictor save_epochs: 50 scheduler: params: @@ -64,6 +36,6 @@ scheduler: - 120 type: LRListScheduler seed: 42 -solver: - type: BottomUpSingleAnimalSolver +runner: + type: PoseRunner with_center: false diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index 61b49d3b46..922cd8933c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -20,7 +20,7 @@ from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_model_snapshots, - videos_in_folder, + list_videos_in_folder, ) from deeplabcut.pose_estimation_tensorflow import load_config from deeplabcut.pose_estimation_tensorflow.lib import trackingutils @@ -120,7 +120,7 @@ def convert_detections2tracklets( ) # TODO: deal with lists of strings - videos = videos_in_folder(videos, videotype) + videos = list_videos_in_folder(videos, videotype) if len(videos) == 0: print(f"No videos were found in {videos}") return diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 5986a1d5f9..0d919dc9be 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -1,73 +1,186 @@ -""" -DeepLabCut2.0 Toolbox (deeplabcut.org) -© A. & M. Mathis Labs -https://github.com/DeepLabCut/DeepLabCut -Please see AUTHORS for contributors. - -https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -Licensed under GNU Lesser General Public License v3.0 -""" +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + import argparse -import os from pathlib import Path -from typing import Dict, Iterable, List, Optional, Union +from typing import Iterable import albumentations as A -import deeplabcut.pose_estimation_pytorch as dlc +import numpy as np import pandas as pd -import torch -from deeplabcut.pose_estimation_pytorch.apis.inference import inference -from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_inference_transform, - build_pose_model, + +import deeplabcut.pose_estimation_pytorch as dlc +import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils +from deeplabcut.pose_estimation_pytorch import Loader, DLCLoader +from deeplabcut.pose_estimation_pytorch.apis.scoring import ( + align_predicted_individuals_to_gt, + get_scores, ) -from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS -from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS -from deeplabcut.pose_estimation_pytorch.solvers.inference import get_scores -from deeplabcut.pose_estimation_pytorch.solvers.utils import ( - get_model_folder, - get_paths, - get_results_filename, - get_snapshots, - build_entire_pred_df, +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_predictions_dataframe, + ensure_multianimal_df_format, + get_runners, ) +from deeplabcut.pose_estimation_pytorch.runners import Runner from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.visualization import plot_evaluation_results -def ensure_multianimal_df_format(df_predictions: pd.DataFrame) -> pd.DataFrame: +def predict( + task: str, + pose_runner: Runner, + loader: Loader, + mode: str, + detector_runner: Runner | None = None, +) -> tuple[list[str], list[dict[str, dict[str, np.ndarray]]]]: + """Predicts poses on data contained in a loader + Args: + task: {'TD', 'BU'} Whether the model is a top-down or bottom-up model + pose_runner: The runner to use for pose estimation + loader: The loader containing the data to predict poses on + mode: {"train", "test"} The mode to predict on + detector_runner: If the task is "TD", a detector runner can be given to detect + individuals in the images. If no detector is given, ground truth bounding + boxes will be used to crop individuals before pose estimation + + Returns: + The paths of images for which predictions were computed + For each image, the predictions made by each model head """ - Convert dataframe to 'multianimal' format (with an "individuals" columns index) + if task not in ["BU", "TD"]: + raise ValueError( + f"Task should be set to either 'BU' (Bottom Up) or 'TD' (Top Down), " + f"currently it is {task}" + ) + + image_paths = loader.image_filenames(mode) + context = None + + if task == "TD": + # Get bounding boxes for context + if detector_runner is not None: + bbox_predictions = detector_runner.inference(images=image_paths) + context = bbox_predictions + else: + ground_truth_bboxes = loader.ground_truth_bboxes(mode=mode) + context = [{"bboxes": ground_truth_bboxes[image]} for image in image_paths] + + images = image_paths + if context is not None: + if len(context) != len(image_paths): + raise ValueError( + f"Missing context for some images: {len(context)} != {len(image_paths)}" + ) + images = list(zip(image_paths, context)) + + predictions = pose_runner.inference(images=images) + return image_paths, predictions # TODO: include bounding boxes if there are any + +def evaluate( + scorer: str, + task: str, + pose_runner: Runner, + loader: Loader, + mode: str, + detector_runner: Runner | None = None, + pcutoff: float = 1, +) -> tuple[dict[str, float], pd.DataFrame]: + """ Args: - df_predictions: the dataframe to convert + scorer: The name of the model making the predictions + task: {'BU' or 'TD'} Whether to run top-down or bottom-up + pose_runner: The runner for pose estimation + loader: The loader containing the data to evaluate + mode: Either 'train' or 'test' + detector_runner: If task == 'TD', a detector can be given to compute bounding + boxes for pose estimation. If no detector is given, ground truth bounding + boxes are used + pcutoff: The p-cutoff to use for evaluation Returns: - the dataframe in MA format + A dict containing the evaluation results + A dataframe in DLC-format containing the predictions """ - df_predictions_ma = df_predictions.copy() - try: - df_predictions_ma.columns.get_level_values("individuals").unique().tolist() - except KeyError: - new_cols = pd.MultiIndex.from_tuples( - [(col[0], "animal", col[1], col[2]) for col in df_predictions_ma.columns], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - df_predictions_ma.columns = new_cols - return df_predictions_ma + parameters = loader._get_dataset_parameters() + image_paths, predictions = predict( + task=task, + pose_runner=pose_runner, + loader=loader, + mode=mode, + detector_runner=detector_runner, + ) + # TODO: move this to postprocessing step + poses = {} + for filename, pred in zip(image_paths, predictions): + keypoints = pred["bodyparts"] + if len(keypoints) < parameters.max_num_animals: + padded_keypoints = np.empty( + (parameters.max_num_animals, *keypoints.shape[1:]) + ) + padded_keypoints.fill(-1) + padded_keypoints[: len(keypoints), ...] = keypoints + keypoints = padded_keypoints + poses[filename] = keypoints + + unique_poses = None + gt_unique_keypoints = None + if parameters.num_unique_bpts > 1: + unique_poses = { + filename: pred["unique_bodyparts"] + for filename, pred in zip(image_paths, predictions) + } + gt_unique_keypoints = loader.ground_truth_keypoints(mode, unique_bodypart=True) + + gt_keypoints = loader.ground_truth_keypoints(mode) + if parameters.max_num_animals > 1: + poses = align_predicted_individuals_to_gt(poses, gt_keypoints) + + # TODO: Check single animal mAP computation + results = get_scores( + poses, + gt_keypoints, + pcutoff=pcutoff, + unique_bodypart_poses=unique_poses, + unique_bodypart_gt=gt_unique_keypoints, + ) + + image_name_to_index = None + if isinstance(loader, DLCLoader): + image_name_to_index = image_to_dlc_df_index + + df_predictions = build_predictions_dataframe( + scorer=scorer, + images=image_paths, + bodypart_predictions=poses, + unique_bodypart_predictions=unique_poses, + parameters=parameters, + image_name_to_index=image_name_to_index, + ) + return results, df_predictions def evaluate_snapshot( - cfg: Dict, + cfg: dict, shuffle: int = 0, trainingsetindex: int = -1, snapshotindex: int = -1, - transform: Union[A.BasicTransform, A.Compose] = None, - plotting: Union[bool, str] = False, + device: str | None = None, + transform: A.Compose | None = None, + plotting: bool | str = False, show_errors: bool = True, modelprefix: str = "", - batch_size: int = 1, -) -> None: + detector_path: str | None = None, +) -> pd.DataFrame: """Evaluates a snapshot. The evaluation results are stored in the .h5 and TODO .csv file under the subdirectory 'evaluation_results'. @@ -85,138 +198,120 @@ def evaluate_snapshot( - snapshot-100.pt and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None, the snapshotindex is loaded from the project configuration. + device: the device to run evaluation on transform: transformation pipeline for evaluation ** Should normalise the data the same way it was normalised during training ** plotting: Plots the predictions on the train and test images. If provided it must be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting to ``True`` defaults as ``"bodypart"`` for multi-animal projects. show_errors: whether to compare predictions and ground truth - batch_size: the batch size to use for evaluation - - Returns: - None + detector_path: Only for TD models. If defined, evaluation metrics are computed + using the detections made by this detector """ - # reading pytorch config train_fraction = cfg["TrainingFraction"][trainingsetindex] - modelfolder = os.path.join( + model_folder = runner_utils.get_model_folder( cfg["project_path"], - auxiliaryfunctions.get_model_folder( - train_fraction, - shuffle, - cfg, - modelprefix=modelprefix, - ), + cfg, + train_fraction, + shuffle, + modelprefix, ) - individuals = cfg.get("individuals", ["animal"]) - bodyparts = auxiliaryfunctions.get_bodyparts(cfg) - unique_bodyparts = auxiliaryfunctions.get_unique_bodyparts(cfg) - max_individuals = len(individuals) - num_joints = len(bodyparts) - pytorch_config = auxiliaryfunctions.read_plainconfig( - os.path.join(modelfolder, "train", "pytorch_config.yaml") - ) - method = pytorch_config.get("method", "bu") + model_config_path = str(Path(model_folder) / "train" / "pytorch_config.yaml") + pytorch_config = auxiliaryfunctions.read_plainconfig(model_config_path) + if device is not None: + pytorch_config["device"] = device + + method = pytorch_config.get("method", "bu").lower() if method not in ["bu", "td"]: raise ValueError( f"Method should be set to either 'bu' (Bottom Up) or 'td' (Top Down), " f"currently it is {method}" ) - device = pytorch_config["device"] - - if transform is None: - print("No transform passed, using default normalisation from config") - transform = build_inference_transform(pytorch_config["data"]) - # if images are resized for inference, need to map keypoints back to original space - images_resized_with_transform = pytorch_config["data"].get("resize", False) - - project = dlc.DLCProject(shuffle=shuffle, proj_root=pytorch_config["project_path"]) - names = get_paths( + loader = dlc.DLCLoader( + project_root=pytorch_config["project_path"], + model_config_path=model_config_path, + shuffle=shuffle, + ) + parameters = loader._get_dataset_parameters() + names = runner_utils.get_paths( + project_path=cfg["project_path"], train_fraction=train_fraction, model_prefix=modelprefix, shuffle=shuffle, - cfg=project.cfg, + cfg=cfg, train_iterations=snapshotindex, method=method, ) - results_filename = get_results_filename( - names["evaluation_folder"], - names["dlc_scorer"], - names["dlc_scorer_legacy"], - names["model_path"][:-3], + pcutoff = cfg.get("pcutoff") + + pose_runner, detector_runner = get_runners( + pytorch_config=pytorch_config, + snapshot_path=names["model_path"], + with_unique_bodyparts=(parameters.num_unique_bpts > 0), + transform=transform, + detector_path=detector_path, + detector_transform=None, ) - pose_cfg = auxiliaryfunctions.read_plainconfig(pytorch_config["pose_cfg_path"]) - model = build_pose_model(pytorch_config["model"], pose_cfg) - model.load_state_dict(torch.load(names["model_path"])["model_state_dict"]) - - predictor = PREDICTORS.build(dict(pytorch_config["predictor"])) - detector = None - if method.lower() == "td": - detector = DETECTORS.build(dict(pytorch_config["detector"]["detector_model"])) - detector.load_state_dict( - torch.load(names["detector_path"])["detector_state_dict"] - ) - - pcutoff = project.cfg.get("pcutoff") + predictions = {} scores = { "Training epochs": int(names["dlc_scorer"].split("_")[-1]), "%Training dataset": train_fraction, "Shuffle number": shuffle, "pcutoff": pcutoff, } - df_mode_predictions: List[pd.DataFrame] = [] - for mode in ["train", "test"]: - dataset = dlc.PoseDataset(project, transform=transform, mode=mode) - dataloader = torch.utils.data.DataLoader( - dataset, batch_size=batch_size, shuffle=False - ) - target_df = dataset.dataframe.copy() - predictions, unique_poses = inference( - dataloader=dataloader, - model=model, - predictor=predictor, - method=method, - max_individuals=max_individuals, - num_keypoints=num_joints, - device=device, - align_predictions_to_ground_truth=True, - images_resized_with_transform=images_resized_with_transform, - detector=detector, - use_ground_truth_bboxes=False, - ) - if unique_poses is not None: - unique_poses = unique_poses.reshape(target_df.index.shape[0], -1) - - df_predictions = build_entire_pred_df( - dlc_scorer=names["dlc_scorer"], - individuals=individuals, - bodyparts=bodyparts, - df_index=target_df.index, - predictions=predictions.reshape(target_df.index.shape[0], -1), - unique_bodyparts=unique_bodyparts, - unique_predictions=unique_poses, + for split in ["train", "test"]: + results, df_split_predictions = evaluate( + scorer=names["dlc_scorer"], + task=pytorch_config.get("method", "BU").upper(), + pose_runner=pose_runner, + loader=loader, + mode=split, + pcutoff=pcutoff, + detector_runner=detector_runner, ) - df_mode_predictions.append(df_predictions) - - df_predictions_ma = ensure_multianimal_df_format(df_predictions) - if plotting: - snapshot_name = Path(names["model_path"]).stem - folder_name = ( - f"{names['evaluation_folder']}/" - f"LabeledImages_{names['dlc_scorer']}_{snapshot_name}" - ) - auxiliaryfunctions.attempt_to_make_folder(folder_name) - df_combined = df_predictions_ma.merge( - target_df, left_index=True, right_index=True - ) + predictions[split] = df_split_predictions + for k, v in results.items(): + scores[f"{split} {k}"] = round(v, 2) - if isinstance(plotting, str): - plot_mode = plotting - else: - plot_mode = "bodypart" + results_filename = runner_utils.get_results_filename( + names["evaluation_folder"], + names["dlc_scorer"], + names["dlc_scorer_legacy"], + names["model_path"][:-3], + ) + df_predictions = pd.concat(predictions.values(), axis=0) + df_predictions = df_predictions.reindex(loader.df_dlc.index) + output_filename = Path(results_filename) + output_filename.parent.mkdir(parents=True, exist_ok=True) + df_predictions.to_hdf(str(output_filename), "df_with_missing") - plot_unique_bodyparts = len(unique_bodyparts) > 0 + df_scores = pd.DataFrame([scores]).set_index( + ["Training epochs", "%Training dataset", "Shuffle number", "pcutoff"] + ) + scores_filepath = Path(results_filename).with_suffix(".csv") + scores_filepath = scores_filepath.with_stem(scores_filepath.stem + "-results") + save_evaluation_results(df_scores, scores_filepath, show_errors, pcutoff) + + if plotting: + snapshot_name = Path(names["model_path"]).stem + folder_name = ( + f"{names['evaluation_folder']}/" + f"LabeledImages_{names['dlc_scorer']}_{snapshot_name}" + ) + Path(folder_name).mkdir(parents=True, exist_ok=True) + if isinstance(plotting, str): + plot_mode = plotting + else: + plot_mode = "bodypart" + + df_ground_truth = ensure_multianimal_df_format(loader.df_dlc) + for mode in ["train", "test"]: + df_combined = predictions[mode].merge( + df_ground_truth, left_index=True, right_index=True + ) + plot_unique_bodyparts = False # TODO: get from parameters plot_evaluation_results( df_combined=df_combined, project_root=cfg["project_path"], @@ -232,38 +327,20 @@ def evaluate_snapshot( p_cutoff=cfg["pcutoff"], ) - mode_scores = get_scores(df_predictions, target_df, pcutoff) - for k, v in mode_scores.items(): - scores[f"{mode} {k}"] = round(v, 2) - - # Create the output dataframe - df_all_predictions = pd.concat(df_mode_predictions, axis=0) - # Re-Index the DataFrame in the same order as the ground truth dataframe - df_all_predictions = df_all_predictions.reindex(project.dlc_df.index) - - output_filename = Path(results_filename) - output_filename.parent.mkdir(parents=True, exist_ok=True) - - df_all_predictions.to_hdf(str(output_filename), "df_with_missing") - - df_scores = pd.DataFrame([scores]).set_index( - ["Training epochs", "%Training dataset", "Shuffle number", "pcutoff"] - ) - scores_filepath = Path(results_filename).with_suffix(".csv") - scores_filepath = scores_filepath.with_stem(scores_filepath.stem + "-results") - save_evaluation_results(df_scores, scores_filepath, show_errors, pcutoff) + return df_predictions def evaluate_network( config: str, shuffles: Iterable[int] = (1,), - trainingsetindex: Union[int, str] = 0, - snapshotindex: Optional[Union[int, str]] = None, - plotting: Union[bool, str] = False, + trainingsetindex: int | str = 0, + snapshotindex: int | str | None = None, + device: str | None = None, + plotting: bool | str = False, show_errors: bool = True, - transform: Union[A.BasicTransform, A.Compose] = None, + transform: A.Compose = None, modelprefix: str = "", - batch_size: int = 1, + detector_path: str | None = None, ) -> None: """Evaluates a snapshot. @@ -283,6 +360,7 @@ def evaluate_network( - snapshot-100.pt and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None, the snapshotindex is loaded from the project configuration. + device: the device to run evaluation on plotting: Plots the predictions on the train and test images. If provided it must be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting to ``True`` defaults as ``"bodypart"`` for multi-animal projects. @@ -291,7 +369,8 @@ def evaluate_network( ** Should normalise the data the same way it was normalised during training ** modelprefix: directory containing the deeplabcut models to use when evaluating the network. By default, they are assumed to exist in the project folder. - batch_size: the batch size to use for evaluation + detector_path: Only for TD models. If defined, evaluation metrics are computed + using the detections made by this detector Examples: If you want to evaluate on shuffle 1 without plotting predictions. @@ -331,13 +410,14 @@ def evaluate_network( for train_set_index in train_set_indices: for shuffle in shuffles: if isinstance(snapshotindex, str) and snapshotindex.lower() == "all": - model_folder = get_model_folder( + model_folder = runner_utils.get_model_folder( + project_path=str(Path(config).parent), + cfg=cfg, train_fraction=cfg["TrainingFraction"][train_set_index], shuffle=shuffle, model_prefix=modelprefix, - test_cfg=cfg, ) - all_snapshots = get_snapshots(Path(model_folder)) + all_snapshots = runner_utils.get_snapshots(Path(model_folder)) snapshot_indices = list(range(len(all_snapshots))) elif isinstance(snapshotindex, int): snapshot_indices = [snapshotindex] @@ -345,19 +425,36 @@ def evaluate_network( raise ValueError(f"Invalid snapshotindex: {snapshotindex}") for snapshot in snapshot_indices: - evaluate_snapshot( + _ = evaluate_snapshot( cfg=cfg, shuffle=shuffle, trainingsetindex=train_set_index, snapshotindex=snapshot, + device=device, transform=transform, plotting=plotting, show_errors=show_errors, modelprefix=modelprefix, - batch_size=batch_size, + detector_path=detector_path, ) +def image_to_dlc_df_index(image: str) -> tuple[str, ...]: + """ + Args: + image: the path of the image to map to a DLC index + + Returns: + the image index to create a multi-animal DLC dataframe: + ("labeled-data", video_name, image_name) + """ + image_path = Path(image) + if len(image_path.parts) >= 3 and image_path.parts[-3] == "labeled-data": + return Path(image_path).parts[-3:] + + raise ValueError(f"Unexpected image filepath for a DLC project") + + def save_evaluation_results( df_scores: pd.DataFrame, scores_path: Path, diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 135a411a79..bb17b03c4e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -38,8 +38,7 @@ def get_predictions_bottom_up( Returns: array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions - None if there are no unique bodyparts, otherwise array of shape (batch_size, num_keypoints, 3) - for unique bodypart predictions + If there are unique bodyparts, array of shape (batch_size, num_unique_keypoints, 3) """ output = model(images) shape_image = images.shape diff --git a/deeplabcut/pose_estimation_pytorch/apis/scoring.py b/deeplabcut/pose_estimation_pytorch/apis/scoring.py new file mode 100644 index 0000000000..47b4eba5c0 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/scoring.py @@ -0,0 +1,287 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import numpy as np + +from deeplabcut.pose_estimation_pytorch.post_processing import ( + rmse_match_prediction_to_gt, +) +from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( + Assembly, + evaluate_assembly, +) + + +def get_scores( + poses: dict[str, np.ndarray], + ground_truth: dict[str, np.ndarray], + unique_bodypart_poses: dict[str, np.ndarray] | None = None, + unique_bodypart_gt: dict[str, np.ndarray] | None = None, + pcutoff: float = -1, +) -> dict[str, float]: + """Computes for the different scores given the ground truth and the predictions. + + The poses and ground truth should already be aligned to the ground truth (the scores + will be computed assuming individual i in the poses matches to individual i in the + ground truth) + + The different scores computed are based on the COCO metrics: https://cocodataset.org/#keypoints-eval + RMSE (Root Mean Square Error) + OKS mAP (Mean Average Precision) + OKS mAR (Mean Average Recall) + + Args: + poses: the predicted poses for each image in the format + {'image': keypoints with shape (num_individuals, num_keypoints, 3)} + ground_truth: ground truth keypoints for each image in the format + {'image': keypoints with shape (num_individuals, num_keypoints, 3)} + pcutoff: the pcutoff used to use + unique_bodypart_poses: the predicted poses for unique bodyparts + unique_bodypart_gt: the ground truth for unique bodyparts + + Returns: + a dictionary of scores containign the following keys + ['rmse', 'rmse_pcutoff', 'mAP', 'mAR', 'mAP_pcutoff', 'mAR_pcutoff'] + + Examples: + >>> # Define the p-cutoff, prediction, and target DataFrames + >>> pcutoff = 0.5 + >>> prediction = {"img0": [[[0.1, 0.5, 0.4], [5.2, 3.3, 0.9]], ...], ...} + >>> ground_truth = {"img0": [[[0, 0], [5, 3]], ...], ...} + >>> # Compute the scores + >>> scores = get_scores(poses, ground_truth, pcutoff) + >>> print(scores) + { + 'rmse': 0.156, + 'rmse_pcutoff': 0.115, + 'mAP': 84.2, + 'mAR': 74.5, + 'mAP_pcutoff': 91.3, + 'mAR_pcutoff': 82.5 + } # Sample output scores + """ + if not len(poses) == len(ground_truth): + raise ValueError( + "The prediction an ground truth dicts must contain the same number of " + f"images (poses={len(poses)}, gt={len(ground_truth)})" + ) + + ground_truth = { + image: mask_invisible(gt_pose, mask_value=np.nan) + for image, gt_pose in ground_truth.items() + } + + pred_poses, gt_poses = [], [] + for image_key in poses.keys(): + pred_poses.append(poses[image_key]) + gt_poses.append(ground_truth[image_key]) + + keys = list(poses.keys()) + pred_poses = build_keypoint_array(poses, keys).reshape((-1, 3)) + gt_poses = build_keypoint_array(ground_truth, keys).reshape((-1, 2)) + if unique_bodypart_poses is not None: + pred_poses = np.concatenate( + [ + pred_poses, + build_keypoint_array(unique_bodypart_poses, keys).reshape((-1, 3)), + ] + ) + gt_poses = np.concatenate( + [ + gt_poses, + build_keypoint_array(unique_bodypart_gt, keys).reshape((-1, 2)), + ] + ) + + rmse, rmse_pcutoff = compute_rmse(pred_poses, gt_poses, pcutoff=pcutoff) + + oks = compute_oks(poses, ground_truth, pcutoff=None) + oks_pcutoff = compute_oks(poses, ground_truth, pcutoff=pcutoff) + + return { + "rmse": rmse, + "rmse_pcutoff": rmse_pcutoff, + "mAP": 100 * oks["mAP"], + "mAR": 100 * oks["mAR"], + "mAP_pcutoff": 100 * oks_pcutoff["mAP"], + "mAR_pcutoff": 100 * oks_pcutoff["mAR"], + } + + +def build_keypoint_array( + keypoints: dict[str, np.ndarray], keys: list[str] +) -> np.ndarray: + """Stacks arrays of keypoints in a given order + + Args: + keypoints: the keypoint arrays to stack + keys: the order of keys to use to stack the arrays + + Returns: + the stacked arrays + """ + image_keypoints = [] + for image_key in keys: + image_keypoints.append(keypoints[image_key]) + return np.stack(image_keypoints) + + +def compute_rmse( + pred: np.ndarray, + ground_truth: np.ndarray, + pcutoff: float = -1, +) -> tuple[float, float]: + """Computes the root mean square error (rmse) for predictions vs the ground truth labels + + Assumes that poses have been aligned to ground truth (keypoint i in the pred array + corresponds to keypoint i in the ground_truth array) + + Args: + pred: (n, 3) the predicted keypoints in format x, y, score + ground_truth: (n, 2) the ground truth keypoints + pcutoff: the pcutoff score + + Returns: + the RMSE and RMSE with pcutoff values + """ + if pred.shape[0] != ground_truth.shape[0]: + raise ValueError( + "Prediction and target arrays must have same number of elements!" + ) + + mask = pred[:, 2] >= pcutoff + square_distances = (pred[:, :2] - ground_truth) ** 2 + mean_square_errors = np.sum(square_distances, axis=1) + rmse = np.nanmean(np.sqrt(mean_square_errors)).item() + rmse_p = np.nanmean(np.sqrt(mean_square_errors[mask])).item() + return rmse, rmse_p + + +def compute_oks( + pred: dict[str, np.array], + ground_truth: dict[str, np.array], + oks_sigma=0.1, + margin=0, + symmetric_kpts=None, + pcutoff: float | None = None, +) -> dict: + """Computes the + + Assumes that poses have been aligned to ground truth (for an image, individual i in + the pred array corresponds to individual i in the ground_truth array) + + Args: + pred: the predicted poses for each image in the format + {'image': keypoints with shape (num_individuals, num_keypoints, 3)} + ground_truth: ground truth keypoints for each image in the format + {'image': keypoints with shape (num_individuals, num_keypoints, 3)} + oks_sigma: sigma for OKS computation. + margin: margin used for bbox computation. + symmetric_kpts: TODO: not supported yet + pcutoff: the pcutoff used to use + + Returns: + the OKS scores + """ + masked_pred = {} + for image_path, keypoints_with_scores in pred.items(): + keypoints = keypoints_with_scores[:, :, :2].copy() + if pcutoff is not None: + keypoints[keypoints_with_scores[:, :, 2] < pcutoff] = np.nan + masked_pred[image_path] = keypoints + + assemblies_pred = build_assemblies(masked_pred) + assemblies_gt = build_assemblies(ground_truth) + return evaluate_assembly( + assemblies_pred, + assemblies_gt, + oks_sigma, + margin=margin, + symmetric_kpts=symmetric_kpts, + ) + + +def build_assemblies(poses: dict[str, np.ndarray]) -> dict[str, list[Assembly]]: + """ + Builds assemblies from a pose array + + Args: + poses: {image: keypoints with shape (num_individuals, num_keypoints, 2)} + + Returns: + the assemblies for each image + """ + assemblies = {} + for image_path, keypoints in poses.items(): + image_assemblies = [] + for idv_bodyparts in keypoints: + assembly = Assembly.from_array(idv_bodyparts) + if len(assembly): + image_assemblies.append(assembly) + + assemblies[image_path] = image_assemblies + + return assemblies + + +def align_predicted_individuals_to_gt( + predictions: dict[str, np.ndarray], + ground_truth: dict[str, np.ndarray], +) -> dict[str, np.ndarray]: + """TODO: implement with OKS as well + Uses RMSE to match predicted individuals to frame annotations for a batch of + frames. This method is preferred to OKS, as OKS needs at least 2 annotated + keypoints per animal (to compute area) + + The poses array is modified in-place, where the order of elements are + swapped in 2nd dimension (individuals) such that the keypoints in predictions[img][i] + is matched to the ground truth annotations of df_target[img][i] + + Args: + predictions: {image_path: predicted pose of shape (individual, keypoints, 3)} + ground_truth: the ground truth annotations to align + + Returns: + the same dictionary as the input predictions, but where the "individual" axis + for each prediction is aligned with the ground truth data + """ + matched_poses = {} + for image, pose in predictions.items(): + gt_pose = mask_invisible(ground_truth[image], mask_value=-1) + gt_pose = np.nan_to_num(gt_pose, nan=-1) + match_individuals = rmse_match_prediction_to_gt(pose, gt_pose) + matched_poses[image] = pose[match_individuals] + + return matched_poses + + +def mask_invisible( + keypoints: np.ndarray, + mask_value: int | float | np.nan = -1.0, +) -> np.ndarray: + """ + Masks keypoints that are not visible in an array. + + Args: + keypoints: a keypoint array of shape (..., 3), where the last axis contains + the x, y and visibility values (0 == invisible) + mask_value: the value to give to the keypoints that are masked + + Returns: + a keypoint array of shape (..., 2) with the coordinates of the keypoints marked + as invisible replaced with the mask value + """ + keypoints = keypoints.copy() + visibility = (keypoints[..., 2] == 0) + keypoints[visibility, 0] = mask_value + keypoints[visibility, 1] = mask_value + return keypoints[..., :2] diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 66a450e8fa..f025d12dfe 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -4,44 +4,125 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + import argparse +import copy import logging -import os from pathlib import Path -from typing import Optional, Union import albumentations as A from torch.utils.data import DataLoader import deeplabcut.pose_estimation_pytorch as dlc +import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils from deeplabcut import auxiliaryfunctions +from deeplabcut.pose_estimation_pytorch import Loader from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_solver, + build_runner, build_transforms, + build_inference_transform, update_config_parameters, ) -from deeplabcut.pose_estimation_pytorch.solvers.base import Solver -from deeplabcut.pose_estimation_pytorch.solvers.logger import ( +from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel +from deeplabcut.pose_estimation_pytorch.runners.logger import ( + LOGGER, setup_file_logging, destroy_file_logging, ) +def _train( + loader: Loader, + model_folder: str, + run_config: dict, + task: str, + device: str, + transform_config: dict, + logger_config: dict | None = None, + snapshot_path: str | None = None, + transform: A.BaseCompose | None = None, +) -> None: + """Builds a model from a configuration and fits it to a dataset + + Args: + loader: the loader containing the data to train on/validate with + model_folder: the folder where the models should be saved + run_config: the model and run configuration + task: {"TD", "BU", "DT"} the task to train the model for + device: the device to train on + transform_config: the configuration of the data augmentation to use. Ignored if + a transform is given + logger_config: the configuration of a logger to use + snapshot_path: if continuing to train from a snapshot, the path containing the + weights to load + transform: if None, a transform is loaded with the given configuration. + Otherwise, this transform is used. + """ + if task == "DT": + model = DETECTORS.build(run_config["model"]) + else: + model = PoseModel.from_cfg(run_config["model"]) + + # TODO: Log the configuration file + # Model should not be needed when building the logger + logger = None + if logger_config is not None: + logger = LOGGER.build(dict(**logger_config, model=model)) + + runner = build_runner( + run_cfg=run_config, + model=model, + device=device, + snapshot_path=snapshot_path, + logger=logger, + ) + + batch_size = run_config.get("batch_size", 1) + epochs = run_config.get("epochs", 200) + save_epochs = run_config.get("save_epochs", 50) + display_iters = run_config.get("display_iters", 50) + + if transform is None: + logging.info(f"No transform passed to augment images for {task}, using default") + transform = build_transforms(transform_config, augment_bbox=True) + valid_transform = build_inference_transform(transform_config, augment_bbox=True) + + train_dataset = loader.create_dataset(transform=transform, mode="train", task=task) + valid_dataset = loader.create_dataset( + transform=valid_transform, mode="test", task=task + ) + print( + f"Using {len(train_dataset)} images to train {task} and {len(valid_dataset)}" + f" for testing" + ) + train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) + valid_dataloader = DataLoader(valid_dataset, batch_size=batch_size, shuffle=False) + runner.fit( + train_dataloader, + valid_dataloader, + model_folder=model_folder, + epochs=epochs, + save_epochs=save_epochs, + display_iters=display_iters, + ) + + def train_network( config: str, shuffle: int = 1, trainingsetindex: int = 0, - transform: Union[A.BaseCompose, A.BasicTransform] = None, - transform_cropped: Union[A.BaseCompose, A.BasicTransform] = None, + transform: A.BaseCompose | None = None, + transform_cropped: A.BaseCompose | None = None, modelprefix: str = "", - snapshot_path: Optional[str] = "", - detector_path: Optional[str] = "", + snapshot_path: str | None = "", + detector_path: str | None = "", **kwargs, -) -> Solver: +) -> None: """Trains a network for a project TODO: max_snapshots_to_keep @@ -73,100 +154,69 @@ def train_network( which to resume **kwargs : could be any entry of the pytorch_config dictionary. Examples are to see the full list see the pytorch_cfg.yaml file in your project folder - - Returns: - solver: solver used for training, stores data about losses during training """ cfg = auxiliaryfunctions.read_config(config) train_fraction = cfg["TrainingFraction"][trainingsetindex] - modelfolder = os.path.join( - cfg["project_path"], - auxiliaryfunctions.get_model_folder( - train_fraction, - shuffle, - cfg, - modelprefix=modelprefix, - ), + model_folder = runner_utils.get_model_folder( + str(Path(config).parent), + cfg, + train_fraction, + shuffle, + modelprefix, ) - log_path = Path(modelfolder) / "train" / "log.txt" + train_folder = Path(model_folder) / "train" + log_path = train_folder / "log.txt" + model_config_path = str(train_folder / "pytorch_config.yaml") + setup_file_logging(log_path) + pytorch_config = auxiliaryfunctions.read_plainconfig(model_config_path) - pytorch_config = auxiliaryfunctions.read_plainconfig( - os.path.join(modelfolder, "train", "pytorch_config.yaml") - ) - update_config_parameters(pytorch_config=pytorch_config, **kwargs) + update_config_parameters(pytorch_config=pytorch_config, **kwargs) # TODO: improve if transform is None: logging.info("No transform specified... using default") transform = build_transforms(dict(pytorch_config["data"]), augment_bbox=True) - batch_size = pytorch_config["batch_size"] - epochs = pytorch_config["epochs"] - dlc.fix_seeds(pytorch_config["seed"]) - project_train = dlc.DLCProject( - proj_root=pytorch_config["project_path"], shuffle=shuffle + loader = dlc.DLCLoader( + project_root=pytorch_config["project_path"], + model_config_path=model_config_path, + shuffle=shuffle, ) - project_valid = dlc.DLCProject( - proj_root=pytorch_config["project_path"], shuffle=shuffle - ) - train_dataset = dlc.PoseDataset(project_train, transform=transform, mode="train") - valid_dataset = dlc.PoseDataset(project_valid, transform=transform, mode="test") - train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) - - valid_dataloader = DataLoader(valid_dataset, batch_size=batch_size, shuffle=False) - - solver = build_solver(pytorch_config, snapshot_path, detector_path) + pose_task = "BU" + transform_config = pytorch_config["data"] if pytorch_config.get("method", "bu").lower() == "td": - if transform_cropped is None: - logging.info( - "No transform passed to augment cropped images, using default augmentations" - ) - transform_cropped = build_transforms( - pytorch_config["cropped_data"], augment_bbox=False - ) - - detector_epochs = pytorch_config.get("detector_max_epochs", epochs) - train_cropped_dataset = dlc.CroppedDataset( - project_train, transform=transform_cropped, mode="train" - ) - valid_cropped_dataset = dlc.CroppedDataset( - project_valid, transform=transform_cropped, mode="test" - ) - train_cropped_dataloader = DataLoader( - train_cropped_dataset, batch_size=batch_size, shuffle=True - ) - valid_cropped_dataloader = DataLoader( - valid_cropped_dataset, batch_size=batch_size, shuffle=False - ) - solver.fit( - train_dataloader, - valid_dataloader, - train_cropped_dataloader, - valid_cropped_dataloader, - train_fraction=train_fraction, - epochs=epochs, - detector_epochs=detector_epochs, - shuffle=shuffle, - model_prefix=modelprefix, - ) - elif pytorch_config.get("method", "bu").lower() == "bu": - solver.fit( - train_dataloader, - valid_dataloader, - train_fraction=train_fraction, - epochs=epochs, - shuffle=shuffle, - model_prefix=modelprefix, - ) - else: - destroy_file_logging() - raise ValueError( - "Method not supported, should be either 'bu' (Bottom Up) or 'td' (Top Down)" + pose_task = "TD" + transform_config = pytorch_config["data_detector"] + logger_config = None + if pytorch_config.get("logger"): + logger_config = copy.deepcopy(pytorch_config["logger"]) + logger_config["run_name"] += "-detector" + _train( + loader=loader, + model_folder=model_folder, + run_config=pytorch_config["detector"], + task="DT", + device=pytorch_config["device"], + transform_config=pytorch_config["data"], + logger_config=logger_config, + snapshot_path=detector_path, + transform=transform_cropped, ) + _train( + loader=loader, + model_folder=model_folder, + run_config=pytorch_config, + task=pose_task, + device=pytorch_config["device"], + transform_config=transform_config, + logger_config=pytorch_config.get("logger"), + snapshot_path=snapshot_path, + transform=transform, + ) + destroy_file_logging() - return solver if __name__ == "__main__": @@ -176,7 +226,7 @@ def train_network( parser.add_argument("--train-ind", type=int, default=0) parser.add_argument("--modelprefix", type=str, default="") args = parser.parse_args() - _ = train_network( + train_network( config=args.config_path, shuffle=args.shuffle, trainingsetindex=args.train_ind, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 2a98558395..316fd7ae92 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -1,178 +1,138 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import Callable import albumentations as A import cv2 +import numpy as np +import pandas as pd import torch -import yaml -from deeplabcut.pose_estimation_pytorch.data.transforms import KeypointAwareCrop -from deeplabcut.pose_estimation_pytorch.models import ( - BACKBONES, - DETECTORS, - HEADS, - LOSSES, - NECKS, - PoseModel, +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch import PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( + Postprocessor, + build_bottom_up_postprocessor, + build_detector_postprocessor, + build_top_down_postprocessor, ) -from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS -from deeplabcut.pose_estimation_pytorch.models.target_generators import ( - TARGET_GENERATORS, +from deeplabcut.pose_estimation_pytorch.data.preprocessor import ( + Preprocessor, + build_bottom_up_preprocessor, + build_top_down_preprocessor, ) -from deeplabcut.pose_estimation_pytorch.solvers import LOGGER, SOLVERS -from deeplabcut.pose_estimation_pytorch.solvers.base import Solver -from deeplabcut.pose_estimation_pytorch.solvers.schedulers import LRListScheduler +from deeplabcut.pose_estimation_pytorch.data.transforms import KeypointAwareCrop +from deeplabcut.pose_estimation_pytorch.models import PoseModel, DETECTORS +from deeplabcut.pose_estimation_pytorch.runners import RUNNERS, Runner +from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger +from deeplabcut.pose_estimation_pytorch.runners.schedulers import LRListScheduler from deeplabcut.utils import auxfun_videos -def build_pose_model(cfg: Dict, pytorch_cfg: Dict) -> PoseModel: - """ - Returns a pytorch pose model based on pytorch config +def build_optimizer(optimizer_cfg: dict, model: nn.Module) -> torch.optim.Optimizer: + """Builds an optimizer from configuration file Args: - cfg : sub dict of the pytorch config that contains all information about the model - pytorch_cfg : entire pytorch config""" - - # TODO not sure why exactly we would need those two dicts as entries - backbone = BACKBONES.build(dict(cfg["backbone"])) - heads = [] - for head_config in cfg["heads"]: - heads.append(HEADS.build(dict(head_config))) - target_generator = TARGET_GENERATORS.build(dict(cfg["target_generator"])) - if cfg.get("neck"): - neck = NECKS.build(dict(cfg["neck"])) - else: - neck = None - pose_model = PoseModel( - cfg=pytorch_cfg, - backbone=backbone, - heads=heads, - target_generator=target_generator, - neck=neck, - **cfg["pose_model"], - ) + optimizer_cfg: the optimizer configuration + model: the model to optimize - return pose_model + Returns: + the optimizer + """ + get_optimizer = getattr(torch.optim, optimizer_cfg["type"]) + return get_optimizer(params=model.parameters(), **optimizer_cfg["params"]) -def build_detector(detector_cfg: Dict): - """Builds detector related objects : detector, its optimizer and its scheduler +def build_scheduler( + scheduler_cfg: dict | None, + optimizer: torch.optim.Optimizer, +) -> torch.optim.lr_scheduler.LRScheduler | None: + """Builds a scheduler from a configuration, if defined Args: - detector_cfg (Dict): detector config dictionary + scheduler_cfg: the configuration of the scheduler to build + optimizer: the optimizer the scheduler will be built for Returns: - detector, detector_optimizer, detector_scheduler + None if scheduler_cfg is None, otherwise the scheduler """ - detector = DETECTORS.build(detector_cfg["detector_model"]) - - get_optimizer = getattr(torch.optim, detector_cfg["detector_optimizer"]["type"]) - detector_optimizer = get_optimizer( - params=detector.parameters(), **detector_cfg["detector_optimizer"]["params"] - ) + if scheduler_cfg is None: + return None - if detector_cfg.get("detector_scheduler"): - if detector_cfg["detector_scheduler"]["type"] == "LRListScheduler": - _scheduler = LRListScheduler - else: - _scheduler = getattr( - torch.optim.lr_scheduler, detector_cfg["detector_scheduler"]["type"] - ) - detector_scheduler = _scheduler( - optimizer=detector_optimizer, **detector_cfg["detector_scheduler"]["params"] - ) + if scheduler_cfg["type"] == "LRListScheduler": + scheduler = LRListScheduler else: - detector_scheduler = None + scheduler = getattr(torch.optim.lr_scheduler, scheduler_cfg["type"]) - return detector, detector_optimizer, detector_scheduler + return scheduler(optimizer=optimizer, **scheduler_cfg["params"]) -def build_solver(pytorch_cfg: Dict, snapshot_path: str, detector_path: str) -> Solver: +def build_pose_model(cfg: dict, pytorch_cfg: dict) -> PoseModel: """ - Build the solver object to run training + TODO: Deprecated but still used in analyze_videos Args: - pytorch_cfg: config dictionary to build the solver - Returns: - solver : solver to train the model - """ - pose_model = build_pose_model(pytorch_cfg["model"], pytorch_cfg) - - get_optimizer = getattr(torch.optim, pytorch_cfg["optimizer"]["type"]) - optimizer = get_optimizer( - params=pose_model.parameters(), **pytorch_cfg["optimizer"]["params"] - ) - - criterion = LOSSES.build(pytorch_cfg["criterion"]) - - predictor = PREDICTORS.build(dict(pytorch_cfg["predictor"])) + cfg : sub dict of the pytorch config that contains all information about the model + pytorch_cfg : entire pytorch config - if pytorch_cfg.get("scheduler"): - if pytorch_cfg["scheduler"]["type"] == "LRListScheduler": - _scheduler = LRListScheduler - else: - _scheduler = getattr( - torch.optim.lr_scheduler, pytorch_cfg["scheduler"]["type"] - ) - scheduler = _scheduler( - optimizer=optimizer, **pytorch_cfg["scheduler"]["params"] - ) - else: - scheduler = None + Returns a pytorch pose model based on pytorch config + """ + return PoseModel.from_cfg(pytorch_cfg["model"]) + + +def build_runner( + run_cfg: dict, + model: nn.Module, + device: str, + snapshot_path: str | None, + logger: BaseLogger | None = None, + preprocessor: Preprocessor | None = None, + postprocessor: Postprocessor | None = None, +) -> Runner: + """ + Build a runner object according to a pytorch configuration file - if pytorch_cfg.get("logger"): - logger = LOGGER.build(dict(**pytorch_cfg["logger"], model=pose_model)) - else: - logger = None - - if pytorch_cfg.get("method", "bu") == "bu": - solver = SOLVERS.build( - dict( - **pytorch_cfg["solver"], - model=pose_model, - criterion=criterion, - optimizer=optimizer, - predictor=predictor, - cfg=pytorch_cfg, - device=pytorch_cfg["device"], - snapshot_path=snapshot_path, - scheduler=scheduler, - logger=logger, - ) - ) - elif pytorch_cfg.get("method", "bu") == "td": - detector, detector_optimizer, detector_scheduler = build_detector( - pytorch_cfg["detector"] - ) + Args: + run_cfg: config dictionary to build the runner + model: the model to run + device: the device to run on + snapshot_path: the snapshot from which to load the weights + logger: the logger to use, if any + preprocessor: the preprocessor to use on images before inference + postprocessor: the postprocessor to use on images after inference - solver = SOLVERS.build( - dict( - **pytorch_cfg["solver"], - model=pose_model, - criterion=criterion, - optimizer=optimizer, - predictor=predictor, - cfg=pytorch_cfg, - device=pytorch_cfg["device"], - snapshot_path=snapshot_path, - detector_path=detector_path, - scheduler=scheduler, - logger=logger, - detector=detector, - detector_optimizer=detector_optimizer, - detector_scheduler=detector_scheduler, - ) - ) - else: - raise ValueError( - "The method in your pytorch config is invalid, possible values are " - "'bu' (Bottom Up) or 'td' (Top Down)." + Returns: + the runner + """ + optimizer = build_optimizer(run_cfg["optimizer"], model) + scheduler = build_scheduler(run_cfg["scheduler"], optimizer) + return RUNNERS.build( + dict( + **run_cfg["runner"], + model=model, + optimizer=optimizer, + device=device, + snapshot_path=snapshot_path, + scheduler=scheduler, + logger=logger, + preprocessor=preprocessor, + postprocessor=postprocessor, ) - return solver + ) -def build_transforms( - aug_cfg: dict, augment_bbox: bool = False -) -> Union[A.BasicTransform, A.BaseCompose]: +def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose: """ Returns the transformation pipeline based on config @@ -277,18 +237,7 @@ def build_transforms( ) if aug_cfg.get("auto_padding"): - params = aug_cfg.get("auto_padding") - pad_height_divisor = params.get("pad_height_divisor", 1) - pad_width_divisor = params.get("pad_width_divisor", 1) - transforms.append( - A.PadIfNeeded( - min_height=None, - min_width=None, - pad_height_divisor=pad_height_divisor, - pad_width_divisor=pad_width_divisor, - position="top_left", - ) - ) + transforms.append(build_auto_padding(**aug_cfg["auto_padding"])) if aug_cfg.get("normalize_images"): transforms.append( A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) @@ -308,15 +257,15 @@ def build_transforms( def build_inference_transform( transform_cfg: dict, augment_bbox: bool = True -) -> Union[A.BasicTransform, A.BaseCompose]: +) -> A.BasicTransform | A.BaseCompose: """Build transform pipeline for inference Mainly about normalising the images a giving them a specific shape Args: transform_cfg (dict): dict containing information about the transforms to apply - should be the same as the one used for build_transforms to - ensure matching distributions between train and test + should be the same as the one used for build_transforms to ensure matching + distributions between train and test augment_bbox (bool): should always be True for inference Returns: @@ -329,18 +278,7 @@ def build_inference_transform( list_transforms.append(A.Resize(input_size[0], input_size[1])) if transform_cfg.get("auto_padding"): - params = transform_cfg.get("auto_padding") - pad_height_divisor = params.get("pad_height_divisor", 1) - pad_width_divisor = params.get("pad_width_divisor", 1) - list_transforms.append( - A.PadIfNeeded( - min_height=None, - min_width=None, - pad_height_divisor=pad_height_divisor, - pad_width_divisor=pad_width_divisor, - position="top_left", - ) - ) + list_transforms.append(build_auto_padding(**transform_cfg["auto_padding"])) if transform_cfg.get("normalize_images"): list_transforms.append( A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) @@ -359,13 +297,7 @@ def build_inference_transform( ) -def read_yaml(path): - with open(path) as f: - file = yaml.safe_load(f) - return file - - -def get_model_snapshots(model_folder: Path) -> List[Path]: +def get_model_snapshots(model_folder: Path) -> list[Path]: """ Assumes that all snapshots are named using the pattern "snapshot-{idx}.pt" @@ -385,7 +317,7 @@ def get_model_snapshots(model_folder: Path) -> List[Path]: ) -def get_detector_snapshots(model_folder: Path) -> List[Path]: +def get_detector_snapshots(model_folder: Path) -> list[Path]: """ Assumes that all snapshots are named using the pattern "detector-snapshot-{idx}.pt" @@ -405,10 +337,10 @@ def get_detector_snapshots(model_folder: Path) -> List[Path]: ) -def videos_in_folder( - data_path: Union[str, List[str]], - video_type: Optional[str], -) -> List[Path]: +def list_videos_in_folder( + data_path: str | list[str], + video_type: str | None, +) -> list[Path]: """ TODO """ @@ -442,4 +374,223 @@ def update_config_parameters(pytorch_config: dict, **kwargs) -> None: for key in kwargs.keys(): pytorch_config[key] = kwargs[key] - return + +def build_auto_padding( + min_height: int | None = None, + min_width: int | None = None, + pad_height_divisor: int | None = 1, + pad_width_divisor: int | None = 1, + position: str = "random", # TODO: Which default to set? + border_mode: str = "reflect_101", # TODO: Which default to set? + border_value: float | None = None, + border_mask_value: float | None = None, +) -> A.PadIfNeeded: + """ + Create an albumentations PadIfNeeded transform from a config + + Args: + min_height: the minimum height of the image + min_width: the minimum width of the image + pad_height_divisor: if not None, ensures height is dividable by value of this argument + pad_width_divisor: if not None, ensures width is dividable by value of this argument + position: position of the image, one of the possible PadIfNeeded + border_mode: 'constant' or 'reflect_101' (see cv2.BORDER modes) + border_value: padding value if border_mode is 'constant' + border_mask_value: padding value for mask if border_mode is 'constant' + + Raises: + ValueError: + Only one of 'min_height' and 'pad_height_divisor' parameters must be set + Only one of 'min_width' and 'pad_width_divisor' parameters must be set + + Returns: + the auto-padding transform + """ + border_modes = { + "constant": cv2.BORDER_CONSTANT, + "reflect_101": cv2.BORDER_REFLECT_101, + } + if border_mode not in border_modes: + raise ValueError( + f"Unknown border mode for auto_padding: {border_mode} " + f"(valid values are: {border_modes.keys()})" + ) + + return A.PadIfNeeded( + min_height=min_height, + min_width=min_width, + pad_height_divisor=pad_height_divisor, + pad_width_divisor=pad_width_divisor, + position=position, + border_mode=border_modes[border_mode], + value=border_value, + mask_value=border_mask_value, + ) + + +def ensure_multianimal_df_format(df_predictions: pd.DataFrame) -> pd.DataFrame: + """ + Convert dataframe to 'multianimal' format (with an "individuals" columns index) + + Args: + df_predictions: the dataframe to convert + + Returns: + the dataframe in MA format + """ + df_predictions_ma = df_predictions.copy() + try: + df_predictions_ma.columns.get_level_values("individuals").unique().tolist() + except KeyError: + new_cols = pd.MultiIndex.from_tuples( + [(col[0], "animal", col[1], col[2]) for col in df_predictions_ma.columns], + names=["scorer", "individuals", "bodyparts", "coords"], + ) + df_predictions_ma.columns = new_cols + return df_predictions_ma + + +def build_predictions_dataframe( + scorer: str, + images: list[str], + bodypart_predictions: dict[str, np.ndarray], + unique_bodypart_predictions: dict[str, np.ndarray] | None, + parameters: PoseDatasetParameters, + image_name_to_index: Callable[[str], tuple[str, ...]] | None = None, +) -> pd.DataFrame: + """ + + Args: + scorer: + images: + bodypart_predictions: + unique_bodypart_predictions: + parameters: + image_name_to_index: + + Returns: + + """ + if parameters.num_unique_bpts > 0 and unique_bodypart_predictions is None: + raise ValueError( + "The parameters contain unique bodyparts but no predictions were given" + ) + + kpt_entries = ["x", "y", "likelihood"] + col_names = ["scorer", "individuals", "bodyparts", "coords"] + + col_values = [] + for i in parameters.individuals: + for b in parameters.bodyparts: + col_values += [(scorer, i, b, entry) for entry in kpt_entries] + for unique_bpt in parameters.unique_bpts: + col_values += [(scorer, "single", unique_bpt, entry) for entry in kpt_entries] + + prediction_data = [] + index_data = [] + for image in images: + image_data = bodypart_predictions[image].reshape(-1) + if unique_bodypart_predictions is not None: + image_data = np.concatenate( + [image_data, unique_bodypart_predictions[image].reshape(-1)] + ) + prediction_data.append(image_data) + if image_name_to_index is not None: + index_data.append(image_name_to_index(image)) + + if len(index_data) > 0: + index = pd.MultiIndex.from_tuples(index_data) + else: + index = images + + return pd.DataFrame( + prediction_data, + index=index, + columns=pd.MultiIndex.from_tuples(col_values, names=col_names), + ) + + +def get_runners( + pytorch_config: dict, + snapshot_path: str, + with_unique_bodyparts: bool, + transform: A.BaseCompose | None = None, + detector_path: str | None = None, + detector_transform: A.BaseCompose | None = None, +) -> tuple[Runner, Runner | None]: + """Builds the runners for pose estimation + + Args: + pytorch_config: the pytorch configuration file + snapshot_path: the path of the snapshot from which to load the weights + with_unique_bodyparts: whether there are unique bodyparts to detect + transform: the transform for pose estimation. if None, uses the transform + defined in the config. + detector_path: the path to the detector snapshot from which to load weights, + for top-down models (if a detector runner is needed) + detector_transform: the transform for object detection. if None, uses the + transform defined in the config. + + Returns: + a runner for pose estimation + a runner for detection, if detector_path is not None + """ + pose_task = pytorch_config.get("method", "BU").upper() + if pose_task not in ["BU", "TD"]: + raise ValueError( + f"Method should be set to either 'BU' (Bottom Up) or 'TD' (Top Down), " + f"currently it is {pose_task}" + ) + + device = pytorch_config["device"] + if transform is None: + transform = build_inference_transform(pytorch_config["data"]) + + detector_runner = None + if pose_task == "BU": + pose_preprocessor = build_bottom_up_preprocessor( + color_mode="RGB", # TODO: read from Loader + transform=transform, + ) + pose_postprocessor = build_bottom_up_postprocessor( + with_unique_bodyparts=with_unique_bodyparts + ) + else: + pose_preprocessor = build_top_down_preprocessor( + color_mode="RGB", # TODO: read from Loader + transform=transform, + cropped_image_size=(256, 256), + ) + pose_postprocessor = build_top_down_postprocessor( + with_unique_bodyparts=with_unique_bodyparts + ) + + if detector_path is not None: + if detector_transform is None: + detector_transform = build_inference_transform( + pytorch_config["data_detector"] + ) + + detector_runner = build_runner( + run_cfg=pytorch_config["detector"], + model=DETECTORS.build(pytorch_config["detector"]["model"]), + device=device, + snapshot_path=detector_path, + logger=None, # No logging for evaluation + preprocessor=build_bottom_up_preprocessor( + color_mode="RGB", # TODO: read from Loader + transform=detector_transform, + ), + postprocessor=build_detector_postprocessor(), + ) + + pose_runner = build_runner( + run_cfg=pytorch_config, + model=PoseModel.from_cfg(pytorch_config["model"]), + device=device, + snapshot_path=snapshot_path, + logger=None, # No logging for evaluation + preprocessor=pose_preprocessor, + postprocessor=pose_postprocessor, + ) + return pose_runner, detector_runner diff --git a/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py b/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py index edf53db361..fbec9f3b8a 100644 --- a/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py +++ b/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py @@ -1,9 +1,9 @@ # Script for reproducing results in Zhou* & Stoffl* et al. for BUCTD with CoAM -#path=datapath -#results=resultspath or put numbers +# path=datapath +# results=resultspath or put numbers -#train model +# train model # evaluate and # check if predicted is close to result diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 23f86ad4bc..366d2ef005 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -16,8 +16,13 @@ import albumentations as A import numpy as np +from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.data.utils import ( + map_id_to_annotations, + _compute_crop_bounds, +) from deeplabcut.utils.auxiliaryfunctions import get_bodyparts from deeplabcut.utils.auxiliaryfunctions import get_unique_bodyparts @@ -37,11 +42,15 @@ class Loader(ABC): Returns a dictionary containing dataset parameters derived from the configuration. """ - def __init__(self, project_root: str) -> None: + def __init__(self, project_root: str, model_config_path: str) -> None: self.project_root = project_root + self.model_config_path = model_config_path + self.model_cfg = auxiliaryfunctions.read_plainconfig(model_config_path) + self._loaded_data: dict[str, dict[str, dict]] = {} + self._get_dataset_parameters() @abstractmethod - def load_data(self, mode: str = "train") -> dict: + def load_data(self, mode: str = "train") -> dict[str, dict]: """Abstract method to convert the project configuration to a standard coco format. Raises: @@ -49,6 +58,108 @@ def load_data(self, mode: str = "train") -> dict: """ raise NotImplementedError + def image_filenames(self, mode: str = "train") -> list[str]: + """ + Args: + mode: {"train", "test"} whether to load train or test data + + Returns: + the image paths for this mode + """ + if mode not in self._loaded_data: + self._loaded_data[mode] = self.load_data(mode) + + data = self._loaded_data[mode] + return [image["file_name"] for image in data["images"]] + + def ground_truth_keypoints( + self, + mode: str = "train", + unique_bodypart: bool = False, + ) -> dict[str, np.ndarray]: + """ + Creates a dictionary containing the ground truth data + + TODO: make more efficient + + Args: + mode: {"train", "test"} whether to load train or test data + unique_bodypart: returns the ground truth for unique bodyparts + + Raises: + ValueError if unique_bodypart=True but there are no unique bodyparts + + Returns: + A dict mapping image paths to the ground truth annotations for the mode in + the format: + {'image': keypoints with shape (num_individuals, num_keypoints, 2)} + """ + parameters = self._get_dataset_parameters() + if unique_bodypart: + if not parameters.num_unique_bpts > 0: + raise ValueError("There are no unique bodyparts in this dataset!") + individuals = ["single"] + num_bodyparts = parameters.num_unique_bpts + else: + individuals = parameters.individuals + num_bodyparts = parameters.num_joints + + if mode not in self._loaded_data: + self._loaded_data[mode] = self.load_data(mode) + data = self._loaded_data[mode] + + annotations = self.filter_annotations(data["annotations"]) + img_to_ann_map = map_id_to_annotations(annotations) + + ground_truth_dict = {} + for image in data["images"]: + image_path = image["file_name"] + individual_keypoints = { + annotations[i]["individual"]: annotations[i]["keypoints"] + for i in img_to_ann_map[image["id"]] + } + gt_array = np.empty((len(individuals), num_bodyparts, 3)) + gt_array.fill(np.nan) + + # Keep the shape of the ground truth + for idv_idx, idv in enumerate(individuals): + if idv in individual_keypoints: + keypoints = individual_keypoints[idv].reshape(num_bodyparts, -1) + gt_array[idv_idx, :, :] = keypoints[:, :3] + + ground_truth_dict[image_path] = gt_array + + return ground_truth_dict + + def ground_truth_bboxes(self, mode: str = "train") -> dict[str, np.ndarray]: + """Creates a dictionary containing the ground truth bounding boxes + + Args: + mode: {"train", "test"} whether to load train or test data + + Returns: + A dict mapping image paths to the ground truth annotations for the mode in + the format: + {'image': bboxes with shape (num_individuals, xywh)} + """ + if mode not in self._loaded_data: + self._loaded_data[mode] = self.load_data(mode) + data = self._loaded_data[mode] + + annotations = self.filter_annotations(data["annotations"]) + img_to_ann_map = map_id_to_annotations(annotations) + + ground_truth_dict = {} + for image in data["images"]: + image_path = image["file_name"] + img_shape = image["height"], image["width"], 3 + bboxes = [annotations[i]["bbox"] for i in img_to_ann_map[image["id"]]] + ground_truth_dict[image_path] = _compute_crop_bounds( + np.stack(bboxes, axis=0), img_shape + ) + + return ground_truth_dict + def create_dataset( self, transform: A.BaseCompose | None = None, @@ -83,7 +194,7 @@ def create_dataset( return dataset def _get_dataset_parameters(self, *args, **kwargs) -> PoseDatasetParameters: - """ TODO: _get_dataset_parameters should be an abstract method + """TODO: _get_dataset_parameters should be an abstract method Retrieves dataset parameters based on the instance's configuration. Args: @@ -97,9 +208,9 @@ def _get_dataset_parameters(self, *args, **kwargs) -> PoseDatasetParameters: bodyparts=get_bodyparts(self.cfg), unique_bpts=get_unique_bodyparts(self.cfg), individuals=self.cfg.get("individuals", ["animal"]), - with_center_keypoints=self.cfg.get("with_center", False), - color_mode=self.cfg.get("color_mode", "RGB"), - cropped_image_size=self.cfg.get("output_size", (256, 256)), + with_center_keypoints=self.model_cfg.get("with_center_keypoints", False), + color_mode=self.model_cfg.get("color_mode", "RGB"), + cropped_image_size=self.model_cfg.get("output_size", (256, 256)), ) @staticmethod @@ -120,11 +231,11 @@ def filter_annotations(annotations: list[dict]) -> list[dict]: continue filtered_annotations.append(annotation) - return annotations + return filtered_annotations @staticmethod def _get_all_bboxes(images, annotations, method: str = "gt"): - """ TODO: Nastya method of bbox computation (detection bbox, seg. mask, ...) + """TODO: Nastya method of bbox computation (detection bbox, seg. mask, ...) Retrieves all bounding boxes based on the given method. Args: @@ -148,7 +259,7 @@ def _get_all_bboxes(images, annotations, method: str = "gt"): return annotations elif method == "gt": - for annotation in annotations: + for i, annotation in enumerate(annotations): if "bbox" not in annotation: # or do something else? raise ValueError( diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py index dcaaecdbb2..fdaddb811e 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py @@ -32,6 +32,7 @@ class COCOLoader(Loader): test_json_filename="test.json", ) """ + project_root: str train_json_filename: str = "train.json" test_json_filename: str | None = "test.json" diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 30495dba46..a96155ef85 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -15,7 +15,6 @@ import albumentations as A import cv2 import numpy as np -import torch from torch.utils.data import Dataset from deeplabcut.pose_estimation_pytorch.data.utils import ( @@ -26,6 +25,7 @@ from deeplabcut.pose_estimation_pytorch.data.utils import apply_transform from deeplabcut.pose_estimation_pytorch.data.utils import map_id_to_annotations from deeplabcut.pose_estimation_pytorch.data.utils import map_image_path_to_id +from deeplabcut.pose_estimation_pytorch.data.utils import pad_to_length @dataclass(frozen=True) @@ -63,6 +63,7 @@ def max_num_animals(self) -> int: @dataclass class PoseDataset(Dataset): """A pose dataset""" + images: list[dict[str, str]] annotations: list[dict] parameters: PoseDatasetParameters @@ -75,7 +76,8 @@ def __post_init__(self): self.annotation_idx_map = map_id_to_annotations(self.annotations) def __len__(self): - return len(self.images) if self.task == "BU" else len(self.annotations) + # TODO: TD should only return the number of annotations that aren't unique_bodyparts + return len(self.images) if self.task in ("BU", "DT") else len(self.annotations) def _get_raw_item(self, index: int) -> tuple[str, list[dict], int]: """ @@ -150,7 +152,7 @@ def __getitem__(self, index: int) -> dict: annotations_merged, ) = self.extract_keypoints_and_bboxes( annotations, - image, + image.shape, ) offsets = np.zeros((self.parameters.max_num_animals, 2)) scales = (1, 1) @@ -183,10 +185,15 @@ def __getitem__(self, index: int) -> dict: # coords, # self.parameters.cropped_image_size, # ) - bboxes = [] # No more bounding boxes as we cropped around them + bboxes = np.zeros( + (0, 4) + ) # No more bounding boxes as we cropped around them transformed = self.apply_transform_all_keypoints( - image, keypoints, keypoints_unique, bboxes, + image, + keypoints, + keypoints_unique, + bboxes, ) keypoints = transformed["keypoints"] @@ -219,28 +226,39 @@ def _prepare_final_data_dict( annotations_merged: dict, offsets: tuple[int, int], scales: tuple[float, float], - ) -> dict: - area = self.calc_area_from_keypoints(keypoints) - image = torch.tensor(image, dtype=torch.float).permute(2, 0, 1) - keypoints = torch.tensor(keypoints, dtype=torch.float) - keypoints_unique = torch.tensor(keypoints_unique, dtype=torch.float) - bboxes = torch.tensor(bboxes, dtype=torch.float) - + ) -> dict[str, np.ndarray | dict[str, np.ndarray]]: return { - "image": image, + "image": image.transpose((2, 0, 1)), "image_id": image_id, "path": image_path, "original_size": original_size, "offsets": offsets, "scales": scales, - "annotations": { - "keypoints": keypoints, - "keypoints_unique": keypoints_unique, - "area": area, - "boxes": bboxes, - "is_crowd": annotations_merged["iscrowd"], - "labels": annotations_merged["category_id"], - }, + "annotations": self._prepare_final_annotation_dict( + keypoints, + keypoints_unique, + bboxes, + annotations_merged, + ), + } + + def _prepare_final_annotation_dict( + self, + keypoints: np.ndarray, + keypoints_unique: np.ndarray, + bboxes: np.array, + annotations_merged: dict, + ) -> dict[str, np.ndarray]: + num_animals = self.parameters.max_num_animals + is_crowd = np.array(annotations_merged["iscrowd"]) + cat_ids = np.array(annotations_merged["category_id"]) + return { + "keypoints": pad_to_length(keypoints[..., :2], num_animals, -1), + "keypoints_unique": keypoints_unique[..., :2], + "area": pad_to_length(annotations_merged["area"], num_animals, 0), + "boxes": pad_to_length(bboxes, num_animals, 0), + "is_crowd": pad_to_length(is_crowd, num_animals, 0), + "labels": pad_to_length(cat_ids, num_animals, -1), } def _load_image(self, image_path): @@ -249,7 +267,7 @@ def _load_image(self, image_path): image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) return image, image.shape - def _get_data_based_on_task(self, index: int) -> tuple[str, dict, int]: + def _get_data_based_on_task(self, index: int) -> tuple[str, list[dict], int]: """ Retrieve data based on the specified task. @@ -271,6 +289,7 @@ def _get_data_based_on_task(self, index: int) -> tuple[str, dict, int]: return self._get_raw_item_crop(index) elif self.task in ["BU", "DT"]: return self._get_raw_item(index) + raise ValueError( f"Unknown task: {self.task}. " 'Task should be one of: "BU", "TD", "DT"', ) @@ -282,7 +301,7 @@ def apply_transform_all_keypoints( keypoints_unique: np.ndarray, bboxes: np.ndarray, ) -> dict[str, np.ndarray]: - """ Transforms the image using this class's transform + """Transforms the image using this class's transform Args: image: the image to transform @@ -305,7 +324,7 @@ def apply_transform_all_keypoints( f"individual{i}_{bpt}" for i in range(self.parameters.max_num_animals) for bpt in self.parameters.bodyparts - ] + [f'unique_{bpt}' for bpt in self.parameters.unique_bpts] + ] + [f"unique_{bpt}" for bpt in self.parameters.unique_bpts] all_keypoints = keypoints.reshape(-1, 3) if self.parameters.num_unique_bpts > 0: @@ -319,10 +338,15 @@ def apply_transform_all_keypoints( class_labels=class_labels, ) if self.parameters.num_unique_bpts > 0: - keypoints = transformed["keypoints"][:-self.parameters.num_unique_bpts] - keypoints = keypoints.reshape(*keypoints.shape) - keypoints_unique = transformed["keypoints"][-self.parameters.num_unique_bpts:] - keypoints_unique = keypoints_unique.reshape(self.parameters.num_unique_bpts, 3) + keypoints = transformed["keypoints"][ + : -self.parameters.num_unique_bpts + ].reshape(*keypoints.shape) + keypoints_unique = transformed["keypoints"][ + -self.parameters.num_unique_bpts : + ] + keypoints_unique = keypoints_unique.reshape( + self.parameters.num_unique_bpts, 3 + ) else: keypoints = transformed["keypoints"].reshape(*keypoints.shape) keypoints_unique = np.zeros((0,)) @@ -331,19 +355,6 @@ def apply_transform_all_keypoints( transformed["keypoints_unique"] = keypoints_unique return transformed - @staticmethod - def calc_area_from_keypoints(keypoints: np.ndarray) -> np.ndarray: - """ - Calculate the area from keypoints - - Args: - keypoints (np.ndarray): array of keypoints - - Returns: - np.ndarray: array containing the computed areas based on the keypoints - """ - return (keypoints.max(axis=1) - keypoints.min(axis=1)).prod(axis=-1) - @staticmethod def crop( image: np.ndarray, @@ -369,25 +380,34 @@ def crop( """ return _crop_image_keypoints(image, keypoints, coords, output_size) - @staticmethod def extract_keypoints_and_bboxes( + self, annotations: list[dict], - image: np.ndarray, + image_shape: tuple[int, int, int], ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, list]]: """ Args: annotations: COCO-style annotations - image: from which extract keypoints and bounding boxes for + image_shape: the (h, w, c) shape of the image for which to get annotations Returns: - keypoints, unique_keypoints, bboxes in xywh format, annotations_merged + keypoints with shape (n_annotation, num_joints, 3) + unique_keypoints with shape (num_unique_bpts, 3) + bboxes in xywh format with shape (n_annotation, 4) + annotations_merged, where each key contains n_annotation values """ - return _extract_keypoints_and_bboxes(annotations, image) + return _extract_keypoints_and_bboxes( + annotations, + image_shape, + self.parameters.num_joints, + self.parameters.num_unique_bpts, + ) @staticmethod def add_center_keypoints(keypoints: np.ndarray) -> np.ndarray: - """ Adds a keypoint in the mean of each individual""" + """Adds a keypoint in the mean of each individual""" center_keypoints = keypoints.copy() center_keypoints[center_keypoints == -1] = np.nan - center_keypoints = np.nanmean(keypoints, axis=1) + center_keypoints = np.nanmean(center_keypoints, axis=1) + np.nan_to_num(center_keypoints, copy=False, nan=-1) return np.concatenate((keypoints, center_keypoints[:, None, :]), axis=1) diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index b3d31eb630..00adfa4d56 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -17,9 +17,12 @@ @dataclass class DLCLoader(Loader, metaclass=CombinedPropertyMeta): """A Loader for DeepLabCut projects""" + project_root: str + model_config_path: str shuffle: int = 0 image_id_offset: int = 0 + # TODO: read train fraction index properties = { "cfg": ( @@ -66,6 +69,7 @@ class DLCLoader(Loader, metaclass=CombinedPropertyMeta): } def __post_init__(self): + super().__init__(self.project_root, self.model_config_path) self.split, self.df_dlc, self.df_train, self.df_test = self._load_dlc_data() def _load_dlc_data(self): @@ -78,7 +82,7 @@ def _load_dlc_data(self): @staticmethod def drop_duplicates(dlc_df, df_train, df_test): - dlc_df = dlc_df[dlc_df.index.duplicated(keep="first")] + dlc_df = dlc_df[~dlc_df.index.duplicated(keep="first")] df_train = df_train[ ~df_train.index.duplicated( keep="first", @@ -86,14 +90,14 @@ def drop_duplicates(dlc_df, df_train, df_test): ] if df_test is not None: df_test = df_test[ - df_test.index.duplicated( + ~df_test.index.duplicated( keep="first", ) ] return dlc_df, df_train, df_test def load_data(self, mode: str = "train") -> dict: - """ Loads DeepLabCut data into COCO-style annotations + """Loads DeepLabCut data into COCO-style annotations This function reads data from h5 file, split the data and returns it in COCO-like format @@ -162,11 +166,12 @@ def split_data( Returns: df_train, df_test """ - df_test = None train_images = dlc_df.index[split["train"]] + df_train = dlc_df.loc[train_images] + + df_test = None if len(split["test"]) != 0: test_images = dlc_df.index[split["test"]] df_test = dlc_df.loc[test_images] - df_train = dlc_df.loc[train_images] return df_train, df_test diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deeplabcut/pose_estimation_pytorch/data/helper.py b/deeplabcut/pose_estimation_pytorch/data/helper.py index 88cd8c235f..447d0a6669 100644 --- a/deeplabcut/pose_estimation_pytorch/data/helper.py +++ b/deeplabcut/pose_estimation_pytorch/data/helper.py @@ -6,6 +6,7 @@ def cfg_getter(key, default=None): def _getter(cfg): return cfg.get(key, default) + return _getter @@ -20,10 +21,13 @@ def class_property(func, arg_func): Returns: - A property with the logic encapsulated in `func` and arguments derived from `arg_func`. """ + def decorator_wrapper(method): def wrapper(self): return func(arg_func(self)) + return property(wrapper) + return decorator_wrapper @@ -54,14 +58,15 @@ class MyClass(metaclass=PropertyMeta): """ def __new__(cls, name, bases, attrs): - if 'properties' not in attrs: + if "properties" not in attrs: raise AttributeError( f"{name} must define a 'properties' dictionary.", ) - properties = attrs.get('properties', {}) + properties = attrs.get("properties", {}) for prop_name, (func, arg_func) in properties.items(): attrs[prop_name] = class_property( - func, arg_func, + func, + arg_func, )(lambda self: None) return super().__new__(cls, name, bases, attrs) diff --git a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py new file mode 100644 index 0000000000..5091e10176 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py @@ -0,0 +1,181 @@ +"""Post-process predictions made by models""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +import numpy as np + +from deeplabcut.pose_estimation_pytorch.data.preprocessor import Context + + +class Postprocessor(ABC): + """A post-processor can be called on the output of a model + TODO: Documentation + """ + + @abstractmethod + def __call__(self, predictions: Any, context: Context) -> Any: + """ + Post-processes the outputs of a model into a single prediction. + + Args: + predictions: the predictions made by the model on a single image + context: the context returned by the pre-processor with the image + + Returns: + a single post-processed prediction + """ + pass + + +def build_bottom_up_postprocessor(with_unique_bodyparts: bool) -> ComposePostprocessor: + """Creates a postprocessor for bottom-up pose estimation (or object detection) + + Args: + with_unique_bodyparts: whether the model outputs unique bodyparts + + Returns: + A default bottom-up Postprocessor + """ + keys_to_concatenate = {"bodyparts": ("bodypart", "poses")} + if with_unique_bodyparts: + keys_to_concatenate["unique_bodyparts"] = ("unique_bodypart", "poses") + return ComposePostprocessor( + components=[ + ConcatenateOutputs(keys_to_concatenate=keys_to_concatenate), + ] + ) + + +def build_top_down_postprocessor(with_unique_bodyparts: bool) -> Postprocessor: + """Creates a postprocessor for top-down pose estimation + + Args: + with_unique_bodyparts: whether the model outputs unique bodyparts + + Returns: + A default top-down Postprocessor + """ + keys_to_concatenate = {"bodyparts": ("bodypart", "poses")} + keys_to_rescale = ["bodyparts"] + if with_unique_bodyparts: + keys_to_concatenate["unique_bodyparts"] = ("unique_bodypart", "poses") + keys_to_rescale.append("unique_bodyparts") + return ComposePostprocessor( + components=[ + ConcatenateOutputs(keys_to_concatenate=keys_to_concatenate), + RescaleAndOffset(keys_to_rescale=keys_to_rescale), + ] + ) + + +def build_detector_postprocessor() -> Postprocessor: + """Creates a postprocessor for top-down pose estimation + + Returns: + A default top-down Postprocessor + """ + return ComposePostprocessor( + components=[ + ConcatenateOutputs(keys_to_concatenate={"bboxes": ("detection", "bboxes")}), + BboxToCoco(bounding_box_keys=["bboxes"]), + ] + ) + + +class ComposePostprocessor(Postprocessor): + """ + Class to preprocess an image and turn it into a batch of + inputs before running inference + """ + + def __init__(self, components: list[Postprocessor]) -> None: + self.components = components + + def __call__(self, predictions: Any, context: Context) -> tuple[Any, Context]: + for postprocessor in self.components: + predictions, context = postprocessor(predictions, context) + return predictions, context + + +class ConcatenateOutputs(Postprocessor): + """Checks that there is a single prediction for the image and returns it""" + + def __init__(self, keys_to_concatenate: dict[str, tuple[str, str]]): + self.keys_to_concatenate = keys_to_concatenate + + def __call__( + self, predictions: Any, context: Context + ) -> tuple[dict[str, np.ndarray], Context]: + if len(predictions) == 0: + raise ValueError("Cannot concatenate outputs: predictions has length 0") + + outputs = {} + for output_name, head_key in self.keys_to_concatenate.items(): + head_name, val_name = head_key + outputs[output_name] = np.concatenate( + [p[head_name][val_name] for p in predictions] + ) + + return outputs, context + + +class RescaleAndOffset(Postprocessor): + """Rescales and offsets images back to their position in the original image""" + + def __init__(self, keys_to_rescale: list[str]) -> None: + super().__init__() + self.keys_to_rescale = keys_to_rescale + + def __call__( + self, + predictions: dict[str, np.ndarray], + context: Context, + ) -> tuple[dict[str, np.ndarray], Context]: + if "scales" not in context or "offsets" not in context: + raise ValueError( + "RescalePostprocessor needs 'scales' and 'offsets' in the context, " + f"found {context}" + ) + + updated_predictions = {} + scales, offsets = np.array(context["scales"]), np.array(context["offsets"]) + for name, outputs in predictions.items(): + if name in self.keys_to_rescale: + if not len(outputs) == len(scales) == len(offsets): + raise ValueError( + "There must be as many 'scales' and 'offsets' as outputs, found " + f"{len(outputs)}, {len(scales)}, {len(offsets)}" + ) + + rescaled = [] + for output, scale, offset in zip(outputs, scales, offsets): + output_rescaled = output.copy() + output_rescaled[:, 0] = output[:, 0] * scale[0] + offset[0] + output_rescaled[:, 1] = output[:, 1] * scale[1] + offset[1] + rescaled.append(output_rescaled) + updated_predictions[name] = np.stack(rescaled) + else: + updated_predictions[name] = outputs.copy() + + return updated_predictions, context + + +class BboxToCoco(Postprocessor): + """Transforms bounding boxes from xyxy to COCO format (xywh)""" + + def __init__(self, bounding_box_keys: list[str]) -> None: + super().__init__() + self.bounding_box_keys = bounding_box_keys + + def __call__( + self, + predictions: dict[str, np.ndarray], + context: Context, + ) -> tuple[dict[str, np.ndarray], Context]: + for bbox_key in self.bounding_box_keys: + predictions[bbox_key][:, 2] -= predictions[bbox_key][:, 0] + predictions[bbox_key][:, 3] -= predictions[bbox_key][:, 1] + + return predictions, context diff --git a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py new file mode 100644 index 0000000000..1fb37d395e --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py @@ -0,0 +1,202 @@ +"""Helpers to run preprocess data before running inference""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TypeVar, Any + +import albumentations as A +import cv2 +import numpy as np +import torch + +from deeplabcut.pose_estimation_pytorch.data.utils import _crop_and_pad_image_torch + + +Image = TypeVar("Image", torch.Tensor, np.ndarray, str, Path) +Context = TypeVar("Context", dict[str, Any], None) + + +class Preprocessor(ABC): + """ + Class to preprocess an image and turn it into a batch of inputs before running + inference. + + As an example, a pre-processor can load an image, use a "bboxes" key from context + to crop bounding boxes for individuals (going from a (h, w, 3) array to a + (num_individuals, h, w, 3) array), and convert it into a tensor ready for inference. + """ + + @abstractmethod + def __call__(self, image: Image, context: Context) -> tuple[Image, Context]: + """Pre-processes an image + + Args: + image: an image (containing height, width and channel dimensions) or a + batch of images linked to a single input (containing an extra batch + dimension) + context: the context for this image or batch of images (such as ) + + Returns: + the pre-processed image (or batch of images) and their context + """ + pass + + +def build_bottom_up_preprocessor( + color_mode: str, + transform: A.BaseCompose, +) -> Preprocessor: + """Creates a preprocessor for bottom-up pose estimation (or object detection) + + Creates a preprocessor that loads an image, runs some transform on it (such as + normalization), creates a tensor from the numpy array (going from (h, w, 3) to + (3, h, w)) and adds a batch dimension (so the final tensor shape is (1, 3, h, w)) + + Args: + color_mode: whether to load the image as an RGB or BGR + transform: the transform to apply to the image + + Returns: + A default bottom-up Preprocessor + """ + return ComposePreprocessor( + components=[ + LoadImage(color_mode), + AugmentImage(transform), + ToTensor(), + ToBatch(), + ] + ) + + +def build_top_down_preprocessor( + color_mode: str, + transform: A.BaseCompose, + cropped_image_size: tuple[int, int], +) -> Preprocessor: + """Creates a preprocessor for top-down pose estimation + + Creates a preprocessor that loads an image, crops all bounding boxes given as a + context (through a "bboxes" key), runs some transforms on each cropped image (such + as normalization), creates a tensor from the numpy array (going from + (num_ind, h, w, 3) to (num_ind, 3, h, w)). + + Args: + color_mode: whether to load the image as an RGB or BGR + transform: the transform to apply to the image + cropped_image_size: the size of images for each individual to give to the pose + estimator + + Returns: + A default top-down Preprocessor + """ + return ComposePreprocessor( + components=[ + LoadImage(color_mode), + TorchCropDetections(cropped_image_size=cropped_image_size[0]), + AugmentImage(transform), + ToTensor(), + ] + ) + + +class ComposePreprocessor(Preprocessor): + """ + Class to preprocess an image and turn it into a batch of + inputs before running inference + """ + + def __init__(self, components: list[Preprocessor]) -> None: + self.components = components + + def __call__(self, image: Image, context: Context) -> tuple[Image, Context]: + for preprocessor in self.components: + image, context = preprocessor(image, context) + return image, context + + +class LoadImage(Preprocessor): + """Loads an image from a file, if not yet loaded""" + + def __init__(self, color_mode: str = "RBG") -> None: + self.color_mode = color_mode + + def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]: + if isinstance(image, (str, Path)): + image_ = cv2.imread(str(image)) + if self.color_mode == "RGB": + image_ = cv2.cvtColor(image_, cv2.COLOR_BGR2RGB) + else: + image_ = image + + return image_, context + + +class AugmentImage(Preprocessor): + """TODO""" + + def __init__(self, transform: A.BaseCompose) -> None: + self.transform = transform + + def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]: + # If the image is a batch, process each entry + if len(image.shape) == 4: + transformed = [ + self.transform( + image=img, keypoints=[], class_labels=[], bboxes=[], bbox_labels=[] + )["image"] + for img in image + ] + image = np.stack(transformed) + else: + image = self.transform( + image=image, keypoints=[], class_labels=[], bboxes=[], bbox_labels=[] + )["image"] + + return image, context + + +class ToTensor(Preprocessor): + """Transforms lists and numpy arrays into tensors""" + + def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]: + image = torch.tensor(image, dtype=torch.float) + if len(image.shape) == 4: + image = image.permute(0, 3, 1, 2) + else: + image = image.permute(2, 0, 1) + return image, context + + +class ToBatch(Preprocessor): + """TODO""" + + def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]: + return image.unsqueeze(0), context + + +class TorchCropDetections(Preprocessor): + """TODO""" + + def __init__(self, cropped_image_size: int, bbox_format: str = "xywh") -> None: + self.cropped_image_size = cropped_image_size + self.bbox_format = bbox_format + + def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]: + """TODO: numpy implementation""" + if "bboxes" not in context: + raise ValueError(f"Must include bboxes to CropDetections, found {context}") + + images, offsets, scales = [], [], [] + for bbox in context["bboxes"]: + cropped_image, offset, scale = _crop_and_pad_image_torch( + image, bbox, "xywh", self.cropped_image_size + ) + images.append(cropped_image) + offsets.append(offset) + scales.append(scale) + + context["offsets"] = offsets + context["scales"] = scales + return np.stack(images, axis=0), context diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 414b403f22..7f0b6e28ec 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -294,6 +294,7 @@ def _crop_and_pad_image_torch( crop_h, crop_w = cropped_image.shape[1:3] pad_size = max(crop_h, crop_w) + offset = (xmin, ymin) # Pad image if not square if not crop_h == crop_w: @@ -301,13 +302,31 @@ def _crop_and_pad_image_torch( (c, pad_size, pad_size), dtype=image.dtype, ) - padded_cropped_image[:, :crop_h, :crop_w] = cropped_image + # Try to center bbox in padding + w_start = 0 + if bbox[0] - (crop_size / 2) < 0: + # padding on the left + w_start = pad_size - crop_w + elif bbox[0] + (crop_size / 2) >= w: + # padding on the right + w_start = 0 + + h_start = 0 + if bbox[1] - (crop_size / 2) < 0: + # padding at the top + h_start = pad_size - crop_h + elif bbox[1] + (crop_size / 2) >= h: + # padding at the bottom + h_start = 0 + + h_end = h_start + crop_h + w_end = w_start + crop_w + offset = (offset[0] - w_start, offset[1] - h_start) + padded_cropped_image[:, h_start:h_end, w_start:w_end] = cropped_image cropped_image = padded_cropped_image scale = pad_size / output_size - offset = (xmin, ymin) - output = F.resize(cropped_image, [output_size, output_size]) - + output = F.resize(cropped_image, [output_size, output_size], antialias=True) return output.permute(1, 2, 0).numpy(), offset, (scale, scale) @@ -336,52 +355,90 @@ def _compute_crop_bounds( def _extract_keypoints_and_bboxes( annotations: list[dict], - image: np.ndarray, + image_shape: tuple[int, int, int], + num_joints: int, + num_unique_bodyparts: int, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, list]]: """ Args: annotations: COCO-style annotations - image: from which extract keypoints and bounding boxes for + image_shape: the (h, w, c) shape of the image for which to get annotations + num_joints: the number of joints in the annotations Returns: keypoints, unique_keypoints, bboxes in xywh format, annotations_merged """ keypoints = [] - unique_keypoints = [] original_bboxes = [] - - annotations_merged = merge_list_of_dicts( - annotations, - keys_to_include=["category_id", "iscrowd"], - ) - + annotations_to_merge = [] + unique_keypoints = None for i, annotation in enumerate(annotations): keypoints_individual = _annotation_to_keypoints(annotation) if annotation["individual"] != "single": bbox_individual = annotation["bbox"] original_bboxes.append(bbox_individual) keypoints.append(keypoints_individual) + annotations_to_merge.append(annotation) else: unique_keypoints = keypoints_individual - unique_keypoints = np.array(unique_keypoints) + if unique_keypoints is None: + unique_keypoints = -1 * np.ones((num_unique_bodyparts, 3), dtype=float) - keypoints = np.stack(keypoints, axis=0) + keypoints = safe_stack(keypoints, (0, num_joints, 3)) + original_bboxes = safe_stack(original_bboxes, (0, 4)) + bboxes = _compute_crop_bounds(original_bboxes, image_shape) - bboxes = np.array( - _compute_crop_bounds(np.stack(original_bboxes, axis=0), image.shape), - ).reshape((-1, 4)) + annotations_merged = merge_list_of_dicts( + annotations_to_merge, + keys_to_include=["area", "category_id", "iscrowd"], + ) + if len(annotations_merged["area"]) == len(keypoints): + area = np.array(annotations_merged["area"]) + area[area < 1] = 1 # TODO: see comments in calc_area_from_keypoints + else: + area = calc_area_from_keypoints(keypoints) + + annotations_merged["area"] = area return keypoints, unique_keypoints, bboxes, annotations_merged +def calc_area_from_keypoints(keypoints: np.ndarray) -> np.ndarray: + """ + Calculate the area from keypoints + + TODO: in the pups benchmark, there are 5 keypoints perfectly aligned so + the area is 0. + How do we deal with that? + Makes more sense to compute the area from the bboxes (they are padded) + Below is a temporary fix, which sets a min height and width to 5 + Suggestion: compute min height/width using labeled data + + Args: + keypoints (np.ndarray): array of keypoints + + Returns: + np.ndarray: array containing the computed areas based on the keypoints + """ + w = keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1) + h = keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1) + area = w * h + area[area < 1] = 1 + return area + + def _annotation_to_keypoints(annotation: dict) -> np.array: """ - Convert the coco annotations into array of keypoints returns the array of the keypoints' visibility - If keypoint is not visible, the value for (x,y) coordinates is set to 0 + Convert the coco annotations into array of keypoints returns the array of the + keypoints' visibility. If keypoint is not visible, the value for (x,y) coordinates + is set to 0 + Args: - annotation: dictionary containing coco-like annotations with essential `keypoints` field + annotation: dictionary containing coco-like annotations with essential + `keypoints` field Returns: - keypoints: np.array where the first two columns are x and y coordinates of the keypoints and the third column is the visibility of the keypoints + keypoints: np.array where the first two columns are x and y coordinates of the + keypoints and the third column is the visibility of the keypoints """ keypoints = annotation["keypoints"].reshape(-1, 3) visibility_mask = keypoints > -1 @@ -414,6 +471,7 @@ def apply_transform( """ if transform: + defined_keypoint_mask = _check_keypoints_within_bounds(keypoints, image.shape) transformed = _apply_transform( transform, image, @@ -422,6 +480,7 @@ def apply_transform( class_labels, ) transformed["keypoints"] = np.array(transformed["keypoints"]) + transformed["keypoints"][~defined_keypoint_mask] = -1 shape_transformed = transformed["image"].shape mask_valid = _check_keypoints_within_bounds( transformed["keypoints"], @@ -520,3 +579,46 @@ def _check_keypoints_within_bounds(keypoints: np.ndarray, shape: tuple) -> np.nd & (keypoints[..., :2] < np.array([shape[1], shape[0]])), axis=1, ) + + +def pad_to_length(data: np.array, length: int, value: float) -> np.array: + """ + Pads the first dimension of an array with a given value + + Args: + data: the array to pad, of shape (l, ...), where l <= length + length: the desired length of the tensor + value: the value to pad with + + Returns: + the padded array of shape (length, ...) + """ + pad_length = length - len(data) + if pad_length == 0: + return data + elif pad_length > 0: + padding = value * np.ones((pad_length, *data.shape[1:]), dtype=data.dtype) + return np.concatenate([data, padding]) + + raise ValueError(f"Cannot pad! data.shape={data.shape} > length={length}") + + +def safe_stack( + data: list[np.ndarray], + default_shape: tuple[int, ...], +) -> np.ndarray: + """ + Stacks a list of arrays if there are any, otherwise returns an array of zeros + of a desired shape. + + Args: + data: the list of arrays to stack + default_shape: the shape of the array to return if the list is empty + + Returns: + the stacked data or empty array + """ + if len(data) == 0: + return np.zeros(default_shape, dtype=float) + + return np.stack(data, axis=0) diff --git a/deeplabcut/pose_estimation_pytorch/default_config.py b/deeplabcut/pose_estimation_pytorch/default_config.py index 67e3b2892f..5cc979bc9e 100644 --- a/deeplabcut/pose_estimation_pytorch/default_config.py +++ b/deeplabcut/pose_estimation_pytorch/default_config.py @@ -1,88 +1,81 @@ -pytorch_cfg_template = {} +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from typing import Any -pytorch_cfg_template["cfg_path"] = "/data/quentin/datasets/daniel3mouse/config.yaml" -pytorch_cfg_template["seed"] = 42 -pytorch_cfg_template["device"] = "cuda:0" -pytorch_cfg_template["display_iters"] = 1000 -pytorch_cfg_template["save_epochs"] = 50 # not iterations, epochs -pytorch_cfg_template["data"] = { - "scale_jitter": [0.5, 1.25], - "rotation": 30, - "translation": 40, - "hist_eq": True, - "motion_blur": True, - "covering": True, - "gaussian_noise": 0.05 * 255, - "normalize_images": True, -} - -pytorch_cfg_template["model"] = { - "backbone": { - "type": "ResNet", - "pretrained": "https://download.pytorch.org/models/resnet50-19c8e357.pth", - }, - "heatmap_head": { - "type": "SimpleHead", - "channels": [2048, 1024, -1], # -1 acts as undefined here - "kernel_size": [2, 2], - "strides": [2, 2], - }, - "locref_head": { - "type": "SimpleHead", - "channels": [2048, 1024, -1], # -1 acts as undefined here - "kernel_size": [2, 2], - "strides": [2, 2], +pytorch_cfg_template: dict[str, Any] = { + "cfg_path": "/data/quentin/datasets/daniel3mouse/config.yaml", + "seed": 42, + "device": "cuda:0", + "display_iters": 1000, + "save_epochs": 50, + "data": { + "scale_jitter": [0.5, 1.25], + "rotation": 30, + "translation": 40, + "hist_eq": True, + "motion_blur": True, + "covering": True, + "gaussian_noise": 0.05 * 255, + "normalize_images": True, }, - "target_generator": { - "type": "PlateauGenerator", - "locref_stdev": 7.2801, - "num_joints": -1, - "pos_dist_thresh": 17, + "model": { + "backbone": { + "type": "ResNet", + "pretrained": "https://download.pytorch.org/models/resnet50-19c8e357.pth", + }, + "heatmap_head": { + "type": "SimpleHead", + "channels": [2048, 1024, -1], # -1 acts as undefined here + "kernel_size": [2, 2], + "strides": [2, 2], + }, + "locref_head": { + "type": "SimpleHead", + "channels": [2048, 1024, -1], # -1 acts as undefined here + "kernel_size": [2, 2], + "strides": [2, 2], + }, + "target_generator": { + "type": "PlateauGenerator", + "locref_stdev": 7.2801, + "num_joints": -1, + "pos_dist_thresh": 17, + }, + "pose_model": { + "stride": 8, + }, }, - "pose_model": { - "stride": 8, + "optimizer": { + "type": "AdamW", + "params": { + "lr": 1e-4, + }, }, -} - -pytorch_cfg_template["optimizer"] = { - "type": "AdamW", - "params": { - "lr": 1e-4, + "scheduler": { + "type": "LRListScheduler", + "params": { + "milestones": [ + 90, + 120, + ], + "lr_list": [[1e-5], [1e-6]], + }, }, + "runner": {"type": "PoseRunner"}, + "with_center_keypoints": False, + "batch_size": 1, + "epochs": 200, } -pytorch_cfg_template["scheduler"] = { - "type": "LRListScheduler", - "params": { - "milestones": [ - 90, - 120, - ], - "lr_list": [[1e-5], [1e-6]], - }, -} - -pytorch_cfg_template["predictor"] = { - "type": "SinglePredictor", - "num_animals": -1, - "location_refinement": True, - "locref_stdev": 7.2801, -} - -pytorch_cfg_template["criterion"] = { - "type": "PoseLoss", - "loss_weight_locref": 0.02, - "locref_huber_loss": True, -} - -pytorch_cfg_template["solver"] = {"type": "BottomUpSingleAnimalSolver"} - -pytorch_cfg_template["pos_dist_thresh"] = 17 -pytorch_cfg_template["with_center"] = False -pytorch_cfg_template["batch_size"] = 1 -pytorch_cfg_template["epochs"] = 200 - if __name__ == "__main__": import yaml diff --git a/deeplabcut/pose_estimation_pytorch/models/__init__.py b/deeplabcut/pose_estimation_pytorch/models/__init__.py index ab699c2944..d0ca89cd01 100644 --- a/deeplabcut/pose_estimation_pytorch/models/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/__init__.py @@ -1,13 +1,9 @@ -from deeplabcut.pose_estimation_pytorch.models.utils import ( - _generate_heatmaps, - gaussian_scmap, -) from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS -from deeplabcut.pose_estimation_pytorch.models.criterion import LOSSES +from deeplabcut.pose_estimation_pytorch.models.criterions import CRITERIONS from deeplabcut.pose_estimation_pytorch.models.target_generators import ( TARGET_GENERATORS, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py index 0eff99af7a..b4e8aba9b4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py @@ -8,5 +8,9 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( + BACKBONES, + BaseBackbone, +) from deeplabcut.pose_estimation_pytorch.models.backbones.hrnet import HRNet from deeplabcut.pose_estimation_pytorch.models.backbones.resnet import ResNet diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index 6d1e0d8127..bcd9b0f3ff 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -11,8 +11,10 @@ from abc import ABC, abstractmethod import torch + from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + BACKBONES = Registry("backbones", build_func=build_from_cfg) @@ -21,6 +23,7 @@ class BaseBackbone(ABC, torch.nn.Module): Attributes: batch_norm_on: Indicates whether batch normalization is activated during training. + Batch Norm should not be on for small batch sizes. """ def __init__(self): @@ -29,26 +32,22 @@ def __init__(self): self.batch_norm_on = False @abstractmethod - def forward(self, x: torch.Tensor): + def forward(self, x: torch.Tensor) -> torch.Tensor: """Abstract method for the forward pass through the backbone. Args: x: Input tensor of shape (batch_size, channels, height, width). Returns: - Output tensor. + a feature map for the input, of shape (batch_size, c', h', w') """ pass - def _init_weights(self, pretrained: str = None): + def _init_weights(self, pretrained: str = None) -> None: """Initialize the backbone with pretrained weights. Args: pretrained: Path to the pretrained weights. - Defaults to None. - - Returns: - None """ if not pretrained: pass @@ -58,29 +57,19 @@ def _init_weights(self, pretrained: str = None): else: self.model.load_state_dict(torch.load(pretrained), strict=False) - def activate_batch_norm(self, activation: bool = False): + def activate_batch_norm(self, activation: bool = False) -> None: """Activate or deactivate batch normalization layers during training. Args: activation: Activate or deactivate batch normalization. - Defaults to False. - - Returns: - None """ self.batch_norm_on = activation - def train(self, mode=True): + def train(self, mode: bool = True) -> None: """Set the training mode with optional batch normalization activation. Args: mode: Training mode. Defaults to True. - - Returns: - None - - Note: - Batch Norm should not be on for small batch sizes. """ super().train(mode) @@ -90,5 +79,3 @@ def train(self, mode=True): module.eval() module.weight.requires_grad = False module.bias.requires_grad = False - - return diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index ab00a7bb1f..9d22246233 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -10,11 +10,13 @@ # import timm import torch +import torch.nn as nn +import torch.nn.functional as F + from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( BACKBONES, BaseBackbone, ) -from torch.nn import functional as F @BACKBONES.register_module @@ -25,24 +27,26 @@ class HRNet(BaseBackbone): This is obtained using bilinear interpolation and concatenation of all the outputs of the HRNet stages. - Args: - model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). - pretrained: If True, loads the model with ImageNet pre-trained weights. + Attributes: + model: the HRNet model """ def __init__( - self, model_name: str = "hrnet_w32", pretrained: bool = True - ) -> torch.nn.Module: - """Constructs an ImageNet pre-trained HRNet from timm. + self, + model_name: str = "hrnet_w32", + pretrained: bool = True, + only_high_res: bool = False, + ) -> None: + """Constructs an ImageNet pretrained HRNet from timm. Args: model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). - pretrained: If True, loads the model with ImageNet pre-trained weights. + pretrained: If True, loads the model with ImageNet pretrained weights. + only_high_res: Whether to only return the high resolution features """ super().__init__() - _backbone = timm.create_model(model_name, pretrained=pretrained) - _backbone.incre_modules = None # Necessary to get high-resolution features; otherwise, _backbone.forward_features will return low-resolution images. - self.model = _backbone + self.model = _load_hrnet(model_name, pretrained) + self.only_high_res = only_high_res def forward(self, x: torch.Tensor) -> torch.Tensor: """Forward pass through the HRNet backbone. @@ -51,7 +55,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: x: Input tensor of shape (batch_size, channels, height, width). Returns: - Output tensor with concatenated high-resolution feature maps. + the feature map Example: >>> import torch @@ -61,6 +65,9 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: >>> y = backbone(x) """ y_list = self.model.forward_features(x) + if self.only_high_res: + return y_list[0] + x0_h, x0_w = y_list[0].size(2), y_list[0].size(3) x = torch.cat( [ @@ -74,44 +81,19 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x -@BACKBONES.register_module -class HRNetTopDown(BaseBackbone): - """HRNet backbone for the top-down approach. - This version returns only the high-resolution stream. - - Args: - model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). - pretrained: If True, loads the model with ImageNet pre-trained weights. +def _load_hrnet(model_name: str, pretrained: bool) -> nn.Module: """ + Loads a TIMM HRNet model, while setting incre_modules to None. This is necessary to + get high-resolution features; otherwise model.forward_features() returns + low-resolution maps. - def __init__(self, model_name: str = "hrnet_w32", pretrained: bool = True): - """Constructs an ImageNet pre-trained HRNet from timm. - - Args: - model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). - pretrained: If True, loads the model with ImageNet pre-trained weights. - """ - super().__init__() - _backbone = timm.create_model(model_name, pretrained=pretrained) - _backbone.incre_modules = None # Necessary to get high-resolution features; otherwise, _backbone.forward_features will return low-resolution images. - self.model = _backbone - - def forward(self, x: torch.Tensor) -> torch.Tensor: - """Forward pass through the HRNet backbone. - - Args: - x: Input tensor of shape (batch_size, channels, height, width). - - Returns: - Output tensor with the high-resolution stream. + Args: + model_name: the name of the HRNet model to load + pretrained: whether the ImageNet pretrained weights should be loaded - Example: - >>> import torch - >>> from deeplabcut.pose_estimation_pytorch.models.backbones import HRNetTopDown - >>> backbone = HRNetTopDown(model_name='hrnet_w32', pretrained=False) - >>> x = torch.randn(1, 3, 256, 256) - >>> y = backbone(x) - """ - return self.model.forward_features(x)[ - 0 - ] # Only take the high-resolution stream at the end + Returns: + the HRNet model + """ + model = timm.create_model(model_name, pretrained=pretrained) + model.incre_modules = None + return model diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index a7eb21a5cb..e47047ce90 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -9,11 +9,12 @@ # Licensed under GNU Lesser General Public License v3.0 # import timm +import torch + from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( BACKBONES, BaseBackbone, ) -import torch.nn as nn @BACKBONES.register_module @@ -22,26 +23,21 @@ class ResNet(BaseBackbone): This class represents a typical ResNet backbone for pose estimation. - Args: - model_name: Name of the ResNet model to use, e.g., 'resnet50', 'resnet101', etc. - Defaults to 'resnet50'. - pretrained: If True, the backbone will be initialized with ImageNet pre-trained weights. - Defaults to True. + Attributes: + model: the ResNet model """ def __init__(self, model_name: str = "resnet50", pretrained: bool = True) -> None: """Initialize the ResNet backbone. Args: - model_name: Name of the ResNet model to use, e.g., 'resnet50', 'resnet101', etc. - Defaults to 'resnet50'. - pretrained: If True, the backbone will be initialized with ImageNet pre-trained weights. - Defaults to True. + model_name: Name of the ResNet model to use, e.g., 'resnet50', 'resnet101' + pretrained: If True, initializes with ImageNet pretrained weights. """ super().__init__() self.model = timm.create_model(model_name, pretrained=pretrained) - def forward(self, x): + def forward(self, x: torch.Tensor) -> torch.Tensor: """Forward pass through the ResNet backbone. Args: @@ -57,6 +53,7 @@ def forward(self, x): >>> y = backbone(x) Expected Output Shape: - If input size is (batch_size, 3, shape_x, shape_y), the output shape will be (batch_size, 3, shape_x//32, shape_y//32) + If input size is (batch_size, 3, shape_x, shape_y), the output shape + will be (batch_size, 3, shape_x//32, shape_y//32) """ return self.model.forward_features(x) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterion.py b/deeplabcut/pose_estimation_pytorch/models/criterion.py deleted file mode 100644 index 621801e16d..0000000000 --- a/deeplabcut/pose_estimation_pytorch/models/criterion.py +++ /dev/null @@ -1,334 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# - -from typing import Dict, Tuple, Union - -import torch -import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - -LOSSES = Registry("losses", build_func=build_from_cfg) - - -class WeightedMSELoss(nn.MSELoss): - """ - Weighted Mean Squared Error (MSE) Loss. - - This loss computes the Mean Squared Error between the prediction and target tensors, - but it also incorporates weights to adjust the contribution of each element in the loss - calculation. The loss is computed element-wise, and elements with a weight of 0 (masked items) - are excluded from the loss calculation. - """ - - def __init__(self) -> None: - """ - Constructor of the class WeightedMSELoss - """ - super(WeightedMSELoss, self).__init__() - self.mse_loss = nn.MSELoss(reduction="none") - - def __call__( - self, - prediction: torch.Tensor, - target: torch.Tensor, - weights: Union[float, torch.Tensor] = 1, - ) -> torch.Tensor: - """Summary: - Compute the weighted Mean Squared Error loss. - - Args: - prediction: predicted tensor - target: target tensor - weights: weights for each element in the loss calculation. If a float, - weights all elements by that value. Defaults to 1. - - Returns: - Weighted Mean Squared Error Loss. - """ - loss_item = self.mse_loss(prediction, target) - loss_item_weighted = loss_item * weights - - loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] - if loss_without_zeros.nelement() == 0: - return torch.tensor(0.0) - return torch.mean(loss_without_zeros) - - -class WeightedHuberLoss(nn.HuberLoss): - """ - Weighted Huber Loss. - - This loss computes the Huber loss between the prediction and target tensors, - but it also incorporates weights to adjust the contribution of each element in the loss - calculation. The loss is computed element-wise, and elements with a weight of 0 are - excluded from the loss calculation. - """ - - def __init__(self) -> None: - """Summary: - Constructor of the WeightedHuberLoss class. - """ - super(WeightedHuberLoss, self).__init__() - self.huber_loss = nn.HuberLoss(reduction="none") - - def __call__( - self, - prediction: torch.Tensor, - target: torch.Tensor, - weights: Union[float, torch.Tensor] = 1, - ) -> torch.Tensor: - """Summary: - Compute the weighted Huber loss. - - Args: - prediction: predicted tensor - target: target tensor - weights: weights for each element in the loss calculation. If a float, - weights all elements by that value. Defaults to 1. - - Returns: - Weighted Huber loss. - """ - loss_item = self.huber_loss(prediction, target) - loss_item_weighted = loss_item * weights - - loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] - if loss_without_zeros.nelement() == 0: - return torch.tensor(0.0) - return torch.mean(loss_without_zeros) - - -class WeightedBCELoss(nn.BCEWithLogitsLoss): - """ - Weighted Binary Cross Entropy (BCE) Loss. - - This loss computes the Binary Cross Entropy loss between the prediction and target tensors, - but it also incorporates weights to adjust the contribution of each element in the loss - calculation. The loss is computed element-wise, and elements with a weight of 0 are - excluded from the loss calculation. - """ - - def __init__(self) -> None: - """Summary: - Constructor of the WeightedBCELoss. - """ - super(WeightedBCELoss, self).__init__() - self.BCELoss = nn.BCEWithLogitsLoss(reduction="none") - - def __call__( - self, - prediction: torch.Tensor, - target: torch.Tensor, - weights: Union[float, torch.Tensor] = 1, - ) -> torch.Tensor: - """Summary: - Compute the weighted Binary Cross Entropy loss. - - Args: - prediction: _description_ - target: _description_ - weights: _description_. Defaults to 1. - - Returns: - Weighted Binary Cross Entropy loss. - """ - loss_item = self.BCELoss(prediction, target) - loss_item_weighted = loss_item * weights - - loss_without_zeros = loss_item_weighted[loss_item_weighted != 0] - if loss_without_zeros.nelement() == 0: - return torch.tensor(0.0) - return torch.mean(loss_without_zeros) - - -@LOSSES.register_module -class PoseLoss(nn.Module): - """ - Pose Lose Function. - - This loss function computes the weighted sum of heatmap and locref loss for keypoint detection and - localization, respectively. The locref loss can be either Mean Squared Error (MSE) or Huber Loss, - depending on the locref_huber_loss flag. - """ - - def __init__( - self, - loss_weight_locref: float = 0.1, - locref_huber_loss: bool = False, - apply_sigmoid: bool = False, - unique_bodyparts: bool = False, - ) -> None: - """Summary: - Constructor of the PoseLoss class. - - Args: - loss_weight_locref: weight for loss_locref part (parsed from the pose_cfg.yaml from the dlc_models folder) - locref_huber_loss: if True uses torch.nn.HuberLoss for locref (default is False). - apply_sigmoid: whether to apply sigmoid to the heatmap predictions should be true - for MSE, false for BCE (since it already applies it by itself) - unique_bodyparts : Is there a unique bodyparts head attached to the model - - Returns: - None. - """ - super(PoseLoss, self).__init__() - if locref_huber_loss: - self.locref_criterion = WeightedHuberLoss() - else: - self.locref_criterion = WeightedMSELoss() - self.loss_weight_locref = loss_weight_locref - self.heatmap_criterion = WeightedBCELoss() - self.apply_sigmoid = apply_sigmoid - self.sigmoid = nn.Sigmoid() - self.unique_bodyparts = unique_bodyparts - - def forward( - self, prediction: Tuple[torch.Tensor, torch.Tensor], target: Dict - ) -> Dict: - """Summary: - Forward pass of the Pose Loss function. - - Args: - prediction: a tuple containing the predicted heatmap and locref of size - '(heatmaps, locref)' of size '(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)' - target: dictionary containing the target tensors, including 'heatmaps', 'heatmaps_ignored' - (optional, default is None), 'locref_maps', 'locref_masks'. - { - 'heatmaps': (batch_size, number_body_parts, w, h), - 'heatmaps_ignored': heatmap weights (batch_size, number_body_parts, h, w) - 'locref_maps': (batch_size, 2 * number_body_parts, h, w), - 'locref_masks': (batch_size, 2 * number_body_parts, h, w), - } - Returns: - dict of the different unweighted loss components, with keys 'total_loss', 'heatmap_loss' and 'locref_loss' - - Examples: - prediction = (predicted_heatmaps, predicted_locref) - target = { - 'heatmaps': torch.tensor([batch_size, num_keypoints, h, w]), - 'heatmaps_ignored': torch.tensor([batch_size, num_keypoints]), - 'locref_maps': torch.tensor([batch_size, 2 * num_keypoints, h, w]), - 'locref_masks': torch.tensor([batch_size, 2 * num_keypoints, h, w]), - } - losses = criterion(prediction, target) - """ - unique_htmp_loss, unique_locref_loss = 0.0, 0.0 - if self.unique_bodyparts: - heatmaps, locref = prediction[:2] - unique_heatmaps, unique_locref = prediction[2:] - if self.apply_sigmoid: - unique_heatmaps = self.sigmoid(unique_heatmaps) - - unique_htmp_loss = self.heatmap_criterion( - unique_heatmaps, target["unique_heatmaps"], 1.0 - ) - unique_locref_loss = self.locref_criterion( - unique_locref, - target["unique_locref_maps"], - target["unique_locref_masks"], - ) - else: - heatmaps, locref = prediction - if self.apply_sigmoid: - heatmap_loss = self.heatmap_criterion( - self.sigmoid(heatmaps), - target["heatmaps"], - target.get("heatmaps_ignored", 1), - ) - else: - heatmap_loss = self.heatmap_criterion( - heatmaps, target["heatmaps"], target.get("heatmaps_ignored", 1) - ) - - locref_loss = self.locref_criterion( - locref, target["locref_maps"], target["locref_masks"] - ) - total_loss = ( - locref_loss * self.loss_weight_locref - + heatmap_loss - + unique_locref_loss * self.loss_weight_locref - + unique_htmp_loss - ) - - if self.unique_bodyparts: - return { - "total_loss": total_loss, - "heatmap_loss": heatmap_loss, - "locref_loss": locref_loss, - "unique_heatmap_loss": unique_htmp_loss, - "unique_locref_loss": unique_locref_loss, - } - else: - return { - "total_loss": total_loss, - "heatmap_loss": heatmap_loss, - "locref_loss": locref_loss, - } - - -@LOSSES.register_module -class HeatmapOnlyLoss(nn.Module): - """ - Heatmap-Only Loss Function. - - This loss function computes the weighted Binary Cross Entropy (BCE) loss for heatmap predictions. - """ - - def __init__(self, apply_sigmoid: bool = False, unique_bodyparts: bool = False): - """Summary: - Constructor for the HeatmapOnlyLoss class. - - Args: - apply_sigmoid: whether to apply sigmoid to the heatmap predictions should be true for MSE, false for BCE (since it already applies it by itself) - - Return: - None - """ - super(HeatmapOnlyLoss, self).__init__() - self.heatmap_criterion = WeightedBCELoss() - self.apply_sigmoid = apply_sigmoid - self.sigmoid = nn.Sigmoid() - - # Unused for now since no model supporting unique_bodyparts use this loss - self.unique_bodyparts = unique_bodyparts - - def forward( - self, prediction: Tuple[torch.Tensor, torch.Tensor], target: Dict - ) -> Dict: - """Summary: - Forward pass of the Heatmap_Only Loss function. - - Args: - prediction: tuple of Tensors `(heatmaps, locref)` of size - `(batch_size, h, w, number_keypoints), (batch_size, h, w, 2*number_keypoints)` - target: dictionary containing the target tensors: { - 'heatmaps': (batch_size, number_body_parts, h, w), - 'heatmaps_ignored': weights for the heatmap of size (batch_size, number_body_parts, h, w) - } - - Returns: - dict with a single 'total_loss' key - """ - heatmaps = prediction[0] - if self.apply_sigmoid: - heatmap_loss = self.heatmap_criterion( - self.sigmoid(heatmaps), - target["heatmaps"], - target.get("heatmaps_ignored", 1), - ) - else: - heatmap_loss = self.heatmap_criterion( - heatmaps, target["heatmaps"], target.get("heatmaps_ignored", 1) - ) - - return { - "total_loss": heatmap_loss, - } diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py new file mode 100644 index 0000000000..938a072192 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py @@ -0,0 +1,14 @@ +from deeplabcut.pose_estimation_pytorch.models.criterions.base import ( + LOSS_AGGREGATORS, + CRITERIONS, + BaseLossAggregator, + BaseCriterion, +) +from deeplabcut.pose_estimation_pytorch.models.criterions.aggregators import ( + WeightedLossAggregator, +) +from deeplabcut.pose_estimation_pytorch.models.criterions.weighted import ( + WeightedBCECriterion, + WeightedHuberCriterion, + WeightedMSECriterion, +) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py b/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py new file mode 100644 index 0000000000..22ad67dfa4 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py @@ -0,0 +1,31 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import torch + +from deeplabcut.pose_estimation_pytorch.models.criterions.base import ( + LOSS_AGGREGATORS, + BaseLossAggregator, +) + + +@LOSS_AGGREGATORS.register_module +class WeightedLossAggregator(BaseLossAggregator): + def __init__(self, weights: dict[str, float]) -> None: + super().__init__() + self.weights = weights + + def forward(self, losses: dict[str, torch.Tensor]) -> torch.Tensor: + weighted_losses = [ + weight * losses[loss_name] for loss_name, weight in self.weights.items() + ] + return torch.mean(torch.stack(weighted_losses)) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/base.py b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py new file mode 100644 index 0000000000..2627e82d19 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py @@ -0,0 +1,55 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations +from abc import ABC, abstractmethod + +import torch +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + + +LOSS_AGGREGATORS = Registry("loss_aggregators", build_func=build_from_cfg) +CRITERIONS = Registry("criterions", build_func=build_from_cfg) + + +class BaseCriterion(ABC, nn.Module): + def __init__(self, apply_sigmoid: bool = False) -> None: + super().__init__() + self.apply_sigmoid = apply_sigmoid + + @abstractmethod + def forward( + self, output: torch.Tensor, target: torch.Tensor, **kwargs + ) -> torch.Tensor: + """ + Args: + output: the output from which to compute the loss + target: the target for the loss + + Returns: + the different losses for the module, including one "total_loss" key which + is the loss from which to start backpropagation + """ + raise NotImplementedError + + +class BaseLossAggregator(ABC, nn.Module): + @abstractmethod + def forward(self, losses: dict[str, torch.Tensor]) -> torch.Tensor: + """ + Args: + losses: the losses to aggregate + + Returns: + the aggregate loss + """ + raise NotImplementedError diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py new file mode 100644 index 0000000000..5188dabba9 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py @@ -0,0 +1,102 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from deeplabcut.pose_estimation_pytorch.models.criterions.base import ( + CRITERIONS, + BaseCriterion, +) + + +class WeightedCriterion(BaseCriterion): + """Base class for weighted criterions""" + + def __init__(self, criterion: nn.Module, apply_sigmoid: bool = False): + super().__init__(apply_sigmoid=apply_sigmoid) + self.criterion = criterion + + def forward( + self, + output: torch.Tensor, + target: torch.Tensor, + weights: torch.Tensor | float = 1.0, + **kwargs, + ) -> torch.Tensor: + """ + Args: + output: predicted tensor + target: target tensor + weights: weights for each element in the loss calculation. If a float, + weights all elements by that value. Defaults to 1. + + Returns: + the weighted loss + """ + if self.apply_sigmoid: + output = F.sigmoid(output) + + loss = self.criterion(output, target) * weights + loss_without_zeros = loss[loss != 0] + if loss_without_zeros.nelement() == 0: + return torch.tensor(0.0, device=output.device) + + return torch.mean(loss_without_zeros) + + +@CRITERIONS.register_module +class WeightedMSECriterion(WeightedCriterion): + """ + Weighted Mean Squared Error (MSE) Loss. + + This loss computes the Mean Squared Error between the prediction and target tensors, + but it also incorporates weights to adjust the contribution of each element in the loss + calculation. The loss is computed element-wise, and elements with a weight of 0 (masked items) + are excluded from the loss calculation. + """ + + def __init__(self, apply_sigmoid: bool = False) -> None: + super().__init__(nn.MSELoss(reduction="none"), apply_sigmoid=apply_sigmoid) + + +@CRITERIONS.register_module +class WeightedHuberCriterion(WeightedCriterion): + """ + Weighted Huber Loss. + + This loss computes the Huber loss between the prediction and target tensors, + but it also incorporates weights to adjust the contribution of each element in the loss + calculation. The loss is computed element-wise, and elements with a weight of 0 are + excluded from the loss calculation. + """ + + def __init__(self, apply_sigmoid: bool = False) -> None: + super().__init__(nn.HuberLoss(reduction="none"), apply_sigmoid=apply_sigmoid) + + +@CRITERIONS.register_module +class WeightedBCECriterion(WeightedCriterion): + """ + Weighted Binary Cross Entropy (BCE) Loss. + + This loss computes the Binary Cross Entropy loss between the prediction and target tensors, + but it also incorporates weights to adjust the contribution of each element in the loss + calculation. The loss is computed element-wise, and elements with a weight of 0 are + excluded from the loss calculation. + """ + + def __init__(self, apply_sigmoid: bool = False) -> None: + super().__init__( + nn.BCEWithLogitsLoss(reduction="none"), apply_sigmoid=apply_sigmoid + ) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index 4146de24c9..a92375173a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -8,12 +8,15 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations from abc import ABC, abstractmethod import torch import torch.nn as nn + from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + DETECTORS = Registry("detectors", build_func=build_from_cfg) @@ -27,42 +30,33 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def forward(self, x: torch.Tensor) -> None: - """Summary: + def forward( + self, + x: torch.Tensor, + targets: list[dict[str, torch.Tensor]] | None = None, + ) -> tuple[dict[str, torch.Tensor], list[dict[str, torch.Tensor]]]: + """ Forward pass of the detector Args: - x: input tensor representing the image + x: images to be processed + targets: ground-truth boxes present in each images Returns: - See base class. + losses: {'loss_name': loss_value} + detections: for each of the b images, {"boxes": bounding_boxes} """ pass @abstractmethod - def get_target(self, annotations) -> None: - """Summary: + def get_target(self, labels: dict) -> list[dict]: + """ Get the target for training the detector Args: - annotations: annotations containing keypoints, bounding boxes, etc. + labels: annotations containing keypoints, bounding boxes, etc. Returns: - None + list of dictionaries, each representing target information for a single annotation. """ pass - - def _init_weights(self, pretrained: bool) -> None: - """Summary: - Initialize weights for the detector - - Args: - pretrained: whether to use pretrained weights. - - Returns: - None - """ - if not pretrained: - pass - else: - self.model.load_state_dict(torch.load(pretrained)) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index c7f73ea0c9..a1dd76e2f0 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -1,11 +1,24 @@ -from typing import List +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations import torch import torchvision from torchvision.models.detection import FasterRCNN_ResNet50_FPN_Weights from torchvision.models.detection.faster_rcnn import FastRCNNPredictor -from .base import DETECTORS, BaseDetector +from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( + DETECTORS, + BaseDetector, +) @DETECTORS.register_module @@ -18,53 +31,57 @@ class FasterRCNN(BaseDetector): Ren, Shaoqing, Kaiming He, Ross Girshick, and Jian Sun. "Faster r-cnn: Towards real-time object detection with region proposal networks." Advances in neural information processing systems 28 (2015). + + See source: https://github.com/pytorch/vision/blob/main/torchvision/models/detection/generalized_rcnn.py + See tutorial: https://pytorch.org/tutorials/intermediate/torchvision_tutorial.html#defining-your-model + + See validation loss issue: + - https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12 + - https://stackoverflow.com/a/65347721 """ - def __init__( - self, - ): + def __init__(self): """Summary: Constructor of the FasterRCNN object. Loads the data. - - Args: - None - - Return: - None """ super().__init__() self.model = torchvision.models.detection.fasterrcnn_resnet50_fpn( weights=FasterRCNN_ResNet50_FPN_Weights.COCO_V1 ) + + # Modify the base predictor to output the correct number of classes num_classes = 2 in_features = self.model.roi_heads.box_predictor.cls_score.in_features - self.model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes) - def forward(self, x: torch.Tensor, targets: dict = None) -> torch.Tensor: - """Summary: + # See source: https://stackoverflow.com/a/65347721 + self.model.eager_outputs = lambda losses, detections: (losses, detections) + + def forward( + self, + x: torch.Tensor, + targets: list[dict[str, torch.Tensor]] | None = None, + ) -> tuple[dict[str, torch.Tensor], list[dict[str, torch.Tensor]]]: + """ Forward pass of the Faster R-CNN Args: - x: input tensor to the detector - targets: dictionary containing target information for training. - Defaults to None. + x: images to be processed, of shape (b, c, h, w) + targets: ground-truth boxes present in the images Returns: - Output tensor from the detector. If targets are provided, returns - a tuple of losses (classification and regression). - If targets are not provided, returns a tensor with predicted bounding - boxes and associated scores. + losses: {'loss_name': loss_value} + detections: for each of the b images, {"boxes": bounding_boxes} """ return self.model(x, targets) - def get_target(self, annotations: dict) -> List[dict]: - """Summary: + def get_target(self, labels: dict) -> list[dict[str, torch.Tensor]]: + """ Returns target in a format FasterRCNN can handle Args: - annotations: dict of annotations, must contain the keys: + labels: dict of annotations, must contain the keys: area: tensor containing area information for each annotation. labels: tensor containing class labels for each annotation. is_crowd: tensor indicating if each annotation is a crowd (1) or not (0). @@ -94,9 +111,7 @@ def get_target(self, annotations: dict) -> List[dict]: tensor([[50., 60., 70., 80.]])}] """ res = [] - for i, _ in enumerate(annotations["image_id"]): - box_ann = annotations["boxes"][i].clone() - + for i, box_ann in enumerate(labels["boxes"]): mask = (box_ann[:, 2] > 0.0) & (box_ann[:, 3] > 0.0) box_ann = box_ann[mask] # bbox format conversion (x, y, w, h) -> (x1, y1, x2, y2) @@ -104,10 +119,10 @@ def get_target(self, annotations: dict) -> List[dict]: box_ann[:, 3] += box_ann[:, 1] res.append( { - "area": annotations["area"][i], - "labels": annotations["labels"][i], - "image_id": annotations["image_id"][i], - "is_crowd": annotations["is_crowd"][i], + "area": labels["area"][i], + "labels": labels["labels"][i], + # "image_id": labels["image_id"][i], + "is_crowd": labels["is_crowd"][i], "boxes": box_ann, } ) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py index 307e4900b9..4abab2a8de 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py @@ -1,6 +1,4 @@ -from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS -from deeplabcut.pose_estimation_pytorch.models.heads.dekr_heads import ( - HeatmapDEKRHead, - OffsetDEKRHead, -) -from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import SimpleHead +from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS, BaseHead +from deeplabcut.pose_estimation_pytorch.models.heads.dekr import DEKRHead +from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import HeatmapHead +from deeplabcut.pose_estimation_pytorch.models.heads.transformer import TransformerHead diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index b4a2b7c9cb..9ea0003098 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -8,29 +8,101 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations from abc import ABC, abstractmethod import torch import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.models.criterions import ( + BaseCriterion, + BaseLossAggregator, +) +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg HEADS = Registry("heads", build_func=build_from_cfg) class BaseHead(ABC, nn.Module): - """ - Head for pose estimation models + """A head for pose estimation models + + Attributes: + predictor: an object to generate predictions from the head outputs + target_generator: a target generator which must output a target for each + output key of this module (i.e. if forward returns a "heatmap" tensor and + an "offset" tensor, then targets must be generated for both) + criterion: either a single criterion (e.g. if this head only outputs heatmaps) + or a dictionary mapping the outputs of this head to the criterion to use + (e.g. a "heatmap" criterion and an "offset" criterion for DEKR). + aggregator: if the criterion is a dictionary, cannot be none. used to combine + the individual losses from this head into one "total_loss" """ - def __init__(self): + def __init__( + self, + predictor: BasePredictor, + target_generator: BaseGenerator, + criterion: dict[str, BaseCriterion] | BaseCriterion, + aggregator: BaseLossAggregator | None = None, + ) -> None: super().__init__() + self.predictor = predictor + self.target_generator = target_generator + self.criterion = criterion + self.aggregator = aggregator + + if isinstance(criterion, dict): + if aggregator is None: + raise ValueError( + f"When multiple criterions are defined, a loss aggregator must " + "also be given" + ) + else: + if aggregator is not None: + raise ValueError( + f"Cannot use a loss aggregator with a single criterion" + ) @abstractmethod - def forward(self, x): + def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: + """ + Given the feature maps for an image () + + Args: + x: the feature maps, of shape (b, c, h, w) + + Returns: + the head outputs (e.g. "heatmap", "locref") + """ pass - def _init_weights(self, pretrained): - if not pretrained: - pass - else: - self.model.load_state_dict(torch.load(pretrained)) + def get_loss( + self, + outputs: dict[str, torch.Tensor], + targets: dict[str, dict[str, torch.Tensor]], + ) -> dict[str, torch.Tensor]: + """ + Computes the loss for this head + + Args: + outputs: the outputs of this head + targets: the targets for this head + + Returns: + A dictionary containing minimally "total_loss" key mapping to the total + loss for this head (from which backwards() should be called). Can contain + other keys containing losses that can be logged for informational purposes. + """ + if self.aggregator is None: + assert len(outputs) == len(targets) == 1 + key = [k for k in outputs.keys()][0] + return {"total_loss": self.criterion(outputs[key], **targets[key])} + + losses = { + name: criterion(outputs[name], **targets[name]) + for name, criterion in self.criterion.items() + } + losses["total_loss"] = self.aggregator(losses) + return losses diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py similarity index 83% rename from deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py rename to deeplabcut/pose_estimation_pytorch/models/heads/dekr.py index 5c9d20aec3..c0a1e38933 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr_heads.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py @@ -8,36 +8,71 @@ # # Licensed under GNU Lesser General Public License v3.0 # -from typing import Tuple +from __future__ import annotations import torch import torch.nn as nn +from deeplabcut.pose_estimation_pytorch.models.criterions import ( + BaseCriterion, + BaseLossAggregator, +) from deeplabcut.pose_estimation_pytorch.models.heads.base import BaseHead, HEADS -from deeplabcut.pose_estimation_pytorch.models.modules import AdaptBlock, BasicBlock -from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import BLOCKS +from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import ( + AdaptBlock, + BaseBlock, + BasicBlock, +) +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator @HEADS.register_module -class HeatmapDEKRHead(BaseHead): +class DEKRHead(BaseHead): """ - DEKR head to compute the heatmaps corresponding to keypoints - based on: + DEKR head based on: Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression - Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang - CVPR - 2021 + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang, CVPR 2021 Code based on: https://github.com/HRNet/DEKR """ def __init__( self, - channels: Tuple[int], + predictor: BasePredictor, + target_generator: BaseGenerator, + criterion: dict[str, BaseCriterion], + aggregator: BaseLossAggregator, + heatmap_config: dict, + offset_config: dict, + ) -> None: + super().__init__(predictor, target_generator, criterion, aggregator) + self.heatmap_head = DEKRHeatmap(**heatmap_config) + self.offset_head = DEKROffset(**offset_config) + + def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: + return { + "heatmap": self.heatmap_head(x), + "offset": self.offset_head(x), + } + + +class DEKRHeatmap(nn.Module): + """ + DEKR head to compute the heatmaps corresponding to keypoints based on: + Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang, CVPR 2021 + Code based on: + https://github.com/HRNet/DEKR + """ + + def __init__( + self, + channels: tuple[int], num_blocks: int, dilation_rate: int, final_conv_kernel: int, - block=BasicBlock, + block: type(BaseBlock) = BasicBlock, ) -> None: """Summary: Constructor of the HeatmapDEKRHead. @@ -100,7 +135,11 @@ def _make_transition_for_head( return nn.Sequential(*transition_layer) def _make_heatmap_head( - self, block: nn.Module, num_blocks: int, num_channels: int, dilation_rate: int + self, + block: type(BaseBlock), + num_blocks: int, + num_channels: int, + dilation_rate: int, ) -> nn.ModuleList: """Summary: Construct the heatmap head @@ -134,7 +173,7 @@ def _make_heatmap_head( def _make_layer( self, - block: nn.Module, + block: type(BaseBlock), in_channels: int, out_channels: int, num_blocks: int, @@ -170,10 +209,9 @@ def _make_layer( ), ) - layers = [] - layers.append( + layers = [ block(in_channels, out_channels, stride, downsample, dilation=dilation) - ) + ] in_channels = out_channels * block.expansion for _ in range(1, num_blocks): layers.append(block(in_channels, out_channels, dilation=dilation)) @@ -186,41 +224,31 @@ def forward(self, x): return heatmap -@HEADS.register_module -class OffsetDEKRHead(BaseHead): +class DEKROffset(nn.Module): """ - DEKR head to compute the offset from the center corresponding to each keypoints - based on: + DEKR module to compute the offset from the center corresponding to each keypoints: Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression - Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang - CVPR - 2021 + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang, CVPR 2021 Code based on: https://github.com/HRNet/DEKR """ def __init__( self, - channels: Tuple[int], + channels: tuple[int, ...], num_offset_per_kpt: int, num_blocks: int, dilation_rate: int, final_conv_kernel: int, - block=AdaptBlock, + block: type(BaseBlock) = AdaptBlock, ) -> None: - """Summary: - Constructor of the OffsetDEKRHead. - Loads the data. - - Args: - channels: tuple containing the number of input, offset, and output channels. - num_offset_per_kpt: number of offset values per keypoint. - num_blocks: number of blocks in the head. - dilation_rate: dilation rate for convolutional layers. - final_conv_kernel: kernel size for the final convolution. - block: type of block to use in the head. Defaults to AdaptBlock. - - Return: None + """Args: + channels: tuple containing the number of input, offset, and output channels. + num_offset_per_kpt: number of offset values per keypoint. + num_blocks: number of blocks in the head. + dilation_rate: dilation rate for convolutional layers. + final_conv_kernel: kernel size for the final convolution. + block: type of block to use in the head. Defaults to AdaptBlock. """ super().__init__() self.inp_channels = channels[0] @@ -253,7 +281,7 @@ def __init__( def _make_layer( self, - block: nn.Module, + block: type(BaseBlock), in_channels: int, out_channels: int, num_blocks: int, @@ -330,7 +358,7 @@ def _make_transition_for_head( def _make_separete_regression_head( self, - block: nn.Module, + block: type(BaseBlock), num_blocks: int, num_channels_per_kpt: int, dilation_rate: int, diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 7db39f27f5..8821946d26 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -8,38 +8,65 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS -from einops import rearrange -from timm.layers import trunc_normal_ -from .base import BaseHead +from deeplabcut.pose_estimation_pytorch.models.criterions import ( + BaseCriterion, + BaseLossAggregator, +) +from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS, BaseHead +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator @HEADS.register_module -class SimpleHead(BaseHead): +class HeatmapHead(BaseHead): """ Deconvolutional head to predict maps from the extracted features. This class implements a simple deconvolutional head to predict maps from the extracted features. """ def __init__( - self, channels: list, kernel_size: list, strides: list, pretrained: str = None + self, + predictor: BasePredictor, + target_generator: BaseGenerator, + criterion: dict[str, BaseCriterion] | BaseCriterion, + aggregator: BaseLossAggregator | None, + heatmap_config: dict, + locref_config: dict | None = None, ) -> None: - """Summary - Constructor of the SimpleHead object. - Loads the data. + super().__init__(predictor, target_generator, criterion, aggregator) + self.heatmap_head = DeconvModule(**heatmap_config) + self.locref_head = None + if locref_config is not None: + self.locref_head = DeconvModule(**locref_config) + + def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: + outputs = {"heatmap": self.heatmap_head(x)} + if self.locref_head is not None: + outputs["locref"] = self.locref_head(x) + return outputs + + +class DeconvModule(nn.Module): + """ + Deconvolutional module to predict maps from the extracted features. + """ + def __init__( + self, + channels: list[int], + kernel_size: list[int], + strides: list[int], + ) -> None: + """ Args: channels: list containing the number of input and output channels for each deconvolutional layer. kernel_size: list containing the kernel size for each deconvolutional layer. strides: list containing the stride for each deconvolutional layer. - pretrained: path to a pretrained model checkpoint. Defaults to None. Defaults to None. - - Returns: - None """ super().__init__() self.kernel_size = kernel_size @@ -60,19 +87,17 @@ def __init__( layers.append(nn.ReLU()) self.model = nn.Sequential(*layers) - self._init_weights(pretrained) - def _make_layer( self, in_channels: int, out_channels: int, kernel_size: int, stride: int ) -> torch.nn.ConvTranspose2d: - """Summary: + """ Helper function to create a deconvolutional layer. Args: in_channels: number of input channels out_channels: number of output channels kernel_size: size of the deconvolutional kernel - stride: stride for the covolution operation + stride: stride for the convolution operation Returns: upsample_layer: the deconvolutional layer. @@ -83,7 +108,7 @@ def _make_layer( return upsample_layer def forward(self, x: torch.Tensor) -> torch.Tensor: - """Summary: + """ Forward pass of the SimpleHead object. Args: @@ -92,92 +117,4 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: Returns: out: output tensor """ - out = self.model(x) - - return out - - -@HEADS.register_module -class TransformerHead(BaseHead): - """ - Transformer Head module to predict heatmaps using a transformer-based approach - """ - - def __init__( - self, - dim: int, - hidden_heatmap_dim: int, - heatmap_dim: int, - apply_multi: bool, - heatmap_size: tuple, - apply_init: bool, - ): - """Summary: - Given the output of a transformer neck, this head applies an mlp head to compute the heatmaps - Args: - dim: Dimension of the input features. - hidden_heatmap_dim: Dimension of the hidden features in the MLP head. - heatmap_dim: Dimension of the output heatmaps. - apply_multi: If True, apply a multi-layer perceptron (MLP) with LayerNorm - to generate heatmaps. If False, directly apply a single linear - layer for heatmap prediction. - heatmap_size: Tuple (height, width) representing the size of the output - heatmaps. - apply_init: If True, apply weight initialization to the module's layers. - - Returns: - None - """ - super().__init__() - self.mlp_head = ( - nn.Sequential( - nn.LayerNorm(dim * 3), - nn.Linear(dim * 3, hidden_heatmap_dim), - nn.LayerNorm(hidden_heatmap_dim), - nn.Linear(hidden_heatmap_dim, heatmap_dim), - ) - if (dim * 3 <= hidden_heatmap_dim * 0.5 and apply_multi) - else nn.Sequential(nn.LayerNorm(dim * 3), nn.Linear(dim * 3, heatmap_dim)) - ) - self.heatmap_size = heatmap_size - # trunc_normal_(self.keypoint_token, std=.02) - if apply_init: - self.apply(self._init_weights) - - def _init_weights(self, m: nn.Module) -> None: - """Summary - Custom weight initialization for linear and layer normalization layers. - - Args: - m: module to initialize - - Returns: - None - """ - if isinstance(m, nn.Linear): - trunc_normal_(m.weight, std=0.02) - if isinstance(m, nn.Linear) and m.bias is not None: - nn.init.constant_(m.bias, 0) - elif isinstance(m, nn.LayerNorm): - nn.init.constant_(m.bias, 0) - nn.init.constant_(m.weight, 1.0) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - """Summary: - Forward pass of the TransformerHead class - - Args: - x: input tensor - - Returns: - x: output tensor containing predicted heatmaps - """ - x = self.mlp_head(x) - x = rearrange( - x, - "b c (p1 p2) -> b c p1 p2", - p1=self.heatmap_size[0], - p2=self.heatmap_size[1], - ) - - return x + return self.model(x) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py new file mode 100644 index 0000000000..ee039c2f70 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py @@ -0,0 +1,93 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import torch +from einops import rearrange +from timm.layers import trunc_normal_ +from torch import nn as nn + +from deeplabcut.pose_estimation_pytorch.models.criterions import BaseCriterion +from deeplabcut.pose_estimation_pytorch.models.heads import HEADS, BaseHead +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator + + +@HEADS.register_module +class TransformerHead(BaseHead): + """ + Transformer Head module to predict heatmaps using a transformer-based approach + """ + + def __init__( + self, + predictor: BasePredictor, + target_generator: BaseGenerator, + criterion: BaseCriterion, + dim: int, + hidden_heatmap_dim: int, + heatmap_dim: int, + apply_multi: bool, + heatmap_size: tuple[int, int], + apply_init: bool, + ): + """ + Args: + dim: Dimension of the input features. + hidden_heatmap_dim: Dimension of the hidden features in the MLP head. + heatmap_dim: Dimension of the output heatmaps. + apply_multi: If True, apply a multi-layer perceptron (MLP) with LayerNorm + to generate heatmaps. If False, directly apply a single linear + layer for heatmap prediction. + heatmap_size: Tuple (height, width) representing the size of the output + heatmaps. + apply_init: If True, apply weight initialization to the module's layers. + """ + super().__init__(predictor, target_generator, criterion) + self.mlp_head = ( + nn.Sequential( + nn.LayerNorm(dim * 3), + nn.Linear(dim * 3, hidden_heatmap_dim), + nn.LayerNorm(hidden_heatmap_dim), + nn.Linear(hidden_heatmap_dim, heatmap_dim), + ) + if (dim * 3 <= hidden_heatmap_dim * 0.5 and apply_multi) + else nn.Sequential(nn.LayerNorm(dim * 3), nn.Linear(dim * 3, heatmap_dim)) + ) + self.heatmap_size = heatmap_size + # trunc_normal_(self.keypoint_token, std=.02) + if apply_init: + self.apply(self._init_weights) + + def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: + x = self.mlp_head(x) + x = rearrange( + x, + "b c (p1 p2) -> b c p1 p2", + p1=self.heatmap_size[0], + p2=self.heatmap_size[1], + ) + return {"heatmap": x} + + def _init_weights(self, m: nn.Module) -> None: + """ + Custom weight initialization for linear and layer normalization layers. + + Args: + m: module to initialize + """ + if isinstance(m, nn.Linear): + trunc_normal_(m.weight, std=0.02) + if isinstance(m, nn.Linear) and m.bias is not None: + nn.init.constant_(m.bias, 0) + elif isinstance(m, nn.LayerNorm): + nn.init.constant_(m.bias, 0) + nn.init.constant_(m.weight, 1.0) diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index edf14a1a07..7e761e9977 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -8,121 +8,176 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations -from typing import List, Tuple +import copy -import numpy as np import torch -from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator -from deeplabcut.pose_estimation_pytorch.models.utils import generate_heatmaps -from deeplabcut.pose_estimation_tensorflow.core.predict import multi_pose_predict -from torch import nn -from typing import List -from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator -from deeplabcut.pose_estimation_pytorch.models.target_generators.gaussian_targets import ( - GaussianGenerator, +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES +from deeplabcut.pose_estimation_pytorch.models.criterions import ( + CRITERIONS, + LOSS_AGGREGATORS, +) +from deeplabcut.pose_estimation_pytorch.models.heads import HEADS, BaseHead +from deeplabcut.pose_estimation_pytorch.models.necks import NECKS +from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS +from deeplabcut.pose_estimation_pytorch.models.target_generators import ( + TARGET_GENERATORS, ) class PoseModel(nn.Module): - """ - Complete model architecture + """A pose estimation model + + A pose estimation model is composed of a backbone, optionally a neck, and an + arbitrary number of heads. Outputs are computed as follows: """ def __init__( self, cfg: dict, backbone: torch.nn.Module, - heads: List[nn.Module], - target_generator: BaseGenerator, + heads: dict[str, BaseHead], neck: torch.nn.Module = None, stride: int = 8, - num_unique_bodyparts: int = 0, ) -> None: - """Summary - Constructor of the PoseModel. - Loads the data. - + """ Args: cfg: configuration dictionary for the model. backbone: backbone network architecture. - heads: list of head modules, one per keypoint. - target_generator: target generator for model training + heads: the heads for the model neck: neck network architecture (default is None). Defaults to None. stride: stride used in the model. Defaults to 8. - - Return: - None """ super().__init__() - self.backbone = backbone - self.backbone.activate_batch_norm( - cfg["batch_size"] >= 8 - ) # We don't want batch norm to update for small batch sizes + self.cfg = cfg - self.heads = nn.ModuleList(heads) + self.backbone = backbone + self.heads = nn.ModuleDict(heads) self.neck = neck self.stride = stride - self.cfg = cfg - self.target_generator = target_generator - - self.num_unique_bodyparts = num_unique_bodyparts - self.compute_unique_bpts = num_unique_bodyparts > 0 - self.unique_bpts_target_gen = GaussianGenerator( - locref_stdev=7.2801, - num_joints=self.num_unique_bodyparts, - pos_dist_thresh=17, - ) - self.sigmoid = nn.Sigmoid() - def forward(self, x: torch.Tensor) -> List[torch.Tensor]: - """Summary: + # TODO: Explore results, check batch size impact + self.backbone.activate_batch_norm(False) + + def forward(self, x: torch.Tensor) -> dict[str, dict[str, torch.Tensor]]: + """ Forward pass of the PoseModel. Args: x: input images Returns: - List of output, one output per head. + Outputs of head groups """ if x.dim() == 3: x = x[None, :] features = self.backbone(x) if self.neck: features = self.neck(features) - outputs = [] - for head in self.heads: - outputs.append(head(features)) + outputs = {} + for head_name, head in self.heads.items(): + outputs[head_name] = head(features) return outputs + def get_loss( + self, + outputs: dict[str, dict[str, torch.Tensor]], + targets: dict[str, dict[str, torch.Tensor]], + ) -> dict[str, torch.Tensor]: + total_losses = [] + losses: dict[str, torch.Tensor] = {} + for name, head in self.heads.items(): + head_losses = head.get_loss(outputs[name], targets[name]) + total_losses.append(head_losses["total_loss"]) + for k, v in head_losses.items(): + losses[f"{name}_{k}"] = v + + # TODO: Different aggregation for multi-head loss? + losses["total_loss"] = torch.mean(torch.stack(total_losses)) + return losses + def get_target( self, - annotations: dict, - prediction: Tuple[torch.Tensor, torch.Tensor], - image_size: Tuple[int, int], - ) -> dict: + inputs: torch.Tensor, + outputs: dict[str, dict[str, torch.Tensor]], + labels: dict, + ) -> dict[str, dict]: """Summary: Get targets for model training. Args: - annotations: dictionary of annotations - prediction: output of the model - (used here to compute the scaling factor of the model) - image_size: image_size, used here to compute the scaling factor of the model + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: output of each head group + labels: dictionary of labels Returns: - targets: dict of the targets needed for model training + targets: dict of the targets for each model head group """ + return { + name: head.target_generator(inputs, outputs[name], labels) + for name, head in self.heads.items() + } - targets_dict = self.target_generator(annotations, prediction, image_size) - if self.compute_unique_bpts: - unique_anno = {"keypoints": annotations["unique_kpts"][:, None, :]} - unique_targets = self.unique_bpts_target_gen( - unique_anno, prediction[-2:], image_size - ) + def get_predictions( + self, + inputs: torch.Tensor, + outputs: dict[str, dict[str, torch.Tensor]], + ) -> dict: + """Abstract method for the forward pass of the Predictor. - for key in unique_targets: - targets_dict["unique_" + key] = unique_targets[key] + Args: + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: outputs of the model heads - return targets_dict + Returns: + A dictionary containing the predictions of each head group + """ + return { + head_name: head.predictor(inputs, outputs[head_name]) + for head_name, head in self.heads.items() + } + + @staticmethod + def from_cfg(cfg: dict) -> "PoseModel": + backbone = BACKBONES.build(dict(cfg["backbone"])) + + neck = None + if cfg.get("neck"): + neck = NECKS.build(dict(cfg["neck"])) + + heads = {} + for name, head_cfg in cfg["heads"].items(): + head_cfg = copy.deepcopy(head_cfg) + if "type" in head_cfg["criterion"]: + head_cfg["criterion"] = CRITERIONS.build(head_cfg["criterion"]) + else: + weights = {} + criterions = {} + for loss_name, criterion_cfg in head_cfg["criterion"].items(): + weights[loss_name] = criterion_cfg.get("weight", 1.0) + criterion_cfg = { + k: v for k, v in criterion_cfg.items() if k != "weight" + } + criterions[loss_name] = CRITERIONS.build(criterion_cfg) + + aggregator_cfg = {"type": "WeightedLossAggregator", "weights": weights} + head_cfg["aggregator"] = LOSS_AGGREGATORS.build(aggregator_cfg) + head_cfg["criterion"] = criterions + + head_cfg["target_generator"] = TARGET_GENERATORS.build( + head_cfg["target_generator"] + ) + head_cfg["predictor"] = PREDICTORS.build(head_cfg["predictor"]) + heads[name] = HEADS.build(head_cfg) + + return PoseModel( + cfg=cfg, + backbone=backbone, + neck=neck, + heads=heads, + **cfg["pose_model"], + ) diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py index 3f5a4bdaa1..2f9a4788d1 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py @@ -1,10 +1,3 @@ -# ------------------------------------------------------------------------------ -# Copyright (c) Microsoft -# Licensed under the MIT License. -# The code is based on HigherHRNet-Human-Pose-Estimation. -# (https://github.com/HRNet/HigherHRNet-Human-Pose-Estimation) -# ------------------------------------------------------------------------------ - from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import ( AdaptBlock, BasicBlock, diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py index eb93711d53..ebe09622ab 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py @@ -9,8 +9,8 @@ # Licensed under GNU Lesser General Public License v3.0 # # The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main +from __future__ import annotations from abc import ABC, abstractmethod -from typing import Optional import torch import torch.nn as nn @@ -31,8 +31,6 @@ class BaseBlock(ABC, nn.Module): Methods: forward(x): Abstract method for defining the forward pass of the block. - _init_weights(pretrained): Method for initializing block weights from pretrained models. - """ def __init__(self): @@ -51,15 +49,13 @@ def forward(self, x: torch.Tensor): """ pass - def _init_weights(self, pretrained): + def _init_weights(self, pretrained: str | None): """Method for initializing block weights from pretrained models. Args: pretrained: Path to pretrained model weights. """ - if not pretrained: - pass - else: + if pretrained: self.load_state_dict(torch.load(pretrained)) @@ -80,14 +76,14 @@ class BasicBlock(BaseBlock): dilation: Dilation rate for the convolutional layers. Default is 1. """ - expansion = 1 + expansion: int = 1 def __init__( self, in_channels: int, out_channels: int, stride: int = 1, - downsample: Optional[nn.Module] = None, + downsample: nn.Module | None = None, dilation: int = 1, ): super(BasicBlock, self).__init__() @@ -159,14 +155,14 @@ class Bottleneck(BaseBlock): dilation: Dilation rate for the convolutional layers. Default is 1. """ - expansion = 4 + expansion: int = 4 def __init__( self, in_channels: int, out_channels: int, stride: int = 1, - downsample: Optional[nn.Module] = None, + downsample: nn.Module | None = None, dilation: int = 1, ): super(Bottleneck, self).__init__() @@ -241,14 +237,14 @@ class AdaptBlock(BaseBlock): deformable_groups: Number of deformable groups in the deformable convolution. Default is 1. """ - expansion = 1 + expansion: int = 1 def __init__( self, in_channels: int, out_channels: int, stride: int = 1, - downsample: Optional[nn.Module] = None, + downsample: nn.Module | None = None, dilation: int = 1, deformable_groups: int = 1, ): diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index d8bf290285..7261832e34 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -10,12 +10,13 @@ # from abc import ABC, abstractmethod -from typing import Tuple import torch -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg from torch import nn +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + + PREDICTORS = Registry("predictors", build_func=build_from_cfg) @@ -24,7 +25,7 @@ class BasePredictor(ABC, nn.Module): This class is an abstract base class (ABC) for defining predictors used in the DeepLabCut Toolbox. All predictor classes should inherit from this base class and implement the forward method. - Regresses keypoint coordinates from model's output maps + Regresses keypoint coordinates from a models output maps Attributes: num_animals: Number of animals in the project. Should be set in subclasses. @@ -43,18 +44,19 @@ def forward(self, outputs): def __init__(self): super().__init__() - self.num_animals = None @abstractmethod def forward( - self, outputs: Tuple[torch.Tensor, ...], scale_factors: Tuple[float, float] - ) -> dict: + self, + inputs: torch.Tensor, + outputs: dict[str, torch.Tensor], + ) -> dict[str, torch.Tensor]: """Abstract method for the forward pass of the Predictor. Args: - outputs: Output tensors from previous layers. - scale_factors: Scale factors for the poses. + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: outputs of the model heads Returns: A dictionary containing a "poses" key with the output tensor as value, and diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 692109e737..78f104fa52 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -9,16 +9,15 @@ # Licensed under GNU Lesser General Public License v3.0 # -from typing import Tuple +from __future__ import annotations import torch +import torch.nn.functional as F + from deeplabcut.pose_estimation_pytorch.models.predictors import ( PREDICTORS, BasePredictor, ) -from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import ( - SinglePredictor, -) @PREDICTORS.register_module @@ -73,57 +72,42 @@ def __init__( detection_threshold: float = 0.01, apply_sigmoid: bool = True, use_heatmap: bool = True, - unique_bodyparts: bool = False, keypoint_score_type: str = "combined", + max_absorb_distance: int = 75, ): - """Initializes the DEKRPredictor class. - + """ Args: num_animals: Number of animals in the project. detection_threshold: Threshold for detection apply_sigmoid: Apply sigmoid to heatmaps use_heatmap: Use heatmap to refine the keypoint predictions. - unique_bodyparts: Whether the model predicts unique bodyparts. keypoint_score_type: Type of score to compute for keypoints. "heatmap" applies the heatmap score to each keypoint. "center" applies the score of the center of each individual to all of its keypoints. "combined" multiplies the score of the heatmap and individual for each keypoint. - - Returns: - None """ super().__init__() - self.num_animals = num_animals self.detection_threshold = detection_threshold self.apply_sigmoid = apply_sigmoid self.use_heatmap = use_heatmap - self.unique_bodyparts = unique_bodyparts - if self.unique_bodyparts: - self.unique_predictor = SinglePredictor( - num_animals=1, - location_refinement=True, - locref_stdev=7.2801, - apply_sigmoid=False, - ) - self.keypoint_score_type = keypoint_score_type if self.keypoint_score_type not in ("heatmap", "center", "combined"): raise ValueError(f"Unknown keypoint score type: {self.keypoint_score_type}") # TODO: Set as in HRNet/DEKR configs. Define as a constant. - self.max_absorb_distance = 75 + self.max_absorb_distance = max_absorb_distance def forward( self, - outputs: Tuple[torch.Tensor, ...], - scale_factors: Tuple[float, float], - ) -> dict: + inputs: torch.Tensor, + outputs: dict[str, torch.Tensor], + ) -> dict[str, torch.Tensor]: """Forward pass of DEKRPredictor. Args: - outputs: Tuple of heatmaps and offsets. - scale_factors: Scale factors for the poses. + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: outputs of the model heads (heatmap, locref) Returns: A dictionary containing a "poses" key with the output tensor as value, and @@ -133,15 +117,13 @@ def forward( # Assuming you have 'outputs' (heatmaps and offsets) and 'scale_factors' for poses poses_with_scores = predictor.forward(outputs, scale_factors) """ - if self.unique_bodyparts: - heatmaps, offsets, unique_heatmaps, unique_locref = outputs - else: - heatmaps, offsets = outputs - if self.apply_sigmoid and not self.unique_bodyparts: - heatmaps = torch.nn.Sigmoid()(heatmaps) # TODO: OPTIMIZE - elif self.apply_sigmoid: - heatmaps = torch.nn.Sigmoid()(heatmaps) # TODO: OPTIMIZE - unique_heatmaps = torch.nn.Sigmoid()(unique_heatmaps) # TODO: OPTIMIZE + heatmaps, offsets = outputs["heatmap"], outputs["offset"] + h_in, w_in = inputs.shape[2:] + h_out, w_out = heatmaps.shape[2:] + scale_factors = h_in / h_out, w_in / w_out + + if self.apply_sigmoid: + heatmaps = F.sigmoid(heatmaps) posemap = self.offset_to_pose(offsets) @@ -190,25 +172,7 @@ def forward( poses_w_scores = torch.cat([poses, score], dim=3) # self.pose_nms(heatmaps, poses_w_scores) - - if self.unique_bodyparts: - # Super trick to compute scale factor without knowing original image size - scale_factors_unique = ( - scale_factors[0] * h / unique_heatmaps.shape[2], - scale_factors[0] * w / unique_heatmaps.shape[3], - ) - unique_poses = self.unique_predictor( - [unique_heatmaps, unique_locref], scale_factors_unique - ) - - return { - "poses": poses_w_scores, - "unique_bodyparts": unique_poses, - } - - return { - "poses": poses_w_scores, - } + return {"poses": poses_w_scores} def get_locations( self, height: int, width: int, device: torch.device @@ -233,7 +197,6 @@ def get_locations( shift_x = shift_x.reshape(-1) shift_y = shift_y.reshape(-1) locations = torch.stack((shift_x, shift_y), dim=1) - return locations def get_reg_poses(self, offsets: torch.Tensor, num_joints: int) -> torch.Tensor: @@ -309,7 +272,7 @@ def max_pool(self, heatmap: torch.Tensor) -> torch.Tensor: def get_top_values( self, heatmap: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: + ) -> tuple[torch.Tensor, torch.Tensor]: """Get top values from the heatmap. Args: diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index 06b6055370..a966b62f5e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -22,6 +22,8 @@ class SinglePredictor(BasePredictor): """Predictor class for single animal pose estimation. + TODO: Refactor to include HeatmapOnlyPredictor + Args: num_animals: Number of animals in the project. location_refinement: Enable location refinement. @@ -45,8 +47,7 @@ def __init__( locref_stdev: float, apply_sigmoid: bool = True, ): - """Initializes the SinglePredictor class. - + """ Args: num_animals: Number of animals in the project. location_refinement : Enable location refinement. @@ -72,17 +73,14 @@ def __init__( def forward( self, - output: Tuple[torch.Tensor, torch.Tensor], - scale_factors: Tuple[float, float], - ) -> dict: + inputs: torch.Tensor, + outputs: dict[str, torch.Tensor], + ) -> dict[str, torch.Tensor]: """Forward pass of SinglePredictor. Gets predictions from model output. Args: - output: Output tensors from previous layers. - output = heatmaps, locref - heatmaps: torch.Tensor([batch_size, num_joints, height, width]) - locref: torch.Tensor([batch_size, num_joints, height, width]) - scale_factors: Scale factors for the poses. + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: output of the model heads (heatmap, locref) Returns: A dictionary containing a "poses" key with the output tensor as value. @@ -93,7 +91,11 @@ def forward( >>> scale_factors = (0.5, 0.5) >>> poses = predictor.forward(output, scale_factors) """ - heatmaps, locrefs = output + heatmaps, locrefs = outputs["heatmap"], outputs["locref"] + h_in, w_in = inputs.shape[2:] + h_out, w_out = heatmaps.shape[2:] + scale_factors = h_in / h_out, w_in / w_out + if self.apply_sigmoid: heatmaps = self.sigmoid(heatmaps) heatmaps = heatmaps.permute(0, 2, 3, 1) @@ -217,28 +219,29 @@ def __init__(self, num_animals: int, apply_sigmoid: bool = True): def forward( self, - output: Tuple[torch.Tensor, torch.Tensor], - scale_factors: Tuple[float, float], - ) -> dict: - """Forward pass of HeatmapOnlyPredictor. Computes predictions from the trained model output. + inputs: torch.Tensor, + outputs: dict[str, torch.Tensor], + ) -> dict[str, torch.Tensor]: + """Forward pass of SinglePredictor. Gets predictions from model output. Args: - output: Output tensors from previous layers. - output = heatmaps - heatmaps: torch.Tensor([batch_size, num_joints, height, width]) - locref: torch.Tensor([batch_size, num_joints, height, width]) - scale_factors: Scale factors for the poses. + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: output of the model heads (heatmap, locref) Returns: A dictionary containing a "poses" key with the output tensor as value. Example: - >>> predictor = HeatmapOnlyPredictor(num_animals=1, apply_sigmoid=True) + >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) >>> output = (torch.rand(32, 17, 64, 64), torch.rand(32, 17, 64, 64)) >>> scale_factors = (0.5, 0.5) >>> poses = predictor.forward(output, scale_factors) """ - heatmaps = output[0] + heatmaps = outputs["heatmap"] + h_in, w_in = inputs.shape[2:] + h_out, w_out = heatmaps.shape[2:] + scale_factors = h_in / h_out, w_in / w_out + if self.apply_sigmoid: heatmaps = self.sigmoid(heatmaps) heatmaps = heatmaps.permute(0, 2, 3, 1) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py index 62241b20a0..ada5819891 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py @@ -21,14 +21,14 @@ class TopDownPredictor(BasePredictor): """Predictor for regressing keypoints in a Top Down fashion based on bbox predictions and regressed keypoints in cropped images. + TODO: Does not respect base class; should not be a predictor + Args: - format_bbox: Format of the bounding box prediction, - either 'xyxy' or 'coco'. Defaults to "xyxy". + format_bbox: Format of the bounding box prediction, either 'xyxy' or 'coco'. Defaults to "xyxy". """ def __init__(self, format_bbox: str = "xyxy"): super().__init__() - self.format_bbox = format_bbox def _convert_bbox_to_coco(self, bboxes: torch.Tensor) -> torch.Tensor: diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py index 54ac2c57f4..42d30ce81f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -8,23 +8,60 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations from abc import ABC, abstractmethod +import torch import torch.nn as nn + from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + TARGET_GENERATORS = Registry("target_generators", build_func=build_from_cfg) -class BaseGenerator(ABC, nn.Module): - """ - Given the ground truth annotation generates the corresponding maps for training the model +class BaseGenerator(ABC, nn.Module): # TODO: Should this really be a module? + """Generates target maps from ground truth annotations to train models + + The outputs of the target generator are used to compute losses for model heads. If + the head outputs "heatmap" and "offset" tensors, then the corresponding generator + must output target "heatmap" and "offset" tensors. The targets themselves are + dictionaries, and passed as keyword-arguments to the criterions. This allows to + pass masks to the criterions. + + Generally, this means that for each head output (such as "heatmap"), a dict will be + generated with a "target" key (for the target heatmap) and optionally a "weights" + key (see the WeightedCriterion classes). """ - def __init__(self): + def __init__(self, label_keypoint_key: str = "keypoints"): super().__init__() - self.batch_norm_on = False + self.label_keypoint_key = label_keypoint_key @abstractmethod - def forward(self, x): - pass + def forward( + self, + inputs: torch.Tensor, + outputs: dict[str, torch.Tensor], + labels: dict, + ) -> dict[str, dict[str, torch.Tensor]]: + """Generates targets + + Args: + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: output of each model head + labels: the labels for the inputs (each tensor should have shape (b, ...)) + + Returns: + a dictionary mapping the heads to the inputs of the criterion + { + "heatmap": { + "target": heatmaps, + "weights": heatmap_weights, + }, + "locref": { + "target": locref_map, + "weights": locref_weights, + } + } + """ diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index 2eced58cc9..3fe93367ff 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -8,11 +8,11 @@ # # Licensed under GNU Lesser General Public License v3.0 # - -from typing import Tuple +from __future__ import annotations import numpy as np import torch + from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( TARGET_GENERATORS, BaseGenerator, @@ -22,105 +22,73 @@ @TARGET_GENERATORS.register_module class DEKRGenerator(BaseGenerator): """ - Generate ground truth target for DEKR model training - based on: + Generate ground truth target for DEKR model training based on: Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression - Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang - CVPR - 2021 + Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang, CVPR 2021 Code based on: https://github.com/HRNet/DEKR """ - def __init__(self, num_joints: int, pos_dist_thresh: int, bg_weight: float = 0.1): - """Summary: - Constructor of the DEKRGenerator class. - Loads the data. - + def __init__( + self, num_joints: int, pos_dist_thresh: int, bg_weight: float = 0.1, **kwargs + ): + """ Args: num_joints: number of keypoints pos_dist_thresh: 3*std of the gaussian bg_weight:background weight. Defaults to 0.1. - - Returns: - None - - Examples: - num_joints = 6 - pos_dist_thresh = 17 - bg_weight = 0.1 (default) """ - super().__init__() + super().__init__(**kwargs) self.num_joints = num_joints + self.num_heatmaps = self.num_joints + 1 self.pos_dist_thresh = pos_dist_thresh self.bg_weight = bg_weight - self.num_joints_with_center = self.num_joints + 1 - - def get_heat_val( - self, sigma: float, x: float, y: float, x0: float, y0: float - ) -> float: - """Summary: - Calculates the corresponding heat value of point (x,y) given the heat distribution centered - at (x0,y0) and spread value of sigma. - - Args: - sigma: controls the spread or width of the heat distribution - x: x coord of a point on the image grid - y: y coord of a point on the image grid - x0: x center coordinate of the heat distribution - y0: y center coordinate of the heat distribution - - Returns: - g: calculated heat value represents the intensity of the heat at a given position - """ - g = np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma**2)) - - return g - def forward( self, - annotations: dict, - prediction: Tuple[torch.Tensor, torch.Tensor], - image_size: Tuple[int, int], - ) -> dict: - """Summary + inputs: torch.Tensor, + outputs: dict[str, torch.Tensor], + labels: dict, + ) -> dict[str, dict[str, torch.Tensor]]: + """ Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. Args: - annotations: each entry should begin with the shape batch_size - prediction: output of model, format could depend on the model, only used to compute output resolution - image_size:size of image (only one tuple since for batch training all images should have the same size) + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: output of each model head + labels: the labels for the inputs (each tensor should have shape (b, ...)) Returns: - #TODO locref is a bad name here and should be 'offset to center', but for code's simplicity it - is easier to use the same keys as for the SingleAnimal target generators - targets, keys: - 'heatmaps' : heatmaps - 'heatmaps_ignored': weights to apply to the heatmaps for loss computation - 'locref_maps' : offset maps - 'locref_masks' : weights to apply to the offset maps for loss computation + The targets for the DEKR heatmap and offset heads: + { + "heatmap": { + "target": heatmaps, + "weights": heatmap_weights, + }, + "offset": { + "target": offset_map, + "weights": offset_weights, + } + } Examples: input: - annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} + labels = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] image_size = (256, 256) output: - targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} + targets = { + "heatmap": {"target": heatmaps, "weights": heatmap_weights}, + "offset": {"target": offset_map, "weights": offset_masks} + } """ + batch_size, _, input_h, input_w = inputs.shape + output_h, output_w = outputs["heatmap"].shape[2:] + stride_y, stride_x = input_h / output_h, input_w / output_w - batch_size, _, output_h, output_w = prediction[0].shape - output_res = output_h, output_w - stride_y, stride_x = image_size[0] / output_h, image_size[1] / output_w - - num_joints_without_center = self.num_joints - num_joints_with_center = num_joints_without_center + 1 - - coords = annotations["keypoints"].cpu().numpy() - num_animals = coords.shape[1] - area = annotations["area"].cpu().numpy() + coords = labels[self.label_keypoint_key].cpu().numpy() + area = labels["area"].cpu().numpy() assert ( self.num_joints + 1 == coords.shape[2] @@ -128,19 +96,19 @@ def forward( # TODO make it possible to differentiate between center sigma and other sigmas scale = max(1 / stride_x, 1 / stride_y) - sgm, ct_sgm = (self.pos_dist_thresh / 2) * scale, (self.pos_dist_thresh) * scale + sgm, ct_sgm = (self.pos_dist_thresh / 2) * scale, self.pos_dist_thresh * scale radius = self.pos_dist_thresh * scale - hms = np.zeros( - (batch_size, num_joints_with_center, output_h, output_w), dtype=np.float32 + heatmaps = np.zeros( + (batch_size, self.num_heatmaps, output_h, output_w), dtype=np.float32 ) - ignored_hms = 2 * np.ones( - (batch_size, num_joints_with_center, output_h, output_w), dtype=np.float32 + heatmap_weights = 2 * np.ones( + (batch_size, self.num_heatmaps, output_h, output_w), dtype=np.float32 ) offset_map = np.zeros( ( batch_size, - num_joints_without_center * 2, + self.num_joints * 2, output_h, output_w, ), @@ -149,16 +117,13 @@ def forward( weight_map = np.zeros( ( batch_size, - num_joints_without_center * 2, + self.num_joints * 2, output_h, output_w, ), dtype=np.float32, ) area_map = np.zeros((batch_size, output_h, output_w), dtype=np.float32) - - hms_list = [hms, ignored_hms] - for b in range(batch_size): for person_id, p in enumerate(coords[b]): idx_center = len(p) - 1 @@ -190,20 +155,20 @@ def forward( np.ceil(y_sm + 3 * sigma + 2) ) - cc, dd = max(0, ul[0]), min(br[0], output_res[1]) - aa, bb = max(0, ul[1]), min(br[1], output_res[0]) + cc, dd = max(0, ul[0]), min(br[0], output_w) + aa, bb = max(0, ul[1]), min(br[1], output_h) joint_rg = np.zeros((bb - aa, dd - cc)) for sy in range(aa, bb): for sx in range(cc, dd): - joint_rg[sy - aa, sx - cc] = self.get_heat_val( + joint_rg[sy - aa, sx - cc] = dekr_heatmap_val( sigma, sx, sy, x_sm, y_sm ) - hms_list[0][b, idx, aa:bb, cc:dd] = np.maximum( - hms_list[0][b, idx, aa:bb, cc:dd], joint_rg + heatmaps[b, idx, aa:bb, cc:dd] = np.maximum( + heatmaps[b, idx, aa:bb, cc:dd], joint_rg ) - hms_list[1][b, idx, aa:bb, cc:dd] = 1.0 + heatmap_weights[b, idx, aa:bb, cc:dd] = 1.0 # OFFSET COMPUTATION if idx != idx_center: @@ -233,12 +198,34 @@ def forward( ] = 1.0 / np.sqrt(area[b, person_id]) area_map[b, pos_y, pos_x] = area[b, person_id] - hms_list[1][hms_list[1] == 2] = self.bg_weight - - targets = { - "heatmaps": hms_list[0], - "heatmaps_ignored": hms_list[1], - "locref_maps": offset_map, - "locref_masks": weight_map, + heatmap_weights[heatmap_weights == 2] = self.bg_weight + return { + "heatmap": { + "target": torch.tensor(heatmaps, device=outputs["heatmap"].device), + "weights": torch.tensor( + heatmap_weights, device=outputs["heatmap"].device + ), + }, + "offset": { + "target": torch.tensor(offset_map, device=outputs["offset"].device), + "weights": torch.tensor(weight_map, device=outputs["offset"].device), + }, } - return targets + + +def dekr_heatmap_val(sigma: float, x: float, y: float, x0: float, y0: float) -> float: + """ + Calculates the corresponding heat value of point (x,y) given the heat distribution centered + at (x0,y0) and spread value of sigma. + + Args: + sigma: controls the spread or width of the heat distribution + x: x coord of a point on the image grid + y: y coord of a point on the image grid + x0: x center coordinate of the heat distribution + y0: y center coordinate of the heat distribution + + Returns: + g: calculated heat value represents the intensity of the heat at a given position + """ + return np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma**2)) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py index 15a759b7ff..5e500be76a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py @@ -8,8 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # - -from typing import Tuple +from __future__ import annotations import numpy as np import torch @@ -22,31 +21,28 @@ @TARGET_GENERATORS.register_module class GaussianGenerator(BaseGenerator): """ + TODO: Remove code duplication with PlateauGenerator + Generate gaussian heatmaps and locref targets from ground truth keypoints in order to train baseline deeplabcut model (ResNet + Deconv) """ - def __init__(self, locref_stdev: float, num_joints: int, pos_dist_thresh: int): - """Summary: - Constructor of the GaussianGenerator class. - Loads the data. - + def __init__( + self, locref_stdev: float, num_joints: int, pos_dist_thresh: int, **kwargs + ): + """ Args: locref_stdev: scaling factor num_joints: number of keypoints pos_dist_thresh: 3*std of the gaussian - Return: - None - Examples: input: locref_stdev = 7.2801, default value in pytorch config num_joints = 6 po_dist_thresh = 17, default value in pytorch config """ - super().__init__() - + super().__init__(**kwargs) self.locref_scale = 1.0 / locref_stdev self.num_joints = num_joints self.dist_thresh = float(pos_dist_thresh) @@ -57,24 +53,31 @@ def __init__(self, locref_stdev: float, num_joints: int, pos_dist_thresh: int): def forward( self, - annotations: dict, - prediction: Tuple[torch.Tensor, torch.Tensor], - image_size: Tuple[int, int], - ) -> dict: + inputs: torch.Tensor, + outputs: torch.Tensor, + labels: dict, + ) -> dict[str, dict[str, torch.Tensor]]: """Summary: Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. Args: - annotations: each entry should begin with the shape batch_size - prediction: output of model format could depend on the model, only used to compute output resolution - image_size: size of image (only one tuple since for batch training all images should have the same size) + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: output of each model head + labels: the labels for the inputs (each tensor should have shape (b, ...)) Returns: - targets: dict of the taregts, keys: - 'heatmaps' : heatmaps - 'locref_maps' : locref maps - 'locref_masks' : weights to apply to the locref maps for loss computation + The targets for the heatmap and locref heads: + { + "heatmap": { + "target": heatmaps, + "weights": heatmap_weights, + }, + "locref": { + "target": locref_map, + "weights": locref_weights, + } + } Examples: input: @@ -84,12 +87,10 @@ def forward( output: targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} """ - - # stride = cfg['stride'] # Apparently, there is no stride in the cfg - # stride = scale_factors # TODO just test - batch_size, _, height, width = prediction[0].shape - stride_y, stride_x = image_size[0] / height, image_size[1] / width - coords = annotations["keypoints"].cpu().numpy() + batch_size, _, input_h, input_w = inputs.shape + height, width = outputs.shape[2:] + stride_y, stride_x = input_h / height, input_w / width + coords = labels[self.label_keypoint_key].cpu().numpy() scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) locref_map = np.zeros( @@ -118,92 +119,12 @@ def forward( scmap = scmap.transpose(0, 3, 1, 2) locref_map = locref_map.transpose(0, 3, 1, 2) locref_mask = locref_mask.transpose(0, 3, 1, 2) - targets = { - "heatmaps": scmap, - "locref_maps": locref_map, - "locref_masks": locref_mask, - } - - return targets - - -@TARGET_GENERATORS.register_module -class GaussianWithoutLocref(BaseGenerator): - """ - Generate plateau heatmaps from ground truth keypoints in order - to train baseline deeplabcut model (ResNet + Deconv) - """ - - def __init__(self, num_joints: int, pos_dist_thresh: int): - """Summary: - Constructor of the GaussianWithoutLocref class. - Loads the data. - - Args: - num_joints: number of keypoints - pos_dist_thresh: 3*std of the gaussian - - Returns: - None - - Examples: - input: - num_joints = 6 - po_dist_thresh = 17, default value in pytorch config - """ - super().__init__() - - self.num_joints = num_joints - self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh**2 - self.std = 2 * self.dist_thresh / 3 - - def forward( - self, - annotations: dict, - prediction: Tuple[torch.Tensor, torch.Tensor], - image_size: Tuple[int, int], - ) -> dict: - """Summary: - Given the annotations and predictions of your keypoints, this function returns the targets, - a dictionary containing the heatmaps, locref_maps and locref_masks. - - Args: - annotations: dict of annoations which should all be tensors of first dimension batch_size - prediction: model's output - image_size: size of input images - - Returns: - input: - annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} - prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] - image_size = (256, 256) - output: - targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} - - """ - batch_size, _, height, width = prediction[0].shape - stride_y, stride_x = image_size[0] / height, image_size[1] / width - coords = annotations["keypoints"].cpu().numpy() - scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) - - grid = np.mgrid[:height, :width].transpose((1, 2, 0)) - grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 - grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 - - for b in range(batch_size): - for idx_animal, kpts_animal in enumerate(coords[b]): - for i, coord in enumerate(kpts_animal): - coord = np.array(coord)[::-1] - if np.any(coord <= 0.0): - continue - dist = np.linalg.norm(grid - coord, axis=2) ** 2 - scmap_j = np.exp(-dist / (2 * self.std**2)) - scmap[b, :, :, i] += scmap_j - - scmap = scmap.transpose(0, 3, 1, 2) - targets = { - "heatmaps": scmap, + return { + "heatmap": { + "target": torch.tensor(scmap, device=outputs["heatmap"].device), + }, + "locref": { + "target": torch.tensor(locref_map, device=outputs["locref"].device), + "weights": torch.tensor(locref_mask, device=outputs["locref"].device), + }, } - - return targets diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py index 57628bc8b8..70b4eff5f1 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py @@ -8,8 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # - -from typing import Tuple +from __future__ import annotations import numpy as np import torch @@ -22,22 +21,26 @@ @TARGET_GENERATORS.register_module class PlateauGenerator(BaseGenerator): """ + TODO: Remove code duplication with GaussianGenerator + TODO: add WITHOUT_LOCREF + Generate plateau heatmaps and locref targets from ground truth keypoints in order to train baseline deeplabcut model (ResNet + Deconv) """ - def __init__(self, locref_stdev: float, num_joints: int, pos_dist_thresh: int): - """Summary: - Constructor of the PlateauGenerator class. - Loads the data. - + def __init__( + self, + num_joints: int, + pos_dist_thresh: int, + generate_locref: bool = True, # TODO: Implement + locref_stdev: float = 7.2801, + **kwargs, + ): + """ Args: - locref_stdev: scaling factor num_joints: number of keypoints pos_dist_thresh: radius plateau on the heatmap - - Returns: - None + locref_stdev: scaling factor Examples: input: @@ -45,33 +48,40 @@ def __init__(self, locref_stdev: float, num_joints: int, pos_dist_thresh: int): num_joints = 6 pos_dist_thresh = 17, default value in pytorch config """ - super().__init__() - - self.locref_scale = 1.0 / locref_stdev + super().__init__(**kwargs) self.num_joints = num_joints self.dist_thresh = float(pos_dist_thresh) self.dist_thresh_sq = self.dist_thresh**2 + self.generate_locref = generate_locref + self.locref_scale = 1.0 / locref_stdev def forward( self, - annotations: dict, - prediction: Tuple[torch.Tensor, torch.Tensor], - image_size: Tuple[int, int], - ) -> dict: + inputs: torch.Tensor, + outputs: torch.Tensor, + labels: dict, + ) -> dict[str, dict[str, torch.Tensor]]: """Summary: Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. Args: - annotations: annoations. Should be tensors of first dimension batch_size - prediction: model's output - image_size: size of input images (only one tuple since for batch training all images should have the same size) + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: output of each model head + labels: the labels for the inputs (each tensor should have shape (b, ...)) Returns: - targets: keys: - 'heatmaps': heatmaps - 'locref_maps': locref maps - 'locref:masks': weights to apply to the locref maps for loss computation + The targets for the heatmap and locref heads: + { + "heatmap": { + "target": heatmaps, + "weights": heatmap_weights, + }, + "locref": { + "target": locref_map, + "weights": locref_weights, + } + } Examples: input: @@ -81,10 +91,13 @@ def forward( output: targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} """ + batch_size, _, input_h, input_w = inputs.shape + height, width = outputs["heatmap"].shape[2:] + stride_y, stride_x = input_h / height, input_w / width + coords = labels[self.label_keypoint_key].cpu().numpy() + if len(coords.shape) == 3: # for single animal: add individual dimension + coords = coords.reshape((batch_size, 1, self.num_joints, 2)) - batch_size, _, height, width = prediction[0].shape - stride_y, stride_x = image_size[0] / height, image_size[1] / width - coords = annotations["keypoints"].cpu().numpy() scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) locref_map = np.zeros( @@ -111,97 +124,18 @@ def forward( locref_map[b, mask, i * 2 + 0] += (dx * self.locref_scale)[mask] locref_map[b, mask, i * 2 + 1] += (dy * self.locref_scale)[mask] - scmap = scmap.transpose(0, 3, 1, 2) - locref_map = locref_map.transpose(0, 3, 1, 2) - locref_mask = locref_mask.transpose(0, 3, 1, 2) - targets = { - "heatmaps": scmap, - "locref_maps": locref_map, - "locref_masks": locref_mask, - } - - return targets - - -@TARGET_GENERATORS.register_module -class PlateauWithoutLocref(BaseGenerator): - """ - Generate plateau heatmaps from ground truth keypoints in order - to train baseline deeplabcut model (ResNet + Deconv) - """ - - def __init__(self, num_joints: int, pos_dist_thresh: int): - """Summary: - Constructurer of the PlateauWithoutLocref class. - Loads the data - - Args: - num_joints: number of keypoints - pos_dist_thresh: radius plateau on the heatmap - - Returns: - None - - Examples: - input: - num_joints = 6 - pos_dist_thresh = 17 - """ - super().__init__() - - self.num_joints = num_joints - self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh**2 - - def forward( - self, - annotations: dict, - prediction: Tuple[torch.Tensor, torch.Tensor], - image_size: Tuple[int, int], - ) -> dict: - """Summary: - Given the annotations and predictions of your keypoints, this function returns the targets, - a dictionary containing the heatmaps. - - Args: - annotations: annoations which should all be tensors of first dimension batch_size - prediction: model's output - image_size: size of input images (only one tuple since for batch training all images should have the same size) - - Returns: - targets: key: - 'heatmaps' - - Examples: - input: - annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} - prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] - image_size = (256, 256) - output: - targets = {'heatmap':scmap} - """ - - batch_size, _, height, width = prediction[0].shape - stride_y, stride_x = image_size[0] / height, image_size[1] / width - coords = annotations["keypoints"].cpu().numpy() - scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) - - grid = np.mgrid[:height, :width].transpose((1, 2, 0)) - grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 - grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 - - for b in range(batch_size): - for idx_animal, kpts_animal in enumerate(coords[b]): - for i, coord in enumerate(kpts_animal): - coord = np.array(coord)[::-1] - if np.any(coord <= 0.0): - continue - dist = np.linalg.norm(grid - coord, axis=2) ** 2 - scmap[b, (dist <= self.dist_thresh_sq), i] += 1 - scmap = scmap.transpose(0, 3, 1, 2) targets = { - "heatmaps": scmap, + "heatmap": { + "target": torch.tensor(scmap, device=outputs["heatmap"].device), + } } + if self.generate_locref: + locref_map = locref_map.transpose(0, 3, 1, 2) + locref_mask = locref_mask.transpose(0, 3, 1, 2) + targets["locref"] = { + "target": torch.tensor(locref_map, device=outputs["locref"].device), + "weights": torch.tensor(locref_mask, device=outputs["locref"].device), + } return targets diff --git a/deeplabcut/pose_estimation_pytorch/models/utils.py b/deeplabcut/pose_estimation_pytorch/models/utils.py deleted file mode 100644 index a6a2f18c35..0000000000 --- a/deeplabcut/pose_estimation_pytorch/models/utils.py +++ /dev/null @@ -1,172 +0,0 @@ -import numpy as np -import torch -from typing import Tuple - -""" FILE NOT USED ANYMORE """ - - -def generate_heatmaps( - cfg: dict, - coords: np.array, - scale_factor, - heatmap_size: tuple = (64, 64), - heatmap_type: str = "gaussian", -): - if heatmap_type == "gaussian": - scmap, weights, locref_map, locref_mask = gaussian_scmap( - cfg, coords, scale_factor, heatmap_size - ) - elif heatmap_type == "plateau": - scmap, weights, locref_map, locref_mask = plateau_scmap( - cfg, coords, scale_factor, heatmap_size - ) - else: - raise ValueError("Only gaussian heatmap is supported!") - scmap = torch.FloatTensor(scmap) - if weights: - weights = torch.FloatTensor(weights) - locref_map = torch.FloatTensor(locref_map) - locref_mask = torch.BoolTensor(locref_mask) - - return scmap, weights, locref_map, locref_mask - - -# Copy from dlc -def gaussian_scmap(cfg, coords, scale_factors, heatmap_size): - """ - - Parameters - ---------- - cfg: dlc config - Standard dlc config in the dlc project folder - joint_id: - coords: list/np.array of coordinates - data_item - heatmap_size - scale - - Returns - ------- - - """ - locref_scale = 1.0 / cfg["locref_stdev"] - num_joints = cfg["num_joints"] - stride_y, stride_x = scale_factors - scmap = np.zeros((heatmap_size[0], heatmap_size[1], num_joints), dtype=np.float32) - - locref_map = np.zeros( - (heatmap_size[0], heatmap_size[1], num_joints * 2), dtype=np.float32 - ) - locref_mask = np.zeros_like(locref_map, dtype=int) - - width = heatmap_size[1] - height = heatmap_size[0] - dist_thresh = float(cfg["pos_dist_thresh"]) - dist_thresh_sq = dist_thresh**2 - - std = dist_thresh / 4 - grid = np.mgrid[:height, :width].transpose((1, 2, 0)) - grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 - grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 - for i, coord in enumerate(coords): - coord = np.array(coord)[::-1] - if np.any(coord <= 0.0): - continue - dist = np.linalg.norm(grid - coord, axis=2) ** 2 - scmap_j = np.exp(-dist / (2 * std**2)) - scmap[:, :, i] = scmap_j - locref_mask[dist <= dist_thresh_sq, i * 2 : i * 2 + 2] = 1 - dx = coord[1] - grid.copy()[:, :, 1] - dy = coord[0] - grid.copy()[:, :, 0] - locref_map[:, :, i * 2 + 0] = dx * locref_scale - locref_map[:, :, i * 2 + 1] = dy * locref_scale - weights = None - return scmap, weights, locref_map, locref_mask - - -def plateau_scmap(cfg, coords, scale_factors, heatmap_size): - """Computes target objectives with plateau function rather than gaussian""" - - locref_scale = 1.0 / cfg["locref_stdev"] - num_joints = cfg["num_joints"] - stride_y, stride_x = scale_factors - scmap = np.zeros((heatmap_size[0], heatmap_size[1], num_joints), dtype=np.float32) - - locref_map = np.zeros( - (heatmap_size[0], heatmap_size[1], num_joints * 2), dtype=np.float32 - ) - locref_mask = np.zeros_like(locref_map, dtype=int) - - width = heatmap_size[1] - height = heatmap_size[0] - dist_thresh = float(cfg["pos_dist_thresh"]) - dist_thresh_sq = dist_thresh**2 - - std = dist_thresh / 4 - grid = np.mgrid[:height, :width].transpose((1, 2, 0)) - - grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 - grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 - - for i, coord in enumerate(coords): - coord = np.array(coord)[::-1] - if np.any(coord <= 0.0): - continue - dist = np.linalg.norm(grid - coord, axis=2) ** 2 - mask = dist <= dist_thresh_sq - scmap[(dist <= dist_thresh_sq), i] = 1 - locref_mask[dist <= dist_thresh_sq, i * 2 : i * 2 + 2] = 1 - dx = coord[1] - grid.copy()[:, :, 1] - dy = coord[0] - grid.copy()[:, :, 0] - locref_map[mask, i * 2 + 0] = (dx * locref_scale)[mask] - locref_map[mask, i * 2 + 1] = (dy * locref_scale)[mask] - weights = None - return ( - scmap, - weights, - locref_map, - locref_mask, - ) - - -# TODO: check this function and rewrite above -def _generate_heatmaps(keypoints, heatmap_size, image_size=(256, 256), sigma=5): - """ - TODO: MAKE FASTER - Parameters - ---------- - keypoints - heatmap_size - image_size - sigma - - Returns - ------- - - """ - target = torch.zeros( - (keypoints.shape[0], heatmap_size[1], heatmap_size[0]), dtype=torch.float32 - ) - scale_x = heatmap_size[0] / image_size[0] - scale_y = heatmap_size[1] / image_size[1] - for joint_id in range(keypoints.shape[0]): - mu_x = keypoints[joint_id, 0] * scale_x - mu_y = keypoints[joint_id, 1] * scale_y - if mu_x == -1: - continue - - x = torch.arange(0, heatmap_size[0], 1, dtype=torch.float32) - y = torch.arange(0, heatmap_size[1], 1, dtype=torch.float32) - y = y[:, None] - - if mu_x > 0: - target[joint_id] = torch.exp( - -((x - mu_x) ** 2 + (y - mu_y) ** 2) / (2 * sigma**2) - ) - - return target - - -def sigmoid(tx: np.ndarray): - exp_x = np.exp(tx) - return exp_x / (1 + exp_x) diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index c80ab6a25e..5a1500d62e 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -33,11 +33,10 @@ def rmse_match_prediction_to_gt( num_animals: number of animals num_keypoints: number of keypoints 3: (x,y,score) coordinates of each keypoint - gt_kpts: ground truth keypoints for each animal. The shape of the array is (num_animals, num_keypoints(+1 if with center), 2): + gt_kpts: ground truth keypoints for each animal. The shape of the array is (num_animals, num_keypoints, 2): num_animals: number of animals num_keypoints: number of keypoints 2: (x,y) coordinates of each keypoint - individual_names: names of individuals Returns: col_ind (np.array): array of the individuals indices for prediction diff --git a/deeplabcut/pose_estimation_pytorch/runners/__init__.py b/deeplabcut/pose_estimation_pytorch/runners/__init__.py new file mode 100644 index 0000000000..31e7bb1d3e --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/runners/__init__.py @@ -0,0 +1,15 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner +from deeplabcut.pose_estimation_pytorch.runners.logger import LOGGER +from deeplabcut.pose_estimation_pytorch.runners.pose import PoseRunner +from deeplabcut.pose_estimation_pytorch.runners.top_down import DetectorRunner diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py new file mode 100644 index 0000000000..5c947ca984 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -0,0 +1,269 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from collections import defaultdict +from pathlib import Path +from typing import Any, Generic, TypeVar, Iterable + +import numpy as np +import torch +import torch.nn as nn +from torch.utils.data import DataLoader + +from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor +from deeplabcut.pose_estimation_pytorch.data.postprocessor import Postprocessor +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg +from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger + + +RUNNERS = Registry("runners", build_func=build_from_cfg) +ModelType = TypeVar("ModelType", bound=nn.Module) + + +class Runner(ABC, Generic[ModelType]): + """Runner base class + + A runner takes a model and runs actions on it, such as training or inference + """ + + def __init__( + self, + model: ModelType, + optimizer: torch.optim.Optimizer, + device: str = "cpu", + snapshot_prefix: str = "snapshot", + snapshot_path: str | None = None, + scheduler: torch.optim.lr_scheduler.LRScheduler | None = None, + logger: BaseLogger | None = None, + preprocessor: Preprocessor | None = None, + postprocessor: Postprocessor | None = None, + ): + """ + Args: + model: the model to run actions on + optimizer: the optimizer to use when fitting the model + device: one of {'cpu', 'cuda', 'mps'}; the device to use for training/inference + snapshot_prefix: the prefix with which to save snapshots + snapshot_path: if defined, the path of a snapshot from which to load pretrained weights + scheduler: Scheduler for adjusting the lr of the optimizer. + logger: logger to monitor training (e.g WandB logger) + preprocessor: the preprocessor to use on images before inference + postprocessor: the postprocessor to use on images after inference + """ + self.model = model + self.device = device + self.optimizer = optimizer + self.scheduler = scheduler + self.history: dict[str, list] = {"train_loss": [], "eval_loss": []} + self.snapshot_prefix = snapshot_prefix + self.logger = logger + self.preprocessor = preprocessor + self.postprocessor = postprocessor + + self.starting_epoch = 0 + if snapshot_path: + snapshot = torch.load(snapshot_path) + self.model.load_state_dict(snapshot["model_state_dict"]) + self.optimizer.load_state_dict(snapshot["optimizer_state_dict"]) + self.starting_epoch = snapshot["epoch"] + + @abstractmethod + def step( + self, + batch: dict[str, Any], + mode: str = "train", + ) -> dict[str, torch.Tensor]: + """Perform a single epoch gradient update or validation step""" + + @abstractmethod + def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: + """Makes predictions from a model input and output + + Args: + the inputs to the model, of shape (batch_size, ...) + + Returns: + the predictions for each of the 'batch_size' inputs + """ + + def fit( + self, + train_loader: DataLoader, + valid_loader: DataLoader, + model_folder: str, + epochs: int, + save_epochs: int, + display_iters: int, + *args, + **kwargs, + ) -> None: + """Train model for the specified number of steps. + + Args: + train_loader: Data loader, which is an iterator over train instances. + Each batch contains image tensor and heat maps tensor input samples. + valid_loader: Data loader used for validation of the model. + model_folder: the folder to which logs should be written and snapshots saved + epochs: The number of training epochs. + save_epochs: The epoch step at which to save models + display_iters: The number of iterations between each loss print + + Example: + runner = Runner(model, optimizer, cfg, device='cuda') + runner.fit(train_loader, valid_loader, "example/models" epochs=50) + """ + Path(model_folder).mkdir(exist_ok=True, parents=True) + self.model.to(self.device) + + for i in range(self.starting_epoch, epochs): + train_loss = self._epoch( + train_loader, + mode="train", + step=i + 1, + display_iters=display_iters, + ) + if self.scheduler: + self.scheduler.step() + + logging.info( + f"Training for epoch {i + 1} done, starting eval on validation data" + ) + valid_loss = self._epoch( + valid_loader, mode="eval", step=i + 1, display_iters=display_iters + ) + + if (i + 1) % save_epochs == 0: + logging.info(f"Finished epoch {i + 1}; saving model") + torch.save( + { + "model_state_dict": self.model.state_dict(), + "epoch": i + 1, + "optimizer_state_dict": self.optimizer.state_dict(), + "train_loss": train_loss, + "validation_loss": valid_loss, + }, + f"{model_folder}/train/{self.snapshot_prefix}-{i + 1}.pt", + ) + + logging.info( + f"Epoch {i + 1}/{epochs}, " + f"train loss {float(train_loss):.5f}, " + f"valid loss {float(valid_loss):.5f}, " + f'lr {self.optimizer.param_groups[0]["lr"]}' + ) + + @torch.no_grad() + def inference( + self, + images: Iterable[str | np.ndarray] + | Iterable[tuple[str | np.ndarray, dict[str, Any]]], + ) -> list[dict[str, dict[str, np.ndarray]]]: + """Run model inference on the given dataset + + TODO: Add an option to also return head outputs (such as heatmaps)? Can be + super useful for debugging + + Args: + images: the images to run inference on, optionally with context + + Returns: + a dict containing head predictions for each image + [ + { + "bodypart": {"poses": np.array}, + "unique_bodypart": "poses": np.array}, + } + ] + """ + self.model.to(self.device) + self.model.eval() + + results = [] + for data in images: + if isinstance(data, (str, np.ndarray)): + input_image, context = data, {} + else: + input_image, context = data + + if self.preprocessor is not None: + # TODO: input batch should also be able to be a dict[str, torch.Tensor] + input_image, context = self.preprocessor(input_image, context) + + image_predictions = self.predict(input_image) + if self.postprocessor is not None: + # TODO: Should we return context? + image_predictions, _ = self.postprocessor(image_predictions, context) + + results.append(image_predictions) + + return results + + def _epoch( + self, + loader: torch.utils.data.DataLoader, + mode: str = "train", + step: int | None = None, + display_iters: int = 500, + ) -> float: + """Facilitates training over an epoch. Returns the loss over the batches. + + Args: + loader: Data loader, which is an iterator over instances. + Each batch contains image tensor and heat maps tensor input samples. + mode: str identifier to instruct the Runner whether to train or evaluate. + Possible values are: "train" or "eval". + step: the global step in processing, used to log metrics. Defaults to None. + display_iters: the number of iterations between each loss print + + Raises: + ValueError: When the given mode is invalid + + Returns: + epoch_loss: Average of the loss over the batches. + """ + if mode == "train": + self.model.train() + elif mode == "eval" or mode == "inference": + self.model.eval() + else: + raise ValueError(f"Runner mode must be train or eval, found mode={mode}.") + + epoch_loss = [] + metrics = defaultdict(list) + for i, batch in enumerate(loader): + losses_dict = self.step(batch, mode) + epoch_loss.append(losses_dict["total_loss"]) + + for key in losses_dict.keys(): + metrics[key].append(losses_dict[key]) + + if (i + 1) % display_iters == 0: + logging.info( + f"Number of iterations: {i + 1}, " + f"loss: {losses_dict['total_loss']:.5f}, " + f"lr: {self.optimizer.param_groups[0]['lr']}" + ) + + epoch_loss = np.mean(epoch_loss).item() + self.history[f"{mode}_loss"].append(epoch_loss) + + if self.logger: + for key in metrics: + self.logger.log( + f"{mode} {key}", + np.nanmean(metrics[key]).item(), + step=step, + ) + + return epoch_loss diff --git a/deeplabcut/pose_estimation_pytorch/solvers/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py similarity index 98% rename from deeplabcut/pose_estimation_pytorch/solvers/logger.py rename to deeplabcut/pose_estimation_pytorch/runners/logger.py index 5839725c70..01b0ce93a2 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -110,6 +110,8 @@ def __init__( logger = WandbLogger(project_name="my_project", run_name="exp1", model=my_model) """ + if wb.run is not None: + wb.finish() self.run = wb.init(project=project_name, name=run_name) if model is None: diff --git a/deeplabcut/pose_estimation_pytorch/runners/pose.py b/deeplabcut/pose_estimation_pytorch/runners/pose.py new file mode 100644 index 0000000000..f8b1282678 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/runners/pose.py @@ -0,0 +1,124 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations +from typing import Any + +import numpy as np +import torch +from torch.utils.data import DataLoader + +from deeplabcut.pose_estimation_pytorch.models import model as models +from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner +from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger + + +@RUNNERS.register_module +class PoseRunner(Runner[models.PoseModel]): + """Runner for pose estimation""" + + def __init__( + self, + model: models.PoseModel, + optimizer: torch.optim.Optimizer, + **kwargs, + ): + """TODO: Update doc to generic (not pose) runner. Constructor of the Runner class. + Args: + model: The neural network for solving pose estimation task. + optimizer: A PyTorch optimizer for updating model parameters. + kwargs: Runner kwargs + + Returns: + None + + Notes/TODO: + Read stride from config file + """ + super().__init__(model, optimizer, **kwargs) + + def step( + self, + batch: dict[str, Any], + mode: str = "train", + ) -> dict[str, torch.Tensor]: + """Perform a single epoch gradient update or validation step. + + Args: + batch: Tuple of input image(s) and target(s) for train or valid single step. + mode: `train` or `eval`. Defaults to "train". + + Raises: + ValueError: "Runner must be in train or eval mode, but {mode} was found." + + Returns: + dict: { + "total_loss": aggregate_loss, + "aux_loss_1": loss_value, + ..., + } + """ + if mode not in ["train", "eval"]: + raise ValueError( + f"BottomUpSolver must be in train or eval mode, but {mode} was found." + ) + + if mode == "train": + self.optimizer.zero_grad() + + batch_inputs = batch["image"] + batch_inputs = batch_inputs.to(self.device) + head_outputs = self.model(batch_inputs) + + target = self.model.get_target( + batch_inputs, + head_outputs, + batch["annotations"], + ) + + losses_dict = self.model.get_loss(head_outputs, target) + if mode == "train": + losses_dict["total_loss"].backward() + self.optimizer.step() + + return {k: v.detach().cpu().numpy() for k, v in losses_dict.items()} + + def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: + """Makes predictions from a model input and output + + Args: + the inputs to the model, of shape (batch_size, ...) + + Returns: + predictions for each of the 'batch_size' inputs, made by each head, e.g. + [ + { + "bodypart": {"poses": np.ndarray}, + "unique_bodypart": "poses": np.ndarray}, + ] + """ + # TODO: iterates over batch one element at a time + batch_size = 1 + batch_predictions = [] + for i in range(0, len(inputs), batch_size): + batch_inputs = inputs[i : i + batch_size] + batch_inputs = batch_inputs.to(self.device) + batch_outputs = self.model(batch_inputs) + raw_predictions = self.model.get_predictions(batch_inputs, batch_outputs) + + for b in range(batch_size): + image_predictions = {} + for head, head_outputs in raw_predictions.items(): + image_predictions[head] = {} + for pred_name, pred in head_outputs.items(): + image_predictions[head][pred_name] = pred[b].cpu().numpy() + batch_predictions.append(image_predictions) + + return batch_predictions diff --git a/deeplabcut/pose_estimation_pytorch/solvers/schedulers.py b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py similarity index 100% rename from deeplabcut/pose_estimation_pytorch/solvers/schedulers.py rename to deeplabcut/pose_estimation_pytorch/runners/schedulers.py diff --git a/deeplabcut/pose_estimation_pytorch/solvers/inference.py b/deeplabcut/pose_estimation_pytorch/runners/scoring.py similarity index 63% rename from deeplabcut/pose_estimation_pytorch/solvers/inference.py rename to deeplabcut/pose_estimation_pytorch/runners/scoring.py index d30292b480..72014fd9e6 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/scoring.py @@ -8,13 +8,10 @@ # # Licensed under GNU Lesser General Public License v3.0 # - -from typing import Dict, List, Tuple, Optional +from __future__ import annotations import numpy as np import pandas as pd -import torch -import torch.nn as nn from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( Assembly, @@ -22,59 +19,12 @@ ) -# TODO: DEPRECATED -def get_prediction( - cfg: dict, output: Tuple[np.ndarray, np.ndarray], stride: int = 8 -) -> np.ndarray: - """Generates pose predictions from the model outputwhich is a tuple given by (heatmaps,location refinement fields)). - - It uses the predicted heatmaps to estimate the keypoints' locations and applies location refinement if enabled. - Refer to: https://www.nature.com/articles/s41592-022-01443-0 for more information about the overall process. - - Args: - cfg: config file in dict - output: heatmaps, locref - heatmaps: the probability that a - keypoint occurs at a particular location - locref: location refinement fields - that predict offsets to mitigate quantization errors due to downsampled score maps - stride: window stride; defaults to 8, Optional - - Returns: - Array of poses - - Examples: - >>> # Define the cfg dictionary and model output - >>> cfg = {'location_refinement': True, 'locref_stdev': 0.1} - >>> heatmaps = np.random.rand(1, 17, 128, 128) - >>> locref = np.random.rand(1, 17, 128, 128) - >>> output = (heatmaps, locref) - >>> # Get the predicted poses - >>> poses = get_prediction(cfg, output) - """ - - poses = [] - heatmaps, locref = output - heatmaps = nn.Sigmoid()(heatmaps) - heatmaps = heatmaps.permute(0, 2, 3, 1).detach().cpu().numpy() - locref = locref.permute(0, 2, 3, 1).detach().cpu().numpy() - for i in range(heatmaps.shape[0]): - shape = locref[i].shape - locref_i = np.reshape(locref, (shape[0], shape[1], -1, 2)) - if cfg["location_refinement"]: - locref_i = locref_i * cfg["locref_stdev"] - pose = multi_pose_predict(heatmaps[i], locref_i, stride, 1) - poses.append(pose) - - return np.stack(poses, axis=0) - - def get_scores( prediction: pd.DataFrame, target: pd.DataFrame, - pcutoff: Optional[float] = None, - bodyparts: List[str] = None, -) -> Dict: + pcutoff: float | None = None, + bodyparts: list[str] | None = None, +) -> dict[str, float]: """Computes for the different scores given the grount truth and the predictions. The different scores computed are based on the COCO metrics: https://cocodataset.org/#keypoints-eval @@ -129,8 +79,8 @@ def get_rmse( prediction: pd.DataFrame, target: pd.DataFrame, pcutoff: float = -1, - bodyparts: List[str] = None, -) -> Tuple[float, float]: + bodyparts: list[str] | None = None, +) -> tuple[float, float]: """Computes the root mean square error (rmse) for predictions vs the ground truth labels Assumes hungarian algorithm matching (https://brilliant.org/wiki/hungarian-matching/)) @@ -179,8 +129,8 @@ def get_oks( margin=0, symmetric_kpts=None, pcutoff: float = -1, - bodyparts: List[str] = None, -) -> Tuple[Dict, Dict]: + bodyparts: list[str] | None = None, +) -> tuple[dict, dict]: """Computes the object keypoint similarity (OKS) scores for predictions. OKS is defined in https://cocodataset.org/#keypoints-eval @@ -262,7 +212,7 @@ def get_oks( return oks_assemblies_raw, oks_pcutoff -def conv_df_to_assemblies(df: pd.DataFrame) -> Tuple[dict, Optional[dict]]: +def conv_df_to_assemblies(df: pd.DataFrame) -> tuple[dict, dict | None]: """ Convert a dataframe to an assemblies dictionary @@ -302,71 +252,3 @@ def _df_to_dict(df: pd.DataFrame) -> dict: data[image_path] = kpt_lst return data - - -# DEPRECATED -def get_top_values(scmap: np.array, n_top: int = 5) -> Tuple[np.array, np.array]: - """This function computes for the top n values from a given scoremap. - - Args: - scmap: score map; - which encode the probability that a keypoint occurs at a particular location - n_top: top n elements in the set. Defaults to 5. - - Returns: - Top n values of in the scoreemap - """ - batchsize, ny, nx, num_joints = scmap.shape - scmap_flat = scmap.reshape(batchsize, nx * ny, num_joints) - if n_top == 1: - scmap_top = np.argmax(scmap_flat, axis=1)[None] - else: - scmap_top = np.argpartition(scmap_flat, -n_top, axis=1)[:, -n_top:] - for ix in range(batchsize): - vals = scmap_flat[ix, scmap_top[ix], np.arange(num_joints)] - arg = np.argsort(-vals, axis=0) - scmap_top[ix] = scmap_top[ix, arg, np.arange(num_joints)] - scmap_top = scmap_top.swapaxes(0, 1) - - Y, X = np.unravel_index(scmap_top, (ny, nx)) - return Y, X - - -# DEPRECATED -def multi_pose_predict( - scmap: np.array, locref: np.array, stride: int, num_outputs: int -) -> np.array: - """This function generates the multi pose predictions from the model of the output (heatmaps and loc refinement fields). - - Refer to: https://www.nature.com/articles/s41592-022-01443-0 for more information about the overall process. - - Args: - scmap: score map; which encode the probability that a keypoint occurs at a particular location - locref: location refinement fields that predict offsets to mitigate quantization errors due to downsampled score maps - stride: window stride; defaults to 8 - num_outputs: The expected number of outputs. - - Returns: - pose: Multi-pose predictions - """ - Y, X = get_top_values(scmap[None], num_outputs) - Y, X = Y[:, 0], X[:, 0] - num_joints = scmap.shape[2] - - DZ = np.zeros((num_outputs, num_joints, 3)) - indices = np.indices((num_outputs, num_joints)) - x = X[indices[0], indices[1]] - y = Y[indices[0], indices[1]] - DZ[:, :, :2] = locref[y, x, indices[1], :] - DZ[:, :, 2] = scmap[y, x, indices[1]] - - X = X.astype("float32") * stride[1] + 0.5 * stride[1] + DZ[:, :, 0] - Y = Y.astype("float32") * stride[0] + 0.5 * stride[0] + DZ[:, :, 1] - P = DZ[:, :, 2] - - pose = np.empty((num_joints, num_outputs * 3), dtype="float32") - pose[:, 0::3] = X.T - pose[:, 1::3] = Y.T - pose[:, 2::3] = P.T - - return pose diff --git a/deeplabcut/pose_estimation_pytorch/runners/top_down.py b/deeplabcut/pose_estimation_pytorch/runners/top_down.py new file mode 100644 index 0000000000..724d64eb55 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/runners/top_down.py @@ -0,0 +1,135 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations +from typing import Any + +import numpy as np +import torch +from torch.utils.data import DataLoader + +import deeplabcut.pose_estimation_pytorch.models.detectors as detectors +from deeplabcut.pose_estimation_pytorch.runners import PoseRunner +from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner + + +@RUNNERS.register_module +class DetectorRunner(Runner[detectors.BaseDetector]): + """Runner for object detection""" + + def __init__( + self, + model: detectors.BaseDetector, + optimizer: torch.optim.Optimizer, + max_individuals: int, + snapshot_prefix: str = "detector-snapshot", + **kwargs, + ): + """ + + Args: + model: + optimizer: + max_individuals: + **kwargs: Runner kwargs + """ + super().__init__(model, optimizer, snapshot_prefix=snapshot_prefix, **kwargs) + self.max_individuals = max_individuals + + def step( + self, + batch: dict[str, Any], + mode: str = "train", + ) -> dict[str, torch.Tensor]: + """Perform a single epoch gradient update or validation step. + + Args: + batch: Tuple of input image(s) and target(s) for train or valid single step. + mode: `train` or `eval`. Defaults to "train". + + Raises: + ValueError: "Runner must be in train or eval mode, but {mode} was found." + + Returns: + dict: { + 'total_loss': torch.Tensor, + 'aux_loss_1': torch.Tensor, + ..., + } + """ + if mode not in ["train", "eval"]: + raise ValueError( + f"DetectorSolver must be in train or eval mode, but {mode} was found." + ) + + if mode == "train": + self.optimizer.zero_grad() + else: + # Override base class + # No losses returned in train mode; + # see https://stackoverflow.com/a/65347721 + # Should be safe as BN is frozen; + # see https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12 + self.model.train() + + images = batch["image"] + images = images.to(self.device) + + target = self.model.get_target( + batch["annotations"] + ) # (batch_size, channels, h, w) + for item in target: # target is a list here + for key in item: + if item[key] is not None: + item[key] = torch.tensor(item[key]).to(self.device) + + losses, _ = self.model(images, target) + losses["total_loss"] = sum(loss_part for loss_part in losses.values()) + if mode == "train": + losses["total_loss"].backward() + self.optimizer.step() + + return {k: v.detach().cpu().numpy() for k, v in losses.items()} + + def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: + """Makes predictions from a model input and output + + Args: + the inputs to the model, of shape (batch_size, ...) + + Returns: + predictions for each of the 'batch_size' inputs, made by each head, e.g. + [ + { + "bodypart": {"poses": np.ndarray}, + "unique_bodypart": "poses": np.ndarray}, + ] + """ + # TODO: iterates over batch one element at a time + batch_size = 1 + batch_predictions = [] + for i in range(0, len(inputs), batch_size): + batch_inputs = inputs[i : i + batch_size] + batch_inputs = batch_inputs.to(self.device) + _, raw_predictions = self.model(batch_inputs) + + for b, item in enumerate(raw_predictions): + # take the top-k bounding boxes as individuals + batch_predictions.append( + { + "detection": { + "bboxes": item["boxes"][: self.max_individuals] + .cpu() + .numpy(), + }, + } + ) + + return batch_predictions diff --git a/deeplabcut/pose_estimation_pytorch/solvers/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py similarity index 94% rename from deeplabcut/pose_estimation_pytorch/solvers/utils.py rename to deeplabcut/pose_estimation_pytorch/runners/utils.py index 510bd49650..3635880469 100644 --- a/deeplabcut/pose_estimation_pytorch/solvers/utils.py +++ b/deeplabcut/pose_estimation_pytorch/runners/utils.py @@ -14,7 +14,7 @@ import re import warnings from pathlib import Path -from typing import Dict, List, Tuple, Optional +from typing import List, Tuple, Optional import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions @@ -145,20 +145,21 @@ def get_detector_path(model_folder: str, load_epoch: int) -> str: def get_dlc_scorer( + project_path: str, + test_cfg: dict, train_fraction: float, shuffle: int, model_prefix: str, - test_cfg: dict, - train_iterations: str, -) -> Tuple[str]: + train_iterations: int, +) -> Tuple[str, str]: """Return dlc_scorer given the ff parameters: train_faction, shuffle, model_prefix, test_cfg, and train_iterations. Args: + project_path: train_fraction: fraction of the dataset assigned for training shuffle: shuffle id - model_prefix: keep as default (included for backwards - compatibility); default value is "" + model_prefix: keep as default (included for backwards compatibility); default value is "" test_cfg: contents of the config file in a dict train_iterations: the iteration number of the snapshot @@ -177,7 +178,9 @@ def get_dlc_scorer( ('DLC_model_w32_behaviordateshuffle1_10','DeepCut_model_w32_behaviordateshuffle1_10') """ - model_folder = get_model_folder(train_fraction, shuffle, model_prefix, test_cfg) + model_folder = get_model_folder( + project_path, test_cfg, train_fraction, shuffle, model_prefix + ) snapshots = get_snapshots(Path(model_folder)) snapshot = snapshots[train_iterations] snapshot_epochs = int(snapshot.split("-")[-1]) @@ -324,16 +327,21 @@ def get_results_filename( def get_model_folder( - train_fraction: float, shuffle: int, model_prefix: str, test_cfg: dict + project_path: str, + cfg: dict, + train_fraction: float, + shuffle: int, + model_prefix: str, ) -> str: """Returns the model folder path given the ff parameters: train_faction, shuffle, model_prefix, and test_cfg Args: + project_path: + cfg: contents of the config file in a dict train_fraction: fraction of the dataset assigned for training shuffle: shuffle id model_prefix: keep as default (included for backwards compatibility); default value is "" - test_cfg: contents of the config file in a dict Returns: model_folder: the path of the model folder @@ -349,10 +357,10 @@ def get_model_folder( """ model_folder = os.path.join( - test_cfg["project_path"], + project_path, str( auxiliaryfunctions.get_model_folder( - train_fraction, shuffle, test_cfg, modelprefix=model_prefix + train_fraction, shuffle, cfg, modelprefix=model_prefix ) ), ) @@ -516,6 +524,7 @@ def build_entire_pred_df( def get_paths( + project_path: str, train_fraction: float = 0.95, shuffle: int = 0, model_prefix: str = "", @@ -524,13 +533,20 @@ def get_paths( method: str = "bu", ): dlc_scorer, dlc_scorer_legacy = get_dlc_scorer( - train_fraction, shuffle, model_prefix, cfg, train_iterations + project_path, + cfg, + train_fraction, + shuffle, + model_prefix, + train_iterations, ) evaluation_folder = get_evaluation_folder( train_fraction, shuffle, model_prefix, cfg ) - model_folder = get_model_folder(train_fraction, shuffle, model_prefix, cfg) + model_folder = get_model_folder( + project_path, cfg, train_fraction, shuffle, model_prefix + ) model_path = get_model_path(model_folder, train_iterations) @@ -546,10 +562,3 @@ def get_paths( "model_path": model_path, "detector_path": detector_path, } - - -# def get_detector_path(model_folder: str, load_epoch: int): -# detector_paths = glob.glob(f"{model_folder}/train/detector-snapshot*") -# sorted_paths = sort_paths(detector_paths) -# detector_path = sorted_paths[load_epoch] -# return detector_path diff --git a/deeplabcut/pose_estimation_pytorch/solvers/__init__.py b/deeplabcut/pose_estimation_pytorch/solvers/__init__.py deleted file mode 100644 index 727f85e06d..0000000000 --- a/deeplabcut/pose_estimation_pytorch/solvers/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# - -from deeplabcut.pose_estimation_pytorch.solvers.base import SOLVERS -from deeplabcut.pose_estimation_pytorch.solvers.logger import LOGGER -from deeplabcut.pose_estimation_pytorch.solvers.single_animal import ( - BottomUpSingleAnimalSolver, -) -from deeplabcut.pose_estimation_pytorch.solvers.top_down import TopDownSolver diff --git a/deeplabcut/pose_estimation_pytorch/solvers/base.py b/deeplabcut/pose_estimation_pytorch/solvers/base.py deleted file mode 100644 index 49a39270e8..0000000000 --- a/deeplabcut/pose_estimation_pytorch/solvers/base.py +++ /dev/null @@ -1,297 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -import logging -from abc import ABC, abstractmethod -from collections import defaultdict -from typing import Dict, Optional, Tuple - -import numpy as np -import torch - -import deeplabcut.pose_estimation_pytorch.data.dataset as deeplabcut_pose_estimation_pytorch_data_dataset -import deeplabcut.pose_estimation_pytorch.models.model as deeplabcut_pose_estimation_pytorch_models_model -import deeplabcut.pose_estimation_pytorch.models.predictors as deeplabcut_pose_estimation_pytorch_models_predictors -import deeplabcut.pose_estimation_pytorch.solvers.inference as deeplabcut_pose_estimation_pytorch_solvers_inference -import deeplabcut.pose_estimation_pytorch.solvers.utils as solver_utils -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -from deeplabcut.pose_estimation_pytorch.solvers.logger import BaseLogger - -SOLVERS = Registry("solvers", build_func=build_from_cfg) - - -class Solver(ABC): - """Solver base class. - - Contains helper methods for bundling a model, criterion and optimizer. - """ - - def __init__( - self, - model: deeplabcut_pose_estimation_pytorch_models_model.PoseModel, - criterion: torch.nn, - optimizer: torch.optim.Optimizer, - predictor: deeplabcut_pose_estimation_pytorch_models_predictors.BasePredictor, - cfg: Dict, - device: str = "cpu", - snapshot_path: Optional[str] = "", - scheduler: torch.optim.lr_scheduler = None, - logger: Optional[BaseLogger] = None, - ): - """Constructor of the Solver class. - - Args: - model: The neural network for solving pose estimation task. - criterion: The criterion computed from the difference - between the prediction and the target. - optimizer: A PyTorch optimizer for - updating model parameters. - cfg: DeepLabCut pose_cfg for training. - See https://github.com/DeepLabCut/DeepLabCut/blob/main/deeplabcut/pose_cfg.yaml - for more details. - device: str representing the device on which a torch.Tensor - is or will be allocated. - Possible value are: ('cpu', 'cuda' or 'mps') - snapshot_path: path of the snapshot/weights file to load before training - scheduler: Scheduler for adjusting the - lr of the optimizer. - logger: logger to monitor training (e.g WandB logger) - - Returns: - None - - Notes/TODO: - Read stride from config file - """ - - if cfg is None: - raise ValueError("") - self.model = model - self.device = device - self.cfg = cfg - self.model.to(device) - self.optimizer = optimizer - self.scheduler = scheduler - self.criterion = criterion - self.predictor = predictor - self.history = {"train_loss": [], "eval_loss": []} - self.logger = logger - self.starting_epoch = 0 - if self.logger: - logger.log_config(cfg) - if snapshot_path: - snapshot = torch.load(snapshot_path) - self.model.load_state_dict(snapshot["model_state_dict"]) - self.optimizer.load_state_dict(snapshot["optimizer_state_dict"]) - self.starting_epoch = snapshot["epoch"] - self.stride = 8 - - def fit( - self, - train_loader: torch.utils.data.DataLoader, - valid_loader: torch.utils.data.DataLoader, - train_fraction: float = 0.95, - shuffle: int = 0, - model_prefix: str = "", - *, - epochs: int = 10000, - ) -> None: - """Train model for the specified number of steps. - - Args: - train_loader: Data loader, which is an iterator over train instances. - Each batch contains image tensor and heat maps tensor input samples. - valid_loader: Data loader used for validation of the model. - train_fraction: Fraction used for training. Defaults to 0.95. - shuffle: Shuffle id to use from the randomized training sets. Defaults to 0. - model_prefix: Defaults to "". - epochs: The number of training epochs. Defaults to 10000. - - Returns: - None - - Example: - solver = Solver(model, criterion, optimizer, predictor, cfg, device='cuda') - solver.fit(train_loader, valid_loader, epochs=50) - """ - - model_folder = solver_utils.get_model_folder( - train_fraction, shuffle, model_prefix, train_loader.dataset.cfg - ) - - for i in range(self.starting_epoch, epochs): - train_loss = self.epoch(train_loader, mode="train", step=i + 1) - if self.scheduler: - self.scheduler.step() - logging.info( - f"Training for epoch {i + 1} done, starting eval on validation data" - ) - valid_loss = self.epoch(valid_loader, mode="eval", step=i + 1) - - if (i + 1) % self.cfg["save_epochs"] == 0: - logging.info(f"Finished epoch {i + 1}; saving model") - torch.save( - { - "model_state_dict": self.model.state_dict(), - "epoch": i + 1, - "optimizer_state_dict": self.optimizer.state_dict(), - "train_loss": train_loss, - "validation_loss": valid_loss, - }, - f"{model_folder}/train/snapshot-{i + 1}.pt", - ) - - logging.info( - f"Epoch {i + 1}/{epochs}, " - f"train loss {float(train_loss):.5f}, " - f"valid loss {float(valid_loss):.5f}, " - f'lr {self.optimizer.param_groups[0]["lr"]}' - ) - - def epoch( - self, - loader: torch.utils.data.DataLoader, - mode: str = "train", - step: Optional[int] = None, - ) -> float: - """Facilitates training over an epoch. Returns the loss over the batches. - - Args: - loader: Data loader, which is an iterator over instances. - Each batch contains image tensor and heat maps tensor input samples. - mode: str identifier to instruct the Solver whether to train or evaluate. - Possible values are: "train" or "eval". - Defaults to "train". - step: the global step in processing, used to log metrics. Defaults to None. - - Raises: - ValueError: "Solver mode must be train or eval, found mode={mode}." - This error is raised when the given mode is invalid (eg. not train nor eval) - - Returns: - epoch_loss: Average of the loss over the batches. - """ - - if mode not in ["train", "eval"]: - raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") - to_mode = getattr(self.model, mode) - to_mode() - epoch_loss = [] - metrics = defaultdict(list) - for i, batch in enumerate(loader): - losses_dict = self.step(batch, mode) - epoch_loss.append(losses_dict["total_loss"]) - - for key in losses_dict.keys(): - metrics[key].append(losses_dict[key]) - - if (i + 1) % self.cfg["display_iters"] == 0: - logging.info( - f"Number of iterations: {i+1}, " - f"loss: {losses_dict['total_loss']:.5f}, " - f"lr: {self.optimizer.param_groups[0]['lr']}" - ) - epoch_loss = np.mean(epoch_loss).item() - self.history[f"{mode}_loss"].append(epoch_loss) - - if self.logger: - for key in metrics: - self.logger.log( - f"{mode} {key}", - np.nanmean(metrics[key]), - step=step, - ) - - return epoch_loss - - @abstractmethod - def step(self, batch: Tuple[torch.Tensor, torch.Tensor], *args) -> dict: - raise NotImplementedError - - @torch.no_grad() - def inference( - self, dataset: deeplabcut_pose_estimation_pytorch_data_dataset.PoseDataset - ) -> np.ndarray: - """Run inference on the given dataset and obtain predicted poses. - - Args: - dataset: The dataset to run the inference over - - Returns: - Numpy array containing the predicted poses - - Notes/TODO: - Add scale - """ - - predicted_poses = [] - for item in dataset: - if isinstance(item, tuple) or isinstance(item, list): - item = item[0] - item = item.to(self.device) - output = self.model(item) - pose = deeplabcut_pose_estimation_pytorch_solvers_inference.get_prediction( - self.cfg, output, self.stride - ) - predicted_poses.append(pose) - predicted_poses = np.concatenate(predicted_poses) - return predicted_poses - - -class BottomUpSolver(Solver): - - """Base solvers for bottom up pose estimation.""" - - def step( - self, batch: Tuple[torch.Tensor, torch.Tensor], mode: str = "train" - ) -> dict: - """Perform a single epoch gradient update or validation step. - - Args: - batch: Tuple of input image(s) and target(s) for train or valid single step. - mode: `train` or `eval`. Defaults to "train". - - Raises: - ValueError: "Solver must be in train or eval mode, but {mode} was found." - - Returns - ------- - dict : { - 'batch loss' : torch.Tensor, - 'heatmap_loss' : torch.Tensor, - 'locref_loss' : torch.Tensor - } - """ - - if mode not in ["train", "eval"]: - raise ValueError( - f"Solver must be in train or eval mode, but {mode} was found." - ) - if mode == "train": - self.optimizer.zero_grad() - image = batch["image"] - image = image.to(self.device) - prediction = self.model(image) - - target = self.model.get_target( - batch["annotations"], prediction, image.shape[2:] - ) # (batch_size, channels, h, w) - for key in target: - if target[key] is not None: - target[key] = torch.Tensor(target[key]).to(self.device) - - losses_dict = self.criterion(prediction, target) - if mode == "train": - losses_dict["total_loss"].backward() - self.optimizer.step() - - for key in losses_dict.keys(): - losses_dict[key] = losses_dict[key].detach().cpu().numpy() - return losses_dict diff --git a/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py b/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py deleted file mode 100644 index f662b039e4..0000000000 --- a/deeplabcut/pose_estimation_pytorch/solvers/single_animal.py +++ /dev/null @@ -1,14 +0,0 @@ -import os -import pandas as pd -import torch - -from deeplabcut.pose_estimation_pytorch.solvers.base import BottomUpSolver, SOLVERS - - -@SOLVERS.register_module -class BottomUpSingleAnimalSolver(BottomUpSolver): - """ - To be extended if needed - """ - - pass diff --git a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py b/deeplabcut/pose_estimation_pytorch/solvers/top_down.py deleted file mode 100644 index 03b81bb8e0..0000000000 --- a/deeplabcut/pose_estimation_pytorch/solvers/top_down.py +++ /dev/null @@ -1,347 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -import logging -from collections import defaultdict -from typing import Dict, Optional, Tuple - -import numpy as np -import torch -import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector -from deeplabcut.pose_estimation_pytorch.solvers.base import SOLVERS, Solver -from deeplabcut.pose_estimation_pytorch.solvers.utils import * - - -@SOLVERS.register_module -class TopDownSolver(Solver): - """Top Down Solver Class - - This class is used for training the top-down pose estimation model - based on a detector model such as FasterRCNN. - Only supports FasterRCNN as a detector for now (https://github.com/rbgirshick/fast-rcnn) - - Attributes: - detector: The detector model used in the top-down approach. - detector_optimizer: Optimizer for the detector model. - detector_criterion: Criterion for the detector model (not used with FasterRCNN). - detector_scheduler: Scheduler for the detector model (optional). - detector_path: Path to a pre-trained detector model checkpoint (optional). - - Examples: - # Initialize the top-down solver with a FasterRCNN detector and its optimizer - detector = FasterRCNN() - detector_optimizer = torch.optim.SGD(detector.parameters(), lr=0.001, momentum=0.9) - solver = TopDownSolver(detector=detector, detector_optimizer=detector_optimizer) - - # Load data loaders for training and validation - train_detector_loader = torch.utils.data.DataLoader(...) - valid_detector_loader = torch.utils.data.DataLoader(...) - train_pose_loader = torch.utils.data.DataLoader(...) - valid_pose_loader = torch.utils.data.DataLoader(...) - - # Train the top-down pose estimation model - solver.fit( - train_detector_loader=train_detector_loader, - valid_detector_loader=valid_detector_loader, - train_pose_loader=train_pose_loader, - valid_pose_loader=valid_pose_loader, - train_fraction=0.95, - shuffle=0, - model_prefix="my_model", - epochs=10000 - ) - """ - - def __init__( - self, - *args, - detector: BaseDetector, - detector_optimizer: torch.optim.Optimizer, - detector_criterion: nn.Module = None, # Not Used with fasterRCNN - detector_scheduler: Optional = None, - detector_path: Optional[str] = "", - **kwargs, - ): - super().__init__(*args, **kwargs) - self.detector = detector - self.detector.to(self.device) - self.detector_optimizer = detector_optimizer - self.detector_criterion = detector_criterion - self.detector_scheduler = detector_scheduler - self.starting_epoch = 0 - self.starting_epoch_detector = 0 - if detector_path: - detector = torch.load(detector_path) - self.detector.load_state_dict(detector["detector_state_dict"]) - self.detector_optimizer.load_state_dict(detector["optimizer_state_dict"]) - self.starting_epoch_detector = detector["epoch"] - - def fit( - self, - train_detector_loader: torch.utils.data.DataLoader, - valid_detector_loader: torch.utils.data.DataLoader, - train_pose_loader: torch.utils.data.DataLoader, - valid_pose_loader: torch.utils.data.DataLoader, - train_fraction: float = 0.95, - shuffle: int = 0, - model_prefix: str = "", - *, - epochs: int = 10000, - detector_epochs: int = 10000, - ): - model_folder = get_model_folder( - train_fraction, shuffle, model_prefix, train_detector_loader.dataset.cfg - ) - - for i in range(self.starting_epoch_detector, detector_epochs): - train_detector_loss = self.epoch_detector( - train_detector_loader, mode="train", step=i + 1 - ) - if self.detector_scheduler: - self.detector_scheduler.step() - logging.info(f"Training the detector for epoch {i + 1} done") - - # TODO no eval pass for the detector since fasterRCNN can't return a loss in eval mode - - if (i + 1) % self.cfg.get("detector_save_epochs", 10) == 0: - logging.info(f"Finished epoch {i + 1}; saving detector") - torch.save( - { - "detector_state_dict": self.detector.state_dict(), - "epoch": i + 1, - "optimizer_state_dict": self.detector_optimizer.state_dict(), - "train_loss": train_detector_loss, - }, - f"{model_folder}/train/detector-snapshot-{i + 1}.pt", - ) - logging.info( - f"Epoch {i + 1}/{detector_epochs}, " - f"train detector loss {train_detector_loss:.5f}" - ) - - if detector_epochs % self.cfg.get("detector_save_epochs", 10) != 0: - torch.save( - self.detector.state_dict(), - f"{model_folder}/train/detector-snapshot-{epochs}.pt", - ) - logging.info(f"Finished epoch {detector_epochs}; saving model") - - for i in range(self.starting_epoch, epochs): - train_pose_loss = self.epoch_pose( - train_pose_loader, mode="train", step=i + 1 - ) - if self.scheduler: - self.scheduler.step() - - logging.info( - f"Training the pose estimator for epoch {i + 1} done, starting eval on " - f"validation data" - ) - - valid_pose_loss = self.epoch_pose( - valid_pose_loader, mode="eval", step=i + 1 - ) - - if (i + 1) % self.cfg["save_epochs"] == 0: - logging.info(f"Finished epoch {i + 1}; saving pose model") - torch.save( - { - "model_state_dict": self.model.state_dict(), - "epoch": i + 1, - "optimizer_state_dict": self.optimizer.state_dict(), - "train_loss": train_pose_loss, - "validation_loss": valid_pose_loss, - }, - f"{model_folder}/train/snapshot-{i + 1}.pt", - ) - - logging.info( - f"Epoch {i + 1}/{epochs}, " - f"train pose loss {train_pose_loss:.5f}, " - f"valid pose loss {valid_pose_loss:.5f}" - ) - - if epochs % self.cfg["save_epochs"] != 0: - logging.info(f"Finished epoch {epochs}; saving model") - torch.save( - self.model.state_dict(), - f"{model_folder}/train/pose-snapshot-{epochs}.pt", - ) - - def epoch(self, *args): - # Unused in top down since we are dealing with two different epoch functions - pass - - def step(self, *args): - """Unused in top down since we are dealing with two different step functions.""" - pass - - def epoch_detector( - self, - detector_loader: torch.utils.data.DataLoader, - mode: str = "train", - step: Optional[int] = None, - ) -> float: - if mode not in ["train", "eval"]: - raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") - to_mode_detector = getattr(self.detector, mode) - to_mode_detector() - epoch_detector_loss = [] - metrics = defaultdict(list) - - # Detector training - for i, batch_d in enumerate(detector_loader): - detector_loss = self.step_detector(batch_d, mode) - epoch_detector_loss.append(detector_loss) - - metrics["detector_loss"].append(detector_loss) - - # TODO good for evaluation speed up but should be optional - # if mode == "eval" and i > 100: - # break - - if (i + 1) % self.cfg["display_iters"] == 0: - logging.info( - f"Number of iterations for detector: {i+1}, " - f"loss: {np.mean(metrics['detector_loss']):.5f}, " - f"lr: {self.detector_optimizer.param_groups[0]['lr']}" - ) - epoch_detector_loss = np.mean(epoch_detector_loss) - - # TODO is history really necessary here ? - # self.history[f'{mode}_loss'].append(epoch_loss) - - if self.logger: - for key in metrics.keys(): - self.logger.log( - f"{mode} {key}", - np.nanmean(metrics[key]), - step=step, - ) - - return epoch_detector_loss - - def epoch_pose( - self, - pose_loader: torch.utils.data.DataLoader, - mode: str = "train", - step: Optional[int] = None, - ) -> float: - if mode not in ["train", "eval"]: - raise ValueError(f"Solver mode must be train or eval, found mode={mode}.") - to_mode_pose = getattr(self.model, mode) - to_mode_pose() - epoch_pose_loss = [] - metrics = defaultdict(list) - - # Pose model training - for i, batch in enumerate(pose_loader): - losses_dict = self.step_pose(batch, mode) - epoch_pose_loss.append(losses_dict["total_loss"]) - - for key in losses_dict.keys(): - metrics["pose_" + key].append(losses_dict[key]) - - # TODO good for evaluation speed up but should be optional - # if mode == "eval" and i > 100: - # break - - if (i + 1) % self.cfg["display_iters"] == 0: - logging.info( - f"Number of iterations for pose: {i+1}, " - f"loss: {np.mean(metrics['pose_total_loss']):.5f}, " - f"lr: {self.optimizer.param_groups[0]['lr']}" - ) - epoch_pose_loss = np.mean(epoch_pose_loss) - - # TODO is history really necessary here ? - # self.history[f'{mode}_loss'].append(epoch_loss) - - if self.logger: - for key in metrics.keys(): - self.logger.log( - f"{mode} {key}", - np.nanmean(metrics[key]), - step=step, - ) - - return epoch_pose_loss - - def step_detector(self, batch: dict, mode: str = "train") -> float: - """Performs a step for the detector over a batch - - Args: - batch: batch returned by the dataloader - mode: "train" or "eval". Defaults to "train". - - Returns: - loss : loss for the detector - """ - if mode not in ["train", "eval"]: - raise ValueError( - f"Solver must be in train or eval mode, but {mode} was found." - ) - if mode == "train": - self.detector_optimizer.zero_grad() - - images = batch["image"] - images = images.to(self.device) - - target = self.detector.get_target( - batch["annotations"] - ) # (batch_size, channels, h, w) - for item in target: # target is a list here - for key in item: - if item[key] is not None: - item[key] = torch.tensor(item[key]).to(self.device) - - if mode == "train": - # For now only FasterRCNN is supported and it already returns the loss dict - # when calling forward() - losses_dict = self.detector(images, target) - loss = sum(l for l in losses_dict.values()) - - loss.backward() - self.detector_optimizer.step() - - return loss.detach().cpu().numpy() - else: - # No way to get losses in eval mode for the moment - return 0.0 - - def step_pose(self, batch: dict, mode: str = "train") -> dict: - if mode not in ["train", "eval"]: - raise ValueError( - f"Solver must be in train or eval mode, but {mode} was found." - ) - if mode == "train": - self.optimizer.zero_grad() - - images = batch["image"] - images = images.to(self.device) - - prediction = self.model(images) - - target = self.model.get_target( - batch["annotations"], prediction, images.shape[2:] - ) # (batch_size, channels, h, w) - for key in target: - if target[key] is not None: - target[key] = torch.tensor(target[key]).to(self.device) - - losses_dict = self.criterion(prediction, target) - if mode == "train": - losses_dict["total_loss"].backward() - self.optimizer.step() - - for key in losses_dict.keys(): - losses_dict[key] = losses_dict[key].detach().cpu().numpy() - return losses_dict diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py b/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py index 4bc96df207..e62e83600f 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py @@ -10,10 +10,12 @@ from deeplabcut.pose_estimation_pytorch.data.helper import merge_list_of_dicts -@pytest.mark.parametrize('repo_path', ['/home/anastasiia/DLCdev']) +@pytest.mark.parametrize("repo_path", ["/home/anastasiia/DLCdev"]) def test_propertymeta_project(repo_path): project_root = os.path.join( - repo_path, 'examples', 'openfield-Pranav-2018-10-30', + repo_path, + "examples", + "openfield-Pranav-2018-10-30", ) dlc_project = DLCProject(project_root, shuffle=1) @@ -21,13 +23,18 @@ def test_propertymeta_project(repo_path): print(prop, getattr(dlc_project, prop)) -@pytest.mark.parametrize('repo_path, mode', [('/home/anastasiia/DLCdev', 'train'), ('/home/anastasiia/DLCdev', 'test')]) +@pytest.mark.parametrize( + "repo_path, mode", + [("/home/anastasiia/DLCdev", "train"), ("/home/anastasiia/DLCdev", "test")], +) def test_propertymeta_dataset(repo_path, mode): - repo_path = '/home/anastasiia/DLCdev' - mode = 'train' - mode = 'train' + repo_path = "/home/anastasiia/DLCdev" + mode = "train" + mode = "train" project_root = os.path.join( - repo_path, 'examples', 'openfield-Pranav-2018-10-30', + repo_path, + "examples", + "openfield-Pranav-2018-10-30", ) dlc_project = DLCProject(project_root, shuffle=1) dataset = PoseDataset(dlc_project, mode) @@ -37,16 +44,23 @@ def test_propertymeta_dataset(repo_path, mode): @pytest.mark.parametrize( - 'list_dicts, keys_to_include', [ - ([{'a': 1, 'b': 2}, {'a': 3, 'b': 4}], ['a']), + "list_dicts, keys_to_include", + [ + ([{"a": 1, "b": 2}, {"a": 3, "b": 4}], ["a"]), ( [ - *[{ - 'keypoints': np.random.randn(27, 3), 'images': np.random.randn( - 256, 192, - ), - }]*10, - ], [*['keypoints', 'images']*10], + *[ + { + "keypoints": np.random.randn(27, 3), + "images": np.random.randn( + 256, + 192, + ), + } + ] + * 10, + ], + [*["keypoints", "images"] * 10], ), ], ) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_helper.py b/deeplabcut/pose_estimation_pytorch/tests/test_helper.py index fdc650f190..ddd1b3ebef 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_helper.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_helper.py @@ -1,11 +1,11 @@ import torch -def test_train_valid_call(): +def test_train_valid_call(): tmp_model = torch.nn.Linear(3, 10) - to_train_mode = getattr(tmp_model, 'train') + to_train_mode = getattr(tmp_model, "train") to_train_mode() assert tmp_model.training == True - to_valid_mode = getattr(tmp_model, 'eval') + to_valid_mode = getattr(tmp_model, "eval") to_valid_mode() - assert tmp_model.training == False \ No newline at end of file + assert tmp_model.training == False diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py index b91780f5c0..18d9c2c5f5 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py @@ -57,7 +57,7 @@ def get_target( ) } # 2 for x,y coords prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] - generator = deeplabcut_torch_plateau_targets.PlateauGenerator( + generator = deeplabcut_torch_plateau_targets.PlateauLocrefGenerator( locref_stdev, num_joints, pos_dist_thresh ) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py index c766d53d3d..ce0dbc10ee 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py @@ -197,4 +197,4 @@ def test_msa_hrnetCOAM(): pass -# TODO: add other model variants our pipeline can build ;) \ No newline at end of file +# TODO: add other model variants our pipeline can build ;) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py b/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py index 534ab45473..73988da431 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py @@ -9,74 +9,101 @@ import numpy as np import deeplabcut from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model -from deeplabcut.pose_estimation_pytorch.solvers.utils import get_paths, get_results_filename +from deeplabcut.pose_estimation_pytorch.solvers.utils import ( + get_paths, + get_results_filename, +) from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction from deeplabcut.pose_estimation_pytorch.models.criterion import PoseLoss + def read_yaml(path): - try : + try: with open(path, "r") as stream: try: return yaml.safe_load(stream) except yaml.YAMLError as exc: print(exc) - except : + except: raise FileNotFoundError("An eero occured whilereading the file") -def get_training_set_length(cfg, train_fraction, shuffle): - training_folder = os.path.join(cfg["project_path"], deeplabcut.auxiliaryfunctions.get_training_set_folder(cfg)) - train_idx_path = os.path.join(training_folder, f'Documentation_data-{cfg["Task"]}_{int(train_fraction*100)}shuffle{shuffle}.pickle') +def get_training_set_length(cfg, train_fraction, shuffle): + training_folder = os.path.join( + cfg["project_path"], deeplabcut.auxiliaryfunctions.get_training_set_folder(cfg) + ) + train_idx_path = os.path.join( + training_folder, + f'Documentation_data-{cfg["Task"]}_{int(train_fraction*100)}shuffle{shuffle}.pickle', + ) with open(train_idx_path, "rb") as file: meta = pickle.load(file) - print(f'length of the training set {len(meta[1])}, length of the test set {len(meta[2])}') + print( + f"length of the training set {len(meta[1])}, length of the test set {len(meta[2])}" + ) return len(meta[1]) + def load_model(cfg, pytorch_config, shuffle, model_prefix="", train_iteration=-1): - names = get_paths(train_fraction=cfg['TrainingFraction'][0], - model_prefix=model_prefix, - shuffle=shuffle, - cfg=cfg, - train_iterations=train_iteration) - print(names['model_path']) - - results_filename = get_results_filename(names['evaluation_folder'], - names['dlc_scorer'], - names['dlc_scorer_legacy'], - names['model_path'][:-3]) - - pose_cfg = deeplabcut.auxiliaryfunctions.read_config(pytorch_config['pose_cfg_path']) - model = build_pose_model(pytorch_config['model'], pose_cfg) - model.load_state_dict(torch.load(names['model_path'])) + names = get_paths( + train_fraction=cfg["TrainingFraction"][0], + model_prefix=model_prefix, + shuffle=shuffle, + cfg=cfg, + train_iterations=train_iteration, + ) + print(names["model_path"]) + + results_filename = get_results_filename( + names["evaluation_folder"], + names["dlc_scorer"], + names["dlc_scorer_legacy"], + names["model_path"][:-3], + ) + + pose_cfg = deeplabcut.auxiliaryfunctions.read_config( + pytorch_config["pose_cfg_path"] + ) + model = build_pose_model(pytorch_config["model"], pose_cfg) + model.load_state_dict(torch.load(names["model_path"])) return model -def evaluate_network_custom(config_path, shuffle, model_prefix="", transform=None, train_iteration=-1): + +def evaluate_network_custom( + config_path, shuffle, model_prefix="", transform=None, train_iteration=-1 +): cfg = read_yaml(config_path) train_fraction = cfg["TrainingFraction"][0] model_folder = os.path.join( cfg["project_path"], deeplabcut.auxiliaryfunctions.get_model_folder( - train_fraction, shuffle, cfg, modelprefix=model_prefix, + train_fraction, + shuffle, + cfg, + modelprefix=model_prefix, ), ) pytorch_config_path = os.path.join(model_folder, "train", "pytorch_config.yaml") pytorch_config = read_yaml(pytorch_config_path) - pose_cfg = deeplabcut.auxiliaryfunctions.read_config(pytorch_config['pose_cfg_path']) - - batch_size = pytorch_config['batch_size'] - project = deeplabcut.pose_estimation_pytorch.DLCProject(shuffle=shuffle, - proj_root=pytorch_config['project_root']) - - valid_dataset = deeplabcut.pose_estimation_pytorch.PoseDataset(project, - transform=transform, - mode='train') - valid_dataloader = torch.utils.data.DataLoader(valid_dataset, - batch_size=batch_size, - shuffle=True) - + pose_cfg = deeplabcut.auxiliaryfunctions.read_config( + pytorch_config["pose_cfg_path"] + ) + + batch_size = pytorch_config["batch_size"] + project = deeplabcut.pose_estimation_pytorch.DLCProject( + shuffle=shuffle, proj_root=pytorch_config["project_root"] + ) + + valid_dataset = deeplabcut.pose_estimation_pytorch.PoseDataset( + project, transform=transform, mode="train" + ) + valid_dataloader = torch.utils.data.DataLoader( + valid_dataset, batch_size=batch_size, shuffle=True + ) + model = load_model(cfg, pytorch_config, shuffle, model_prefix, train_iteration) model.to("cuda") model.eval() @@ -90,19 +117,22 @@ def evaluate_network_custom(config_path, shuffle, model_prefix="", transform=Non if isinstance(item, tuple) or (isinstance, list): item = item[0].to("cuda") output = model(item) - - scale_factor = (item.shape[2]/output[0].shape[2] , item.shape[3]/output[0].shape[3]) + + scale_factor = ( + item.shape[2] / output[0].shape[2], + item.shape[3] / output[0].shape[3], + ) gt = model.get_target(keypoints, output[0].shape[2:], scale_factor) for key in gt: if gt[key] is not None: - gt[key] = gt[key].to("cuda") + gt[key] = gt[key].to("cuda") - predictions= get_prediction(pose_cfg, output, scale_factor) + predictions = get_prediction(pose_cfg, output, scale_factor) rmse = keypoints.numpy() - predictions[:, :, :2] - rmse*=rmse - rmse = np.sqrt(rmse.sum(axis = 2)) + rmse *= rmse + rmse = np.sqrt(rmse.sum(axis=2)) rmses.append(np.nanmean(rmse)) losses.append(criterion(output, gt)[0].cpu().numpy()) @@ -110,25 +140,27 @@ def evaluate_network_custom(config_path, shuffle, model_prefix="", transform=Non print(np.mean(losses), np.nanmean(rmses)) return np.mean(losses), np.nanmean(rmses) -def runBenchmark(path_dataset, train_fraction, shuffle, transform = None): + +def runBenchmark(path_dataset, train_fraction, shuffle, transform=None): """Trains the model and evaluates it on a given dataset""" config_path = os.path.join(path_dataset, "config.yaml") # Training the network print("Training started") start_time = time.time() - deeplabcut.pose_estimation_pytorch.apis.train.train_network(config_path, shuffle=shuffle, transform=transform) + deeplabcut.pose_estimation_pytorch.apis.train.train_network( + config_path, shuffle=shuffle, transform=transform + ) delta_time = time.time() - start_time print("Training ended") # #evaluate the nework print("Starting evaluation of the last saved model") - evaluate_network_custom(config_path, shuffle, transform = transform) + evaluate_network_custom(config_path, shuffle, transform=transform) class CustomHorizontalFlip(A.HorizontalFlip): - - def __init__(self, flipped_keypoints, always_apply = False, p = 0.5): + def __init__(self, flipped_keypoints, always_apply=False, p=0.5): """ flipped_keypoints : list of the new order of keypoints """ @@ -141,27 +173,26 @@ def apply_to_keypoints(self, keypoints, **params): return [keypoints[i] for i in self.flipped_keypoints] - if __name__ == "__main__": path_dataset = "/home/quentin/datasets/Openfield_pytorch" config_path = os.path.join(path_dataset, "config.yaml") cfg = read_yaml(config_path) - if cfg.get('flipped_keypoints'): - flip_transform = CustomHorizontalFlip(cfg['flipped_keypoints']) + if cfg.get("flipped_keypoints"): + flip_transform = CustomHorizontalFlip(cfg["flipped_keypoints"]) else: flip_transform = A.HorizontalFlip() - - transform = A.Compose([ - flip_transform, - A.RandomScale(scale_limit=[-0.25, 0.25]), - A.RandomBrightnessContrast(p=0.5), - A.Rotate(limit=10), - A.MotionBlur(), - A.PixelDropout(), - A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]), + transform = A.Compose( + [ + flip_transform, + A.RandomScale(scale_limit=[-0.25, 0.25]), + A.RandomBrightnessContrast(p=0.5), + A.Rotate(limit=10), + A.MotionBlur(), + A.PixelDropout(), + A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ], - keypoint_params=A.KeypointParams(format='xy', remove_invisible=False) + keypoint_params=A.KeypointParams(format="xy", remove_invisible=False), ) - runBenchmark(path_dataset, 0.95, 1, transform = transform) \ No newline at end of file + runBenchmark(path_dataset, 0.95, 1, transform=transform) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py index 11fcd8c581..bb108b557c 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py @@ -3,26 +3,25 @@ import deeplabcut.pose_estimation_pytorch.models as dlc_models -def _get_keypoints(number_of_joints: int = 4, - axis: int = 2): + +def _get_keypoints(number_of_joints: int = 4, axis: int = 2): keypoints_torch = torch.Tensor(number_of_joints, axis) return keypoints_torch, number_of_joints def test_generate_heatmaps(): - keypoints_torch, number_of_joints = _get_keypoints() image_size = (256, 256) sigma = 5 heatmap_size = (64, 64) - heatmaps = dlc_models._generate_heatmaps(keypoints_torch, - heatmap_size, - image_size, - sigma=sigma) + heatmaps = dlc_models._generate_heatmaps( + keypoints_torch, heatmap_size, image_size, sigma=sigma + ) assert heatmaps.shape == (number_of_joints, heatmap_size[0], heatmap_size[1]) -#Create fake config dict for testing purposes + +# Create fake config dict for testing purposes def write_config(multianimal: bool) -> dict: cfg_file = {} if multianimal: # parameters specific to multianimal project @@ -75,4 +74,4 @@ def write_config(multianimal: bool) -> dict: cfg_file["alphavalue"] = 0.7 # for plots transparency of markers cfg_file["colormap"] = "rainbow" # for plots type of colormap - return cfg_file \ No newline at end of file + return cfg_file diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index 1be87846db..37819a92fb 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -41,31 +41,39 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> dict: A dictionary in COCO format containing the images, annotations, and categories. """ try: - individuals = df.columns.get_level_values( - 'individuals', - ).unique().tolist() + individuals = ( + df.columns.get_level_values( + "individuals", + ) + .unique() + .tolist() + ) except KeyError: new_cols = pd.MultiIndex.from_tuples( - [(col[0], 'animal', col[1], col[2]) for col in df.columns], - names=['scorer', 'individuals', 'bodyparts', 'coords'], + [(col[0], "animal", col[1], col[2]) for col in df.columns], + names=["scorer", "individuals", "bodyparts", "coords"], ) df.columns = new_cols - individuals = df.columns.get_level_values( - 'individuals', - ).unique().tolist() + individuals = ( + df.columns.get_level_values( + "individuals", + ) + .unique() + .tolist() + ) unique_bpts = [] - if 'single' in individuals: + if "single" in individuals: unique_bpts.extend( - df.xs('single', level='individuals', axis=1) - .columns.get_level_values('bodyparts') + df.xs("single", level="individuals", axis=1) + .columns.get_level_values("bodyparts") .unique(), ) multi_bpts = ( - df.xs(individuals[0], level='individuals', axis=1) - .columns.get_level_values('bodyparts') + df.xs(individuals[0], level="individuals", axis=1) + .columns.get_level_values("bodyparts") .unique() .tolist() ) @@ -73,19 +81,20 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> coco_categories = [] # assuming all individuals have the same name and same category id + # TODO: Should have 1 category ID for unique bodyparts and 1 for bodyparts individual = individuals[0] category = { - 'name': individual, - 'id': 0, - 'supercategory': 'animal', + "name": individual, + "id": 0, + "supercategory": "animal", } - if individual == 'single': - category['keypoints'] = unique_bpts + if individual == "single": + category["keypoints"] = unique_bpts else: - category['keypoints'] = multi_bpts + category["keypoints"] = multi_bpts coco_categories.append(category) @@ -107,7 +116,9 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> category_id = 1 # 0 is for background by default try: kpts = ( - data.xs(individual, level='individuals').to_numpy().reshape( + data.xs(individual, level="individuals") + .to_numpy() + .reshape( (-1, 2), ) ) @@ -115,7 +126,9 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> # somehow there are duplicates. So only use the first occurrence data = data.iloc[0] kpts = ( - data.xs(individual, level='individuals').to_numpy().reshape( + data.xs(individual, level="individuals") + .to_numpy() + .reshape( (-1, 2), ) ) @@ -145,15 +158,15 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> annotation_id += 1 annotation = { - 'image_id': image_id + image_id_offset, - 'num_keypoints': num_keypoints, - 'keypoints': keypoints, - 'id': annotation_id, - 'category_id': category_id, - 'individual': individual, - 'area': area, - 'bbox': bbox, - 'iscrowd': 0, + "image_id": image_id + image_id_offset, + "num_keypoints": num_keypoints, + "keypoints": keypoints, + "id": annotation_id, + "category_id": category_id, + "individual": individual, + "area": area, + "bbox": bbox, + "iscrowd": 0, } # adds an annotation even if no keypoint is annotated for the current individual @@ -171,17 +184,17 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> _, height, width = read_image_shape_fast(image_path) image = { - 'file_name': image_path, - 'width': width, - 'height': height, - 'id': image_id + image_id_offset, + "file_name": image_path, + "width": width, + "height": height, + "id": image_id + image_id_offset, } coco_images.append(image) ret_obj = { - 'images': coco_images, - 'annotations': coco_annotations, - 'categories': coco_categories, + "images": coco_images, + "annotations": coco_annotations, + "categories": coco_categories, } return ret_obj @@ -232,7 +245,9 @@ def is_seq_of(seq, expected_type, seq_type=None): def get_pytorch_config(modelfolder): pytorch_config_path = os.path.join( - modelfolder, 'train', 'pytorch_config.yaml', + modelfolder, + "train", + "pytorch_config.yaml", ) pytorch_cfg = read_plainconfig(pytorch_config_path) diff --git a/tests/pose_estimation_pytorch/runners/bottum_up.py b/tests/pose_estimation_pytorch/runners/bottum_up.py new file mode 100644 index 0000000000..d27aca6dab --- /dev/null +++ b/tests/pose_estimation_pytorch/runners/bottum_up.py @@ -0,0 +1,94 @@ +""" Tests for the bottom-up pytorch runner """ +from pathlib import Path +from typing import Dict, Any + +import pytest +import torch + +from deeplabcut.generate_training_dataset import make_pytorch_config +from deeplabcut.pose_estimation_pytorch.models import PoseModel, LOSSES, PREDICTORS +from deeplabcut.pose_estimation_pytorch.models.criterion import WeightedAggregateLoss +from deeplabcut.pose_estimation_pytorch.runners import RUNNERS +from deeplabcut.pose_estimation_pytorch.runners.schedulers import LRListScheduler +from deeplabcut.utils import auxiliaryfunctions + + +SINGLE_ANIMAL_NETS = ["resnet_50"] +MULTI_ANIMAL_NETS = ["dekr_w18"] +NETS = [(n, False) for n in SINGLE_ANIMAL_NETS] + [(n, True) for n in MULTI_ANIMAL_NETS] + + +def print_dict(data: Dict, indent: int = 0): + for k, v in data.items(): + if isinstance(v, dict): + print_dict(v, indent=indent + 2) + else: + print(f"{indent * ' '}{k}: {v}") + + +@pytest.mark.parametrize("net_type, multianimal", NETS) +def test_build_bottom_up_runner( + net_type: str, + multianimal: bool, +) -> None: + project_cfg: Dict[str, Any] = {"multianimalproject": multianimal} + if multianimal: + project_cfg["bodyparts"] = "MULTI!" + project_cfg["multianimalbodyparts"] = ["head", "shoulder", "knee", "toe"] + project_cfg["uniquebodyparts"] = [] + project_cfg["individuals"] = ["tom", "jerry"] + else: + project_cfg["bodyparts"] = ["head", "shoulder", "knee", "toe"] + project_cfg["uniquebodyparts"] = [] + project_cfg["individuals"] = ["tom"] + + root_path = Path(auxiliaryfunctions.get_deeplabcut_path()) + template_path = root_path / "pose_estimation_pytorch" / "apis" / "pytorch_config.yaml" + template = auxiliaryfunctions.read_plainconfig(str(template_path)) + pytorch_cfg = make_pytorch_config(project_cfg, net_type, config_template=template) + print_dict(pytorch_cfg) + + pose_model = PoseModel.from_cfg(pytorch_cfg["model"]) + + head_criterions = [] + for head_cfg in pytorch_cfg["model"]["heads"]: + crit_cfg = head_cfg["criterion"] + criterion_weight = crit_cfg.get("weight", 1) + criterion = LOSSES.build({k: v for k, v in crit_cfg.items() if k != "weight"}) + head_criterions.append((criterion_weight, criterion)) + criterion = WeightedAggregateLoss(head_criterions) + + get_optimizer = getattr(torch.optim, pytorch_cfg["optimizer"]["type"]) + optimizer = get_optimizer( + params=pose_model.parameters(), **pytorch_cfg["optimizer"]["params"] + ) + + predictor = PREDICTORS.build(dict(pytorch_cfg["model"]["predictor"])) + + if pytorch_cfg.get("scheduler"): + if pytorch_cfg["scheduler"]["type"] == "LRListScheduler": + _scheduler = LRListScheduler + else: + _scheduler = getattr( + torch.optim.lr_scheduler, pytorch_cfg["scheduler"]["type"] + ) + scheduler = _scheduler( + optimizer=optimizer, **pytorch_cfg["scheduler"]["params"] + ) + else: + scheduler = None + + logger = None + runner = RUNNERS.build( + dict( + **pytorch_cfg["solver"], + model=pose_model, + criterion=criterion, + optimizer=optimizer, + predictor=predictor, + cfg=pytorch_cfg, + device=pytorch_cfg["device"], + scheduler=scheduler, + logger=logger, + ) + ) From b67c3460d807faeb29137b18022925059be7023e Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Fri, 27 Oct 2023 15:58:28 +0200 Subject: [PATCH 052/293] PAF heads and DLCRNet * add PartAffinityFieldPredictor * add PAF generator --- .../make_pytorch_config.py | 174 ++++++--- .../pose_estimation_pytorch/apis/utils.py | 3 +- .../models/backbones/__init__.py | 2 +- .../models/backbones/resnet.py | 85 ++++- .../models/heads/__init__.py | 1 + .../models/heads/dlcrnet.py | 137 +++++++ .../models/predictors/__init__.py | 3 + .../models/predictors/paf_predictor.py | 352 ++++++++++++++++++ .../models/target_generators/__init__.py | 4 + .../models/target_generators/base.py | 28 +- .../models/target_generators/pafs_targets.py | 115 ++++++ .../tests/test_get_predictions.py | 4 +- .../tests/test_paf_targets.py | 27 ++ .../tests/test_seq_targets.py | 46 +++ .../lib/inferenceutils.py | 49 +++ 15 files changed, 961 insertions(+), 69 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 3ce46c7148..73c2933958 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -11,6 +11,7 @@ from __future__ import annotations import torch +from itertools import combinations from deeplabcut.utils import auxiliaryfunctions @@ -75,8 +76,9 @@ def make_pytorch_config( """ + # FIXME Handle gracefully models that apply to both single- and multi-animal setups single_animal_nets = [ - "resnet_50", + # "resnet_50", "mobilenet_v2_1.0", "mobilenet_v2_0.75", "mobilenet_v2_0.5", @@ -96,6 +98,7 @@ def make_pytorch_config( ] multi_animal_nets = [ + "resnet_50", "dekr_w18", "dekr_w32", "dekr_w48", @@ -153,6 +156,32 @@ def make_pytorch_config( num_unique_bpts, backbone_type ) + elif "resnet" in net_type: + dim = BACKBONE_OUT_CHANNELS["resnet-50"] + graph = [list(edge) for edge in combinations(range(num_joints), 2)] # TODO Parse from config + num_limbs = len(graph) + pytorch_config["model"]["backbone"] = {"type": "ResNet"} + pytorch_config["model"]["heads"] = { + "bodypart": make_dlcrnet_head( + num_joints, + num_unique_bpts, + num_individuals, + graph, + edges_to_keep=list( + pytorch_config.get("paf_best", list(range(num_limbs))) + ), + heatmap_channels=[dim, dim // 2, num_joints], + locref_channels=[dim, dim // 2, 2 * num_joints], + paf_channels=[dim, dim // 2, 2 * num_limbs], + # TODO Set remaining params from config + ) + } + # pytorch_config["data"]["crop_sampling"] = { + # "height": 400, + # "width": 400, + # "max_shift": 0.4, + # "method": "hybrid", + # } elif "token_pose" in net_type: if compute_unique_bpts: raise NotImplementedError( @@ -187,46 +216,91 @@ def make_pytorch_config( return pytorch_config -def make_heatmap_head( +def make_dlcrnet_head( num_joints: int, + num_unique_joints: int, + num_animals: int, + graph: list[tuple[int, int]], + edges_to_keep: list[int], heatmap_channels: list[int], locref_channels: list[int], + paf_channels: list[int], + locref_weight: float = 0.05, + paf_weight: float = 0.1, + paf_width: int = 20, + nms_radius: int = 5, + sigma: float = 1.0, + min_affinity: float = 0.05, ) -> dict: + dict_ = make_heatmap_head(num_joints, heatmap_channels, locref_channels) + dict_["type"] = "DLCRNetHead" + dict_["criterion"]["locref"]["weight"] = locref_weight + dict_["criterion"]["paf"] = {"type": "WeightedHuberCriterion", "weight": paf_weight} + n_deconv_layers = len(paf_channels) - 1 + dict_["paf_config"] = { + "channels": paf_channels, + "kernel_size": [3] * n_deconv_layers, + "strides": [2] * n_deconv_layers, + } + dict_["target_generator"] = { + "type": "SequentialGenerator", + "generators": [ + dict_["target_generator"], + {"type": "PartAffinityFieldGenerator", "graph": graph, "width": paf_width}, + ], + } + dict_["predictor"] = { + "type": "PartAffinityFieldPredictor", + "num_animals": num_animals, + "num_multibodyparts": num_joints, + "num_uniquebodyparts": num_unique_joints, + "nms_radius": nms_radius, + "sigma": sigma, + "locref_stdev": 7.2801, + "min_affinity": min_affinity, + "graph": graph, + "edges_to_keep": edges_to_keep, + } + return dict_ + + +def make_heatmap_head( + num_joints: int, heatmap_channels: list[int], locref_channels: list[int] +) -> dict: + n_deconv_heatmap = len(heatmap_channels) - 1 + n_deconv_locref = len(locref_channels) - 1 return { - "type": "HeatmapHead", - "predictor": { - "type": "SinglePredictor", - "location_refinement": True, - "locref_stdev": 7.2801, - "num_animals": 1, - }, - "target_generator": { - "type": "PlateauGenerator", - "locref_stdev": 7.2801, - "num_joints": num_joints, - "pos_dist_thresh": 17, - }, - "criterion": { - "heatmap": { - "type": "WeightedBCECriterion", - "weight": 1.0, - }, - "locref": { - "type": "WeightedHuberCriterion", # or WeightedMSECriterion - "weight": 0.03, - } - }, - "heatmap_config": { - "channels": heatmap_channels, - "kernel_size": [2, 2], - "strides": [2, 2], - }, - "locref_config": { - "channels": locref_channels, - "kernel_size": [2, 2], - "strides": [2, 2], + "type": "HeatmapHead", + "predictor": { + "type": "SinglePredictor", + "location_refinement": True, + "locref_stdev": 7.2801, + "num_animals": 1, + }, + "target_generator": { + "type": "PlateauGenerator", + "locref_stdev": 7.2801, + "num_joints": num_joints, + "pos_dist_thresh": 17, + }, + "criterion": { + "heatmap": {"type": "WeightedBCECriterion", "weight": 1.0}, + "locref": { + "type": "WeightedHuberCriterion", # or WeightedMSECriterion + "weight": 0.03, }, - } + }, + "heatmap_config": { + "channels": heatmap_channels, + "kernel_size": [3] * n_deconv_heatmap, + "strides": [2] * n_deconv_heatmap, + }, + "locref_config": { + "channels": locref_channels, + "kernel_size": [3] * n_deconv_locref, + "strides": [2] * n_deconv_locref, + }, + } def make_single_head_cfg(num_joints: int, net_type: str) -> dict: @@ -284,10 +358,7 @@ def make_unique_bodyparts_head(num_unique_bodyparts: int, backbone_type: str) -> def make_dekr_head_cfg( - num_individuals: int, - num_joints: int, - backbone_type: str, - num_offset_per_kpt: int, + num_individuals: int, num_joints: int, backbone_type: str, num_offset_per_kpt: int ): return { "type": "DEKRHead", @@ -298,14 +369,11 @@ def make_dekr_head_cfg( "bg_weight": 0.1, }, "criterion": { - "heatmap": { - "type": "WeightedBCECriterion", - "weight": 1, - }, + "heatmap": {"type": "WeightedBCECriterion", "weight": 1}, "offset": { "type": "WeightedHuberCriterion", # or WeightedMSECriterion "weight": 0.03, - } + }, }, "predictor": { "type": "DEKRPredictor", @@ -364,32 +432,24 @@ def make_token_pose_model_cfg(num_joints, backbone_type): "num_joints": num_joints, "pos_dist_thresh": 17, }, - "criterion": { - "type": "WeightedBCECriterion", - }, - "predictor": { - "type": "HeatmapOnlyPredictor", - "num_animals": 1, - }, + "criterion": {"type": "WeightedBCECriterion"}, + "predictor": {"type": "HeatmapOnlyPredictor", "num_animals": 1}, "dim": 192, "hidden_heatmap_dim": 384, "heatmap_dim": 4096, "apply_multi": True, "heatmap_size": [64, 64], "apply_init": True, - }, + } }, - "pose_model": {"stride": 4} + "pose_model": {"stride": 4}, } def make_detector_cfg(num_individuals: int): return { "model": {"type": "FasterRCNN"}, - "optimizer": { - "type": "AdamW", - "params": {"lr": 1e-4}, - }, + "optimizer": {"type": "AdamW", "params": {"lr": 1e-4}}, "scheduler": { "type": "LRListScheduler", "params": {"milestones": [90], "lr_list": [[1e-5]]}, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 316fd7ae92..386ebaa388 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -78,12 +78,11 @@ def build_scheduler( return scheduler(optimizer=optimizer, **scheduler_cfg["params"]) -def build_pose_model(cfg: dict, pytorch_cfg: dict) -> PoseModel: +def build_pose_model(pytorch_cfg: dict) -> PoseModel: """ TODO: Deprecated but still used in analyze_videos Args: - cfg : sub dict of the pytorch config that contains all information about the model pytorch_cfg : entire pytorch config Returns a pytorch pose model based on pytorch config diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py index b4e8aba9b4..6bbc95baf4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py @@ -13,4 +13,4 @@ BaseBackbone, ) from deeplabcut.pose_estimation_pytorch.models.backbones.hrnet import HRNet -from deeplabcut.pose_estimation_pytorch.models.backbones.resnet import ResNet +from deeplabcut.pose_estimation_pytorch.models.backbones.resnet import ResNet, DLCRNet diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index e47047ce90..6b18e72e7a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -10,6 +10,8 @@ # import timm import torch +import torch.nn as nn +from torchvision.transforms.functional import resize from deeplabcut.pose_estimation_pytorch.models.backbones.base import ( BACKBONES, @@ -27,7 +29,12 @@ class ResNet(BaseBackbone): model: the ResNet model """ - def __init__(self, model_name: str = "resnet50", pretrained: bool = True) -> None: + def __init__( + self, + model_name: str = "resnet50", + output_stride: int = 16, + pretrained: bool = True, + ) -> None: """Initialize the ResNet backbone. Args: @@ -35,7 +42,9 @@ def __init__(self, model_name: str = "resnet50", pretrained: bool = True) -> Non pretrained: If True, initializes with ImageNet pretrained weights. """ super().__init__() - self.model = timm.create_model(model_name, pretrained=pretrained) + self.model = timm.create_model( + model_name, output_stride=output_stride, pretrained=pretrained + ) def forward(self, x: torch.Tensor) -> torch.Tensor: """Forward pass through the ResNet backbone. @@ -54,6 +63,76 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: Expected Output Shape: If input size is (batch_size, 3, shape_x, shape_y), the output shape - will be (batch_size, 3, shape_x//32, shape_y//32) + will be (batch_size, 3, shape_x//16, shape_y//16) """ return self.model.forward_features(x) + + +@BACKBONES.register_module +class DLCRNet(ResNet): + def __init__( + self, + model_name: str = "resnet50", + output_stride: int = 16, + pretrained: bool = True, + ) -> None: + super().__init__(model_name, output_stride, pretrained) + self.interm_features = {} + self.model.layer1[2].register_forward_hook(self._get_features("bank1")) + self.model.layer2[2].register_forward_hook(self._get_features("bank2")) + self.conv_block1 = self._make_conv_block( + in_channels=512, out_channels=512, kernel_size=3, stride=2 + ) + self.conv_block2 = self._make_conv_block( + in_channels=512, out_channels=128, kernel_size=1, stride=1 + ) + self.conv_block3 = self._make_conv_block( + in_channels=256, out_channels=256, kernel_size=3, stride=2 + ) + self.conv_block4 = self._make_conv_block( + in_channels=256, out_channels=256, kernel_size=3, stride=2 + ) + self.conv_block5 = self._make_conv_block( + in_channels=256, out_channels=128, kernel_size=1, stride=1 + ) + + def _make_conv_block( + self, + in_channels: int, + out_channels: int, + kernel_size: int, + stride: int, + momentum: float = 0.001, # (1 - decay) + ) -> torch.nn.Sequential: + return nn.Sequential( + nn.Conv2d( + in_channels, out_channels, kernel_size=kernel_size, stride=stride + ), + nn.BatchNorm2d(out_channels, momentum=momentum), + nn.ReLU(), + ) + + def _get_features(self, name): + def hook(model, input, output): + self.interm_features[name] = output.detach() + + return hook + + def forward(self, x): + out = super().forward(x) + + # Fuse intermediate features + h, w = out.shape[-2:] + bank_2_s8 = self.interm_features["bank2"] + bank_1_s4 = self.interm_features["bank1"] + bank_2_s16 = self.conv_block1(bank_2_s8) + bank_2_s16 = self.conv_block2(bank_2_s16) + bank_1_s8 = self.conv_block3(bank_1_s4) + bank_1_s16 = self.conv_block4(bank_1_s8) + bank_1_s16 = self.conv_block5(bank_1_s16) + # Resizing here is required to guarantee all shapes match, as + # Conv2D(..., padding='same') is invalid for strided convolutions. + bank_1_s16 = resize(bank_1_s16, [h, w], antialias=False) + bank_2_s16 = resize(bank_2_s16, [h, w], antialias=False) + + return torch.cat((bank_1_s16, bank_2_s16, out), dim=1) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py index 4abab2a8de..beda47ff44 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py @@ -1,4 +1,5 @@ from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS, BaseHead from deeplabcut.pose_estimation_pytorch.models.heads.dekr import DEKRHead +from deeplabcut.pose_estimation_pytorch.models.heads.dlcrnet import DLCRNetHead from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import HeatmapHead from deeplabcut.pose_estimation_pytorch.models.heads.transformer import TransformerHead diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py new file mode 100644 index 0000000000..26a4fc9606 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py @@ -0,0 +1,137 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import torch +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.models.criterions import ( + BaseCriterion, + BaseLossAggregator, +) +from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS +from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import ( + HeatmapHead, + DeconvModule, +) +from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor +from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator + + +@HEADS.register_module +class DLCRNetHead(HeatmapHead): + """ """ + + def __init__( + self, + predictor: BasePredictor, + target_generator: BaseGenerator, + criterion: dict[str, BaseCriterion] | BaseCriterion, + aggregator: BaseLossAggregator | None, + heatmap_config: dict, + locref_config: dict, + paf_config: dict, + num_stages: int = 5, + features_dim: int = 128, + ) -> None: + in_channels = heatmap_config["channels"][0] + num_keypoints = heatmap_config["channels"][-1] + num_limbs = paf_config["channels"][-1] # Already has the 2x multiplier + in_refined_channels = features_dim + num_keypoints + num_limbs + heatmap_config["channels"][0] = paf_config["channels"][0] = in_refined_channels + locref_config["channels"][0] = locref_config["channels"][-1] + super().__init__( + predictor, + target_generator, + criterion, + aggregator, + heatmap_config, + locref_config, + ) + self.paf_head = DeconvModule(**paf_config) + + self.convt1 = self._make_layer_same_padding( + in_channels=in_channels, out_channels=num_keypoints + ) + self.convt2 = self._make_layer_same_padding( + in_channels=in_channels, out_channels=locref_config["channels"][-1] + ) + self.convt3 = self._make_layer_same_padding( + in_channels=in_channels, out_channels=num_limbs + ) + self.convt4 = self._make_layer_same_padding( + in_channels=in_channels, out_channels=features_dim + ) + self.hm_ref_layers = nn.ModuleList() + self.paf_ref_layers = nn.ModuleList() + for _ in range(num_stages): + self.hm_ref_layers.append( + self._make_refinement_layer( + in_channels=in_refined_channels, out_channels=num_keypoints + ) + ) + self.paf_ref_layers.append( + self._make_refinement_layer( + in_channels=in_refined_channels, out_channels=num_limbs + ) + ) + + def _make_layer_same_padding( + self, in_channels: int, out_channels: int + ) -> nn.ConvTranspose2d: + # FIXME There is no consensual solution to emulate TF behavior in pytorch + # see https://github.com/pytorch/pytorch/issues/3867 + return nn.ConvTranspose2d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=3, + stride=2, + padding=1, + output_padding=1, + ) + + def _make_refinement_layer(self, in_channels: int, out_channels: int) -> nn.Conv2d: + """Summary: + Helper function to create a refinement layer. + + Args: + in_channels: number of input channels + out_channels: number of output channels + + Returns: + refinement_layer: the refinement layer. + """ + return nn.Conv2d( + in_channels, out_channels, kernel_size=3, stride=1, padding="same" + ) + + def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: + stage1_hm_out = self.convt1(x) + stage1_paf_out = self.convt3(x) + features = self.convt4(x) + stage2_in = torch.cat((stage1_hm_out, stage1_paf_out, features), dim=1) + stage_in = stage2_in + stage_paf_out = stage1_paf_out + stage_hm_out = stage1_hm_out + for i, (hm_ref_layer, paf_ref_layer) in enumerate( + zip(self.hm_ref_layers, self.paf_ref_layers) + ): + pre_stage_hm_out = stage_hm_out + stage_hm_out = hm_ref_layer(stage_in) + stage_paf_out = paf_ref_layer(stage_in) + if i > 0: + stage_hm_out += pre_stage_hm_out + stage_in = torch.cat((stage_hm_out, stage_paf_out, features), dim=1) + return { + "heatmap": self.heatmap_head(stage_in), + "locref": self.locref_head(self.convt2(x)), + "paf": self.paf_head(stage_in), + } diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index 23369a5880..786ef049b6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -15,6 +15,9 @@ from deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor import ( DEKRPredictor, ) +from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import ( + PartAffinityFieldPredictor, +) from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import ( SinglePredictor, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py new file mode 100644 index 0000000000..546970aeeb --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -0,0 +1,352 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import numpy as np +import torch +import torch.nn.functional as F +from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( + PREDICTORS, + BasePredictor, +) +from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils + + +@PREDICTORS.register_module +class PartAffinityFieldPredictor(BasePredictor): + """Predictor class for multiple animal pose estimation with part affinity fields. + + Args: + num_animals: Number of animals in the project. + num_multibodyparts: Number of animal's body parts (ignoring unique body parts). + num_uniquebodyparts: Number of unique body parts. + graph: Part affinity field graph edges. + edges_to_keep: List of indices in `graph` of the edges to keep. + locref_stdev: Standard deviation for location refinement. + nms_radius: Radius of the Gaussian kernel. + sigma: Width of the 2D Gaussian distribution. + min_affinity: Minimal edge affinity to add a body part to an Assembly. + + Returns: + Regressed keypoints from heatmaps, locref_maps and part affinity fields, as in Tensorflow maDLC. + """ + + default_init = { + "locref_stdev": 7.2801, + "nms_radius": 5, + "sigma": 1, + "min_affinity": 0.05, + } + + def __init__( + self, + num_animals: int, + num_multibodyparts: int, + num_uniquebodyparts: int, + graph: list[tuple[int, int]], + edges_to_keep: list[int], + locref_stdev: float, + nms_radius: int, + sigma: float, + min_affinity: float, + ): + """Initialize the PartAffinityFieldPredictor class. + + Args: + num_animals: Number of animals in the project. + num_multibodyparts: Number of animal's body parts (ignoring unique body parts). + num_uniquebodyparts: Number of unique body parts. + graph: Part affinity field graph edges. + edges_to_keep: List of indices in `graph` of the edges to keep. + locref_stdev: Standard deviation for location refinement. + nms_radius: Radius of the Gaussian kernel. + sigma: Width of the 2D Gaussian distribution. + min_affinity: Minimal edge affinity to add a body part to an Assembly. + + Returns: + None + """ + super().__init__() + self.num_animals = num_animals + self.num_multibodyparts = num_multibodyparts + self.num_uniquebodyparts = num_uniquebodyparts + self.graph = graph + self.edges_to_keep = edges_to_keep + self.locref_stdev = locref_stdev + self.nms_radius = nms_radius + self.sigma = sigma + self.sigmoid = torch.nn.Sigmoid() + self.assembler = inferenceutils.Assembler.empty( + num_animals, + n_multibodyparts=num_multibodyparts, + n_uniquebodyparts=num_uniquebodyparts, + graph=graph, + paf_inds=edges_to_keep, + min_affinity=min_affinity, + ) + + def forward( + self, + inputs: torch.Tensor, + outputs: dict[str, torch.Tensor], + ) -> dict[str, torch.Tensor]: + """Forward pass of PartAffinityFieldPredictor. Gets predictions from model output. + + Args: + output: Output tensors from previous layers. + output = heatmaps, locref, pafs + heatmaps: torch.Tensor([batch_size, num_joints, height, width]) + locref: torch.Tensor([batch_size, num_joints, height, width]) + + Returns: + A dictionary containing a "poses" key with the output tensor as value. + + Example: + >>> predictor = PartAffinityFieldPredictor(num_animals=3, location_refinement=True, locref_stdev=7.2801) + >>> output = (torch.rand(32, 17, 64, 64), torch.rand(32, 34, 64, 64), torch.rand(32, 136, 64, 64)) + >>> scale_factors = (0.5, 0.5) + >>> poses = predictor.forward(output, scale_factors) + """ + heatmaps = outputs["heatmap"] + locrefs = outputs["locref"] + pafs = outputs["pafs"] + h_in, w_in = inputs.shape[2:] + h_out, w_out = heatmaps.shape[2:] + scale_factors = h_in / h_out, w_in / w_out + batch_size, n_channels, height, width = heatmaps.shape + heatmaps = self.sigmoid(heatmaps) + + # Filter predicted heatmaps with a 2D Gaussian kernel as in: + # https://openaccess.thecvf.com/content_CVPR_2020/papers/Huang_The_Devil_Is_in_the_Details_Delving_Into_Unbiased_Data_CVPR_2020_paper.pdf + kernel = self.make_2d_gaussian_kernel( + sigma=self.sigma, + size=self.nms_radius * 2 + 1, + )[None, None] + kernel = kernel.repeat(n_channels, 1, 1, 1).to(heatmaps.device) + heatmaps = F.conv2d( + heatmaps, + kernel, + stride=1, + padding='same', + groups=n_channels, + ) + + peaks = self.find_local_peak_indices_maxpool_nms( + heatmaps, + self.nms_radius, + threshold=0.01, + ) + if ~torch.any(peaks): + return {"poses": []} + + locrefs = locrefs.reshape(batch_size, n_channels, 2, height, width) + locrefs *= self.locref_stdev + pafs = pafs.reshape(batch_size, -1, 2, height, width) + + graph = [self.graph[ind] for ind in self.edges_to_keep] + preds = self.compute_peaks_and_costs( + heatmaps, + locrefs, + pafs, + peaks, + graph, + self.edges_to_keep, + scale_factors, + n_id_channels=0, # FIXME Handle identity training + ) + poses = torch.empty((batch_size, self.num_animals, self.num_multibodyparts, 4)) + poses_unique = torch.empty((batch_size, 1, self.num_uniquebodyparts, 4)) + for i, data_dict in enumerate(preds): + assemblies, unique = self.assembler._assemble(data_dict, ind_frame=0) + for j, assembly in enumerate(assemblies): + poses[i, j] = torch.from_numpy(assembly.data) + if unique is not None: + poses_unique[i, 0] = torch.from_numpy(unique) + + return {"poses": poses, "unique_bodyparts": {"poses": poses_unique}} + + @staticmethod + def find_local_peak_indices_maxpool_nms(input_, radius, threshold): + pooled = F.max_pool2d(input_, kernel_size=radius, stride=1, padding=radius // 2) + maxima = input_ * torch.eq(input_, pooled).float() + peak_indices = torch.nonzero(maxima >= threshold, as_tuple=False) + return peak_indices.int() + + @staticmethod + def make_2d_gaussian_kernel(sigma, size): + k = torch.arange(-size // 2 + 1, size // 2 + 1, dtype=torch.float32) ** 2 + k = F.softmax(-k / (2 * (sigma ** 2)), dim=0) + return torch.einsum("i,j->ij", k, k) + + @staticmethod + def calc_peak_locations( + locrefs, + peak_inds_in_batch, + strides, + n_decimals=3, + ): + s, b, r, c = peak_inds_in_batch.T + stride_y, stride_x = strides + strides = torch.Tensor((stride_x, stride_y)).to(locrefs.device) + off = locrefs[s, b, :, r, c] + loc = strides * peak_inds_in_batch[:, [3, 2]] + strides // 2 + off + return torch.round(loc, decimals=n_decimals) + + def compute_edge_costs( + self, + pafs, + peak_inds_in_batch, + graph, + paf_inds, + n_bodyparts, + n_points=10, + n_decimals=3, + ): + # Clip peak locations to PAFs dimensions + h, w = pafs.shape[-2:] + peak_inds_in_batch[:, 2] = np.clip(peak_inds_in_batch[:, 2], 0, h - 1) + peak_inds_in_batch[:, 3] = np.clip(peak_inds_in_batch[:, 3], 0, w - 1) + + n_samples = pafs.shape[0] + sample_inds = [] + edge_inds = [] + all_edges = [] + all_peaks = [] + for i in range(n_samples): + samples_i = peak_inds_in_batch[:, 0] == i + peak_inds = peak_inds_in_batch[samples_i, 1:] + if not np.any(peak_inds): + continue + peaks = peak_inds[:, 1:] + bpt_inds = peak_inds[:, 0] + idx = np.arange(peaks.shape[0]) + idx_per_bpt = {j: idx[bpt_inds == j].tolist() for j in range(n_bodyparts)} + edges = [] + for k, (s, t) in zip(paf_inds, graph): + inds_s = idx_per_bpt[s] + inds_t = idx_per_bpt[t] + if not (inds_s and inds_t): + continue + candidate_edges = ((i, j) for i in inds_s for j in inds_t) + edges.extend(candidate_edges) + edge_inds.extend([k] * len(inds_s) * len(inds_t)) + if not edges: + continue + sample_inds.extend([i] * len(edges)) + all_edges.extend(edges) + all_peaks.append(peaks[np.asarray(edges)]) + if not all_peaks: + return [dict() for _ in range(n_samples)] + + sample_inds = np.asarray(sample_inds, dtype=np.int32) + edge_inds = np.asarray(edge_inds, dtype=np.int32) + all_edges = np.asarray(all_edges, dtype=np.int32) + all_peaks = np.concatenate(all_peaks) + vecs_s = all_peaks[:, 0] + vecs_t = all_peaks[:, 1] + vecs = vecs_t - vecs_s + lengths = np.linalg.norm(vecs, axis=1).astype(np.float32) + lengths += np.spacing(1, dtype=np.float32) + xy = np.linspace(vecs_s, vecs_t, n_points, axis=1, dtype=np.int32) + y = pafs[ + sample_inds.reshape((-1, 1)), + edge_inds.reshape((-1, 1)), + :, + xy[..., 0], + xy[..., 1], + ] + integ = np.trapz(y, xy[..., ::-1], axis=1) + affinities = np.linalg.norm(integ, axis=1).astype(np.float32) + affinities /= lengths + np.round(affinities, decimals=n_decimals, out=affinities) + np.round(lengths, decimals=n_decimals, out=lengths) + + # Form cost matrices + all_costs = [] + for i in range(n_samples): + samples_i_mask = sample_inds == i + costs = dict() + for k in paf_inds: + edges_k_mask = edge_inds == k + idx = np.flatnonzero(samples_i_mask & edges_k_mask) + s, t = all_edges[idx].T + n_sources = np.unique(s).size + n_targets = np.unique(t).size + costs[k] = dict() + costs[k]["m1"] = affinities[idx].reshape((n_sources, n_targets)) + costs[k]["distance"] = lengths[idx].reshape((n_sources, n_targets)) + all_costs.append(costs) + + return all_costs + + @staticmethod + def _linspace(start: torch.Tensor, stop: torch.Tensor, num: int) -> torch.Tensor: + # Taken from https://github.com/pytorch/pytorch/issues/61292#issue-937937159 + steps = torch.linspace(0, 1, num, dtype=torch.float32, device=start.device) + steps = steps.reshape([-1, *([1] * start.ndim)]) + out = start[None] + steps * (stop - start)[None] + return out.swapaxes(0, 1) + + def compute_peaks_and_costs( + self, + heatmaps, + locrefs, + pafs, + peak_inds_in_batch, + graph, + paf_inds, + strides, + n_id_channels, + n_points=10, + n_decimals=3, + ): + n_samples, n_channels = heatmaps.shape[:2] + n_bodyparts = n_channels - n_id_channels + pos = self.calc_peak_locations(locrefs, peak_inds_in_batch, strides, n_decimals) + pos = pos.detach().cpu().numpy() + heatmaps = heatmaps.detach().cpu().numpy() + pafs = pafs.detach().cpu().numpy() + peak_inds_in_batch = peak_inds_in_batch.detach().cpu().numpy() + costs = self.compute_edge_costs( + pafs, + peak_inds_in_batch, + graph, + paf_inds, + n_bodyparts, + n_points, + n_decimals, + ) + s, b, r, c = peak_inds_in_batch.T + prob = np.round(heatmaps[s, b, r, c], n_decimals).reshape((-1, 1)) + if n_id_channels: + ids = np.round(heatmaps[s, -n_id_channels:, r, c], n_decimals) + + peaks_and_costs = [] + for i in range(n_samples): + xy = [] + p = [] + id_ = [] + samples_i_mask = peak_inds_in_batch[:, 0] == i + for j in range(n_bodyparts): + bpts_j_mask = peak_inds_in_batch[:, 1] == j + idx = np.flatnonzero(samples_i_mask & bpts_j_mask) + xy.append(pos[idx]) + p.append(prob[idx]) + if n_id_channels: + id_.append(ids[idx]) + dict_ = {"coordinates": (xy,), "confidence": p} + if costs is not None: + dict_["costs"] = costs[i] + if n_id_channels: + dict_["identity"] = id_ + peaks_and_costs.append(dict_) + + return peaks_and_costs diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py index 7da5e4af20..2d33aad033 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py @@ -11,6 +11,7 @@ from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( TARGET_GENERATORS, BaseGenerator, + SequentialGenerator, ) from deeplabcut.pose_estimation_pytorch.models.target_generators.dekr_targets import ( DEKRGenerator, @@ -18,6 +19,9 @@ from deeplabcut.pose_estimation_pytorch.models.target_generators.gaussian_targets import ( GaussianGenerator, ) +from deeplabcut.pose_estimation_pytorch.models.target_generators.pafs_targets import ( + PartAffinityFieldGenerator, +) from deeplabcut.pose_estimation_pytorch.models.target_generators.plateau_targets import ( PlateauGenerator, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py index 42d30ce81f..e2a7b8943f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -40,10 +40,7 @@ def __init__(self, label_keypoint_key: str = "keypoints"): @abstractmethod def forward( - self, - inputs: torch.Tensor, - outputs: dict[str, torch.Tensor], - labels: dict, + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict ) -> dict[str, dict[str, torch.Tensor]]: """Generates targets @@ -65,3 +62,26 @@ def forward( } } """ + + +@TARGET_GENERATORS.register_module +class SequentialGenerator(BaseGenerator): + def __init__(self, generators: list[dict], label_keypoint_key: str = "keypoints"): + super().__init__(label_keypoint_key) + self._generators = [TARGET_GENERATORS.build(dict_) for dict_ in generators] + + @property + def generators(self): + return self._generators + + def forward( + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict + ) -> dict[str, dict[str, torch.Tensor]]: + dict_ = {} + for gen in self.generators: + dict_.update(gen(inputs, outputs, labels)) + return dict_ + + def __repr__(self): + generators_repr = ", ".join(repr(gen) for gen in self._generators) + return f"<{self.__class__.__name__}(generators=[{generators_repr}], label_keypoint_key='{self.label_keypoint_key}')>" diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py new file mode 100644 index 0000000000..b61a0fc85a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py @@ -0,0 +1,115 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +from math import sqrt + +import numpy as np +import torch +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( + TARGET_GENERATORS, + BaseGenerator, +) + + +@TARGET_GENERATORS.register_module +class PartAffinityFieldGenerator(BaseGenerator): + """ + Generate part affinity field targets from ground truth keypoints in order + to train baseline multi-animal deeplabcut model (ResNet + Deconv) + """ + + def __init__(self, graph: list[list[int, int]], width: float): + """Summary: + Constructor of the PartAffinityFieldGenerator class. + Loads the data. + + Args: + graph: list of pairs of keypoint indices forming + the graph edges + width: width of the vector field in pixels + + Returns: + None + + Examples: + input: + graph = [(0, 1), (0, 2), (1, 2)] + width = 20.0, default value in pytorch config + """ + super().__init__() + self.graph = graph + self.width = width + self.num_limbs = len(graph) + + def forward( + self, + inputs: torch.Tensor, + outputs: dict[str, torch.Tensor], + labels: dict, + ) -> dict[str, dict[str, torch.Tensor]]: + batch_size, _, input_h, input_w = inputs.shape + height, width = outputs["heatmap"].shape[2:] + stride_y, stride_x = input_h / height, input_w / width + coords = labels[self.label_keypoint_key].cpu().numpy() + + partaffinityfield_map = np.zeros( + (batch_size, height, width, self.num_limbs * 2), dtype=np.float32 + ) + grid = np.mgrid[:height, :width].transpose((1, 2, 0)) + grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 + grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 + y, x = np.rollaxis(grid, 2) + + for b in range(batch_size): + for _, kpts_animal in enumerate(coords[b]): + visible = set(np.flatnonzero(np.any(kpts_animal > 0.0, axis=1))) + for l, (bp1, bp2) in enumerate(self.graph): + if not (bp1 in visible and bp2 in visible): + continue + + j1_x, j1_y = kpts_animal[bp1] + j2_x, j2_y = kpts_animal[bp2] + vec_x = j2_x - j1_x + vec_y = j2_y - j1_y + dist = sqrt(vec_x**2 + vec_y**2) + if dist > 0: + vec_x_norm = vec_x / dist + vec_y_norm = vec_y / dist + vec = [ + vec_x_norm * j1_x + vec_y_norm * j1_y, + vec_x_norm * j2_x + vec_y_norm * j2_y, + ] + vec_ortho = j1_y * vec_x_norm - j1_x * vec_y_norm + + distance_along = vec_x_norm * x + vec_y_norm * y + distance_across = ( + ((y * vec_x_norm - x * vec_y_norm) - vec_ortho) + * 1.0 + / self.width + ) + + mask1 = (distance_along >= min(vec)) & ( + distance_along <= max(vec) + ) + distance_across_abs = np.abs(distance_across) + mask2 = distance_across_abs <= 1 + mask = mask1 & mask2 + temp = 1 - distance_across_abs[mask] + partaffinityfield_map[b, mask, l * 2 + 0] = vec_x_norm * temp + partaffinityfield_map[b, mask, l * 2 + 1] = vec_y_norm * temp + + partaffinityfield_map = partaffinityfield_map.transpose(0, 3, 1, 2) + return { + "paf": { + "target": torch.tensor(partaffinityfield_map, device=outputs["paf"].device), + }, + } diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py index c0b81a336c..ace419eabe 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py @@ -55,7 +55,7 @@ def test_get_predictions_bottom_up( # Pretrained set to False to initialize model without using a snapshot pytorch_config["model"]["backbone"]["pretrained"] = False # build model - model = utils.build_pose_model(pytorch_config["model"], pytorch_config) + model = utils.build_pose_model(pytorch_config) # build predictor predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config["predictor"])) @@ -101,7 +101,7 @@ def test_get_predicitons_top_down( dict(pytorch_config["detector"]["detector_model"]) ) # build model - model = utils.build_pose_model(pytorch_config["model"], pytorch_config) + model = utils.build_pose_model(pytorch_config) # build predictors top_down_predictor: BasePredictor = PREDICTORS.build( diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py new file mode 100644 index 0000000000..82ec5183c9 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py @@ -0,0 +1,27 @@ +import pytest +import torch +from deeplabcut.pose_estimation_pytorch.models.target_generators import pafs_targets + + +@pytest.mark.parametrize( + "batch_size, num_keypoints, image_size", + [(2, 2, (64, 64)), (1, 5, (48, 64)), (8, 50, (64, 48))], +) +def test_paf_target_generation( + batch_size: int, num_keypoints: int, image_size: tuple, num_animals=2, +): + annotations = { + "keypoints": torch.randint( + 1, min(image_size), (batch_size, num_animals, num_keypoints, 2) + ) + } # 2 for x,y coords + prediction = [torch.rand((batch_size, num_keypoints, image_size[0], image_size[1]))] + graph = [(i, j) for i in range(num_keypoints) for j in range(i + 1, num_keypoints)] + generator = pafs_targets.PartAffinityFieldGenerator(graph=graph, width=20) + targets_output = generator(annotations, prediction, image_size) + assert targets_output["paf"]["target"].shape == ( + batch_size, + len(graph) * 2, + image_size[0], + image_size[1], + ) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py new file mode 100644 index 0000000000..417360929f --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py @@ -0,0 +1,46 @@ +import torch +from itertools import combinations +from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS + + +def test_sequential_generator(): + batch_size = 4 + image_size = 256, 256 + num_keypoints = 12 + num_animals = 2 + graph = [list(edge) for edge in combinations(range(num_keypoints), 2)] + num_limbs = len(graph) + cfg = { + "type": "SequentialGenerator", + "generators": [ + { + "type": "PlateauGenerator", + "locref_stdev": 7.2801, + "num_joints": num_keypoints, + "pos_dist_thresh": 17, + }, + { + "type": "PartAffinityFieldGenerator", + "graph": graph, + "width": 20, + }, + ] + } + gen = TARGET_GENERATORS.build(cfg) + + annotations = { + "keypoints": torch.randint( + 1, min(image_size), (batch_size, num_animals, num_keypoints, 2) + ) + } + prediction = [torch.rand((batch_size, num_keypoints, image_size[0], image_size[1]))] + inputs = torch.rand(batch_size, 3, *image_size) + head_outputs = { + 'heatmap': torch.rand(batch_size, num_keypoints, 32, 32), + 'locref': torch.rand(batch_size, num_keypoints * 2, 32, 32), + 'paf': torch.rand(batch_size, num_limbs * 2, 32, 32), + } + out = gen(inputs=inputs, outputs=head_outputs, labels=annotations) + assert all(s in out for s in list(head_outputs)) + for k, v in head_outputs.items(): + assert out[k]['target'].shape == v.shape diff --git a/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py b/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py index 8a7dc4a690..92bac71ace 100644 --- a/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py +++ b/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py @@ -276,6 +276,55 @@ def __init__( def __getitem__(self, item): return self.data[self.metadata["imnames"][item]] + @classmethod + def empty( + cls, + max_n_individuals, + n_multibodyparts, + n_uniquebodyparts, + graph, + paf_inds, + greedy=False, + pcutoff=0.1, + min_affinity=0.05, + min_n_links=2, + max_overlap=0.8, + identity_only=False, + nan_policy="little", + force_fusion=False, + add_discarded=False, + window_size=0, + method="m1", + ): + # Dummy data + n_bodyparts = n_multibodyparts + n_uniquebodyparts + data = { + "metadata": { + "all_joints_names": ["" for _ in range(n_bodyparts)], + "PAFgraph": graph, + "PAFinds": paf_inds, + }, + "0": {}, + } + return cls( + data, + max_n_individuals=max_n_individuals, + n_multibodyparts=n_multibodyparts, + graph=graph, + paf_inds=paf_inds, + greedy=greedy, + pcutoff=pcutoff, + min_affinity=min_affinity, + min_n_links=min_n_links, + max_overlap=max_overlap, + identity_only=identity_only, + nan_policy=nan_policy, + force_fusion=force_fusion, + add_discarded=add_discarded, + window_size=window_size, + method=method, + ) + @property def n_keypoints(self): return self.metadata["num_joints"] From 0f64daaefa039c37f82f59a9918a22522dd2479c Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 8 Nov 2023 10:54:29 +0100 Subject: [PATCH 053/293] criterions bug fix --- deeplabcut/pose_estimation_pytorch/models/criterions/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/base.py b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py index 2627e82d19..c4aec84983 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py @@ -26,6 +26,10 @@ def __init__(self, apply_sigmoid: bool = False) -> None: super().__init__() self.apply_sigmoid = apply_sigmoid + def __init__(self, apply_sigmoid: bool = False) -> None: + super().__init__() + self.apply_sigmoid = apply_sigmoid + @abstractmethod def forward( self, output: torch.Tensor, target: torch.Tensor, **kwargs From 1e52abeec7bf20555cff318068393d4958fd63fe Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Thu, 9 Nov 2023 13:32:31 +0100 Subject: [PATCH 054/293] PAF and DLCRNet improvements --- .../make_pytorch_config.py | 101 ++++++++-------- .../apis/analyze_videos.py | 50 ++------ .../pose_estimation_pytorch/apis/config.yaml | 1 - .../apis/convert_detections_to_tracklets.py | 9 +- .../pose_estimation_pytorch/apis/evaluate.py | 13 +- .../pose_estimation_pytorch/apis/inference.py | 25 ++-- .../pose_estimation_pytorch/apis/scoring.py | 34 ++---- .../pose_estimation_pytorch/apis/train.py | 10 +- .../pose_estimation_pytorch/apis/utils.py | 31 ++--- .../pose_estimation_pytorch/data/base.py | 20 ++-- .../data/cocoloader.py | 6 +- .../pose_estimation_pytorch/data/dataset.py | 46 +++---- .../pose_estimation_pytorch/data/dlcloader.py | 36 ++---- .../pose_estimation_pytorch/data/helper.py | 9 +- .../data/postprocessor.py | 12 +- .../data/preprocessor.py | 10 +- .../data/transforms.py | 3 +- .../pose_estimation_pytorch/data/utils.py | 113 ++++-------------- .../pose_estimation_pytorch/default_config.py | 21 +--- .../models/backbones/base.py | 3 +- .../models/backbones/resnet.py | 10 +- .../models/criterions/aggregators.py | 2 +- .../models/criterions/base.py | 11 +- .../models/criterions/utils.py | 40 +++++++ .../models/criterions/weighted.py | 33 +++-- .../models/detectors/base.py | 8 +- .../models/detectors/fasterRCNN.py | 6 +- .../models/heads/base.py | 3 +- .../models/heads/dekr.py | 16 +-- .../models/heads/dlcrnet.py | 55 +++++---- .../models/heads/simple_head.py | 7 +- .../models/heads/transformer.py | 2 +- .../pose_estimation_pytorch/models/model.py | 12 +- .../models/modules/conv_block.py | 3 +- .../models/necks/base.py | 3 +- .../models/necks/layers.py | 2 +- .../models/necks/transformer.py | 5 +- .../models/predictors/base.py | 7 +- .../models/predictors/dekr_predictor.py | 6 +- .../models/predictors/paf_predictor.py | 46 +++---- .../models/predictors/single_predictor.py | 11 +- .../models/predictors/top_down_prediction.py | 3 +- .../models/target_generators/base.py | 4 +- .../models/target_generators/dekr_targets.py | 42 +++---- .../target_generators/gaussian_targets.py | 14 +-- .../models/target_generators/pafs_targets.py | 16 +-- .../target_generators/plateau_targets.py | 26 ++-- .../match_predictions_to_gt.py | 12 +- .../pose_estimation_pytorch/runners/base.py | 20 +--- .../pose_estimation_pytorch/runners/logger.py | 7 +- .../pose_estimation_pytorch/runners/pose.py | 18 +-- .../runners/top_down.py | 12 +- .../pose_estimation_pytorch/runners/utils.py | 62 ++-------- .../tests/test_api_utils.py | 39 ++---- .../tests/test_data_helper.py | 19 +-- .../tests/test_gaussian_targets.py | 1 + .../tests/test_get_predictions.py | 16 +-- .../tests/test_paf_targets.py | 5 +- .../tests/test_plateau_targets.py | 3 +- .../tests/test_pose_model.py | 34 ++---- .../tests/test_schedulers.py | 3 +- .../tests/test_seq_targets.py | 24 ++-- .../tests/test_single_animal.py | 15 +-- .../tests/test_transforms.py | 20 +--- .../tests/test_utils.py | 4 +- deeplabcut/pose_estimation_pytorch/utils.py | 45 ++----- 66 files changed, 462 insertions(+), 843 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/models/criterions/utils.py diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 73c2933958..1f5545aa50 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -11,6 +11,7 @@ from __future__ import annotations import torch +from copy import deepcopy from itertools import combinations from deeplabcut.utils import auxiliaryfunctions @@ -35,6 +36,31 @@ "hrnet_w32": 480, "hrnet_w48": 720, } +SUPPORTED_MODELS = ( + "resnet_50", + "mobilenet_v2_1.0", + "mobilenet_v2_0.75", + "mobilenet_v2_0.5", + "mobilenet_v2_0.35", + "efficientnet-b0", + "efficientnet-b1", + "efficientnet-b2", + "efficientnet-b3", + "efficientnet-b4", + "efficientnet-b5", + "efficientnet-b6", + "efficientnet-b7", + "efficientnet-b8", + "hrnet_w18", + "hrnet_w32", + "hrnet_w48", + "dekr_w18", + "dekr_w32", + "dekr_w48", + "token_pose_w18", + "token_pose_w32", + "token_pose_w48", +) def make_pytorch_config( @@ -75,37 +101,8 @@ def make_pytorch_config( - token_pose_w48 """ - - # FIXME Handle gracefully models that apply to both single- and multi-animal setups - single_animal_nets = [ - # "resnet_50", - "mobilenet_v2_1.0", - "mobilenet_v2_0.75", - "mobilenet_v2_0.5", - "mobilenet_v2_0.35", - "efficientnet-b0", - "efficientnet-b1", - "efficientnet-b2", - "efficientnet-b3", - "efficientnet-b4", - "efficientnet-b5", - "efficientnet-b6", - "efficientnet-b7", - "efficientnet-b8", - "hrnet_w18", - "hrnet_w32", - "hrnet_w48", - ] - - multi_animal_nets = [ - "resnet_50", - "dekr_w18", - "dekr_w32", - "dekr_w48", - "token_pose_w18", - "token_pose_w32", - "token_pose_w48", - ] + if net_type not in SUPPORTED_MODELS: + raise ValueError(f"Unsupported network {net_type}.") bodyparts = auxiliaryfunctions.get_bodyparts(project_config) num_joints = len(bodyparts) @@ -113,22 +110,10 @@ def make_pytorch_config( num_unique_bpts = len(unique_bpts) compute_unique_bpts = num_unique_bpts > 0 - pytorch_config = config_template + pytorch_config = deepcopy(config_template) pytorch_config["device"] = "cuda" if torch.cuda.is_available() else "cpu" pytorch_config["method"] = "bu" - if net_type in single_animal_nets: - pytorch_config["model"]["heads"] = { - "bodypart": make_single_head_cfg(num_joints, net_type), - } - - if "efficientnet" in net_type: - raise NotImplementedError("efficientnet config not yet implemented") - elif "mobilenetv2" in net_type: - raise NotImplementedError("mobilenet config not yet implemented") - elif "hrnet" in net_type: - raise NotImplementedError("hrnet config not yet implemented") - - elif net_type in multi_animal_nets: + if pytorch_config.get("multianimal", False): num_individuals = len(project_config.get("individuals", [0])) if "dekr" in net_type: version = net_type.split("_")[-1] @@ -157,8 +142,11 @@ def make_pytorch_config( ) elif "resnet" in net_type: - dim = BACKBONE_OUT_CHANNELS["resnet-50"] - graph = [list(edge) for edge in combinations(range(num_joints), 2)] # TODO Parse from config + num_stages = pytorch_config.get("num_stages", 0) + dim = BACKBONE_OUT_CHANNELS["resnet-50"] if num_stages == 0 else 2304 + graph = [ + list(edge) for edge in combinations(range(num_joints), 2) + ] # TODO Parse from config num_limbs = len(graph) pytorch_config["model"]["backbone"] = {"type": "ResNet"} pytorch_config["model"]["heads"] = { @@ -170,9 +158,11 @@ def make_pytorch_config( edges_to_keep=list( pytorch_config.get("paf_best", list(range(num_limbs))) ), + # TODO Below is hardcoded for output stride 32; heatmap_channels=[dim, dim // 2, num_joints], locref_channels=[dim, dim // 2, 2 * num_joints], paf_channels=[dim, dim // 2, 2 * num_limbs], + num_stages=num_stages, # TODO Set remaining params from config ) } @@ -200,11 +190,19 @@ def make_pytorch_config( pytorch_config["with_center_keypoints"] = False else: raise NotImplementedError( - "Currently no other model than dekr and token_pose are implemented" + "Currently no other model than dlcrnet, dekr, and token_pose are implemented" ) - else: - raise ValueError("This net type is not supported by DeepLabCut PyTorch") + pytorch_config["model"]["heads"] = { + "bodypart": make_single_head_cfg(num_joints, net_type) + } + + if "efficientnet" in net_type: + raise NotImplementedError("efficientnet config not yet implemented") + elif "mobilenetv2" in net_type: + raise NotImplementedError("mobilenet config not yet implemented") + elif "hrnet" in net_type: + raise NotImplementedError("hrnet config not yet implemented") if augmenter_type == None: pytorch_config["data"] = {} @@ -231,6 +229,7 @@ def make_dlcrnet_head( nms_radius: int = 5, sigma: float = 1.0, min_affinity: float = 0.05, + num_stages: int = 5, ) -> dict: dict_ = make_heatmap_head(num_joints, heatmap_channels, locref_channels) dict_["type"] = "DLCRNetHead" @@ -242,6 +241,7 @@ def make_dlcrnet_head( "kernel_size": [3] * n_deconv_layers, "strides": [2] * n_deconv_layers, } + dict_["num_stages"] = num_stages dict_["target_generator"] = { "type": "SequentialGenerator", "generators": [ @@ -474,5 +474,4 @@ def make_detector_data_aug() -> dict: "normalize_images": True, "rotation": 30, "scale_jitter": [0.5, 1.25], - "translation": 40, } diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 8a880c4d17..88a11fd830 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -14,32 +14,31 @@ import pickle import time from pathlib import Path -from typing import List, Optional, Tuple, Union, Any +from typing import Any, List, Optional, Tuple, Union import albumentations as A import numpy as np import pandas as pd + from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( convert_detections2tracklets, ) from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_detector_snapshots, get_model_snapshots, - list_videos_in_folder, get_runners, + list_videos_in_folder, ) from deeplabcut.pose_estimation_pytorch.runners import Runner from deeplabcut.refine_training_dataset.stitch import stitch_tracklets -from deeplabcut.utils import VideoReader, auxfun_multianimal, auxiliaryfunctions +from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, VideoReader class VideoIterator(VideoReader): """A class to iterate over videos, with possible added context""" def __init__( - self, - video_path: str, - context: list[dict[str, Any]] | None = None, + self, video_path: str, context: list[dict[str, Any]] | None = None ) -> None: super().__init__(video_path) self._context = context @@ -190,10 +189,7 @@ def analyze_videos( project_path = Path(cfg["project_path"]) train_fraction = cfg["TrainingFraction"][trainingsetindex] model_folder = project_path / auxiliaryfunctions.get_model_folder( - train_fraction, - shuffle, - cfg, - modelprefix=modelprefix, + train_fraction, shuffle, cfg, modelprefix=modelprefix ) model_path = _get_model_path(model_folder, snapshotindex, cfg) model_epochs = int(model_path.stem.split("-")[-1]) @@ -314,12 +310,7 @@ def analyze_videos( ) df = df.join(df_u, how="outer") - df.to_hdf( - str(output_h5), - "df_with_missing", - format="table", - mode="w", - ) + df.to_hdf(str(output_h5), "df_with_missing", format="table", mode="w") results.append((str(video), df)) output_data = _generate_output_data(pose_cfg, predictions) _ = auxfun_multianimal.SaveFullMultiAnimalData( @@ -342,9 +333,7 @@ def analyze_videos( assemblies["single"] = {} for i, unique_prediction in enumerate(unique_predictions): extra_column = np.full( - (unique_prediction.shape[1], 1), - -1.0, - dtype=np.float32, + (unique_prediction.shape[1], 1), -1.0, dtype=np.float32 ) ass = np.concatenate( (unique_prediction[0], extra_column), axis=-1 @@ -387,12 +376,7 @@ def analyze_videos( columns=results_df_index, index=range(len(predictions)), ) - df.to_hdf( - str(output_h5), - "df_with_missing", - format="table", - mode="w", - ) + df.to_hdf(str(output_h5), "df_with_missing", format="table", mode="w") results.append((str(video), df)) return results @@ -421,12 +405,7 @@ def _generate_metadata( w, h = video.dimensions cropping = cfg.get("cropping", False) if cropping: - cropping_parameters = [ - cfg["x1"], - cfg["x2"], - cfg["y1"], - cfg["y2"], - ] + cropping_parameters = [cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"]] else: cropping_parameters = [0, w, 0, h] @@ -472,9 +451,7 @@ def _get_model_path(model_folder: Path, snapshot_index: int, config: dict) -> Pa def _get_detector_path( - model_folder: Path, - snapshot_index: int | str, - config: dict | None, + model_folder: Path, snapshot_index: int | str, config: dict | None ) -> Path: trained_models = get_detector_snapshots(model_folder / "train") @@ -498,10 +475,7 @@ def _get_detector_path( return trained_models[snapshot_index] -def _generate_output_data( - pose_config: dict, - predictions: np.ndarray, -) -> dict: +def _generate_output_data(pose_config: dict, predictions: np.ndarray) -> dict: output = { "metadata": { "nms radius": pose_config.get("nmsradius"), diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml index f045ea6dd0..9565c95546 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ b/deeplabcut/pose_estimation_pytorch/apis/config.yaml @@ -11,7 +11,6 @@ data: scale_jitter: - 0.5 - 1.25 - translation: 40 device: cuda:0 display_iters: 50 epochs: 200 diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index 922cd8933c..dd42dc80bf 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -17,6 +17,8 @@ import numpy as np import pandas as pd +from tqdm import tqdm + from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_model_snapshots, @@ -26,7 +28,6 @@ from deeplabcut.pose_estimation_tensorflow.lib import trackingutils from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly from deeplabcut.utils import auxfun_multianimal, read_pickle -from tqdm import tqdm def convert_detections2tracklets( @@ -244,8 +245,7 @@ def convert_detections2tracklets( else: if track_method == "box": xy = trackingutils.calc_bboxes_from_keypoints( - animals[:, keep_inds], - inference_cfg["boundingboxslack"], + animals[:, keep_inds], inference_cfg["boundingboxslack"] ) # TODO: get cropping parameters and utilize! else: xy = animals[:, keep_inds, :2] @@ -268,8 +268,7 @@ def convert_detections2tracklets( def _conv_predictions_to_assemblies( - image_names: List[str], - predictions: Dict[str, np.ndarray], + image_names: List[str], predictions: Dict[str, np.ndarray] ) -> Dict[int, List[Assembly]]: """ Converts predictions to an assemblies dictionary diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 0d919dc9be..c3ea28406f 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -20,7 +20,7 @@ import deeplabcut.pose_estimation_pytorch as dlc import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils -from deeplabcut.pose_estimation_pytorch import Loader, DLCLoader +from deeplabcut.pose_estimation_pytorch import DLCLoader, Loader from deeplabcut.pose_estimation_pytorch.apis.scoring import ( align_predicted_individuals_to_gt, get_scores, @@ -210,11 +210,7 @@ def evaluate_snapshot( """ train_fraction = cfg["TrainingFraction"][trainingsetindex] model_folder = runner_utils.get_model_folder( - cfg["project_path"], - cfg, - train_fraction, - shuffle, - modelprefix, + cfg["project_path"], cfg, train_fraction, shuffle, modelprefix ) model_config_path = str(Path(model_folder) / "train" / "pytorch_config.yaml") pytorch_config = auxiliaryfunctions.read_plainconfig(model_config_path) @@ -456,10 +452,7 @@ def image_to_dlc_df_index(image: str) -> tuple[str, ...]: def save_evaluation_results( - df_scores: pd.DataFrame, - scores_path: Path, - print_results: bool, - pcutoff: float, + df_scores: pd.DataFrame, scores_path: Path, print_results: bool, pcutoff: float ) -> None: """ Saves the evaluation results to a CSV file. Adds the evaluation results for the diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index bb17b03c4e..638364707a 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -15,7 +15,7 @@ from torchvision.ops import box_convert from torchvision.transforms import Resize as TorchResize -from deeplabcut.pose_estimation_pytorch.models import PREDICTORS, PoseModel +from deeplabcut.pose_estimation_pytorch.models import PoseModel, PREDICTORS from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.post_processing import ( @@ -133,9 +133,7 @@ def get_predictions_top_down( def get_detections_batch( - detector: BaseDetector, - images: torch.Tensor, - max_num_animals: int, + detector: BaseDetector, images: torch.Tensor, max_num_animals: int ) -> torch.Tensor: """Given a batch of images, outputs the predicted bboxes. @@ -162,9 +160,7 @@ def get_detections_batch( def get_pose_batch( - pose_model: PoseModel, - predictor: BasePredictor, - cropped_images: torch.Tensor, + pose_model: PoseModel, predictor: BasePredictor, cropped_images: torch.Tensor ) -> torch.Tensor: """Given a batch of cropped images, outputs a batch of predicted pose coordinates. Coordinates are still in cropped image space and needs to be handled accordingly to @@ -197,9 +193,7 @@ def get_pose_batch( def match_predicted_individuals_to_annotations( - predictions: np.ndarray, - ground_truth: List[np.ndarray], - max_individuals: int, + predictions: np.ndarray, ground_truth: List[np.ndarray], max_individuals: int ) -> None: """ Uses RMSE to match predicted individuals to frame annotations for a batch of @@ -219,16 +213,13 @@ def match_predicted_individuals_to_annotations( if max_individuals > 1: for b in range(predictions.shape[0]): match_individuals = rmse_match_prediction_to_gt( - predictions[b], - ground_truth[b], + predictions[b], ground_truth[b] ) predictions[b] = predictions[b][match_individuals] def resize_batch_predictions( - predictions: np.ndarray, - original_sizes: np.ndarray, - image_shape: Tuple[int, int], + predictions: np.ndarray, original_sizes: np.ndarray, image_shape: Tuple[int, int] ) -> None: """ Converts keypoint coordinates to their values in the original image. Call if the @@ -360,9 +351,7 @@ def inference( ) else: predictions, unique_pred = get_predictions_bottom_up( - model=model, - predictor=predictor, - images=item["image"], + model=model, predictor=predictor, images=item["image"] ) if align_predictions_to_ground_truth: diff --git a/deeplabcut/pose_estimation_pytorch/apis/scoring.py b/deeplabcut/pose_estimation_pytorch/apis/scoring.py index 47b4eba5c0..867e23ebf1 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/apis/scoring.py @@ -75,30 +75,22 @@ def get_scores( f"images (poses={len(poses)}, gt={len(ground_truth)})" ) - ground_truth = { - image: mask_invisible(gt_pose, mask_value=np.nan) - for image, gt_pose in ground_truth.items() - } - - pred_poses, gt_poses = [], [] - for image_key in poses.keys(): - pred_poses.append(poses[image_key]) - gt_poses.append(ground_truth[image_key]) - - keys = list(poses.keys()) - pred_poses = build_keypoint_array(poses, keys).reshape((-1, 3)) - gt_poses = build_keypoint_array(ground_truth, keys).reshape((-1, 2)) + image_paths = list(poses) + pred_poses = build_keypoint_array(poses, image_paths)[..., :3].reshape((-1, 3)) + gt_poses = build_keypoint_array(ground_truth, image_paths).reshape((-1, 2)) if unique_bodypart_poses is not None: pred_poses = np.concatenate( [ pred_poses, - build_keypoint_array(unique_bodypart_poses, keys).reshape((-1, 3)), + build_keypoint_array(unique_bodypart_poses, image_paths)[ + ..., :3 + ].reshape((-1, 3)), ] ) gt_poses = np.concatenate( [ gt_poses, - build_keypoint_array(unique_bodypart_gt, keys).reshape((-1, 2)), + build_keypoint_array(unique_bodypart_gt, image_paths).reshape((-1, 2)), ] ) @@ -136,9 +128,7 @@ def build_keypoint_array( def compute_rmse( - pred: np.ndarray, - ground_truth: np.ndarray, - pcutoff: float = -1, + pred: np.ndarray, ground_truth: np.ndarray, pcutoff: float = -1 ) -> tuple[float, float]: """Computes the root mean square error (rmse) for predictions vs the ground truth labels @@ -234,8 +224,7 @@ def build_assemblies(poses: dict[str, np.ndarray]) -> dict[str, list[Assembly]]: def align_predicted_individuals_to_gt( - predictions: dict[str, np.ndarray], - ground_truth: dict[str, np.ndarray], + predictions: dict[str, np.ndarray], ground_truth: dict[str, np.ndarray] ) -> dict[str, np.ndarray]: """TODO: implement with OKS as well Uses RMSE to match predicted individuals to frame annotations for a batch of @@ -265,8 +254,7 @@ def align_predicted_individuals_to_gt( def mask_invisible( - keypoints: np.ndarray, - mask_value: int | float | np.nan = -1.0, + keypoints: np.ndarray, mask_value: int | float | np.nan = -1.0 ) -> np.ndarray: """ Masks keypoints that are not visible in an array. @@ -281,7 +269,7 @@ def mask_invisible( as invisible replaced with the mask value """ keypoints = keypoints.copy() - visibility = (keypoints[..., 2] == 0) + visibility = keypoints[..., 2] == 0 keypoints[visibility, 0] = mask_value keypoints[visibility, 1] = mask_value return keypoints[..., :2] diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index f025d12dfe..088cd8974d 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -23,16 +23,16 @@ from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch import Loader from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_inference_transform, build_runner, build_transforms, - build_inference_transform, update_config_parameters, ) from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel from deeplabcut.pose_estimation_pytorch.runners.logger import ( + destroy_file_logging, LOGGER, setup_file_logging, - destroy_file_logging, ) @@ -158,11 +158,7 @@ def train_network( cfg = auxiliaryfunctions.read_config(config) train_fraction = cfg["TrainingFraction"][trainingsetindex] model_folder = runner_utils.get_model_folder( - str(Path(config).parent), - cfg, - train_fraction, - shuffle, - modelprefix, + str(Path(config).parent), cfg, train_fraction, shuffle, modelprefix ) train_folder = Path(model_folder) / "train" log_path = train_folder / "log.txt" diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 386ebaa388..854fd3be3d 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -22,19 +22,19 @@ from deeplabcut.pose_estimation_pytorch import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( - Postprocessor, build_bottom_up_postprocessor, build_detector_postprocessor, build_top_down_postprocessor, + Postprocessor, ) from deeplabcut.pose_estimation_pytorch.data.preprocessor import ( - Preprocessor, build_bottom_up_preprocessor, build_top_down_preprocessor, + Preprocessor, ) from deeplabcut.pose_estimation_pytorch.data.transforms import KeypointAwareCrop -from deeplabcut.pose_estimation_pytorch.models import PoseModel, DETECTORS -from deeplabcut.pose_estimation_pytorch.runners import RUNNERS, Runner +from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel +from deeplabcut.pose_estimation_pytorch.runners import Runner, RUNNERS from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger from deeplabcut.pose_estimation_pytorch.runners.schedulers import LRListScheduler from deeplabcut.utils import auxfun_videos @@ -55,8 +55,7 @@ def build_optimizer(optimizer_cfg: dict, model: nn.Module) -> torch.optim.Optimi def build_scheduler( - scheduler_cfg: dict | None, - optimizer: torch.optim.Optimizer, + scheduler_cfg: dict | None, optimizer: torch.optim.Optimizer ) -> torch.optim.lr_scheduler.LRScheduler | None: """Builds a scheduler from a configuration, if defined @@ -114,6 +113,7 @@ def build_runner( Returns: the runner """ + model.to(device) # Move model before giving its parameters to the optimizer optimizer = build_optimizer(run_cfg["optimizer"], model) scheduler = build_scheduler(run_cfg["scheduler"], optimizer) return RUNNERS.build( @@ -183,12 +183,10 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose # ) scale_jitter_lo, scale_jitter_up = aug_cfg.get("scale_jitter", (1, 1)) rotation = aug_cfg.get("rotation", 0) - translation = aug_cfg.get("translation", 0) transforms.append( A.Affine( scale=(scale_jitter_lo, scale_jitter_up), rotate=(-rotation, rotation), - translate_px=(-translation, translation), p=0.5, ) ) @@ -219,7 +217,7 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose if type(opt) == int or type(opt) == float: transforms.append( A.GaussNoise( - var_limit=(0, opt**2), + var_limit=(0, opt ** 2), mean=0, per_channel=True, # Albumentations doesn't support per_cahnnel = 0.5 p=0.5, @@ -228,10 +226,7 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose else: transforms.append( A.GaussNoise( - var_limit=(0, (0.05 * 255) ** 2), - mean=0, - per_channel=True, - p=0.5, + var_limit=(0, (0.05 * 255) ** 2), mean=0, per_channel=True, p=0.5 ) ) @@ -337,8 +332,7 @@ def get_detector_snapshots(model_folder: Path) -> list[Path]: def list_videos_in_folder( - data_path: str | list[str], - video_type: str | None, + data_path: str | list[str], video_type: str | None ) -> list[Path]: """ TODO @@ -488,10 +482,10 @@ def build_predictions_dataframe( prediction_data = [] index_data = [] for image in images: - image_data = bodypart_predictions[image].reshape(-1) + image_data = bodypart_predictions[image][..., :3].reshape(-1) if unique_bodypart_predictions is not None: image_data = np.concatenate( - [image_data, unique_bodypart_predictions[image].reshape(-1)] + [image_data, unique_bodypart_predictions[image][..., :3].reshape(-1)] ) prediction_data.append(image_data) if image_name_to_index is not None: @@ -548,8 +542,7 @@ def get_runners( detector_runner = None if pose_task == "BU": pose_preprocessor = build_bottom_up_preprocessor( - color_mode="RGB", # TODO: read from Loader - transform=transform, + color_mode="RGB", transform=transform # TODO: read from Loader ) pose_postprocessor = build_bottom_up_postprocessor( with_unique_bodyparts=with_unique_bodyparts diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 366d2ef005..c503ab1a6b 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -10,21 +10,21 @@ # from __future__ import annotations -from abc import ABC -from abc import abstractmethod +from abc import ABC, abstractmethod import albumentations as A import numpy as np from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset -from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.data.dataset import ( + PoseDataset, + PoseDatasetParameters, +) from deeplabcut.pose_estimation_pytorch.data.utils import ( - map_id_to_annotations, _compute_crop_bounds, + map_id_to_annotations, ) -from deeplabcut.utils.auxiliaryfunctions import get_bodyparts -from deeplabcut.utils.auxiliaryfunctions import get_unique_bodyparts +from deeplabcut.utils.auxiliaryfunctions import get_bodyparts, get_unique_bodyparts class Loader(ABC): @@ -73,9 +73,7 @@ def image_filenames(self, mode: str = "train") -> list[str]: return [image["file_name"] for image in data["images"]] def ground_truth_keypoints( - self, - mode: str = "train", - unique_bodypart: bool = False, + self, mode: str = "train", unique_bodypart: bool = False ) -> dict[str, np.ndarray]: """ Creates a dictionary containing the ground truth data @@ -263,7 +261,7 @@ def _get_all_bboxes(images, annotations, method: str = "gt"): if "bbox" not in annotation: # or do something else? raise ValueError( - "Bounding box not found in annotation, please chose another method", + "Bounding box not found in annotation, please chose another method" ) return annotations diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py index fdaddb811e..02a8e4e016 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py @@ -94,10 +94,6 @@ def load_data(self, mode: str = "train") -> dict: for image in json_obj["images"]: image_path = image["file_name"] - image["file_name"] = os.path.join( - self.project_root, - "images", - image_path, - ) + image["file_name"] = os.path.join(self.project_root, "images", image_path) return json_obj diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index a96155ef85..8c919830be 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -18,14 +18,14 @@ from torch.utils.data import Dataset from deeplabcut.pose_estimation_pytorch.data.utils import ( - _crop_image_keypoints, _crop_and_pad_image_torch, + _crop_image_keypoints, + _extract_keypoints_and_bboxes, + apply_transform, + map_id_to_annotations, + map_image_path_to_id, + pad_to_length, ) -from deeplabcut.pose_estimation_pytorch.data.utils import _extract_keypoints_and_bboxes -from deeplabcut.pose_estimation_pytorch.data.utils import apply_transform -from deeplabcut.pose_estimation_pytorch.data.utils import map_id_to_annotations -from deeplabcut.pose_estimation_pytorch.data.utils import map_image_path_to_id -from deeplabcut.pose_estimation_pytorch.data.utils import pad_to_length @dataclass(frozen=True) @@ -109,10 +109,7 @@ def _get_raw_item_crop(self, index: int) -> tuple[str, list[dict], int]: image_id2path = { image_id: image_path - for ( - image_path, - image_id, - ) in self.image_path_id_map.items() + for (image_path, image_id) in self.image_path_id_map.items() } return image_id2path[image_id], annotations, image_id @@ -150,16 +147,13 @@ def __getitem__(self, index: int) -> dict: keypoints_unique, bboxes, annotations_merged, - ) = self.extract_keypoints_and_bboxes( - annotations, - image.shape, - ) + ) = self.extract_keypoints_and_bboxes(annotations, image.shape) offsets = np.zeros((self.parameters.max_num_animals, 2)) scales = (1, 1) if self.task == "TD": if self.parameters.cropped_image_size is None: raise ValueError( - "You must specify a cropped image size for top-down models", + "You must specify a cropped image size for top-down models" ) if len(bboxes) > 1: raise ValueError( @@ -190,10 +184,7 @@ def __getitem__(self, index: int) -> dict: ) # No more bounding boxes as we cropped around them transformed = self.apply_transform_all_keypoints( - image, - keypoints, - keypoints_unique, - bboxes, + image, keypoints, keypoints_unique, bboxes ) keypoints = transformed["keypoints"] @@ -235,10 +226,7 @@ def _prepare_final_data_dict( "offsets": offsets, "scales": scales, "annotations": self._prepare_final_annotation_dict( - keypoints, - keypoints_unique, - bboxes, - annotations_merged, + keypoints, keypoints_unique, bboxes, annotations_merged ), } @@ -291,7 +279,7 @@ def _get_data_based_on_task(self, index: int) -> tuple[str, list[dict], int]: return self._get_raw_item(index) raise ValueError( - f"Unknown task: {self.task}. " 'Task should be one of: "BU", "TD", "DT"', + f"Unknown task: {self.task}. " 'Task should be one of: "BU", "TD", "DT"' ) def apply_transform_all_keypoints( @@ -331,11 +319,7 @@ def apply_transform_all_keypoints( all_keypoints = np.concatenate([all_keypoints, keypoints_unique], axis=0) transformed = apply_transform( - self.transform, - image, - all_keypoints, - bboxes, - class_labels=class_labels, + self.transform, image, all_keypoints, bboxes, class_labels=class_labels ) if self.parameters.num_unique_bpts > 0: keypoints = transformed["keypoints"][ @@ -381,9 +365,7 @@ def crop( return _crop_image_keypoints(image, keypoints, coords, output_size) def extract_keypoints_and_bboxes( - self, - annotations: list[dict], - image_shape: tuple[int, int, int], + self, annotations: list[dict], image_shape: tuple[int, int, int] ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, list]]: """ Args: diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index 00adfa4d56..4d0dc2006c 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -30,10 +30,7 @@ class DLCLoader(Loader, metaclass=CombinedPropertyMeta): lambda self: os.path.join(self.project_root, "config.yaml"), ), "model_folder": ( - lambda x: os.path.join( - x[0], - get_model_folder(x[1], x[2], x[3]), - ), + lambda x: os.path.join(x[0], get_model_folder(x[1], x[2], x[3])), lambda self: ( self.project_root, self.cfg["TrainingFraction"][0], @@ -43,8 +40,7 @@ class DLCLoader(Loader, metaclass=CombinedPropertyMeta): ), "_datasets_folder": ( lambda x: os.path.join( - x[0], - deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(x[1]), + x[0], deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(x[1]) ), lambda self: (self.project_root, self.cfg), ), @@ -54,15 +50,12 @@ class DLCLoader(Loader, metaclass=CombinedPropertyMeta): ), "_path_dlc_doc": ( lambda x: os.path.join( - x[0], - f"Documentation_data-{x[1]}_{x[2]}shuffle{x[3]}.pickle", + x[0], f"Documentation_data-{x[1]}_{x[2]}shuffle{x[3]}.pickle" ), lambda self: ( self._datasets_folder, self.cfg["Task"], - int( - 100 * self.cfg["TrainingFraction"][0], - ), + int(100 * self.cfg["TrainingFraction"][0]), self.shuffle, ), ), @@ -83,17 +76,9 @@ def _load_dlc_data(self): @staticmethod def drop_duplicates(dlc_df, df_train, df_test): dlc_df = dlc_df[~dlc_df.index.duplicated(keep="first")] - df_train = df_train[ - ~df_train.index.duplicated( - keep="first", - ) - ] + df_train = df_train[~df_train.index.duplicated(keep="first")] if df_test is not None: - df_test = df_test[ - ~df_test.index.duplicated( - keep="first", - ) - ] + df_test = df_test[~df_test.index.duplicated(keep="first")] return dlc_df, df_train, df_test def load_data(self, mode: str = "train") -> dict: @@ -119,14 +104,9 @@ def load_data(self, mode: str = "train") -> dict: else: raise AttributeError(f"Unknown mode: {mode}") - data = df_to_generic( - self.project_root, - data_dlc_format, - self.image_id_offset, - ) + data = df_to_generic(self.project_root, data_dlc_format, self.image_id_offset) annotations_with_bbox = self._get_all_bboxes( - data["images"], - data["annotations"], + data["images"], data["annotations"] ) data["annotations"] = annotations_with_bbox diff --git a/deeplabcut/pose_estimation_pytorch/data/helper.py b/deeplabcut/pose_estimation_pytorch/data/helper.py index 447d0a6669..287bf22db8 100644 --- a/deeplabcut/pose_estimation_pytorch/data/helper.py +++ b/deeplabcut/pose_estimation_pytorch/data/helper.py @@ -59,15 +59,10 @@ class MyClass(metaclass=PropertyMeta): def __new__(cls, name, bases, attrs): if "properties" not in attrs: - raise AttributeError( - f"{name} must define a 'properties' dictionary.", - ) + raise AttributeError(f"{name} must define a 'properties' dictionary.") properties = attrs.get("properties", {}) for prop_name, (func, arg_func) in properties.items(): - attrs[prop_name] = class_property( - func, - arg_func, - )(lambda self: None) + attrs[prop_name] = class_property(func, arg_func)(lambda self: None) return super().__new__(cls, name, bases, attrs) diff --git a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py index 5091e10176..22bf27e1a7 100644 --- a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py @@ -42,9 +42,7 @@ def build_bottom_up_postprocessor(with_unique_bodyparts: bool) -> ComposePostpro if with_unique_bodyparts: keys_to_concatenate["unique_bodyparts"] = ("unique_bodypart", "poses") return ComposePostprocessor( - components=[ - ConcatenateOutputs(keys_to_concatenate=keys_to_concatenate), - ] + components=[ConcatenateOutputs(keys_to_concatenate=keys_to_concatenate)] ) @@ -129,9 +127,7 @@ def __init__(self, keys_to_rescale: list[str]) -> None: self.keys_to_rescale = keys_to_rescale def __call__( - self, - predictions: dict[str, np.ndarray], - context: Context, + self, predictions: dict[str, np.ndarray], context: Context ) -> tuple[dict[str, np.ndarray], Context]: if "scales" not in context or "offsets" not in context: raise ValueError( @@ -170,9 +166,7 @@ def __init__(self, bounding_box_keys: list[str]) -> None: self.bounding_box_keys = bounding_box_keys def __call__( - self, - predictions: dict[str, np.ndarray], - context: Context, + self, predictions: dict[str, np.ndarray], context: Context ) -> tuple[dict[str, np.ndarray], Context]: for bbox_key in self.bounding_box_keys: predictions[bbox_key][:, 2] -= predictions[bbox_key][:, 0] diff --git a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py index 1fb37d395e..ba60260488 100644 --- a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from pathlib import Path -from typing import TypeVar, Any +from typing import Any, TypeVar import albumentations as A import cv2 @@ -12,7 +12,6 @@ from deeplabcut.pose_estimation_pytorch.data.utils import _crop_and_pad_image_torch - Image = TypeVar("Image", torch.Tensor, np.ndarray, str, Path) Context = TypeVar("Context", dict[str, Any], None) @@ -44,8 +43,7 @@ def __call__(self, image: Image, context: Context) -> tuple[Image, Context]: def build_bottom_up_preprocessor( - color_mode: str, - transform: A.BaseCompose, + color_mode: str, transform: A.BaseCompose ) -> Preprocessor: """Creates a preprocessor for bottom-up pose estimation (or object detection) @@ -71,9 +69,7 @@ def build_bottom_up_preprocessor( def build_top_down_preprocessor( - color_mode: str, - transform: A.BaseCompose, - cropped_image_size: tuple[int, int], + color_mode: str, transform: A.BaseCompose, cropped_image_size: tuple[int, int] ) -> Preprocessor: """Creates a preprocessor for top-down pose estimation diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index e1ec768552..62b2369fe5 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -1,10 +1,11 @@ from __future__ import annotations +from typing import Any + import numpy as np from albumentations.augmentations.crops import RandomCrop from numpy.typing import NDArray from scipy.spatial.distance import pdist, squareform -from typing import Any class KeypointAwareCrop(RandomCrop): diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 7f0b6e28ec..6cfcea09b8 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -21,8 +21,7 @@ def merge_list_of_dicts( - list_of_dicts: list[dict], - keys_to_include: list[str], + list_of_dicts: list[dict], keys_to_include: list[str] ) -> dict[str, list]: """ Flattens a list of dictionaries into a dictionary with the lists concatenated. @@ -111,11 +110,7 @@ def _crop_and_pad_image( Cropped (and possibly padded) image Padding (pad_h, pad_w) """ - cropped_image = image[ - coords[1][0] : coords[1][1], - coords[0][0] : coords[0][1], - :, - ] + cropped_image = image[coords[1][0] : coords[1][1], coords[0][0] : coords[0][1], :] crop_h, crop_w, c = cropped_image.shape pad_h, pad_w = 0, 0 @@ -144,9 +139,7 @@ def _crop_and_pad_image( def _crop_and_pad_keypoints( - keypoints: np.ndarray, - coords: tuple[int, int], - pad_size: tuple[int, int], + keypoints: np.ndarray, coords: tuple[int, int], pad_size: tuple[int, int] ): """ Adjust the keypoints after cropping and padding. @@ -167,10 +160,7 @@ def _crop_and_pad_keypoints( def _crop_image_keypoints( - image, - keypoints, - coords, - output_size, + image, keypoints, coords, output_size ) -> tuple[np.ndarray, np.ndarray, tuple[int, int], tuple[int, int]]: """TODO: Requires fixing Crop the image based on a given bounding box and resize it to the desired output @@ -193,15 +183,9 @@ def _crop_image_keypoints( The scale to map predicted keypoints back to the original image """ - cropped_image, pad_size = _crop_and_pad_image( - image, - coords, - output_size, - ) + cropped_image, pad_size = _crop_and_pad_image(image, coords, output_size) cropped_keypoints = _crop_and_pad_keypoints( - keypoints, - (coords[0][0], coords[1][0]), - pad_size, + keypoints, (coords[0][0], coords[1][0]), pad_size ) offsets = (coords[0][0], coords[1][0]) @@ -212,22 +196,16 @@ def _crop_image_keypoints( # TODO: Fix resizing, use OpenCV cropped_resized_image = np.resize( - cropped_image, - (*output_size, cropped_image.shape[2]), + cropped_image, (*output_size, cropped_image.shape[2]) ) - cropped_resized_keypoints = np.array( - cropped_keypoints, - ) * np.array(scales + [1]) + cropped_resized_keypoints = np.array(cropped_keypoints) * np.array(scales + [1]) return cropped_resized_image, cropped_resized_keypoints, offsets, scales def _crop_and_pad_image_torch( - image: np.array, - bbox: np.array, - bbox_format: str, - output_size: int, + image: np.array, bbox: np.array, bbox_format: str, output_size: int ) -> tuple[np.array, tuple[int, int], tuple[int, int]]: """TODO: Reimplement this function with numpy and for non-square resize :) Only works for square cropped bounding boxes. Crops images around bounding boxes @@ -254,42 +232,10 @@ def _crop_and_pad_image_torch( c, h, w = image.shape crop_size = torch.max(bbox[2:]) - xmin = int( - torch.clip( - bbox[0] - (crop_size / 2), - min=0, - max=w - 1, - ) - .cpu() - .item(), - ) - xmax = int( - torch.clip( - bbox[0] + (crop_size / 2), - min=1, - max=w, - ) - .cpu() - .item(), - ) - ymin = int( - torch.clip( - bbox[1] - (crop_size / 2), - min=0, - max=h - 1, - ) - .cpu() - .item(), - ) - ymax = int( - torch.clip( - bbox[1] + (crop_size / 2), - min=1, - max=h, - ) - .cpu() - .item(), - ) + xmin = int(torch.clip(bbox[0] - (crop_size / 2), min=0, max=w - 1).cpu().item()) + xmax = int(torch.clip(bbox[0] + (crop_size / 2), min=1, max=w).cpu().item()) + ymin = int(torch.clip(bbox[1] - (crop_size / 2), min=0, max=h - 1).cpu().item()) + ymax = int(torch.clip(bbox[1] + (crop_size / 2), min=1, max=h).cpu().item()) cropped_image = image[:, ymin:ymax, xmin:xmax] crop_h, crop_w = cropped_image.shape[1:3] @@ -298,10 +244,7 @@ def _crop_and_pad_image_torch( # Pad image if not square if not crop_h == crop_w: - padded_cropped_image = torch.zeros( - (c, pad_size, pad_size), - dtype=image.dtype, - ) + padded_cropped_image = torch.zeros((c, pad_size, pad_size), dtype=image.dtype) # Try to center bbox in padding w_start = 0 if bbox[0] - (crop_size / 2) < 0: @@ -331,8 +274,7 @@ def _crop_and_pad_image_torch( def _compute_crop_bounds( - bboxes: np.ndarray, - image_shape: tuple[int, int, int], + bboxes: np.ndarray, image_shape: tuple[int, int, int] ) -> np.ndarray: """ Compute the boundaries for cropping an image based on a COCO-format bounding box @@ -390,8 +332,7 @@ def _extract_keypoints_and_bboxes( bboxes = _compute_crop_bounds(original_bboxes, image_shape) annotations_merged = merge_list_of_dicts( - annotations_to_merge, - keys_to_include=["area", "category_id", "iscrowd"], + annotations_to_merge, keys_to_include=["area", "category_id", "iscrowd"] ) if len(annotations_merged["area"]) == len(keypoints): area = np.array(annotations_merged["area"]) @@ -473,18 +414,13 @@ def apply_transform( if transform: defined_keypoint_mask = _check_keypoints_within_bounds(keypoints, image.shape) transformed = _apply_transform( - transform, - image, - keypoints, - bboxes, - class_labels, + transform, image, keypoints, bboxes, class_labels ) transformed["keypoints"] = np.array(transformed["keypoints"]) transformed["keypoints"][~defined_keypoint_mask] = -1 shape_transformed = transformed["image"].shape mask_valid = _check_keypoints_within_bounds( - transformed["keypoints"], - shape_transformed, + transformed["keypoints"], shape_transformed ) transformed["keypoints"][~mask_valid] = -1 else: @@ -525,17 +461,13 @@ def _apply_transform( bbox_labels=np.arange(len(bboxes)), ) transformed = _set_invalid_keypoints_to_neg_one( - transformed, - keypoints, - class_labels, + transformed, keypoints, class_labels ) return transformed def _set_invalid_keypoints_to_neg_one( - transformed: dict[str, list], - keypoints: np.ndarray, - class_labels: list, + transformed: dict[str, list], keypoints: np.ndarray, class_labels: list ) -> dict[str, list]: """ Updates keypoints that are out of bounds or undefined to (-1, -1). @@ -603,10 +535,7 @@ def pad_to_length(data: np.array, length: int, value: float) -> np.array: raise ValueError(f"Cannot pad! data.shape={data.shape} > length={length}") -def safe_stack( - data: list[np.ndarray], - default_shape: tuple[int, ...], -) -> np.ndarray: +def safe_stack(data: list[np.ndarray], default_shape: tuple[int, ...]) -> np.ndarray: """ Stacks a list of arrays if there are any, otherwise returns an array of zeros of a desired shape. diff --git a/deeplabcut/pose_estimation_pytorch/default_config.py b/deeplabcut/pose_estimation_pytorch/default_config.py index 5cc979bc9e..99e73bb36e 100644 --- a/deeplabcut/pose_estimation_pytorch/default_config.py +++ b/deeplabcut/pose_estimation_pytorch/default_config.py @@ -10,7 +10,6 @@ # from typing import Any - pytorch_cfg_template: dict[str, Any] = { "cfg_path": "/data/quentin/datasets/daniel3mouse/config.yaml", "seed": 42, @@ -20,7 +19,6 @@ "data": { "scale_jitter": [0.5, 1.25], "rotation": 30, - "translation": 40, "hist_eq": True, "motion_blur": True, "covering": True, @@ -50,25 +48,12 @@ "num_joints": -1, "pos_dist_thresh": 17, }, - "pose_model": { - "stride": 8, - }, - }, - "optimizer": { - "type": "AdamW", - "params": { - "lr": 1e-4, - }, + "pose_model": {"stride": 8}, }, + "optimizer": {"type": "AdamW", "params": {"lr": 1e-4}}, "scheduler": { "type": "LRListScheduler", - "params": { - "milestones": [ - 90, - 120, - ], - "lr_list": [[1e-5], [1e-6]], - }, + "params": {"milestones": [90, 120], "lr_list": [[1e-5], [1e-6]]}, }, "runner": {"type": "PoseRunner"}, "with_center_keypoints": False, diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index bcd9b0f3ff..d8ab679883 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -12,8 +12,7 @@ import torch -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry BACKBONES = Registry("backbones", build_func=build_from_cfg) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 6b18e72e7a..0c579a9f3a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -32,7 +32,7 @@ class ResNet(BaseBackbone): def __init__( self, model_name: str = "resnet50", - output_stride: int = 16, + output_stride: int = 32, pretrained: bool = True, ) -> None: """Initialize the ResNet backbone. @@ -73,7 +73,7 @@ class DLCRNet(ResNet): def __init__( self, model_name: str = "resnet50", - output_stride: int = 16, + output_stride: int = 32, pretrained: bool = True, ) -> None: super().__init__(model_name, output_stride, pretrained) @@ -122,7 +122,6 @@ def forward(self, x): out = super().forward(x) # Fuse intermediate features - h, w = out.shape[-2:] bank_2_s8 = self.interm_features["bank2"] bank_1_s4 = self.interm_features["bank1"] bank_2_s16 = self.conv_block1(bank_2_s8) @@ -132,7 +131,8 @@ def forward(self, x): bank_1_s16 = self.conv_block5(bank_1_s16) # Resizing here is required to guarantee all shapes match, as # Conv2D(..., padding='same') is invalid for strided convolutions. - bank_1_s16 = resize(bank_1_s16, [h, w], antialias=False) - bank_2_s16 = resize(bank_2_s16, [h, w], antialias=False) + h, w = out.shape[-2:] + bank_1_s16 = resize(bank_1_s16, [h, w], antialias=True) + bank_2_s16 = resize(bank_2_s16, [h, w], antialias=True) return torch.cat((bank_1_s16, bank_2_s16, out), dim=1) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py b/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py index 22ad67dfa4..71c28ce98b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py @@ -13,8 +13,8 @@ import torch from deeplabcut.pose_estimation_pytorch.models.criterions.base import ( - LOSS_AGGREGATORS, BaseLossAggregator, + LOSS_AGGREGATORS, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/base.py b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py index c4aec84983..f4456b40bf 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py @@ -9,26 +9,21 @@ # Licensed under GNU Lesser General Public License v3.0 # from __future__ import annotations + from abc import ABC, abstractmethod import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry LOSS_AGGREGATORS = Registry("loss_aggregators", build_func=build_from_cfg) CRITERIONS = Registry("criterions", build_func=build_from_cfg) class BaseCriterion(ABC, nn.Module): - def __init__(self, apply_sigmoid: bool = False) -> None: - super().__init__() - self.apply_sigmoid = apply_sigmoid - - def __init__(self, apply_sigmoid: bool = False) -> None: + def __init__(self) -> None: super().__init__() - self.apply_sigmoid = apply_sigmoid @abstractmethod def forward( diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py b/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py new file mode 100644 index 0000000000..b33aeb8d1a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import torch + + +def count_nonzero_elems( + losses: torch.Tensor, weights: float | torch.Tensor, per_batch: bool = False +): + """ + Compute the number of elements in the loss function induced by `weights`. + This is a torch implementation of https://github.com/tensorflow/tensorflow/blob/4dacf3f368eb7965e9b5c3bbdd5193986081c3b2/tensorflow/python/ops/losses/losses_impl.py#L89 + + Args: + losses (Tensor): Tensor of shape [batch_size, d1, ... dN]. + weights (Tensor): Tensor of shape [], [batch_size] or [batch_size, d1, ... dK], where K < N. + per_batch (bool): Whether to return the number of elements per batch or as a sum total. + + Returns: + Tensor: The number of present (non-zero) elements in the losses tensor. + """ + if isinstance(weights, float): + if weights != 0.0: + return losses.numel() + else: + return torch.tensor(0) + + weights = torch.as_tensor(weights, dtype=torch.float32) + + # Check for non-zero weights and broadcast to match losses + present = torch.where( + weights == 0.0, torch.zeros_like(weights), torch.ones_like(weights) + ) + present = present.expand_as(losses) + + # Reduce sum across the desired dimensions + if per_batch: + reduction_dims = tuple(range(1, present.dim())) + return torch.sum(present, dim=reduction_dims, keepdim=True) + else: + return torch.sum(present) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py index 5188dabba9..8a1ea4a20a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py @@ -14,17 +14,18 @@ import torch.nn as nn import torch.nn.functional as F +from deeplabcut.pose_estimation_pytorch.models.criterions import utils from deeplabcut.pose_estimation_pytorch.models.criterions.base import ( - CRITERIONS, BaseCriterion, + CRITERIONS, ) class WeightedCriterion(BaseCriterion): """Base class for weighted criterions""" - def __init__(self, criterion: nn.Module, apply_sigmoid: bool = False): - super().__init__(apply_sigmoid=apply_sigmoid) + def __init__(self, criterion: nn.Module): + super().__init__() self.criterion = criterion def forward( @@ -44,15 +45,11 @@ def forward( Returns: the weighted loss """ - if self.apply_sigmoid: - output = F.sigmoid(output) - - loss = self.criterion(output, target) * weights - loss_without_zeros = loss[loss != 0] - if loss_without_zeros.nelement() == 0: + loss = self.criterion(output, target) + n_elems = utils.count_nonzero_elems(loss, weights) + if n_elems == 0: return torch.tensor(0.0, device=output.device) - - return torch.mean(loss_without_zeros) + return torch.sum(loss * weights) / n_elems @CRITERIONS.register_module @@ -66,8 +63,8 @@ class WeightedMSECriterion(WeightedCriterion): are excluded from the loss calculation. """ - def __init__(self, apply_sigmoid: bool = False) -> None: - super().__init__(nn.MSELoss(reduction="none"), apply_sigmoid=apply_sigmoid) + def __init__(self) -> None: + super().__init__(nn.MSELoss(reduction="none")) @CRITERIONS.register_module @@ -81,8 +78,8 @@ class WeightedHuberCriterion(WeightedCriterion): excluded from the loss calculation. """ - def __init__(self, apply_sigmoid: bool = False) -> None: - super().__init__(nn.HuberLoss(reduction="none"), apply_sigmoid=apply_sigmoid) + def __init__(self) -> None: + super().__init__(nn.HuberLoss(reduction="none")) @CRITERIONS.register_module @@ -96,7 +93,5 @@ class WeightedBCECriterion(WeightedCriterion): excluded from the loss calculation. """ - def __init__(self, apply_sigmoid: bool = False) -> None: - super().__init__( - nn.BCEWithLogitsLoss(reduction="none"), apply_sigmoid=apply_sigmoid - ) + def __init__(self) -> None: + super().__init__(nn.BCEWithLogitsLoss(reduction="none")) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index a92375173a..b6d0086780 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -9,13 +9,13 @@ # Licensed under GNU Lesser General Public License v3.0 # from __future__ import annotations + from abc import ABC, abstractmethod import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry DETECTORS = Registry("detectors", build_func=build_from_cfg) @@ -31,9 +31,7 @@ def __init__(self) -> None: @abstractmethod def forward( - self, - x: torch.Tensor, - targets: list[dict[str, torch.Tensor]] | None = None, + self, x: torch.Tensor, targets: list[dict[str, torch.Tensor]] | None = None ) -> tuple[dict[str, torch.Tensor], list[dict[str, torch.Tensor]]]: """ Forward pass of the detector diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index a1dd76e2f0..cd847abe1d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -16,8 +16,8 @@ from torchvision.models.detection.faster_rcnn import FastRCNNPredictor from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( - DETECTORS, BaseDetector, + DETECTORS, ) @@ -59,9 +59,7 @@ def __init__(self): self.model.eager_outputs = lambda losses, detections: (losses, detections) def forward( - self, - x: torch.Tensor, - targets: list[dict[str, torch.Tensor]] | None = None, + self, x: torch.Tensor, targets: list[dict[str, torch.Tensor]] | None = None ) -> tuple[dict[str, torch.Tensor], list[dict[str, torch.Tensor]]]: """ Forward pass of the Faster R-CNN diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index 9ea0003098..68da8a92e4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -9,6 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # from __future__ import annotations + from abc import ABC, abstractmethod import torch @@ -20,7 +21,7 @@ ) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry HEADS = Registry("heads", build_func=build_from_cfg) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py index c0a1e38933..7e6e4635e2 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py @@ -51,10 +51,7 @@ def __init__( self.offset_head = DEKROffset(**offset_config) def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: - return { - "heatmap": self.heatmap_head(x), - "offset": self.offset_head(x), - } + return {"heatmap": self.heatmap_head(x), "offset": self.offset_head(x)} class DEKRHeatmap(nn.Module): @@ -104,14 +101,10 @@ def __init__( self.final_conv_kernel = final_conv_kernel self.transition_heatmap = self._make_transition_for_head( - self.inp_channels, - channels[1], + self.inp_channels, channels[1] ) self.head_heatmap = self._make_heatmap_head( - block, - num_blocks, - channels[1], - dilation_rate, + block, num_blocks, channels[1], dilation_rate ) def _make_transition_for_head( @@ -266,8 +259,7 @@ def __init__( self.final_conv_kernel = final_conv_kernel self.transition_offset = self._make_transition_for_head( - self.inp_channels, - self.offset_channels, + self.inp_channels, self.offset_channels ) ( self.offset_feature_layers, diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py index 26a4fc9606..91ab44a180 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py @@ -19,8 +19,8 @@ ) from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import ( - HeatmapHead, DeconvModule, + HeatmapHead, ) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator @@ -42,12 +42,17 @@ def __init__( num_stages: int = 5, features_dim: int = 128, ) -> None: + self.num_stages = num_stages + # FIXME Cleaner __init__ to avoid initializing unused layers in_channels = heatmap_config["channels"][0] num_keypoints = heatmap_config["channels"][-1] num_limbs = paf_config["channels"][-1] # Already has the 2x multiplier in_refined_channels = features_dim + num_keypoints + num_limbs - heatmap_config["channels"][0] = paf_config["channels"][0] = in_refined_channels - locref_config["channels"][0] = locref_config["channels"][-1] + if num_stages > 0: + heatmap_config["channels"][0] = paf_config["channels"][ + 0 + ] = in_refined_channels + locref_config["channels"][0] = locref_config["channels"][-1] super().__init__( predictor, target_generator, @@ -114,24 +119,30 @@ def _make_refinement_layer(self, in_channels: int, out_channels: int) -> nn.Conv ) def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: - stage1_hm_out = self.convt1(x) - stage1_paf_out = self.convt3(x) - features = self.convt4(x) - stage2_in = torch.cat((stage1_hm_out, stage1_paf_out, features), dim=1) - stage_in = stage2_in - stage_paf_out = stage1_paf_out - stage_hm_out = stage1_hm_out - for i, (hm_ref_layer, paf_ref_layer) in enumerate( - zip(self.hm_ref_layers, self.paf_ref_layers) - ): - pre_stage_hm_out = stage_hm_out - stage_hm_out = hm_ref_layer(stage_in) - stage_paf_out = paf_ref_layer(stage_in) - if i > 0: - stage_hm_out += pre_stage_hm_out - stage_in = torch.cat((stage_hm_out, stage_paf_out, features), dim=1) + if self.num_stages > 0: + stage1_hm_out = self.convt1(x) + stage1_paf_out = self.convt3(x) + features = self.convt4(x) + stage2_in = torch.cat((stage1_hm_out, stage1_paf_out, features), dim=1) + stage_in = stage2_in + stage_paf_out = stage1_paf_out + stage_hm_out = stage1_hm_out + for i, (hm_ref_layer, paf_ref_layer) in enumerate( + zip(self.hm_ref_layers, self.paf_ref_layers) + ): + pre_stage_hm_out = stage_hm_out + stage_hm_out = hm_ref_layer(stage_in) + stage_paf_out = paf_ref_layer(stage_in) + if i > 0: + stage_hm_out += pre_stage_hm_out + stage_in = torch.cat((stage_hm_out, stage_paf_out, features), dim=1) + return { + "heatmap": self.heatmap_head(stage_in), + "locref": self.locref_head(self.convt2(x)), + "paf": self.paf_head(stage_in), + } return { - "heatmap": self.heatmap_head(stage_in), - "locref": self.locref_head(self.convt2(x)), - "paf": self.paf_head(stage_in), + "heatmap": self.heatmap_head(x), + "locref": self.locref_head(x), + "paf": self.paf_head(x), } diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 8821946d26..58a41a5d31 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -17,7 +17,7 @@ BaseCriterion, BaseLossAggregator, ) -from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS, BaseHead +from deeplabcut.pose_estimation_pytorch.models.heads.base import BaseHead, HEADS from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator @@ -57,10 +57,7 @@ class DeconvModule(nn.Module): """ def __init__( - self, - channels: list[int], - kernel_size: list[int], - strides: list[int], + self, channels: list[int], kernel_size: list[int], strides: list[int] ) -> None: """ Args: diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py index ee039c2f70..33b8e954c4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py @@ -16,7 +16,7 @@ from torch import nn as nn from deeplabcut.pose_estimation_pytorch.models.criterions import BaseCriterion -from deeplabcut.pose_estimation_pytorch.models.heads import HEADS, BaseHead +from deeplabcut.pose_estimation_pytorch.models.heads import BaseHead, HEADS from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 7e761e9977..90e1caa5b4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -20,7 +20,7 @@ CRITERIONS, LOSS_AGGREGATORS, ) -from deeplabcut.pose_estimation_pytorch.models.heads import HEADS, BaseHead +from deeplabcut.pose_estimation_pytorch.models.heads import BaseHead, HEADS from deeplabcut.pose_estimation_pytorch.models.necks import NECKS from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS from deeplabcut.pose_estimation_pytorch.models.target_generators import ( @@ -123,9 +123,7 @@ def get_target( } def get_predictions( - self, - inputs: torch.Tensor, - outputs: dict[str, dict[str, torch.Tensor]], + self, inputs: torch.Tensor, outputs: dict[str, dict[str, torch.Tensor]] ) -> dict: """Abstract method for the forward pass of the Predictor. @@ -175,9 +173,5 @@ def from_cfg(cfg: dict) -> "PoseModel": heads[name] = HEADS.build(head_cfg) return PoseModel( - cfg=cfg, - backbone=backbone, - neck=neck, - heads=heads, - **cfg["pose_model"], + cfg=cfg, backbone=backbone, neck=neck, heads=heads, **cfg["pose_model"] ) diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py index ebe09622ab..d827382a61 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py @@ -10,13 +10,14 @@ # # The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main from __future__ import annotations + from abc import ABC, abstractmethod import torch import torch.nn as nn import torchvision.ops as ops -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry BLOCKS = Registry("blocks", build_func=build_from_cfg) diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/base.py b/deeplabcut/pose_estimation_pytorch/models/necks/base.py index dd049b0b01..4273523bfd 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/base.py @@ -11,7 +11,8 @@ from abc import ABC, abstractmethod import torch -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg + +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry NECKS = Registry("necks", build_func=build_from_cfg) diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py index edfb9a55c9..b46c28c4c2 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py @@ -157,7 +157,7 @@ def __init__( """ super().__init__() self.heads = heads - self.scale = (dim // heads) ** -0.5 if scale_with_head else dim**-0.5 + self.scale = (dim // heads) ** -0.5 if scale_with_head else dim ** -0.5 self.to_qkv = torch.nn.Linear(dim, dim * 3, bias=False) self.to_out = torch.nn.Sequential( diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py index 346e8e150c..948ed13d4e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py @@ -111,8 +111,9 @@ def __init__( self.keypoint_token = torch.nn.Parameter( torch.zeros(1, self.num_keypoints, dim) ) - h, w = feature_size[0] // (self.patch_size[0]), feature_size[1] // ( - self.patch_size[1] + h, w = ( + feature_size[0] // (self.patch_size[0]), + feature_size[1] // (self.patch_size[1]), ) self._make_position_embedding(w, h, dim, pos_embedding_type) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index 7261832e34..3f29f5991d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -14,8 +14,7 @@ import torch from torch import nn -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry PREDICTORS = Registry("predictors", build_func=build_from_cfg) @@ -48,9 +47,7 @@ def __init__(self): @abstractmethod def forward( - self, - inputs: torch.Tensor, - outputs: dict[str, torch.Tensor], + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Abstract method for the forward pass of the Predictor. diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 78f104fa52..76e3c7dc04 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -15,8 +15,8 @@ import torch.nn.functional as F from deeplabcut.pose_estimation_pytorch.models.predictors import ( - PREDICTORS, BasePredictor, + PREDICTORS, ) @@ -99,9 +99,7 @@ def __init__( self.max_absorb_distance = max_absorb_distance def forward( - self, - inputs: torch.Tensor, - outputs: dict[str, torch.Tensor], + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Forward pass of DEKRPredictor. diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index 546970aeeb..456c0dea6d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -13,9 +13,10 @@ import numpy as np import torch import torch.nn.functional as F + from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( - PREDICTORS, BasePredictor, + PREDICTORS, ) from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils @@ -57,6 +58,8 @@ def __init__( nms_radius: int, sigma: float, min_affinity: float, + add_discarded: bool = False, + force_fusion: bool = False, ): """Initialize the PartAffinityFieldPredictor class. @@ -91,12 +94,12 @@ def __init__( graph=graph, paf_inds=edges_to_keep, min_affinity=min_affinity, + add_discarded=add_discarded, + force_fusion=force_fusion, ) def forward( - self, - inputs: torch.Tensor, - outputs: dict[str, torch.Tensor], + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Forward pass of PartAffinityFieldPredictor. Gets predictions from model output. @@ -117,7 +120,7 @@ def forward( """ heatmaps = outputs["heatmap"] locrefs = outputs["locref"] - pafs = outputs["pafs"] + pafs = outputs["paf"] h_in, w_in = inputs.shape[2:] h_out, w_out = heatmaps.shape[2:] scale_factors = h_in / h_out, w_in / w_out @@ -127,28 +130,21 @@ def forward( # Filter predicted heatmaps with a 2D Gaussian kernel as in: # https://openaccess.thecvf.com/content_CVPR_2020/papers/Huang_The_Devil_Is_in_the_Details_Delving_Into_Unbiased_Data_CVPR_2020_paper.pdf kernel = self.make_2d_gaussian_kernel( - sigma=self.sigma, - size=self.nms_radius * 2 + 1, + sigma=self.sigma, size=self.nms_radius * 2 + 1 )[None, None] kernel = kernel.repeat(n_channels, 1, 1, 1).to(heatmaps.device) heatmaps = F.conv2d( - heatmaps, - kernel, - stride=1, - padding='same', - groups=n_channels, + heatmaps, kernel, stride=1, padding="same", groups=n_channels ) peaks = self.find_local_peak_indices_maxpool_nms( - heatmaps, - self.nms_radius, - threshold=0.01, + heatmaps, self.nms_radius, threshold=0.01 ) if ~torch.any(peaks): return {"poses": []} locrefs = locrefs.reshape(batch_size, n_channels, 2, height, width) - locrefs *= self.locref_stdev + locrefs = locrefs * self.locref_stdev pafs = pafs.reshape(batch_size, -1, 2, height, width) graph = [self.graph[ind] for ind in self.edges_to_keep] @@ -171,7 +167,8 @@ def forward( if unique is not None: poses_unique[i, 0] = torch.from_numpy(unique) - return {"poses": poses, "unique_bodyparts": {"poses": poses_unique}} + # FIXME Handle unique bodyparts in a separate HeatmapHead + return {"poses": poses} @staticmethod def find_local_peak_indices_maxpool_nms(input_, radius, threshold): @@ -187,12 +184,7 @@ def make_2d_gaussian_kernel(sigma, size): return torch.einsum("i,j->ij", k, k) @staticmethod - def calc_peak_locations( - locrefs, - peak_inds_in_batch, - strides, - n_decimals=3, - ): + def calc_peak_locations(locrefs, peak_inds_in_batch, strides, n_decimals=3): s, b, r, c = peak_inds_in_batch.T stride_y, stride_x = strides strides = torch.Tensor((stride_x, stride_y)).to(locrefs.device) @@ -316,13 +308,7 @@ def compute_peaks_and_costs( pafs = pafs.detach().cpu().numpy() peak_inds_in_batch = peak_inds_in_batch.detach().cpu().numpy() costs = self.compute_edge_costs( - pafs, - peak_inds_in_batch, - graph, - paf_inds, - n_bodyparts, - n_points, - n_decimals, + pafs, peak_inds_in_batch, graph, paf_inds, n_bodyparts, n_points, n_decimals ) s, b, r, c = peak_inds_in_batch.T prob = np.round(heatmaps[s, b, r, c], n_decimals).reshape((-1, 1)) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index a966b62f5e..49b03d7693 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -12,9 +12,10 @@ from typing import Tuple import torch + from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( - PREDICTORS, BasePredictor, + PREDICTORS, ) @@ -72,9 +73,7 @@ def __init__( self.sigmoid = torch.nn.Sigmoid() def forward( - self, - inputs: torch.Tensor, - outputs: dict[str, torch.Tensor], + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Forward pass of SinglePredictor. Gets predictions from model output. @@ -218,9 +217,7 @@ def __init__(self, num_animals: int, apply_sigmoid: bool = True): self.sigmoid = torch.nn.Sigmoid() def forward( - self, - inputs: torch.Tensor, - outputs: dict[str, torch.Tensor], + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Forward pass of SinglePredictor. Gets predictions from model output. diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py index ada5819891..3c2e8d57bb 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py @@ -10,9 +10,10 @@ # import torch + from deeplabcut.pose_estimation_pytorch.models.predictors import ( - PREDICTORS, BasePredictor, + PREDICTORS, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py index e2a7b8943f..bc018fe7ae 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -9,13 +9,13 @@ # Licensed under GNU Lesser General Public License v3.0 # from __future__ import annotations + from abc import ABC, abstractmethod import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg - +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry TARGET_GENERATORS = Registry("target_generators", build_func=build_from_cfg) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index 3fe93367ff..9d88fc8e85 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -14,8 +14,8 @@ import torch from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - TARGET_GENERATORS, BaseGenerator, + TARGET_GENERATORS, ) @@ -46,10 +46,7 @@ def __init__( self.bg_weight = bg_weight def forward( - self, - inputs: torch.Tensor, - outputs: dict[str, torch.Tensor], - labels: dict, + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict ) -> dict[str, dict[str, torch.Tensor]]: """ Given the annotations and predictions of your keypoints, this function returns the targets, @@ -106,22 +103,10 @@ def forward( (batch_size, self.num_heatmaps, output_h, output_w), dtype=np.float32 ) offset_map = np.zeros( - ( - batch_size, - self.num_joints * 2, - output_h, - output_w, - ), - dtype=np.float32, + (batch_size, self.num_joints * 2, output_h, output_w), dtype=np.float32 ) weight_map = np.zeros( - ( - batch_size, - self.num_joints * 2, - output_h, - output_w, - ), - dtype=np.float32, + (batch_size, self.num_joints * 2, output_h, output_w), dtype=np.float32 ) area_map = np.zeros((batch_size, output_h, output_w), dtype=np.float32) for b in range(batch_size): @@ -140,19 +125,22 @@ def forward( if np.any(pt <= 0.0): continue x, y = pt[0], pt[1] - x_sm, y_sm = (x - stride_x / 2) / stride_x, ( - y - stride_y / 2 - ) / stride_y + x_sm, y_sm = ( + (x - stride_x / 2) / stride_x, + (y - stride_y / 2) / stride_y, + ) if x_sm < 0 or y_sm < 0 or x_sm >= output_w or y_sm >= output_h: continue # HEATMAP COMPUTATION - ul = int(np.floor(x_sm - 3 * sigma - 1)), int( - np.floor(y_sm - 3 * sigma - 1) + ul = ( + int(np.floor(x_sm - 3 * sigma - 1)), + int(np.floor(y_sm - 3 * sigma - 1)), ) - br = int(np.ceil(x_sm + 3 * sigma + 2)), int( - np.ceil(y_sm + 3 * sigma + 2) + br = ( + int(np.ceil(x_sm + 3 * sigma + 2)), + int(np.ceil(y_sm + 3 * sigma + 2)), ) cc, dd = max(0, ul[0]), min(br[0], output_w) @@ -228,4 +216,4 @@ def dekr_heatmap_val(sigma: float, x: float, y: float, x0: float, y0: float) -> Returns: g: calculated heat value represents the intensity of the heat at a given position """ - return np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma**2)) + return np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma ** 2)) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py index 5e500be76a..ea270c0c1a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py @@ -12,9 +12,10 @@ import numpy as np import torch + from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - TARGET_GENERATORS, BaseGenerator, + TARGET_GENERATORS, ) @@ -46,16 +47,13 @@ def __init__( self.locref_scale = 1.0 / locref_stdev self.num_joints = num_joints self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh**2 + self.dist_thresh_sq = self.dist_thresh ** 2 self.std = ( 2 * self.dist_thresh / 3 ) # We think of dist_thresh as a radius and std is a 'diameter' def forward( - self, - inputs: torch.Tensor, - outputs: torch.Tensor, - labels: dict, + self, inputs: torch.Tensor, outputs: torch.Tensor, labels: dict ) -> dict[str, dict[str, torch.Tensor]]: """Summary: Given the annotations and predictions of your keypoints, this function returns the targets, @@ -109,7 +107,7 @@ def forward( if np.any(coord <= 0.0): continue dist = np.linalg.norm(grid - coord, axis=2) ** 2 - scmap_j = np.exp(-dist / (2 * self.std**2)) + scmap_j = np.exp(-dist / (2 * self.std ** 2)) scmap[b, :, :, i] += scmap_j locref_mask[b, dist <= self.dist_thresh_sq, i * 2 : i * 2 + 2] = 1 dx = coord[1] - grid.copy()[:, :, 1] @@ -121,7 +119,7 @@ def forward( locref_mask = locref_mask.transpose(0, 3, 1, 2) return { "heatmap": { - "target": torch.tensor(scmap, device=outputs["heatmap"].device), + "target": torch.tensor(scmap, device=outputs["heatmap"].device) }, "locref": { "target": torch.tensor(locref_map, device=outputs["locref"].device), diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py index b61a0fc85a..11eab03a8d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py @@ -14,9 +14,10 @@ import numpy as np import torch + from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - TARGET_GENERATORS, BaseGenerator, + TARGET_GENERATORS, ) @@ -51,10 +52,7 @@ def __init__(self, graph: list[list[int, int]], width: float): self.num_limbs = len(graph) def forward( - self, - inputs: torch.Tensor, - outputs: dict[str, torch.Tensor], - labels: dict, + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict ) -> dict[str, dict[str, torch.Tensor]]: batch_size, _, input_h, input_w = inputs.shape height, width = outputs["heatmap"].shape[2:] @@ -80,7 +78,7 @@ def forward( j2_x, j2_y = kpts_animal[bp2] vec_x = j2_x - j1_x vec_y = j2_y - j1_y - dist = sqrt(vec_x**2 + vec_y**2) + dist = sqrt(vec_x ** 2 + vec_y ** 2) if dist > 0: vec_x_norm = vec_x / dist vec_y_norm = vec_y / dist @@ -110,6 +108,8 @@ def forward( partaffinityfield_map = partaffinityfield_map.transpose(0, 3, 1, 2) return { "paf": { - "target": torch.tensor(partaffinityfield_map, device=outputs["paf"].device), - }, + "target": torch.tensor( + partaffinityfield_map, device=outputs["paf"].device + ) + } } diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py index 70b4eff5f1..2c131cce9e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py @@ -12,9 +12,10 @@ import numpy as np import torch + from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - TARGET_GENERATORS, BaseGenerator, + TARGET_GENERATORS, ) @@ -51,15 +52,12 @@ def __init__( super().__init__(**kwargs) self.num_joints = num_joints self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh**2 + self.dist_thresh_sq = self.dist_thresh ** 2 self.generate_locref = generate_locref self.locref_scale = 1.0 / locref_stdev def forward( - self, - inputs: torch.Tensor, - outputs: torch.Tensor, - labels: dict, + self, inputs: torch.Tensor, outputs: torch.Tensor, labels: dict ) -> dict[str, dict[str, torch.Tensor]]: """Summary: Given the annotations and predictions of your keypoints, this function returns the targets, @@ -110,25 +108,23 @@ def forward( grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 for b in range(batch_size): - for idx_animal, kpts_animal in enumerate(coords[b]): + for _, kpts_animal in enumerate(coords[b]): for i, coord in enumerate(kpts_animal): coord = np.array(coord)[::-1] if np.any(coord <= 0.0): continue - dist = np.linalg.norm(grid - coord, axis=2) ** 2 + dist = np.sum((grid - coord) ** 2, axis=2) mask = dist <= self.dist_thresh_sq - scmap[b, (dist <= self.dist_thresh_sq), i] += 1 - locref_mask[b, dist <= self.dist_thresh_sq, i * 2 : i * 2 + 2] = 1 + scmap[b, mask, i] = 1 + locref_mask[b, mask, i * 2 : i * 2 + 2] = 1 dx = coord[1] - grid.copy()[:, :, 1] dy = coord[0] - grid.copy()[:, :, 0] - locref_map[b, mask, i * 2 + 0] += (dx * self.locref_scale)[mask] - locref_map[b, mask, i * 2 + 1] += (dy * self.locref_scale)[mask] + locref_map[b, mask, i * 2 + 0] = (dx * self.locref_scale)[mask] + locref_map[b, mask, i * 2 + 1] = (dy * self.locref_scale)[mask] scmap = scmap.transpose(0, 3, 1, 2) targets = { - "heatmap": { - "target": torch.tensor(scmap, device=outputs["heatmap"].device), - } + "heatmap": {"target": torch.tensor(scmap, device=outputs["heatmap"].device)} } if self.generate_locref: locref_map = locref_map.transpose(0, 3, 1, 2) diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 5a1500d62e..4307fdadc2 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -10,15 +10,15 @@ # import numpy as np +from scipy.optimize import linear_sum_assignment + from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( calc_object_keypoint_similarity, ) -from scipy.optimize import linear_sum_assignment def rmse_match_prediction_to_gt( - pred_kpts: np.ndarray, - gt_kpts: np.ndarray, + pred_kpts: np.ndarray, gt_kpts: np.ndarray ) -> np.ndarray: """Summary: Hungarian algorithm predicted individuals to ground truth ones, using root mean squared error (rmse). The function provides a way to @@ -58,11 +58,11 @@ def rmse_match_prediction_to_gt( distance_matrix = np.zeros((num_animals_gt, num_animals)) for g in range(num_animals_gt): for p in range(num_animals): - distance_matrix[g, p] = np.linalg.norm( - gt_kpts_without_ctr[g] - pred_kpts[p, :, :2] + distance_matrix[g, p] = np.nansum( + (gt_kpts_without_ctr[g] - pred_kpts[p, :, :2]) ** 2 ) - row_ind, col_ind = linear_sum_assignment(distance_matrix) + _, col_ind = linear_sum_assignment(distance_matrix) # if animals are missing in the frame, the predictions corresponding to nothing are not shuffled col_ind = extend_col_ind(col_ind, num_animals) diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index 5c947ca984..bcb6196d9f 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -14,19 +14,18 @@ from abc import ABC, abstractmethod from collections import defaultdict from pathlib import Path -from typing import Any, Generic, TypeVar, Iterable +from typing import Any, Generic, Iterable, TypeVar import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader -from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor from deeplabcut.pose_estimation_pytorch.data.postprocessor import Postprocessor -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg +from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger - RUNNERS = Registry("runners", build_func=build_from_cfg) ModelType = TypeVar("ModelType", bound=nn.Module) @@ -80,9 +79,7 @@ def __init__( @abstractmethod def step( - self, - batch: dict[str, Any], - mode: str = "train", + self, batch: dict[str, Any], mode: str = "train" ) -> dict[str, torch.Tensor]: """Perform a single epoch gradient update or validation step""" @@ -128,10 +125,7 @@ def fit( for i in range(self.starting_epoch, epochs): train_loss = self._epoch( - train_loader, - mode="train", - step=i + 1, - display_iters=display_iters, + train_loader, mode="train", step=i + 1, display_iters=display_iters ) if self.scheduler: self.scheduler.step() @@ -261,9 +255,7 @@ def _epoch( if self.logger: for key in metrics: self.logger.log( - f"{mode} {key}", - np.nanmean(metrics[key]).item(), - step=step, + f"{mode} {key}", np.nanmean(metrics[key]).item(), step=step ) return epoch_loss diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index 01b0ce93a2..124558fb37 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -13,15 +13,12 @@ from pathlib import Path from typing import Any, Optional -import wandb as wb - import deeplabcut.pose_estimation_pytorch.registry as deeplabcut_pose_estimation_pytorch_registry +import wandb as wb from deeplabcut.pose_estimation_pytorch.models.model import PoseModel - LOGGER = deeplabcut_pose_estimation_pytorch_registry.Registry( - "loggers", - build_func=deeplabcut_pose_estimation_pytorch_registry.build_from_cfg, + "loggers", build_func=deeplabcut_pose_estimation_pytorch_registry.build_from_cfg ) diff --git a/deeplabcut/pose_estimation_pytorch/runners/pose.py b/deeplabcut/pose_estimation_pytorch/runners/pose.py index f8b1282678..e18f7029be 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/pose.py +++ b/deeplabcut/pose_estimation_pytorch/runners/pose.py @@ -9,6 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # from __future__ import annotations + from typing import Any import numpy as np @@ -16,7 +17,7 @@ from torch.utils.data import DataLoader from deeplabcut.pose_estimation_pytorch.models import model as models -from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner +from deeplabcut.pose_estimation_pytorch.runners.base import Runner, RUNNERS from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger @@ -25,10 +26,7 @@ class PoseRunner(Runner[models.PoseModel]): """Runner for pose estimation""" def __init__( - self, - model: models.PoseModel, - optimizer: torch.optim.Optimizer, - **kwargs, + self, model: models.PoseModel, optimizer: torch.optim.Optimizer, **kwargs ): """TODO: Update doc to generic (not pose) runner. Constructor of the Runner class. Args: @@ -45,9 +43,7 @@ def __init__( super().__init__(model, optimizer, **kwargs) def step( - self, - batch: dict[str, Any], - mode: str = "train", + self, batch: dict[str, Any], mode: str = "train" ) -> dict[str, torch.Tensor]: """Perform a single epoch gradient update or validation step. @@ -77,11 +73,7 @@ def step( batch_inputs = batch_inputs.to(self.device) head_outputs = self.model(batch_inputs) - target = self.model.get_target( - batch_inputs, - head_outputs, - batch["annotations"], - ) + target = self.model.get_target(batch_inputs, head_outputs, batch["annotations"]) losses_dict = self.model.get_loss(head_outputs, target) if mode == "train": diff --git a/deeplabcut/pose_estimation_pytorch/runners/top_down.py b/deeplabcut/pose_estimation_pytorch/runners/top_down.py index 724d64eb55..01dd8e157c 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/runners/top_down.py @@ -9,6 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # from __future__ import annotations + from typing import Any import numpy as np @@ -16,8 +17,7 @@ from torch.utils.data import DataLoader import deeplabcut.pose_estimation_pytorch.models.detectors as detectors -from deeplabcut.pose_estimation_pytorch.runners import PoseRunner -from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner +from deeplabcut.pose_estimation_pytorch.runners.base import Runner, RUNNERS @RUNNERS.register_module @@ -44,9 +44,7 @@ def __init__( self.max_individuals = max_individuals def step( - self, - batch: dict[str, Any], - mode: str = "train", + self, batch: dict[str, Any], mode: str = "train" ) -> dict[str, torch.Tensor]: """Perform a single epoch gradient update or validation step. @@ -127,8 +125,8 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] "detection": { "bboxes": item["boxes"][: self.max_individuals] .cpu() - .numpy(), - }, + .numpy() + } } ) diff --git a/deeplabcut/pose_estimation_pytorch/runners/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py index 3635880469..628d502267 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/utils.py +++ b/deeplabcut/pose_estimation_pytorch/runners/utils.py @@ -14,13 +14,14 @@ import re import warnings from pathlib import Path -from typing import List, Tuple, Optional +from typing import List, Optional, Tuple -import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils -import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions import numpy as np import pandas as pd +import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils +import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions + def verify_paths( paths: List[str], pattern: str = r"^(.*)?snapshot-(\d+)\.pt$" @@ -185,10 +186,7 @@ def get_dlc_scorer( snapshot = snapshots[train_iterations] snapshot_epochs = int(snapshot.split("-")[-1]) - ( - dlc_scorer, - dlc_scorer_legacy, - ) = auxiliaryfunctions.get_scorer_name( + (dlc_scorer, dlc_scorer_legacy) = auxiliaryfunctions.get_scorer_name( test_cfg, shuffle, train_fraction, snapshot_epochs, modelprefix=model_prefix ) @@ -315,11 +313,7 @@ def get_results_filename( Output: 'proj_name/evaluation-results/iteration-0/behaviordate-trainset95shuffle1/DLC_dekr_w32_behaviordateshuffle1_1-snapshot-10.h5' """ - ( - _, - results_filename, - _, - ) = auxiliaryfunctions.check_if_not_evaluated( + (_, results_filename, _) = auxiliaryfunctions.check_if_not_evaluated( evaluation_folder, dlc_scorer, dlc_scorerlegacy, os.path.basename(model_path) ) @@ -327,11 +321,7 @@ def get_results_filename( def get_model_folder( - project_path: str, - cfg: dict, - train_fraction: float, - shuffle: int, - model_prefix: str, + project_path: str, cfg: dict, train_fraction: float, shuffle: int, model_prefix: str ) -> str: """Returns the model folder path given the ff parameters: train_faction, shuffle, model_prefix, and test_cfg @@ -459,22 +449,13 @@ def build_predictions_df( if num_individuals == 1: # Single animal prediction dataframe index = pd.MultiIndex.from_product( - [ - [dlc_scorer], - bodyparts, - ["x", "y", "likelihood"], - ], + [[dlc_scorer], bodyparts, ["x", "y", "likelihood"]], names=["scorer", "bodyparts", "coords"], ) else: # Multi-animal prediction dataframe index = pd.MultiIndex.from_product( - [ - [dlc_scorer], - individuals, - bodyparts, - ["x", "y", "likelihood"], - ], + [[dlc_scorer], individuals, bodyparts, ["x", "y", "likelihood"]], names=["scorer", "individuals", "bodyparts", "coords"], ) @@ -493,26 +474,14 @@ def build_entire_pred_df( num_individuals = len(individuals) if num_individuals == 1 or len(unique_bodyparts) == 0 or unique_predictions is None: return build_predictions_df( - dlc_scorer, - individuals, - bodyparts, - df_index, - predictions, + dlc_scorer, individuals, bodyparts, df_index, predictions ) animals_df = build_predictions_df( - dlc_scorer, - individuals, - bodyparts, - df_index, - predictions, + dlc_scorer, individuals, bodyparts, df_index, predictions ) unique_df = build_predictions_df( - dlc_scorer, - ["single"], - unique_bodyparts, - df_index, - unique_predictions, + dlc_scorer, ["single"], unique_bodyparts, df_index, unique_predictions ) new_cols = pd.MultiIndex.from_tuples( [(col[0], "single", col[1], col[2]) for col in unique_df.columns], @@ -533,12 +502,7 @@ def get_paths( method: str = "bu", ): dlc_scorer, dlc_scorer_legacy = get_dlc_scorer( - project_path, - cfg, - train_fraction, - shuffle, - model_prefix, - train_iterations, + project_path, cfg, train_fraction, shuffle, model_prefix, train_iterations ) evaluation_folder = get_evaluation_folder( train_fraction, shuffle, model_prefix, cfg diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py b/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py index 7461b50827..8966d7cc06 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py @@ -1,22 +1,13 @@ -import os -import pytest -import numpy as np import random -import deeplabcut.pose_estimation_pytorch as dlc +import numpy as np +import pytest + import deeplabcut.pose_estimation_pytorch.apis.utils as dlc_api_utils -import deeplabcut.utils.auxiliaryfunctions as dlc_auxfun transform_dicts = [ - { - "auto_padding": { - "pad_height_divisor": 64, - "pad_width_divisor": 27, - } - }, - { - "resize": [512, 256], - }, + {"auto_padding": {"pad_height_divisor": 64, "pad_width_divisor": 27}}, + {"resize": [512, 256]}, { "covering": True, "gaussian_noise": 12.75, @@ -25,11 +16,7 @@ "normalize_images": True, "rotation": 30, "scale_jitter": [0.5, 1.25], - "translation": 40, - "auto_padding": { - "pad_width_divisor": 64, - "pad_height_divisor": 27, - }, + "auto_padding": {"pad_width_divisor": 64, "pad_height_divisor": 27}, }, { "covering": True, @@ -39,11 +26,7 @@ "normalize_images": True, "rotation": 180, "scale_jitter": [0.03, 20], - "translation": 300, - "auto_padding": { - "pad_width_divisor": 64, - "pad_height_divisor": 27, - }, + "auto_padding": {"pad_width_divisor": 64, "pad_height_divisor": 27}, }, ] @@ -51,10 +34,7 @@ def _get_random_params(transform_idx): return ( transform_dicts[transform_idx], - ( - random.randint(100, 1000), - random.randint(100, 1000), - ), + (random.randint(100, 1000), random.randint(100, 1000)), random.randint(1, 100), random.randint(1, 100), ) @@ -72,8 +52,7 @@ def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals transform_dict, augment_bbox=False ) transform_inference = dlc_api_utils.build_inference_transform( - transform_dict, - augment_bbox=False, + transform_dict, augment_bbox=False ) w, h = size_image diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py b/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py index e62e83600f..6daf0d6e3a 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py @@ -12,11 +12,7 @@ @pytest.mark.parametrize("repo_path", ["/home/anastasiia/DLCdev"]) def test_propertymeta_project(repo_path): - project_root = os.path.join( - repo_path, - "examples", - "openfield-Pranav-2018-10-30", - ) + project_root = os.path.join(repo_path, "examples", "openfield-Pranav-2018-10-30") dlc_project = DLCProject(project_root, shuffle=1) for prop in dlc_project.properties: @@ -31,11 +27,7 @@ def test_propertymeta_dataset(repo_path, mode): repo_path = "/home/anastasiia/DLCdev" mode = "train" mode = "train" - project_root = os.path.join( - repo_path, - "examples", - "openfield-Pranav-2018-10-30", - ) + project_root = os.path.join(repo_path, "examples", "openfield-Pranav-2018-10-30") dlc_project = DLCProject(project_root, shuffle=1) dataset = PoseDataset(dlc_project, mode) @@ -52,13 +44,10 @@ def test_propertymeta_dataset(repo_path, mode): *[ { "keypoints": np.random.randn(27, 3), - "images": np.random.randn( - 256, - 192, - ), + "images": np.random.randn(256, 192), } ] - * 10, + * 10 ], [*["keypoints", "images"] * 10], ), diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py index 5cdaf3f798..96b8692284 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py @@ -1,5 +1,6 @@ import pytest import torch + from deeplabcut.pose_estimation_pytorch.models.target_generators import gaussian_targets diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py index ace419eabe..e49184f35c 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py @@ -6,23 +6,17 @@ from deeplabcut.generate_training_dataset.make_pytorch_config import * from deeplabcut.pose_estimation_pytorch.apis import inference, utils from deeplabcut.pose_estimation_pytorch.default_config import * -from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS, BaseDetector +from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector, DETECTORS from deeplabcut.pose_estimation_pytorch.models.predictors import ( - PREDICTORS, BasePredictor, + PREDICTORS, ) from deeplabcut.pose_estimation_pytorch.tests.test_utils import write_config # Check implemented net types -single_nets = [ - "resnet_50", -] -multi_nets = [ - "dekr_w18", -] -multi_nets_td = [ - "token_pose_w32", -] +single_nets = ["resnet_50"] +multi_nets = ["dekr_w18"] +multi_nets_td = ["token_pose_w32"] single = [ele for ele in product(single_nets, [False])] multi = [ele for ele in product(multi_nets, [True])] diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py index 82ec5183c9..362dd10dfe 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py @@ -1,5 +1,6 @@ import pytest import torch + from deeplabcut.pose_estimation_pytorch.models.target_generators import pafs_targets @@ -8,7 +9,7 @@ [(2, 2, (64, 64)), (1, 5, (48, 64)), (8, 50, (64, 48))], ) def test_paf_target_generation( - batch_size: int, num_keypoints: int, image_size: tuple, num_animals=2, + batch_size: int, num_keypoints: int, image_size: tuple, num_animals=2 ): annotations = { "keypoints": torch.randint( @@ -24,4 +25,4 @@ def test_paf_target_generation( len(graph) * 2, image_size[0], image_size[1], - ) \ No newline at end of file + ) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py index 18d9c2c5f5..2620494a29 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py @@ -11,11 +11,12 @@ from typing import List, Tuple -import deeplabcut.pose_estimation_pytorch.models.target_generators.plateau_targets as deeplabcut_torch_plateau_targets import numpy as np import pytest import torch +import deeplabcut.pose_estimation_pytorch.models.target_generators.plateau_targets as deeplabcut_torch_plateau_targets + def get_target( batch_size: int, diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py index ce0dbc10ee..b6440c7885 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py @@ -1,28 +1,15 @@ -import pytest import random + +import pytest import torch + import deeplabcut.pose_estimation_pytorch.models as dlc_models -from deeplabcut.pose_estimation_pytorch.models.modules import BasicBlock, AdaptBlock +from deeplabcut.pose_estimation_pytorch.models.modules import AdaptBlock, BasicBlock backbones_dicts = [ - { - "type": "HRNet", - "model_name": "hrnet_w32", - "output_channels": 480, - "stride": 4, - }, - { - "type": "HRNet", - "model_name": "hrnet_w18", - "output_channels": 270, - "stride": 4, - }, - { - "type": "HRNet", - "model_name": "hrnet_w48", - "output_channels": 720, - "stride": 4, - }, + {"type": "HRNet", "model_name": "hrnet_w32", "output_channels": 480, "stride": 4}, + {"type": "HRNet", "model_name": "hrnet_w18", "output_channels": 270, "stride": 4}, + {"type": "HRNet", "model_name": "hrnet_w48", "output_channels": 720, "stride": 4}, { "type": "HRNetTopDown", "model_name": "hrnet_w32", @@ -41,12 +28,7 @@ "output_channels": 48, "stride": 4, }, - { - "type": "ResNet", - "model_name": "resnet50", - "output_channels": 2048, - "stride": 32, - }, + {"type": "ResNet", "model_name": "resnet50", "output_channels": 2048, "stride": 32}, ] heads_dicts = [ diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py b/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py index 4731fa4ca9..aa582f58ae 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py @@ -11,11 +11,12 @@ import random -import deeplabcut.pose_estimation_pytorch.solvers.schedulers as deeplabcut_torch_schedulers import pytest import torch from torch.optim import SGD +import deeplabcut.pose_estimation_pytorch.solvers.schedulers as deeplabcut_torch_schedulers + def generate_random_lr_list(num_floats: int): """Summary: diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py index 417360929f..4b2b23dce0 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py @@ -1,6 +1,10 @@ -import torch from itertools import combinations -from deeplabcut.pose_estimation_pytorch.models.target_generators import TARGET_GENERATORS + +import torch + +from deeplabcut.pose_estimation_pytorch.models.target_generators import ( + TARGET_GENERATORS, +) def test_sequential_generator(): @@ -19,12 +23,8 @@ def test_sequential_generator(): "num_joints": num_keypoints, "pos_dist_thresh": 17, }, - { - "type": "PartAffinityFieldGenerator", - "graph": graph, - "width": 20, - }, - ] + {"type": "PartAffinityFieldGenerator", "graph": graph, "width": 20}, + ], } gen = TARGET_GENERATORS.build(cfg) @@ -36,11 +36,11 @@ def test_sequential_generator(): prediction = [torch.rand((batch_size, num_keypoints, image_size[0], image_size[1]))] inputs = torch.rand(batch_size, 3, *image_size) head_outputs = { - 'heatmap': torch.rand(batch_size, num_keypoints, 32, 32), - 'locref': torch.rand(batch_size, num_keypoints * 2, 32, 32), - 'paf': torch.rand(batch_size, num_limbs * 2, 32, 32), + "heatmap": torch.rand(batch_size, num_keypoints, 32, 32), + "locref": torch.rand(batch_size, num_keypoints * 2, 32, 32), + "paf": torch.rand(batch_size, num_limbs * 2, 32, 32), } out = gen(inputs=inputs, outputs=head_outputs, labels=annotations) assert all(s in out for s in list(head_outputs)) for k, v in head_outputs.items(): - assert out[k]['target'].shape == v.shape + assert out[k]["target"].shape == v.shape diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py b/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py index 73988da431..368df9ab6f 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py @@ -1,20 +1,20 @@ -import yaml import os import pickle import time import albumentations as A -import torch -import torch.nn as nn import numpy as np +import torch +import yaml + import deeplabcut from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model +from deeplabcut.pose_estimation_pytorch.models.criterion import PoseLoss +from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction from deeplabcut.pose_estimation_pytorch.solvers.utils import ( get_paths, get_results_filename, ) -from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction -from deeplabcut.pose_estimation_pytorch.models.criterion import PoseLoss def read_yaml(path): @@ -80,10 +80,7 @@ def evaluate_network_custom( model_folder = os.path.join( cfg["project_path"], deeplabcut.auxiliaryfunctions.get_model_folder( - train_fraction, - shuffle, - cfg, - modelprefix=model_prefix, + train_fraction, shuffle, cfg, modelprefix=model_prefix ), ) pytorch_config_path = os.path.join(model_folder, "train", "pytorch_config.yaml") diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py b/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py index 6a509f404a..879d943f3d 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py @@ -1,29 +1,17 @@ import numpy as np import pytest + from deeplabcut.pose_estimation_pytorch.data import transforms -@pytest.mark.parametrize( - "width, height", - [ - (200, 200), - (300, 300), - (400, 400), - ], -) -def test_keypoint_aware_cropping( - width, - height, -): +@pytest.mark.parametrize("width, height", [(200, 200), (300, 300), (400, 400)]) +def test_keypoint_aware_cropping(width, height): fake_image = np.empty((600, 600, 3)) fake_keypoints = [(i * 100, i * 100, 0, 0) for i in range(1, 6)] aug = transforms.KeypointAwareCrop( width=width, height=height, crop_sampling="density" ) - transformed = aug( - image=fake_image, - keypoints=fake_keypoints, - ) + transformed = aug(image=fake_image, keypoints=fake_keypoints) assert transformed["image"].shape[:2] == (height, width) # Ensure at least a keypoint is visible in each crop assert len(transformed["keypoints"]) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py index bb108b557c..f5af28c586 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py @@ -65,7 +65,9 @@ def write_config(multianimal: bool) -> dict: cfg_file["y2"] = 624 cfg_file[ "batch_size" - ] = 8 # batch size during inference (video - analysis); see https://www.biorxiv.org/content/early/2018/10/30/457242 + ] = ( + 8 + ) # batch size during inference (video - analysis); see https://www.biorxiv.org/content/early/2018/10/30/457242 cfg_file["corner2move2"] = (50, 50) cfg_file["move2corner"] = True cfg_file["skeleton_color"] = "black" diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index 37819a92fb..bc1395bd2b 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -41,13 +41,7 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> dict: A dictionary in COCO format containing the images, annotations, and categories. """ try: - individuals = ( - df.columns.get_level_values( - "individuals", - ) - .unique() - .tolist() - ) + individuals = df.columns.get_level_values("individuals").unique().tolist() except KeyError: new_cols = pd.MultiIndex.from_tuples( [(col[0], "animal", col[1], col[2]) for col in df.columns], @@ -55,13 +49,7 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> ) df.columns = new_cols - individuals = ( - df.columns.get_level_values( - "individuals", - ) - .unique() - .tolist() - ) + individuals = df.columns.get_level_values("individuals").unique().tolist() unique_bpts = [] @@ -69,7 +57,7 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> unique_bpts.extend( df.xs("single", level="individuals", axis=1) .columns.get_level_values("bodyparts") - .unique(), + .unique() ) multi_bpts = ( df.xs(individuals[0], level="individuals", axis=1) @@ -85,11 +73,7 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> individual = individuals[0] - category = { - "name": individual, - "id": 0, - "supercategory": "animal", - } + category = {"name": individual, "id": 0, "supercategory": "animal"} if individual == "single": category["keypoints"] = unique_bpts @@ -116,21 +100,13 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> category_id = 1 # 0 is for background by default try: kpts = ( - data.xs(individual, level="individuals") - .to_numpy() - .reshape( - (-1, 2), - ) + data.xs(individual, level="individuals").to_numpy().reshape((-1, 2)) ) except: # somehow there are duplicates. So only use the first occurrence data = data.iloc[0] kpts = ( - data.xs(individual, level="individuals") - .to_numpy() - .reshape( - (-1, 2), - ) + data.xs(individual, level="individuals").to_numpy().reshape((-1, 2)) ) keypoints = np.zeros((len(kpts), 3)) @@ -146,8 +122,7 @@ def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> bbox_margin = 20 xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( - [keypoints], - slack=bbox_margin, + [keypoints], slack=bbox_margin )[0][:4] w = xmax - xmin @@ -244,11 +219,7 @@ def is_seq_of(seq, expected_type, seq_type=None): def get_pytorch_config(modelfolder): - pytorch_config_path = os.path.join( - modelfolder, - "train", - "pytorch_config.yaml", - ) + pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") pytorch_cfg = read_plainconfig(pytorch_config_path) return pytorch_cfg From 52d3a612533ee178241eec63a0caeca27783c982 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 15 Nov 2023 10:06:21 +0100 Subject: [PATCH 055/293] COCO benchmarking, improvements to COCOLoader, bug fixes --- benchmark/coco/README.md | 108 +++++ benchmark/coco/evaluate.py | 147 ++++++ benchmark/coco/make_config.py | 94 ++++ benchmark/coco/train.py | 112 +++++ benchmark/train_benchmark.py | 433 ++++++++++++++++++ .../make_pytorch_config.py | 11 +- .../pose_estimation_pytorch/__init__.py | 14 +- .../apis/analyze_videos.py | 18 +- .../pose_estimation_pytorch/apis/evaluate.py | 124 ++--- .../pose_estimation_pytorch/apis/inference.py | 2 +- .../pose_estimation_pytorch/apis/scoring.py | 12 +- .../pose_estimation_pytorch/apis/train.py | 37 +- .../pose_estimation_pytorch/apis/utils.py | 148 +++--- .../pose_estimation_pytorch/data/__init__.py | 4 + .../pose_estimation_pytorch/data/base.py | 33 +- .../data/cocoloader.py | 117 ++++- .../pose_estimation_pytorch/data/dataset.py | 19 +- .../pose_estimation_pytorch/data/dlcloader.py | 24 +- .../data/postprocessor.py | 230 ++++++++-- .../data/preprocessor.py | 155 ++++++- .../data/transforms.py | 60 ++- .../models/__init__.py | 8 +- .../models/criterions/__init__.py | 10 +- .../models/detectors/base.py | 2 +- .../models/detectors/fasterRCNN.py | 25 +- .../runners/__init__.py | 2 +- .../pose_estimation_pytorch/runners/base.py | 21 +- .../pose_estimation_pytorch/runners/logger.py | 3 +- .../pose_estimation_pytorch/runners/pose.py | 4 +- .../runners/top_down.py | 10 +- .../pose_estimation_pytorch/runners/utils.py | 5 +- docs/pytorch/pytorch_config.md | 65 +++ .../data/test_postprocessor.py | 139 ++++++ .../data/test_preprocessor.py | 51 +++ .../data/test_transforms.py | 82 ++++ .../runners/test_task.py | 17 + 36 files changed, 2053 insertions(+), 293 deletions(-) create mode 100644 benchmark/coco/README.md create mode 100644 benchmark/coco/evaluate.py create mode 100644 benchmark/coco/make_config.py create mode 100644 benchmark/coco/train.py create mode 100644 benchmark/train_benchmark.py create mode 100644 docs/pytorch/pytorch_config.md create mode 100644 tests/pose_estimation_pytorch/data/test_postprocessor.py create mode 100644 tests/pose_estimation_pytorch/data/test_preprocessor.py create mode 100644 tests/pose_estimation_pytorch/data/test_transforms.py create mode 100644 tests/pose_estimation_pytorch/runners/test_task.py diff --git a/benchmark/coco/README.md b/benchmark/coco/README.md new file mode 100644 index 0000000000..c16004c937 --- /dev/null +++ b/benchmark/coco/README.md @@ -0,0 +1,108 @@ +# Training DeepLabCut models on COCO Projects + +## Training and Evaluation Process + +There are three essential steps to follow to train a model + +1. Creating a `pytorch_config.yaml` model configuration file, which specifies the +architecture of the model, but also the optimizer, learning rate and data augmentations. +2. Training the network. For bottom up models, this means only training the pose model, +while for top-down models you can either just train the pose model (if you already have +a detector), or train a detector and then a pose model. +3. Evaluation. Once you have a trained model, you can evaluate all of the snapshots +you've saved. For top-down models, you can evaluate using ground-truth bounding boxes or +detector bounding boxes (by passing a detector snapshot as well). + +### Creating a Model Configuration File + +You can either copy an existing model configuration file and modify it to fit your +updated project (or run a new experiment), or you can create a default one for a given +model architecture using `make_config.py`. + +This will create a `train` and a `test` folder in the `output` folder that you've +specified. The configuration file will be saved in the `train` folder, and an +`inference_cfg.yaml` file will be created in the `test` folder, which contains +parameters for tracking. + +You can log to Weights & Biases by adding a logger to your configuration: + +```yaml +logger: + type: 'WandbLogger' + project_name: 'dlc3-rodent' + run_name: 'test-tokenpose' +``` + +### Training a Model + +If you're training a top-down model and don't want to train a detector, simply pass +`--detector-epochs 0` as a command line parameter. + +### Evaluation + +For top-down models, running `evaluate.py` without given the path to a +`--detector-snapshot` will use ground-truth bounding boxes. If you specify a detector +snapshot, it will first compute bounding boxes in the images using the trained detector, +and then will use these bounding boxes for inference. + +## Training on Images of Variable Size + +If your images have different sizes (and you want to make sure all of them have the same +size when being given to the models), you can add a `resize` parameter to your +`pytorch_config.yaml` file: + +```yaml +data: + resize: + width: 1300 + height: 800 + keep_ratio: true +``` + +This will resize images using +`deeplabcut.pose_estimation_pytorch.data.transforms.DLCResize`, which resizes while +preserving the aspect ratio, and then pads the image to the correct size. If all of +your images have the same aspect ratio (and your target width/height), you can resize +using `keep_ratio: false`. + +## Example + +Creating the model configuration file + +```bash +python make_config.py \ + /home/niels/datasets/rodent \ + /home/niels/datasets/rodent/experiments/exp_1 \ + dekr_w32 \ + --train_file corrected_train.json +``` + +Then modify that configuration (such as the data augmentations) to have it match +whatever is required for your project. Once you're done and happy with your project +configuration, you can use it to train a model: + +```bash +python train.py \ + /home/niels/datasets/rodent \ + /home/niels/datasets/rodent/experiments/exp_4/train/pytorch_config.yaml \ + --detector-epochs 20 \ + --detector-save-epochs 5 \ + --epochs 50 \ + --save-epochs 10 \ + --train_file train.json \ + --test_file test.json \ + --device cuda:0 +``` + +Evaluating a trained model: + +```bash +python evaluate.py \ + /home/niels/datasets/rodent \ + /home/niels/datasets/rodent/experiments/exp_2/train/pytorch_config.yaml \ + /home/niels/datasets/rodent/experiments/exp_2/train/snapshot-10.pt \ + --detector_path /home/niels/datasets/rodent/experiments/exp_2/train/detector-snapshot-200.pt + --train_file train.json \ + --test_file test.json \ + --device cuda:0 +``` diff --git a/benchmark/coco/evaluate.py b/benchmark/coco/evaluate.py new file mode 100644 index 0000000000..9c92917a50 --- /dev/null +++ b/benchmark/coco/evaluate.py @@ -0,0 +1,147 @@ +"""Evaluating COCO models""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np +import torch + +from deeplabcut.pose_estimation_pytorch import COCOLoader +from deeplabcut.pose_estimation_pytorch.apis.evaluate import evaluate +from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.pose_estimation_pytorch.runners import Task + + +def pycocotools_evaluation( + kpt_oks_sigmas: list[int], + gt_path: str, + predictions_path: str, + annotation_type: str, +) -> None: + """Evaluation of models using Pycocotools + + Evaluates the predictions using OKS sigma 0.1, margin 0 and prints the results to + the console. + + Args: + kpt_oks_sigmas: the OKS sigma for each keypoint + gt_path: the path to the ground truth annotations + predictions_path: the path to the predictions + annotation_type: {"bbox", "keypoints"} the annotation type to evaluate + """ + print(80 * "-") + print(f"Attempting `pycocotools` evaluation for {annotation_type}!") + try: + from pycocotools.coco import COCO + from pycocotools.cocoeval import COCOeval + + coco_gt = COCO(gt_path) + coco_det = coco_gt.loadRes(predictions_path) + coco_eval = COCOeval(coco_gt, coco_det, annotation_type) + coco_eval.params.kpt_oks_sigmas = kpt_oks_sigmas + + coco_eval.evaluate() + coco_eval.accumulate() + coco_eval.summarize() + + except Exception as err: + print(f"Could not evaluate with `pycocotools`: {err}") + finally: + print(80 * "-") + + +def main( + project_root: str, + train_file: str, + test_file: str, + pytorch_config_path: str, + device: str | None, + snapshot_path: str, + detector_path: str | None, + pcutoff: float, + oks_sigma: float, +): + loader = COCOLoader( + project_root=project_root, + model_config_path=pytorch_config_path, + train_json_filename=train_file, + test_json_filename=test_file, + ) + parameters = loader.get_dataset_parameters() + pytorch_config = loader.model_cfg + if device is not None: + pytorch_config["device"] = device + + pose_runner, detector_runner = get_runners( + pytorch_config=pytorch_config, + snapshot_path=snapshot_path, + max_individuals=parameters.max_num_animals, + num_bodyparts=parameters.num_joints, + num_unique_bodyparts=parameters.num_unique_bpts, + transform=None, # Load transform from config + detector_path=detector_path, + detector_transform=None, + ) + + output_path = Path(pytorch_config_path).parent.parent / "results" + output_path.mkdir(exist_ok=True) + for mode in ["train", "test"]: + scores, predictions = evaluate( + pose_task=Task(pytorch_config.get("method", "bu")), + pose_runner=pose_runner, + loader=loader, + mode=mode, + detector_runner=detector_runner, + pcutoff=pcutoff, + ) + coco_predictions = loader.predictions_to_coco(predictions, mode=mode) + model_name = Path(snapshot_path).stem + if detector_path is not None: + model_name += Path(detector_path).stem + predictions_file = output_path / f"{model_name}-{mode}-predictions.json" + with open(predictions_file, "w") as f: + json.dump(coco_predictions, f) + + annotation_types = ["keypoints"] + if detector_runner is not None: + annotation_types.append("bbox") + for annotation_type in annotation_types: + kpt_oks_sigmas = oks_sigma * np.ones(parameters.num_joints) + pycocotools_evaluation( + kpt_oks_sigmas=kpt_oks_sigmas, + annotation_type=annotation_type, + gt_path=str(Path(project_root) / "annotations" / train_file), + predictions_path=str(predictions_file), + ) + + print(80 * "-") + print(f"{mode} results") + for k, v in scores.items(): + print(f" {k}: {v}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("project_root") + parser.add_argument("pytorch_config_path") + parser.add_argument("snapshot_path") + parser.add_argument("--train_file", default="train.json") + parser.add_argument("--test_file", default="test.json") + parser.add_argument("--device", default=None) + parser.add_argument("--detector_path", default=None) + parser.add_argument("--pcutoff", type=float, default=0.6) + parser.add_argument("--oks_sigma", type=float, default=0.1) + args = parser.parse_args() + main( + args.project_root, + args.train_file, + args.test_file, + args.pytorch_config_path, + args.device, + args.snapshot_path, + args.detector_path, + args.pcutoff, + args.oks_sigma, + ) diff --git a/benchmark/coco/make_config.py b/benchmark/coco/make_config.py new file mode 100644 index 0000000000..b70eda37ce --- /dev/null +++ b/benchmark/coco/make_config.py @@ -0,0 +1,94 @@ +"""Creates a base model configuration file to train a model on a COCO dataset + +""" +from __future__ import annotations + +import argparse +from pathlib import Path + +import torch + +import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.generate_training_dataset import MakeInference_yaml, make_pytorch_config +from deeplabcut.pose_estimation_pytorch import COCOLoader + + +def get_base_config( + dlc_path: str, + model_architecture: str, + bodyparts: list[str], + unique_bodyparts: list[str], + individuals: list[str], +) -> dict: + pytorch_cfg_template = af.read_plainconfig( + str(Path(dlc_path) / "pose_estimation_pytorch" / "apis" / "pytorch_config.yaml") + ) + cfg = { + "bodyparts": bodyparts, + "unique_bodyparts": unique_bodyparts, + "individuals": individuals, + } + return make_pytorch_config( + cfg, + model_architecture, + config_template=pytorch_cfg_template, + ) + + +def make_inference_config( + dlc_path: str, + output_path: str, + bodyparts: list[str], + num_individuals: int, +): + default_config_path = Path(dlc_path) / "inference_cfg.yaml" + items2change = { + "minimalnumberofconnections": int(len(bodyparts) / 2), + "topktoretain": num_individuals, + "withid": False, # TODO: implement + } + MakeInference_yaml(items2change, output_path, default_config_path) + + +def main(project_root: str, train_file: str, output: str, model_arch: str): + output_path = Path(output) + if output_path.exists(): + raise RuntimeError( + f"The output path must not exist yet, as otherwise we would risk overwriting" + f" existing configurations ({output_path} exists)" + ) + + train_dict = COCOLoader.load_json(project_root, train_file) + num_individuals, bodyparts = COCOLoader.get_project_parameters(train_dict) + dlc_path = af.get_deeplabcut_path() + pytorch_cfg = get_base_config( + dlc_path=dlc_path, + model_architecture=model_arch, + bodyparts=bodyparts, + unique_bodyparts=[], + individuals=[f"individual{i}" for i in range(num_individuals)], + ) + output_path.mkdir(parents=True) + train_dir = output_path / "train" + test_dir = output_path / "test" + train_dir.mkdir() + test_dir.mkdir() + + af.write_plainconfig(str(train_dir / "pytorch_config.yaml"), pytorch_cfg) + make_inference_config( + dlc_path, + str(test_dir / "inference_cfg.yaml"), + bodyparts, + num_individuals, + ) + print(f"Saved your model configuration in {output_path}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("project_root") + parser.add_argument("output") + parser.add_argument("model_arch") + parser.add_argument("--train_file", default="train.json") + args = parser.parse_args() + main(args.project_root, args.train_file, args.output, args.model_arch) diff --git a/benchmark/coco/train.py b/benchmark/coco/train.py new file mode 100644 index 0000000000..7d0d693d83 --- /dev/null +++ b/benchmark/coco/train.py @@ -0,0 +1,112 @@ +"""File to train a model on a COCO dataset""" +from __future__ import annotations + +import argparse +import copy +from pathlib import Path + +import torch + +from deeplabcut.pose_estimation_pytorch import COCOLoader +from deeplabcut.pose_estimation_pytorch.apis.train import train +from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.runners.logger import setup_file_logging + + +def main( + project_root: str, + train_file: str, + test_file: str, + pytorch_config: str, + device: str | None, + epochs: int | None, + save_epochs: int | None, + detector_epochs: int | None, + detector_save_epochs: int | None, + snapshot_path: str | None, + detector_path: str | None, +): + model_folder = Path(pytorch_config).parent.parent + log_path = Path(pytorch_config).parent / "log.txt" + setup_file_logging(log_path) + + loader = COCOLoader( + project_root=project_root, + model_config_path=pytorch_config, + train_json_filename=train_file, + test_json_filename=test_file, + ) + pytorch_config = loader.model_cfg + if device is not None: + pytorch_config["device"] = device + + if epochs is not None: + pytorch_config["epochs"] = epochs + if save_epochs is not None: + pytorch_config["save_epochs"] = save_epochs + + pose_task = Task(pytorch_config.get("method", "bu")) + if pytorch_config.get("method", "bu").lower() == "td": + logger_config = None + if pytorch_config.get("logger"): + logger_config = copy.deepcopy(pytorch_config["logger"]) + logger_config["run_name"] += "-detector" + + if detector_epochs is not None: + pytorch_config["detector"]["epochs"] = detector_epochs + if detector_save_epochs is not None: + pytorch_config["detector"]["save_epochs"] = detector_save_epochs + + if detector_epochs > 0: + train( + loader=loader, + model_folder=str(model_folder), + run_config=pytorch_config["detector"], + task=Task.DETECT, + device=pytorch_config["device"], + transform_config=pytorch_config["data_detector"], + logger_config=logger_config, + snapshot_path=detector_path, + transform=None, # Load transform from config + ) + + train( + loader=loader, + model_folder=str(model_folder), + run_config=pytorch_config, + task=pose_task, + device=pytorch_config["device"], + transform_config=pytorch_config["data"], + logger_config=pytorch_config.get("logger"), + snapshot_path=snapshot_path, + transform=None, # Load transform from config + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("project_root") + parser.add_argument("pytorch_config") + parser.add_argument("--train_file", default="train.json") + parser.add_argument("--test_file", default="test.json") + parser.add_argument("--device", default=None) + parser.add_argument("--epochs", type=int, default=None) + parser.add_argument("--save-epochs", type=int, default=None) + parser.add_argument("--detector-epochs", type=int, default=None) + parser.add_argument("--detector-save-epochs", type=int, default=None) + parser.add_argument("--snapshot_path", default=None) + parser.add_argument("--detector_path", default=None) + args = parser.parse_args() + main( + args.project_root, + args.train_file, + args.test_file, + args.pytorch_config, + args.device, + args.epochs, + args.save_epochs, + args.detector_epochs, + args.detector_save_epochs, + args.snapshot_path, + args.detector_path, + ) diff --git a/benchmark/train_benchmark.py b/benchmark/train_benchmark.py new file mode 100644 index 0000000000..eebea13dfc --- /dev/null +++ b/benchmark/train_benchmark.py @@ -0,0 +1,433 @@ +""" Benchmarking maDLC datasets + +TODO: Document data format + +In a first step, create_dataset=True was used to create the training dataset files and +the pytorch configurations for the models. The data augmentation parameters were then +updated for the shuffle that I wanted to train. This also included adding the following: + +``` +logger: + type: 'WandbLogger' + project_name: 'dlc3-ff5f2af-fish' + run_name: 'dekr-w32-shuffle3' +``` + +Which specifies to log the run to wandb, (including the project and with which name each +shuffle should be logged). + +Then run with `create_dataset=False, train=True` to train the models. +""" +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pandas as pd +import ruamel.yaml as yaml +import wandb + +import deeplabcut +import deeplabcut.pose_estimation_pytorch.apis as api +from deeplabcut.pose_estimation_pytorch import DLCLoader +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_inference_transform, + get_runners, +) +from deeplabcut.utils.visualization import make_labeled_images_from_dataframe + + +@dataclass +class ProjectConfig: + data_root: Path + name: str + iteration: int + shuffle_prefix: str + + def snapshot_path( + self, + train_percentage: int, + shuffle: int, + num_epochs: int, + ) -> Path: + return ( + self.data_root + / self.name + / "dlc-models" + / f"iteration-{self.iteration}" + / f"{self.shuffle_prefix}-trainset{train_percentage}shuffle{shuffle}" + / "train" + / f"snapshot-{num_epochs}.pt" + ) + + +@dataclass +class DataParameters: + model_prefix: str = "" + output_folder: str = "videos" + shuffle: int = 0 + trainset_index: int = 0 + net_types: tuple[str, ...] = ("dekr_w18", "dekr_w32", "dekr_w48") + + +@dataclass +class RunParameters: + create_dataset: bool = False + train: bool = False + evaluate: bool = False + analyze_videos: bool = False + track: bool = False + create_labeled_video: bool = False + device: str = "cuda:0" + + +@dataclass +class TrainParameters: + batch_size: int = 16 + display_iters: int = 500 + epochs: int | None = None + save_epochs: int | None = None + snapshot_path: Path | None = None + detector_max_epochs: int | None = None + detector_save_epochs: int | None = None + + def train_kwargs(self) -> dict: + kwargs = { + "batch_size": self.batch_size, + "display_iters": self.display_iters, + } + if self.epochs is not None: + kwargs["epochs"] = self.epochs + if self.save_epochs is not None: + kwargs["save_epochs"] = self.save_epochs + if self.snapshot_path is not None: + kwargs["snapshot_path"] = str(self.snapshot_path) + if self.detector_max_epochs is not None: + kwargs["detector_max_epochs"] = self.detector_max_epochs + if self.detector_save_epochs is not None: + kwargs["detector_save_epochs"] = self.detector_save_epochs + return kwargs + + +@dataclass +class EvalParameters: + snapshotindex: int | list[int] | str | None = (None,) + plotting: str | bool = False + show_errors: bool = True + + def eval_kwargs(self) -> dict: + return { + "plotting": self.plotting, + "show_errors": self.show_errors, + } + + +def run_inference_on_all_images( + project: ProjectConfig, + snapshot: Path, + plot: bool, +) -> None: + warnings.simplefilter("ignore", yaml.error.UnsafeLoaderWarning) + + with open(project.data_root / project.name / "config.yaml", "r") as file: + config = yaml.load(file) + + pytorch_config_path = snapshot.parent / "pytorch_config.yaml" + with open(pytorch_config_path, "r") as file: + pytorch_config = yaml.load(file) + + loader = DLCLoader( + project_root=str(project.data_root / project.name), + model_config_path=str(pytorch_config_path), + ) + parameters = loader.get_dataset_parameters() + + shuffle_name = snapshot.parent.parent.name + + video_folders = [ + p + for p in ( + project.data_root / (project.name + "-test-images") / "labeled-data" + ).iterdir() + if p.is_dir() + ] + images = [] + for video_folder in video_folders: + images += [ + p # f"labeled-data/{video_folder.name}/{p.name}" + for p in video_folder.iterdir() + if p.suffix == ".png" + ] + + transform_cfg = { + "auto_padding": { + "pad_width_divisor": 32, + "pad_height_divisor": 32, + }, + "normalize_images": True, + "resize": False, + } + transform = build_inference_transform(transform_cfg, augment_bbox=True) + runner, _ = get_runners( + pytorch_config=pytorch_config, + snapshot_path=str(snapshot), + max_individuals=parameters.max_num_animals, + num_bodyparts=parameters.num_joints, + num_unique_bodyparts=parameters.num_unique_bpts, + transform=transform, + detector_path=None, # TODO: Fix for top-down models + detector_transform=None, + ) + predictions = runner.inference([str(i) for i in images]) + poses = np.array([p["bodyparts"] for p in predictions]) + + output_path = ( + project.data_root + / (project.name + "-test-images") + / "evaluation-results" + / f"iteration-{project.iteration}" + / shuffle_name + / "benchmark" + / f"{shuffle_name}-{snapshot.stem}.h5" + ) + output_path.parent.mkdir(exist_ok=True, parents=True) + + index = pd.MultiIndex.from_tuples( + [(f"labeled-data", f"{i.parent.name}", f"{i.name}") for i in images], + names=["dir", "video", "image"], + ) + columns = pd.MultiIndex.from_product( + [ + [shuffle_name], + config["individuals"], + config["multianimalbodyparts"], + ["x", "y", "likelihood"], + ], + names=["scorer", "individuals", "bodyparts", "coords"], + ) + df = pd.DataFrame(poses.reshape(len(images), -1), index=index, columns=columns) + df.to_hdf(output_path, "df_with_missing") + if plot: + image_output_folder = output_path.parent / "images" + image_output_folder.mkdir(exist_ok=True) + for video in video_folders: + index_filter = [v == video.name for v in df.index.get_level_values("video")] + col_filter = [ + c in ("x", "y") and bpt not in ("dfin1", "dfin2") + for c, bpt in zip( + df.columns.get_level_values("coords"), + df.columns.get_level_values("bodyparts"), + ) + ] + df_video = df.loc[index_filter, col_filter] + plot_output_folder = image_output_folder / video.name + make_labeled_images_from_dataframe( + df_video, + config, + destfolder=str(plot_output_folder), + scale=1.0, + dpi=200, + keypoint="+", + draw_skeleton=False, + color_by="bodypart", + ) + + +def main( + project_root: Path, + iteration: int, + data_parameters: DataParameters, + run_parameters: RunParameters, + train_parameters: TrainParameters, + eval_parameters: EvalParameters, +) -> None: + project = project_root.name + cfg = project_root / "config.yaml" + + print("Training on dataset:") + print(f" Project {project}") + print(f" Iteration {iteration}") + print(f" Shuffle {data_parameters.shuffle}") + print(f" Model prefix {data_parameters.model_prefix}") + + # Configuration + videos = str(project_root / "videos") + deeplabcut.auxiliaryfunctions.edit_config( + str(cfg), + {"iteration": iteration}, + ) + + if run_parameters.create_dataset: + deeplabcut.create_training_model_comparison( + str(cfg), + trainindex=data_parameters.trainset_index, + num_shuffles=1, + net_types=list(data_parameters.net_types), + ) + + if run_parameters.train: + api.train_network( + str(cfg), + shuffle=data_parameters.shuffle, + trainingsetindex=data_parameters.trainset_index, + transform=None, + modelprefix=data_parameters.model_prefix, + device=run_parameters.device, + **train_parameters.train_kwargs(), + ) + + if run_parameters.evaluate: + snapshot_indices = eval_parameters.snapshotindex + if isinstance(snapshot_indices, int) or isinstance(snapshot_indices, str): + snapshot_indices = [snapshot_indices] + + for idx in snapshot_indices: + api.evaluate_network( + config=str(cfg), + shuffles=[data_parameters.shuffle], + trainingsetindex=data_parameters.trainset_index, + snapshotindex=idx, + device=run_parameters.device, + transform=None, + modelprefix=data_parameters.model_prefix, + **eval_parameters.eval_kwargs(), + ) + + if run_parameters.analyze_videos: + api.analyze_videos( + config=str(cfg), + videos=videos, + videotype=".mp4", + trainingsetindex=data_parameters.trainset_index, + destfolder=str(project_root / data_parameters.output_folder), + snapshotindex=5, + device=run_parameters.device, + modelprefix=data_parameters.model_prefix, + batchsize=train_parameters.batch_size, + transform=None, + overwrite=False, + auto_track=False, + ) + + if run_parameters.track: + api.convert_detections2tracklets( + config=str(cfg), + videos=videos, + videotype=".mp4", + modelprefix=data_parameters.model_prefix, + destfolder=str(project_root / data_parameters.output_folder), + track_method="box", + ) + deeplabcut.stitch_tracklets( + str(cfg), + [videos], + shuffle=1, + trainingsetindex=data_parameters.trainset_index, + destfolder=str(project_root / data_parameters.output_folder), + modelprefix=data_parameters.model_prefix, + save_as_csv=True, + track_method="box", + ) + + if run_parameters.create_labeled_video: + deeplabcut.create_labeled_video( + config=str(cfg), + videos=[videos], + videotype="mp4", + trainingsetindex=data_parameters.trainset_index, + color_by="individual", # bodypart, individual + modelprefix=data_parameters.model_prefix, + destfolder=str(project_root / data_parameters.output_folder), + track_method="box", + ) + + +if __name__ == "__main__": + benchmarks = { + "trimouse": ProjectConfig( + data_root=Path("/home/datasets"), + name="trimice-dlc-2021-06-22", + iteration=1, + shuffle_prefix="trimiceJun22", + ), + "fish": ProjectConfig( + data_root=Path("/home/datasets"), + name="fish-dlc-2021-05-07", + iteration=4, + shuffle_prefix="fishMay7", + ), + "parenting": ProjectConfig( + data_root=Path("/home/datasets"), + name="pups-dlc-2021-03-24", + iteration=1, + shuffle_prefix="pupsMar24", + ), + } + + for name, project in benchmarks.items(): + if wandb.run is not None: # TODO: Finish wandb run in DLC + wandb.finish() + + print(f"Running {name}") + data_parameters = DataParameters( + model_prefix="", + output_folder=f"videos-iter{project.iteration}", + shuffle=0, + trainset_index=0, + net_types=( + "dekr_w18", + "dekr_w18", + "dekr_w18", + "dekr_w32", + "dekr_w32", + "dekr_w32", + ), + ) + run_parameters = RunParameters( + create_dataset=False, + train=True, + evaluate=True, + analyze_videos=False, + track=False, + create_labeled_video=False, + device="cuda:0", + ) + train_parameters = TrainParameters( + batch_size=2, + epochs=125, + save_epochs=25, + ) + + try: + main( + project_root=(project.data_root / project.name), + iteration=project.iteration, + data_parameters=data_parameters, + run_parameters=run_parameters, + train_parameters=train_parameters, + eval_parameters=EvalParameters( + snapshotindex="all", + plotting=False, + ), + ) + + if run_parameters.train: + for num_epochs in range( + train_parameters.save_epochs, + train_parameters.epochs + 1, + train_parameters.save_epochs, + ): + snapshot = project.snapshot_path( + train_percentage=95, + shuffle=data_parameters.shuffle, + num_epochs=num_epochs, + ) + run_inference_on_all_images( + project, + snapshot=snapshot, + plot=(num_epochs == train_parameters.epochs), + ) + except Exception as err: + print(f"Failed to run {project}: {err}") diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 1f5545aa50..86bbdd31c0 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -113,7 +113,7 @@ def make_pytorch_config( pytorch_config = deepcopy(config_template) pytorch_config["device"] = "cuda" if torch.cuda.is_available() else "cpu" pytorch_config["method"] = "bu" - if pytorch_config.get("multianimal", False): + if project_config.get("multianimalproject", False): num_individuals = len(project_config.get("individuals", [0])) if "dekr" in net_type: version = net_type.split("_")[-1] @@ -467,10 +467,11 @@ def make_detector_cfg(num_individuals: int): def make_detector_data_aug() -> dict: return { - "covering": True, - "gaussian_noise": 12.75, - "hist_eq": True, - "motion_blur": True, + "covering": False, + "gaussian_noise": False, + "hist_eq": False, + "hflip": True, + "motion_blur": False, "normalize_images": True, "rotation": 30, "scale_jitter": [0.5, 1.25], diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index 64c6fb11e2..d23b349601 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -1,14 +1,14 @@ +from deeplabcut.pose_estimation_pytorch.apis import ( + analyze_videos, + convert_detections2tracklets, + evaluate_network, + train_network, +) from deeplabcut.pose_estimation_pytorch.data.base import Loader +from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader from deeplabcut.pose_estimation_pytorch.data.dataset import ( PoseDataset, PoseDatasetParameters, ) -from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader from deeplabcut.pose_estimation_pytorch.utils import fix_seeds -from deeplabcut.pose_estimation_pytorch.apis import ( - analyze_videos, - convert_detections2tracklets, - evaluate_network, - train_network, -) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 88a11fd830..2945d26ad1 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -29,9 +29,9 @@ get_runners, list_videos_in_folder, ) -from deeplabcut.pose_estimation_pytorch.runners import Runner +from deeplabcut.pose_estimation_pytorch.runners import Runner, Task from deeplabcut.refine_training_dataset.stitch import stitch_tracklets -from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, VideoReader +from deeplabcut.utils import VideoReader, auxfun_multianimal, auxiliaryfunctions class VideoIterator(VideoReader): @@ -82,7 +82,7 @@ def __next__(self) -> np.ndarray | tuple[str, dict[str, Any]]: def video_inference( video_path: str | Path, - task: str, + task: Task, pose_runner: Runner, detector_runner: Runner | None = None, ) -> tuple[np.ndarray, np.ndarray | None]: @@ -97,7 +97,7 @@ def video_inference( f" resolution: w={vid_w}, h={vid_h}\n" ) - if task == "TD": + if task == Task.TOP_DOWN: # Get bounding boxes for context if detector_runner is None: raise ValueError("Must use a detector for top-down video analysis") @@ -213,13 +213,13 @@ def analyze_videos( ) pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" pose_cfg = auxiliaryfunctions.read_plainconfig(pose_cfg_path) - method = pytorch_config.get("method", "BU").upper() + pose_task = Task(pytorch_config.get("method", "BU")) if device is not None: pytorch_config["device"] = device detector_path = None - if method == "TD": + if pose_task == Task.TOP_DOWN: # TODO: Choose which detector to use detector_path = _get_detector_path(model_folder, -1, cfg) @@ -227,7 +227,9 @@ def analyze_videos( pose_runner, detector_runner = get_runners( pytorch_config=pytorch_config, snapshot_path=str(model_path), - with_unique_bodyparts=(len(unique_bodyparts) > 0), + max_individuals=max_num_animals, + num_bodyparts=len(bodyparts), + num_unique_bodyparts=len(unique_bodyparts), transform=transform, detector_path=detector_path, detector_transform=None, @@ -252,7 +254,7 @@ def analyze_videos( predictions, unique_predictions = video_inference( video_path=video, pose_runner=pose_runner, - task=method, + task=pose_task, detector_runner=detector_runner, ) runtime.append(time.time()) diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index c3ea28406f..9e2803df3d 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -18,33 +18,33 @@ import numpy as np import pandas as pd -import deeplabcut.pose_estimation_pytorch as dlc import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils -from deeplabcut.pose_estimation_pytorch import DLCLoader, Loader +from deeplabcut.pose_estimation_pytorch.data import DLCLoader, Loader from deeplabcut.pose_estimation_pytorch.apis.scoring import ( - align_predicted_individuals_to_gt, get_scores, + pair_predicted_individuals_with_gt, ) from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_predictions_dataframe, ensure_multianimal_df_format, get_runners, ) -from deeplabcut.pose_estimation_pytorch.runners import Runner +from deeplabcut.pose_estimation_pytorch.runners import Runner, Task from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.visualization import plot_evaluation_results def predict( - task: str, + pose_task: Task, pose_runner: Runner, loader: Loader, mode: str, detector_runner: Runner | None = None, -) -> tuple[list[str], list[dict[str, dict[str, np.ndarray]]]]: +) -> dict[str, dict[str, np.ndarray]]: """Predicts poses on data contained in a loader + Args: - task: {'TD', 'BU'} Whether the model is a top-down or bottom-up model + pose_task: Whether the model is a top-down or bottom-up model pose_runner: The runner to use for pose estimation loader: The loader containing the data to predict poses on mode: {"train", "test"} The mode to predict on @@ -53,19 +53,13 @@ def predict( boxes will be used to crop individuals before pose estimation Returns: - The paths of images for which predictions were computed - For each image, the predictions made by each model head + The paths of images for which predictions were computed mapping to the + different predictions made by each model head """ - if task not in ["BU", "TD"]: - raise ValueError( - f"Task should be set to either 'BU' (Bottom Up) or 'TD' (Top Down), " - f"currently it is {task}" - ) - image_paths = loader.image_filenames(mode) context = None - if task == "TD": + if pose_task == Task.TOP_DOWN: # Get bounding boxes for context if detector_runner is not None: bbox_predictions = detector_runner.inference(images=image_paths) @@ -74,31 +68,32 @@ def predict( ground_truth_bboxes = loader.ground_truth_bboxes(mode=mode) context = [{"bboxes": ground_truth_bboxes[image]} for image in image_paths] - images = image_paths + images_with_context = image_paths if context is not None: if len(context) != len(image_paths): raise ValueError( f"Missing context for some images: {len(context)} != {len(image_paths)}" ) - images = list(zip(image_paths, context)) + images_with_context = list(zip(image_paths, context)) - predictions = pose_runner.inference(images=images) - return image_paths, predictions # TODO: include bounding boxes if there are any + predictions = pose_runner.inference(images=images_with_context) + return { + image_path: image_predictions + for image_path, image_predictions in zip(image_paths, predictions) + } def evaluate( - scorer: str, - task: str, + pose_task: Task, pose_runner: Runner, loader: Loader, mode: str, detector_runner: Runner | None = None, pcutoff: float = 1, -) -> tuple[dict[str, float], pd.DataFrame]: +) -> tuple[dict[str, float], dict[str, dict[str, np.ndarray]]]: """ Args: - scorer: The name of the model making the predictions - task: {'BU' or 'TD'} Whether to run top-down or bottom-up + pose_task: Whether to run top-down or bottom-up pose_runner: The runner for pose estimation loader: The loader containing the data to evaluate mode: Either 'train' or 'test' @@ -109,42 +104,30 @@ def evaluate( Returns: A dict containing the evaluation results - A dataframe in DLC-format containing the predictions + A dict mapping the paths of images for which predictions were computed to the + different predictions made by each model head """ - parameters = loader._get_dataset_parameters() - image_paths, predictions = predict( - task=task, + parameters = loader.get_dataset_parameters() + predictions = predict( + pose_task=pose_task, pose_runner=pose_runner, loader=loader, mode=mode, detector_runner=detector_runner, ) - # TODO: move this to postprocessing step - poses = {} - for filename, pred in zip(image_paths, predictions): - keypoints = pred["bodyparts"] - if len(keypoints) < parameters.max_num_animals: - padded_keypoints = np.empty( - (parameters.max_num_animals, *keypoints.shape[1:]) - ) - padded_keypoints.fill(-1) - padded_keypoints[: len(keypoints), ...] = keypoints - keypoints = padded_keypoints - poses[filename] = keypoints + poses = {filename: pred["bodyparts"] for filename, pred in predictions.items()} + gt_keypoints = loader.ground_truth_keypoints(mode) + if parameters.max_num_animals > 1: + poses = pair_predicted_individuals_with_gt(poses, gt_keypoints) unique_poses = None gt_unique_keypoints = None if parameters.num_unique_bpts > 1: unique_poses = { - filename: pred["unique_bodyparts"] - for filename, pred in zip(image_paths, predictions) + filename: pred["unique_bodyparts"] for filename, pred in predictions.items() } gt_unique_keypoints = loader.ground_truth_keypoints(mode, unique_bodypart=True) - gt_keypoints = loader.ground_truth_keypoints(mode) - if parameters.max_num_animals > 1: - poses = align_predicted_individuals_to_gt(poses, gt_keypoints) - # TODO: Check single animal mAP computation results = get_scores( poses, @@ -154,19 +137,11 @@ def evaluate( unique_bodypart_gt=gt_unique_keypoints, ) - image_name_to_index = None - if isinstance(loader, DLCLoader): - image_name_to_index = image_to_dlc_df_index - - df_predictions = build_predictions_dataframe( - scorer=scorer, - images=image_paths, - bodypart_predictions=poses, - unique_bodypart_predictions=unique_poses, - parameters=parameters, - image_name_to_index=image_name_to_index, - ) - return results, df_predictions + # Updating poses to be aligned and padded + for image, pose in poses.items(): + predictions[image]["bodyparts"] = pose + + return results, predictions def evaluate_snapshot( @@ -217,19 +192,13 @@ def evaluate_snapshot( if device is not None: pytorch_config["device"] = device - method = pytorch_config.get("method", "bu").lower() - if method not in ["bu", "td"]: - raise ValueError( - f"Method should be set to either 'bu' (Bottom Up) or 'td' (Top Down), " - f"currently it is {method}" - ) - - loader = dlc.DLCLoader( + pose_task = Task(pytorch_config.get("method", "bu")) + loader = DLCLoader( project_root=pytorch_config["project_path"], model_config_path=model_config_path, shuffle=shuffle, ) - parameters = loader._get_dataset_parameters() + parameters = loader.get_dataset_parameters() names = runner_utils.get_paths( project_path=cfg["project_path"], train_fraction=train_fraction, @@ -237,14 +206,16 @@ def evaluate_snapshot( shuffle=shuffle, cfg=cfg, train_iterations=snapshotindex, - method=method, + task=pose_task, ) pcutoff = cfg.get("pcutoff") pose_runner, detector_runner = get_runners( pytorch_config=pytorch_config, snapshot_path=names["model_path"], - with_unique_bodyparts=(parameters.num_unique_bpts > 0), + max_individuals=parameters.max_num_animals, + num_bodyparts=parameters.num_joints, + num_unique_bodyparts=parameters.num_unique_bpts, transform=transform, detector_path=detector_path, detector_transform=None, @@ -258,15 +229,20 @@ def evaluate_snapshot( "pcutoff": pcutoff, } for split in ["train", "test"]: - results, df_split_predictions = evaluate( - scorer=names["dlc_scorer"], - task=pytorch_config.get("method", "BU").upper(), + results, predictions_for_split = evaluate( + pose_task=pose_task, pose_runner=pose_runner, loader=loader, mode=split, pcutoff=pcutoff, detector_runner=detector_runner, ) + df_split_predictions = build_predictions_dataframe( + scorer=names["dlc_scorer"], + predictions=predictions_for_split, + parameters=parameters, + image_name_to_index=image_to_dlc_df_index, + ) predictions[split] = df_split_predictions for k, v in results.items(): scores[f"{split} {k}"] = round(v, 2) diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py index 638364707a..928c1489a2 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ b/deeplabcut/pose_estimation_pytorch/apis/inference.py @@ -15,7 +15,7 @@ from torchvision.ops import box_convert from torchvision.transforms import Resize as TorchResize -from deeplabcut.pose_estimation_pytorch.models import PoseModel, PREDICTORS +from deeplabcut.pose_estimation_pytorch.models import PREDICTORS, PoseModel from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.post_processing import ( diff --git a/deeplabcut/pose_estimation_pytorch/apis/scoring.py b/deeplabcut/pose_estimation_pytorch/apis/scoring.py index 867e23ebf1..298d47260a 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/apis/scoring.py @@ -75,10 +75,19 @@ def get_scores( f"images (poses={len(poses)}, gt={len(ground_truth)})" ) + ground_truth = { + image: mask_invisible(gt_pose, mask_value=np.nan) + for image, gt_pose in ground_truth.items() + } + image_paths = list(poses) pred_poses = build_keypoint_array(poses, image_paths)[..., :3].reshape((-1, 3)) gt_poses = build_keypoint_array(ground_truth, image_paths).reshape((-1, 2)) if unique_bodypart_poses is not None: + unique_bodypart_gt = { + image: mask_invisible(gt_pose, mask_value=np.nan) + for image, gt_pose in unique_bodypart_gt.items() + } pred_poses = np.concatenate( [ pred_poses, @@ -94,6 +103,7 @@ def get_scores( ] ) + pred_poses[pred_poses == -1] = np.nan rmse, rmse_pcutoff = compute_rmse(pred_poses, gt_poses, pcutoff=pcutoff) oks = compute_oks(poses, ground_truth, pcutoff=None) @@ -223,7 +233,7 @@ def build_assemblies(poses: dict[str, np.ndarray]) -> dict[str, list[Assembly]]: return assemblies -def align_predicted_individuals_to_gt( +def pair_predicted_individuals_with_gt( predictions: dict[str, np.ndarray], ground_truth: dict[str, np.ndarray] ) -> dict[str, np.ndarray]: """TODO: implement with OKS as well diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 088cd8974d..117e2f2410 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -18,29 +18,30 @@ import albumentations as A from torch.utils.data import DataLoader -import deeplabcut.pose_estimation_pytorch as dlc import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils +import deeplabcut.pose_estimation_pytorch.utils as utils from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch import Loader from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_inference_transform, build_runner, build_transforms, update_config_parameters, ) +from deeplabcut.pose_estimation_pytorch.data import Loader, DLCLoader from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel +from deeplabcut.pose_estimation_pytorch.runners import Task from deeplabcut.pose_estimation_pytorch.runners.logger import ( - destroy_file_logging, LOGGER, + destroy_file_logging, setup_file_logging, ) -def _train( +def train( loader: Loader, model_folder: str, run_config: dict, - task: str, + task: Task, device: str, transform_config: dict, logger_config: dict | None = None, @@ -53,7 +54,7 @@ def _train( loader: the loader containing the data to train on/validate with model_folder: the folder where the models should be saved run_config: the model and run configuration - task: {"TD", "BU", "DT"} the task to train the model for + task: the task to train the model for device: the device to train on transform_config: the configuration of the data augmentation to use. Ignored if a transform is given @@ -63,7 +64,7 @@ def _train( transform: if None, a transform is loaded with the given configuration. Otherwise, this transform is used. """ - if task == "DT": + if task == Task.DETECT: model = DETECTORS.build(run_config["model"]) else: model = PoseModel.from_cfg(run_config["model"]) @@ -172,41 +173,39 @@ def train_network( logging.info("No transform specified... using default") transform = build_transforms(dict(pytorch_config["data"]), augment_bbox=True) - dlc.fix_seeds(pytorch_config["seed"]) - loader = dlc.DLCLoader( + utils.fix_seeds(pytorch_config["seed"]) + loader = DLCLoader( project_root=pytorch_config["project_path"], model_config_path=model_config_path, shuffle=shuffle, ) - pose_task = "BU" - transform_config = pytorch_config["data"] - if pytorch_config.get("method", "bu").lower() == "td": - pose_task = "TD" - transform_config = pytorch_config["data_detector"] + pose_task = Task(pytorch_config.get("method", "bu")) + if pose_task == Task.TOP_DOWN and pytorch_config["detector"]["epochs"] > 0: logger_config = None if pytorch_config.get("logger"): logger_config = copy.deepcopy(pytorch_config["logger"]) logger_config["run_name"] += "-detector" - _train( + + train( loader=loader, model_folder=model_folder, run_config=pytorch_config["detector"], - task="DT", + task=Task.DETECT, device=pytorch_config["device"], - transform_config=pytorch_config["data"], + transform_config=pytorch_config["data_detector"], logger_config=logger_config, snapshot_path=detector_path, transform=transform_cropped, ) - _train( + train( loader=loader, model_folder=model_folder, run_config=pytorch_config, task=pose_task, device=pytorch_config["device"], - transform_config=transform_config, + transform_config=pytorch_config["data"], logger_config=pytorch_config.get("logger"), snapshot_path=snapshot_path, transform=transform, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 854fd3be3d..05fa7cbb16 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import warnings from pathlib import Path from typing import Callable @@ -20,21 +21,24 @@ import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch import PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( + Postprocessor, build_bottom_up_postprocessor, build_detector_postprocessor, build_top_down_postprocessor, - Postprocessor, ) from deeplabcut.pose_estimation_pytorch.data.preprocessor import ( + Preprocessor, build_bottom_up_preprocessor, build_top_down_preprocessor, - Preprocessor, ) -from deeplabcut.pose_estimation_pytorch.data.transforms import KeypointAwareCrop +from deeplabcut.pose_estimation_pytorch.data.transforms import ( + KeepAspectRatioResize, + KeypointAwareCrop, +) from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel -from deeplabcut.pose_estimation_pytorch.runners import Runner, RUNNERS +from deeplabcut.pose_estimation_pytorch.runners import RUNNERS, Runner, Task from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger from deeplabcut.pose_estimation_pytorch.runners.schedulers import LRListScheduler from deeplabcut.utils import auxfun_videos @@ -143,10 +147,6 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose """ transforms = [] - if aug_cfg.get("resize", False): - input_size = aug_cfg.get("resize", False) - transforms.append(A.Resize(input_size[0], input_size[1])) - crop_sampling = aug_cfg.get("crop_sampling", False) if crop_sampling: # Add smart, keypoint-aware image cropping @@ -167,6 +167,19 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose ) ) + if resize_aug := aug_cfg.get("resize", False): + transforms += build_resize_transforms(resize_aug) + + if aug_cfg.get("hflip"): + warnings.warn( + "Be careful! Do not train pose models with horizontal flips if you have" + " symmetric keypoints!" + ) + hflip_proba = 0.5 + if isinstance(aug_cfg["hflip"], float): + hflip_proba = aug_cfg["hflip"] + transforms.append(A.HorizontalFlip(p=hflip_proba)) + # TODO code again this augmentation to match the symmetric_pair syntax in original dlc # if aug_cfg.get('flipr', False) and aug_cfg.get('symmetric_pair', False): # opt = aug_cfg.get("fliplr", False) @@ -217,7 +230,7 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose if type(opt) == int or type(opt) == float: transforms.append( A.GaussNoise( - var_limit=(0, opt ** 2), + var_limit=(0, opt**2), mean=0, per_channel=True, # Albumentations doesn't support per_cahnnel = 0.5 p=0.5, @@ -232,21 +245,21 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose if aug_cfg.get("auto_padding"): transforms.append(build_auto_padding(**aug_cfg["auto_padding"])) + if aug_cfg.get("normalize_images"): transforms.append( A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ) + bbox_params = None if augment_bbox: - return A.Compose( - transforms, - keypoint_params=A.KeypointParams("xy", remove_invisible=False), - bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), - ) - else: - return A.Compose( - transforms, keypoint_params=A.KeypointParams("xy", remove_invisible=False) - ) + bbox_params = A.BboxParams(format="coco", label_fields=["bbox_labels"]) + + return A.Compose( + transforms, + keypoint_params=A.KeypointParams("xy", remove_invisible=False), + bbox_params=bbox_params, + ) def build_inference_transform( @@ -265,30 +278,27 @@ def build_inference_transform( Returns: Union[A.BasicTransform, A.BaseCompose]: the transformation pipeline """ - list_transforms = [] - if transform_cfg.get("resize", False): - input_size = transform_cfg.get("resize", False) - list_transforms.append(A.Resize(input_size[0], input_size[1])) + if resize_aug := transform_cfg.get("resize"): + list_transforms += build_resize_transforms(resize_aug) if transform_cfg.get("auto_padding"): list_transforms.append(build_auto_padding(**transform_cfg["auto_padding"])) + if transform_cfg.get("normalize_images"): list_transforms.append( A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ) + bbox_params = None if augment_bbox: - return A.Compose( - list_transforms, - keypoint_params=A.KeypointParams("xy", remove_invisible=False), - bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), - ) - else: - return A.Compose( - list_transforms, - keypoint_params=A.KeypointParams("xy", remove_invisible=False), - ) + bbox_params = A.BboxParams(format="coco", label_fields=["bbox_labels"]) + + return A.Compose( + list_transforms, + keypoint_params=A.KeypointParams("xy", remove_invisible=False), + bbox_params=bbox_params, + ) def get_model_snapshots(model_folder: Path) -> list[Path]: @@ -421,6 +431,26 @@ def build_auto_padding( ) +def build_resize_transforms(resize_cfg: dict) -> list[A.BasicTransform]: + height, width = resize_cfg["height"], resize_cfg["width"] + + transforms = [] + if resize_cfg.get("keep_ratio", True): + transforms.append(KeepAspectRatioResize(width=width, height=height, mode="pad")) + transforms.append( + A.PadIfNeeded( + min_height=height, + min_width=width, + border_mode=cv2.BORDER_CONSTANT, + position=A.PadIfNeeded.PositionType.TOP_LEFT, + ) + ) + else: + transforms.append(A.Resize(height, width)) + + return transforms + + def ensure_multianimal_df_format(df_predictions: pd.DataFrame) -> pd.DataFrame: """ Convert dataframe to 'multianimal' format (with an "individuals" columns index) @@ -445,9 +475,7 @@ def ensure_multianimal_df_format(df_predictions: pd.DataFrame) -> pd.DataFrame: def build_predictions_dataframe( scorer: str, - images: list[str], - bodypart_predictions: dict[str, np.ndarray], - unique_bodypart_predictions: dict[str, np.ndarray] | None, + predictions: dict[str, dict[str, np.ndarray]], parameters: PoseDatasetParameters, image_name_to_index: Callable[[str], tuple[str, ...]] | None = None, ) -> pd.DataFrame: @@ -455,20 +483,13 @@ def build_predictions_dataframe( Args: scorer: - images: - bodypart_predictions: - unique_bodypart_predictions: + predictions parameters: image_name_to_index: Returns: """ - if parameters.num_unique_bpts > 0 and unique_bodypart_predictions is None: - raise ValueError( - "The parameters contain unique bodyparts but no predictions were given" - ) - kpt_entries = ["x", "y", "likelihood"] col_names = ["scorer", "individuals", "bodyparts", "coords"] @@ -481,12 +502,13 @@ def build_predictions_dataframe( prediction_data = [] index_data = [] - for image in images: - image_data = bodypart_predictions[image][..., :3].reshape(-1) - if unique_bodypart_predictions is not None: + for image, image_predictions in predictions.items(): + image_data = image_predictions["bodyparts"].reshape(-1) + if "unique_bodyparts" in image_predictions: image_data = np.concatenate( - [image_data, unique_bodypart_predictions[image][..., :3].reshape(-1)] + [image_data, image_predictions["unique_bodyparts"].reshape(-1)] ) + prediction_data.append(image_data) if image_name_to_index is not None: index_data.append(image_name_to_index(image)) @@ -494,7 +516,7 @@ def build_predictions_dataframe( if len(index_data) > 0: index = pd.MultiIndex.from_tuples(index_data) else: - index = images + index = list(predictions.keys()) return pd.DataFrame( prediction_data, @@ -506,7 +528,9 @@ def build_predictions_dataframe( def get_runners( pytorch_config: dict, snapshot_path: str, - with_unique_bodyparts: bool, + max_individuals: int, + num_bodyparts: int, + num_unique_bodyparts: int, transform: A.BaseCompose | None = None, detector_path: str | None = None, detector_transform: A.BaseCompose | None = None, @@ -516,7 +540,11 @@ def get_runners( Args: pytorch_config: the pytorch configuration file snapshot_path: the path of the snapshot from which to load the weights - with_unique_bodyparts: whether there are unique bodyparts to detect + max_individuals: the maximum number of individuals per image + num_bodyparts: the number of bodyparts predicted by the model + num_unique_bodyparts: the number of unique_bodyparts predicted by the model + pad_outputs_to_full_shape: if true, pose arrays are padded with -1s for missing + individuals transform: the transform for pose estimation. if None, uses the transform defined in the config. detector_path: the path to the detector snapshot from which to load weights, @@ -528,24 +556,20 @@ def get_runners( a runner for pose estimation a runner for detection, if detector_path is not None """ - pose_task = pytorch_config.get("method", "BU").upper() - if pose_task not in ["BU", "TD"]: - raise ValueError( - f"Method should be set to either 'BU' (Bottom Up) or 'TD' (Top Down), " - f"currently it is {pose_task}" - ) - + pose_task = Task(pytorch_config.get("method", "bu")) device = pytorch_config["device"] if transform is None: transform = build_inference_transform(pytorch_config["data"]) detector_runner = None - if pose_task == "BU": + if pose_task == Task.BOTTOM_UP: pose_preprocessor = build_bottom_up_preprocessor( color_mode="RGB", transform=transform # TODO: read from Loader ) pose_postprocessor = build_bottom_up_postprocessor( - with_unique_bodyparts=with_unique_bodyparts + max_individuals=max_individuals, + num_bodyparts=num_bodyparts, + num_unique_bodyparts=num_unique_bodyparts, ) else: pose_preprocessor = build_top_down_preprocessor( @@ -554,7 +578,9 @@ def get_runners( cropped_image_size=(256, 256), ) pose_postprocessor = build_top_down_postprocessor( - with_unique_bodyparts=with_unique_bodyparts + max_individuals=max_individuals, + num_bodyparts=num_bodyparts, + num_unique_bodyparts=num_unique_bodyparts, ) if detector_path is not None: diff --git a/deeplabcut/pose_estimation_pytorch/data/__init__.py b/deeplabcut/pose_estimation_pytorch/data/__init__.py index e69de29bb2..24a88d5eed 100644 --- a/deeplabcut/pose_estimation_pytorch/data/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/data/__init__.py @@ -0,0 +1,4 @@ +from deeplabcut.pose_estimation_pytorch.data.base import Loader +from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader +from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader +from deeplabcut.pose_estimation_pytorch.data.dataset import Dataset diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index c503ab1a6b..fd024ef996 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -24,6 +24,7 @@ _compute_crop_bounds, map_id_to_annotations, ) +from deeplabcut.pose_estimation_pytorch.runners import Task from deeplabcut.utils.auxiliaryfunctions import get_bodyparts, get_unique_bodyparts @@ -34,11 +35,12 @@ class Loader(ABC): Methods: load_data(mode: str = 'train') -> dict: Abstract method to convert the project configuration to a standard COCO format. - create_dataset(images: dict = None, annotations: dict = None, transform: object = None, mode: str = "train", task: str = "BU") -> PoseDataset: + create_dataset(images: dict = None, annotations: dict = None, transform: object = None, + mode: str = "train", task: Task = Task.BOTTOM_UP) -> PoseDataset: Creates and returns a PoseDataset given a set of images, annotations, and other parameters. _get_all_bboxes(images, annotations, method: str = 'gt') -> dict: Retrieves all bounding boxes based on the specified method. - _get_dataset_parameters(*args, **kwargs) -> dict: + get_dataset_parameters(*args, **kwargs) -> dict: Returns a dictionary containing dataset parameters derived from the configuration. """ @@ -47,7 +49,6 @@ def __init__(self, project_root: str, model_config_path: str) -> None: self.model_config_path = model_config_path self.model_cfg = auxiliaryfunctions.read_plainconfig(model_config_path) self._loaded_data: dict[str, dict[str, dict]] = {} - self._get_dataset_parameters() @abstractmethod def load_data(self, mode: str = "train") -> dict[str, dict]: @@ -92,7 +93,7 @@ def ground_truth_keypoints( the format: {'image': keypoints with shape (num_individuals, num_keypoints, 2)} """ - parameters = self._get_dataset_parameters() + parameters = self.get_dataset_parameters() if unique_bodypart: if not parameters.num_unique_bpts > 0: raise ValueError("There are no unique bodyparts in this dataset!") @@ -162,7 +163,7 @@ def create_dataset( self, transform: A.BaseCompose | None = None, mode: str = "train", - task: str = "BU", + task: Task = Task.BOTTOM_UP, ) -> PoseDataset: """ Creates a PoseDataset based on provided arguments. @@ -176,9 +177,9 @@ def create_dataset( PoseDataset: An instance of the PoseDataset class. Raises: - Any exception raised by `_get_dataset_parameters` or `load_data` methods. + Any exception raised by `get_dataset_parameters` or `load_data` methods. """ - parameters = self._get_dataset_parameters() + parameters = self.get_dataset_parameters() data = self.load_data(mode) data["annotations"] = self.filter_annotations(data["annotations"]) dataset = PoseDataset( @@ -191,25 +192,15 @@ def create_dataset( ) return dataset - def _get_dataset_parameters(self, *args, **kwargs) -> PoseDatasetParameters: - """TODO: _get_dataset_parameters should be an abstract method + @abstractmethod + def get_dataset_parameters(self) -> PoseDatasetParameters: + """ Retrieves dataset parameters based on the instance's configuration. - Args: - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - Returns: An instance of the PoseDatasetParameters with the parameters set. """ - return PoseDatasetParameters( - bodyparts=get_bodyparts(self.cfg), - unique_bpts=get_unique_bodyparts(self.cfg), - individuals=self.cfg.get("individuals", ["animal"]), - with_center_keypoints=self.model_cfg.get("with_center_keypoints", False), - color_mode=self.model_cfg.get("color_mode", "RGB"), - cropped_image_size=self.model_cfg.get("output_size", (256, 256)), - ) + raise NotImplementedError @staticmethod def filter_annotations(annotations: list[dict]) -> list[dict]: diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py index 02a8e4e016..8175a12578 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py @@ -13,7 +13,14 @@ import os from dataclasses import dataclass +import numpy as np + from deeplabcut.pose_estimation_pytorch.data.base import Loader +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.data.utils import ( + map_id_to_annotations, + map_image_path_to_id, +) @dataclass @@ -34,23 +41,40 @@ class COCOLoader(Loader): """ project_root: str + model_config_path: str train_json_filename: str = "train.json" test_json_filename: str | None = "test.json" def __post_init__(self) -> None: - self.train_json = self._load_json(self.project_root, self.train_json_filename) + super().__init__(self.project_root, self.model_config_path) + self.train_json = self.load_json(self.project_root, self.train_json_filename) self.test_json = None if self.test_json_filename: - self.test_json = self._load_json(self.project_root, self.test_json_filename) + self.test_json = self.load_json(self.project_root, self.test_json_filename) + + def get_dataset_parameters(self) -> PoseDatasetParameters: + """ + Retrieves dataset parameters based on the instance's configuration. - # TODO: change when _get_dataset_parameters is abstract - self.cfg = {} + Returns: + An instance of the PoseDatasetParameters with the parameters set. + """ + num_individuals, bodyparts = self.get_project_parameters(self.train_json) + return PoseDatasetParameters( + bodyparts=bodyparts, + unique_bpts=[], + individuals=[f"individual{i}" for i in range(num_individuals)], + with_center_keypoints=self.model_cfg.get("with_center_keypoints", False), + color_mode=self.model_cfg.get("color_mode", "RGB"), + cropped_image_size=self.model_cfg.get("output_size", (256, 256)), + ) @staticmethod - def _load_json(project_root: str, filename: str) -> dict: + def load_json(project_root: str, filename: str) -> dict: """Load a JSON file from the annotations directory. Args: + project_root: path to the root directory for the project filename: filename of JSON file to load Returns: @@ -86,14 +110,89 @@ def load_data(self, mode: str = "train") -> dict: """ # todo: add validation if mode == "train": - json_obj = self.train_json + data = self.train_json elif mode == "test": - json_obj = self.test_json + data = self.test_json else: raise AttributeError(f"Unknown mode: {mode}") - for image in json_obj["images"]: + for image in data["images"]: image_path = image["file_name"] image["file_name"] = os.path.join(self.project_root, "images", image_path) - return json_obj + for annotation in data["annotations"]: + annotation["keypoints"] = np.array(annotation["keypoints"], dtype=float) + annotation["bbox"] = np.array(annotation["bbox"], dtype=float) + annotation["individual"] = "unknown" + + annotations_with_bbox = self._get_all_bboxes( + data["images"], + data["annotations"], + ) + data["annotations"] = annotations_with_bbox + return data + + @staticmethod + def get_project_parameters(train_json: dict) -> tuple[int, list[str]]: + """ + Loads the parameters for the project from the train json file + TODO: Should this compute the number also using the test json? + + Args: + train_json: the json dictionary containing the data for training + + Returns: + int: the maximum number of individuals in a single image + list[str]: the name of keypoints annotated in this project + """ + # TODO: Check that there's a single category + bodyparts = train_json["categories"][0]["keypoints"] + img_to_annotations = map_id_to_annotations(train_json["annotations"]) + num_individuals = max(*[len(a_ids) for a_ids in img_to_annotations.values()]) + return num_individuals, bodyparts + + def predictions_to_coco( + self, + predictions: dict[str, dict[str, np.ndarray]], + mode: str = "train", + ) -> list[dict]: + """Converts detections to COCO format + + Args: + predictions: a dictionary mapping image name to the predictions made for it + mode: {"train", "test"} the mode that the predictions were made with + + Returns: + The COCO-format predictions + """ + data = self.load_data(mode) + image_path_to_id = map_image_path_to_id(data["images"]) + + # TODO: no unique bodyparts for COCO + coco_predictions = [] + for image_path, pred in predictions.items(): + image_id = image_path_to_id[image_path] + + # Shape (num_individuals, num_keypoints, 3) + individuals = pred["bodyparts"] + for idx, keypoints in enumerate(individuals): + if not np.all(keypoints == -1): + score = np.mean(keypoints[:, 2]).item() + keypoints = keypoints.copy() + keypoints[:, 2] = 2 # set visibility instead of score + coco_pred = { + "image_id": int(image_id), + "category_id": 1, # TODO: get category ID from prediction? + "keypoints": keypoints.reshape(-1).tolist(), + "score": float(score), + } + if "bboxes" in pred: + coco_pred["bbox"] = pred["bboxes"][idx].reshape(-1).tolist() + if "bbox_scores" in pred: + coco_pred["bbox_scores"] = ( + pred["bbox_scores"][idx].reshape(-1).tolist() + ) + + coco_predictions.append(coco_pred) + + return coco_predictions diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 8c919830be..709c40b9b7 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -26,6 +26,7 @@ map_image_path_to_id, pad_to_length, ) +from deeplabcut.pose_estimation_pytorch.runners import Task @dataclass(frozen=True) @@ -69,7 +70,7 @@ class PoseDataset(Dataset): parameters: PoseDatasetParameters transform: A.BaseCompose | None = None mode: str = "train" - task: str = "BU" + task: Task = Task.BOTTOM_UP def __post_init__(self): self.image_path_id_map = map_image_path_to_id(self.images) @@ -77,7 +78,10 @@ def __post_init__(self): def __len__(self): # TODO: TD should only return the number of annotations that aren't unique_bodyparts - return len(self.images) if self.task in ("BU", "DT") else len(self.annotations) + if self.task in (Task.BOTTOM_UP, Task.DETECT): + return len(self.images) + + return len(self.annotations) def _get_raw_item(self, index: int) -> tuple[str, list[dict], int]: """ @@ -150,7 +154,7 @@ def __getitem__(self, index: int) -> dict: ) = self.extract_keypoints_and_bboxes(annotations, image.shape) offsets = np.zeros((self.parameters.max_num_animals, 2)) scales = (1, 1) - if self.task == "TD": + if self.task == Task.TOP_DOWN: if self.parameters.cropped_image_size is None: raise ValueError( "You must specify a cropped image size for top-down models" @@ -187,6 +191,7 @@ def __getitem__(self, index: int) -> dict: image, keypoints, keypoints_unique, bboxes ) keypoints = transformed["keypoints"] + bboxes = np.array(transformed["bboxes"]) if self.parameters.with_center_keypoints: keypoints = self.add_center_keypoints(keypoints) @@ -273,14 +278,12 @@ def _get_data_based_on_task(self, index: int) -> tuple[str, list[dict], int]: Returns: tuple: Tuple containing the image path, annotations, and image ID. """ - if self.task == "TD": + if self.task == Task.TOP_DOWN: return self._get_raw_item_crop(index) - elif self.task in ["BU", "DT"]: + elif self.task in [Task.BOTTOM_UP, Task.DETECT]: return self._get_raw_item(index) - raise ValueError( - f"Unknown task: {self.task}. " 'Task should be one of: "BU", "TD", "DT"' - ) + raise ValueError(f"Unknown task: {self.task}") def apply_transform_all_keypoints( self, diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index 4d0dc2006c..c50de71cbd 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -4,14 +4,18 @@ import pickle from dataclasses import dataclass -import numpy as np import pandas as pd import deeplabcut from deeplabcut.pose_estimation_pytorch.data.base import Loader +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.helper import CombinedPropertyMeta from deeplabcut.pose_estimation_pytorch.utils import df_to_generic -from deeplabcut.utils.auxiliaryfunctions import get_model_folder +from deeplabcut.utils.auxiliaryfunctions import ( + get_bodyparts, + get_model_folder, + get_unique_bodyparts, +) @dataclass @@ -65,6 +69,22 @@ def __post_init__(self): super().__init__(self.project_root, self.model_config_path) self.split, self.df_dlc, self.df_train, self.df_test = self._load_dlc_data() + def get_dataset_parameters(self) -> PoseDatasetParameters: + """ + Retrieves dataset parameters based on the instance's configuration. + + Returns: + An instance of the PoseDatasetParameters with the parameters set. + """ + return PoseDatasetParameters( + bodyparts=get_bodyparts(self.cfg), + unique_bpts=get_unique_bodyparts(self.cfg), + individuals=self.cfg.get("individuals", ["animal"]), + with_center_keypoints=self.model_cfg.get("with_center_keypoints", False), + color_mode=self.model_cfg.get("color_mode", "RGB"), + cropped_image_size=self.model_cfg.get("output_size", (256, 256)), + ) + def _load_dlc_data(self): split = self._load_split(self._path_dlc_doc) df_dlc = pd.read_hdf(self._path_dlc_data) diff --git a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py index 22bf27e1a7..7875944fe9 100644 --- a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from enum import Enum from typing import Any import numpy as np @@ -29,41 +30,94 @@ def __call__(self, predictions: Any, context: Context) -> Any: pass -def build_bottom_up_postprocessor(with_unique_bodyparts: bool) -> ComposePostprocessor: +def build_bottom_up_postprocessor( + max_individuals: int, + num_bodyparts: int, + num_unique_bodyparts: int, +) -> ComposePostprocessor: """Creates a postprocessor for bottom-up pose estimation (or object detection) Args: - with_unique_bodyparts: whether the model outputs unique bodyparts + max_individuals: the maximum number of individuals in a single image + num_bodyparts: the number of bodyparts output by the model + num_unique_bodyparts: the number of unique_bodyparts output by the model Returns: A default bottom-up Postprocessor """ keys_to_concatenate = {"bodyparts": ("bodypart", "poses")} - if with_unique_bodyparts: + empty_shapes = {"bodyparts": (num_bodyparts, 3)} + keys_to_rescale = ["bodyparts"] + if num_unique_bodyparts > 0: keys_to_concatenate["unique_bodyparts"] = ("unique_bodypart", "poses") + empty_shapes = {"bodyparts": (num_bodyparts, 3)} + keys_to_rescale.append("unique_bodyparts") return ComposePostprocessor( - components=[ConcatenateOutputs(keys_to_concatenate=keys_to_concatenate)] + components=[ + ConcatenateOutputs( + keys_to_concatenate=keys_to_concatenate, + empty_shapes=empty_shapes, + create_empty_outputs=True, + ), + RescaleAndOffset( + keys_to_rescale=keys_to_rescale, + data=RescaleAndOffset.DataType.KEYPOINT, + ), + PadOutputs( + max_individuals={ + "bodyparts": max_individuals, + "unique_bodyparts": 0, # no need to pad + }, + pad_value=-1, + ), + ] ) -def build_top_down_postprocessor(with_unique_bodyparts: bool) -> Postprocessor: +def build_top_down_postprocessor( + max_individuals: int, + num_bodyparts: int, + num_unique_bodyparts: int, +) -> Postprocessor: """Creates a postprocessor for top-down pose estimation Args: - with_unique_bodyparts: whether the model outputs unique bodyparts + max_individuals: the maximum number of individuals in a single image + num_bodyparts: the number of bodyparts output by the model + num_unique_bodyparts: the number of unique_bodyparts output by the model Returns: A default top-down Postprocessor """ keys_to_concatenate = {"bodyparts": ("bodypart", "poses")} + empty_shapes = {"bodyparts": (num_bodyparts, 3)} keys_to_rescale = ["bodyparts"] - if with_unique_bodyparts: + if num_unique_bodyparts > 0: keys_to_concatenate["unique_bodyparts"] = ("unique_bodypart", "poses") + empty_shapes["unique_bodyparts"] = (num_unique_bodyparts, 3) keys_to_rescale.append("unique_bodyparts") + return ComposePostprocessor( components=[ - ConcatenateOutputs(keys_to_concatenate=keys_to_concatenate), - RescaleAndOffset(keys_to_rescale=keys_to_rescale), + ConcatenateOutputs( + keys_to_concatenate=keys_to_concatenate, + empty_shapes=empty_shapes, + create_empty_outputs=True, + ), + RescaleAndOffset( + keys_to_rescale=keys_to_rescale, + data=RescaleAndOffset.DataType.KEYPOINT_TD, + ), + AddContextToOutput(keys=["bboxes", "bbox_scores"]), + PadOutputs( + max_individuals={ + "bodyparts": max_individuals, + "bboxes": max_individuals, + "bbox_scores": max_individuals, + "unique_bodyparts": 0, # no need to pad + }, + pad_value=-1, + ), ] ) @@ -76,8 +130,17 @@ def build_detector_postprocessor() -> Postprocessor: """ return ComposePostprocessor( components=[ - ConcatenateOutputs(keys_to_concatenate={"bboxes": ("detection", "bboxes")}), + ConcatenateOutputs( + keys_to_concatenate={ + "bboxes": ("detection", "bboxes"), + "bbox_scores": ("detection", "scores"), + } + ), BboxToCoco(bounding_box_keys=["bboxes"]), + RescaleAndOffset( + keys_to_rescale=["bboxes"], + data=RescaleAndOffset.DataType.BBOX_XYWH, + ), ] ) @@ -100,14 +163,32 @@ def __call__(self, predictions: Any, context: Context) -> tuple[Any, Context]: class ConcatenateOutputs(Postprocessor): """Checks that there is a single prediction for the image and returns it""" - def __init__(self, keys_to_concatenate: dict[str, tuple[str, str]]): + def __init__( + self, + keys_to_concatenate: dict[str, tuple[str, str]], + empty_shapes: dict[str, tuple[int, ...]] | None = None, + create_empty_outputs: bool = False, + ): self.keys_to_concatenate = keys_to_concatenate + self.empty_shapes = empty_shapes + self.create_empty_outputs = create_empty_outputs + + if self.create_empty_outputs: + if not all([k in self.empty_shapes for k in self.keys_to_concatenate]): + raise ValueError( + "You must provide the expected shape for all keys to concatenate" + f"when create_empty_outputs is true, found {self.empty_shapes}" + ) def __call__( self, predictions: Any, context: Context ) -> tuple[dict[str, np.ndarray], Context]: if len(predictions) == 0: - raise ValueError("Cannot concatenate outputs: predictions has length 0") + outputs = { + name: np.zeros((0, *self.empty_shapes[name])) + for name in self.keys_to_concatenate.keys() + } + return outputs, context outputs = {} for output_name, head_key in self.keys_to_concatenate.items(): @@ -119,39 +200,103 @@ def __call__( return outputs, context +class PadOutputs(Postprocessor): + """Pads the outputs to have the maximum number of individuals""" + + def __init__( + self, + max_individuals: dict[str, int], + pad_value: int, + ): + self.max_individuals = max_individuals + self.pad_value = pad_value + + def __call__( + self, predictions: dict[str, np.ndarray], context: Context + ) -> tuple[dict[str, np.ndarray], Context]: + for name in predictions: + output = predictions[name] + if len(output) < self.max_individuals[name]: + pad_size = self.max_individuals[name] - len(output) + tail_shape = output.shape[1:] + padding = -np.ones((pad_size, *tail_shape)) + predictions[name] = np.concatenate([output, padding]) + + return predictions, context + + class RescaleAndOffset(Postprocessor): - """Rescales and offsets images back to their position in the original image""" + """Rescales and offsets predictions back to their position in the original image + + This can be done in 3 ways: + BBOX_XYWH: the data has shape (num_individuals, 4), in xywh format, and there + is a single scale and offset for all bounding boxes (e.g., because the image + was resized before being passed to a detector) + KEYPOINT: the data has shape (num_individuals, num_keypoints, 2/3), and there + is a single scale and offset for all individuals (e.g., because the image + was resized before being passed to a BU pose model) + KEYPOINT_TD: the data has shape (num_individuals, num_keypoints, 2/3), and there + are num_individuals scales and offsets (one for each individual, as TD crops + one image per individual) + + If no scale and no offsets are given, then this postprocessor simply forwards the + predictions and context. + """ - def __init__(self, keys_to_rescale: list[str]) -> None: + class Mode(Enum): + BBOX_XYWH = "bbox_xywh" + KEYPOINT = "keypoint" + KEYPOINT_TD = "keypoint_td" + + def __init__( + self, + keys_to_rescale: list[str], + mode: RescaleAndOffset.Mode, + ) -> None: super().__init__() self.keys_to_rescale = keys_to_rescale + self.mode = mode def __call__( self, predictions: dict[str, np.ndarray], context: Context ) -> tuple[dict[str, np.ndarray], Context]: - if "scales" not in context or "offsets" not in context: - raise ValueError( - "RescalePostprocessor needs 'scales' and 'offsets' in the context, " - f"found {context}" - ) + if "scales" not in context and "offsets" not in context: + # no rescaling needed + return predictions, context updated_predictions = {} scales, offsets = np.array(context["scales"]), np.array(context["offsets"]) for name, outputs in predictions.items(): if name in self.keys_to_rescale: - if not len(outputs) == len(scales) == len(offsets): - raise ValueError( - "There must be as many 'scales' and 'offsets' as outputs, found " - f"{len(outputs)}, {len(scales)}, {len(offsets)}" - ) - - rescaled = [] - for output, scale, offset in zip(outputs, scales, offsets): - output_rescaled = output.copy() - output_rescaled[:, 0] = output[:, 0] * scale[0] + offset[0] - output_rescaled[:, 1] = output[:, 1] * scale[1] + offset[1] - rescaled.append(output_rescaled) - updated_predictions[name] = np.stack(rescaled) + if self.mode == self.Mode.BBOX_XYWH: + rescaled = outputs.copy() + rescaled[:, 0] = outputs[:, 0] * scales[0] + offsets[0] + rescaled[:, 1] = outputs[:, 1] * scales[1] + offsets[1] + rescaled[:, 2] = outputs[:, 2] * scales[0] + rescaled[:, 3] = outputs[:, 3] * scales[1] + elif self.mode == self.Mode.KEYPOINT: + rescaled = outputs.copy() + rescaled[:, :, 0] = outputs[:, :, 0] * scales[0] + offsets[0] + rescaled[:, :, 1] = outputs[:, :, 1] * scales[1] + offsets[1] + else: # Mode.KEYPOINT_TD + if not len(outputs) == len(scales) == len(offsets): + raise ValueError( + "There must be as many 'scales' and 'offsets' as outputs, found " + f"{len(outputs)}, {len(scales)}, {len(offsets)}" + ) + + if len(outputs) == 0: + rescaled = outputs + else: + rescaled_individuals = [] + for output, scale, offset in zip(outputs, scales, offsets): + output_rescaled = output.copy() + output_rescaled[:, 0] = output[:, 0] * scale[0] + offset[0] + output_rescaled[:, 1] = output[:, 1] * scale[1] + offset[1] + rescaled_individuals.append(output_rescaled) + rescaled = np.stack(rescaled_individuals) + + updated_predictions[name] = rescaled else: updated_predictions[name] = outputs.copy() @@ -173,3 +318,24 @@ def __call__( predictions[bbox_key][:, 3] -= predictions[bbox_key][:, 1] return predictions, context + + +class AddContextToOutput(Postprocessor): + """ + Adds items from the context to the output, such as the bounding boxes contained + during top-down inference. + """ + + def __init__(self, keys: list[str]) -> None: + super().__init__() + self.keys = keys + + def __call__( + self, + predictions: dict[str, np.ndarray], + context: Context, + ) -> tuple[dict[str, np.ndarray], Context]: + for k in self.keys: + if k in context: + predictions[k] = context[k].copy() + return predictions, context diff --git a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py index ba60260488..8d0c7026a4 100644 --- a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py @@ -130,26 +130,146 @@ def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context] class AugmentImage(Preprocessor): - """TODO""" + """ + + Adds an offset and scale key to the context: + offset: (x, y) position of the pixel in the top left corner of the augmented + image in the original image + scale: size of the original image divided by the size of the new image + + This allows to map the position of predictions in the transformed image back to the + original image space. + p_original = p_transformed * scale + offset + p_transformed = (p_original - offset) / scale + """ def __init__(self, transform: A.BaseCompose) -> None: self.transform = transform + @staticmethod + def get_offsets_and_scales( + h: int, + w: int, + output_bboxes: list[tuple[float, float, float, float]], + ) -> tuple[list[tuple[float, float]], list[tuple[float, float]]]: + offsets, scales = [], [] + for bbox in output_bboxes: + x_origin, y_origin, w_out, h_out = bbox + x_scale, y_scale = w / w_out, h / h_out + x_offset = -x_origin * x_scale + y_offset = -y_origin * y_scale + offsets.append((x_offset, y_offset)) + scales.append((x_scale, y_scale)) + + return offsets, scales + + @staticmethod + def update_offset( + offset: tuple[float, float], + scale: tuple[float, float], + new_offset: tuple[float, float], + ) -> tuple[float, float]: + return ( + scale[0] * new_offset[0] + offset[0], + scale[1] * new_offset[1] + offset[1], + ) + + @staticmethod + def update_scale( + scale: tuple[float, float], new_scale: tuple[float, float] + ) -> tuple[float, float]: + return scale[0] * new_scale[0], scale[1] * new_scale[1] + + @staticmethod + def update_offsets_and_scales(context, new_offsets, new_scales) -> tuple: + """ + x = x' * scale' + offset' + x' = x'' * scale'' + offset'' + -> x = x'' * (scale' * scale'') + (scale' * offset'' + offset') + """ + # scales and offsets are either both lists or both tuples + offsets = context.get("offsets", (0, 0)) + scales = context.get("scales", (1, 1)) + if isinstance(offsets, tuple): + if isinstance(new_offsets, list): + updated_offsets = [ + AugmentImage.update_offset(offsets, scales, new_offset) + for new_offset in new_offsets + ] + updated_scales = [ + AugmentImage.update_scale(scales, new_scale) + for new_scale in new_scales + ] + else: + if not len(offsets) == len(new_offsets): + raise ValueError("Cannot rescale lists when not same length") + + updated_offsets = AugmentImage.update_offset( + offsets, scales, new_offsets + ) + updated_scales = AugmentImage.update_scale(scales, new_scales) + else: + if isinstance(new_offsets, list): + if not len(offsets) == len(new_offsets): + raise ValueError("Cannot rescale lists when not same length") + + updated_offsets = [ + AugmentImage.update_offset(offset, scale, new_offset) + for offset, scale, new_offset in zip(offsets, scales, new_offsets) + ] + updated_scales = [ + AugmentImage.update_scale(scale, new_scale) + for scale, new_scale in zip(scales, new_scales) + ] + else: + updated_offsets = [ + AugmentImage.update_offset(offset, scale, new_offsets) + for offset, scale in zip(offsets, scales) + ] + updated_scales = [ + AugmentImage.update_scale(scale, new_scales) for scale in scales + ] + return updated_offsets, updated_scales + def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]: # If the image is a batch, process each entry if len(image.shape) == 4: - transformed = [ - self.transform( - image=img, keypoints=[], class_labels=[], bboxes=[], bbox_labels=[] - )["image"] - for img in image - ] - image = np.stack(transformed) + batch_size, h, w, _ = image.shape + if batch_size == 0: + # no images in top-down when no detections + offsets, scales = (0, 0), (1, 1) + else: + transformed = [ + self.transform( + image=img, + keypoints=[], + class_labels=[], + bboxes=[[0, 0, w, h]], + bbox_labels=["image"], + ) + for img in image + ] + image = np.stack([t["image"] for t in transformed]) + output_bboxes = [t["bboxes"][0] for t in transformed] + offsets, scales = self.get_offsets_and_scales(h, w, output_bboxes) else: - image = self.transform( - image=image, keypoints=[], class_labels=[], bboxes=[], bbox_labels=[] - )["image"] + h, w, _ = image.shape + transformed = self.transform( + image=image, + keypoints=[], + class_labels=[], + bboxes=[[0, 0, w, h]], + bbox_labels=["image"], + ) + image = transformed["image"] + output_bboxes = [transformed["bboxes"][0]] + offsets, scales = self.get_offsets_and_scales(h, w, output_bboxes) + offsets = offsets[0] + scales = scales[0] + offsets, scales = self.update_offsets_and_scales(context, offsets, scales) + context["offsets"] = offsets + context["scales"] = scales return image, context @@ -179,7 +299,9 @@ def __init__(self, cropped_image_size: int, bbox_format: str = "xywh") -> None: self.cropped_image_size = cropped_image_size self.bbox_format = bbox_format - def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]: + def __call__( + self, image: np.ndarray, context: Context + ) -> tuple[np.ndarray, Context]: """TODO: numpy implementation""" if "bboxes" not in context: raise ValueError(f"Must include bboxes to CropDetections, found {context}") @@ -195,4 +317,11 @@ def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context] context["offsets"] = offsets context["scales"] = scales - return np.stack(images, axis=0), context + + # can have no bounding boxes if detector made no detections + if len(images) == 0: + images = np.zeros((0, *image.shape)) + else: + images = np.stack(images, axis=0) + + return images, context diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 62b2369fe5..4ed579ba49 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -2,13 +2,14 @@ from typing import Any +import albumentations as A +import cv2 import numpy as np -from albumentations.augmentations.crops import RandomCrop from numpy.typing import NDArray from scipy.spatial.distance import pdist, squareform -class KeypointAwareCrop(RandomCrop): +class KeypointAwareCrop(A.RandomCrop): def __init__( self, width: int, @@ -84,3 +85,58 @@ def get_params_dependent_on_targets(self, params: dict[str, Any]) -> dict[str, A def get_transform_init_args_names(self) -> tuple[str, ...]: return "width", "height", "max_shift", "crop_sampling" + + +class KeepAspectRatioResize(A.DualTransform): + """Resizes images while preserving their aspect ratio + + In 'pad' mode, the image will be rescaled to the largest possible size such that + it can be padded to the correct size (with PadIfNeeded). So we'll have: + output_width <= width, output_height <= height + + In 'crop' mode, the image will be rescaled to the smallest possible size such that + it can be cropped to the correct size (with any random crop you want), so: + output_width >= width, output_height >= height + """ + + def __init__( + self, + width: int, + height: int, + mode: str = "pad", + interpolation: Any = cv2.INTER_LINEAR, + p: float = 1.0, + always_apply: bool = True, + ) -> None: + super().__init__(always_apply=always_apply, p=p) + self.height = height + self.width = width + self.mode = mode + self.interpolation = interpolation + + def apply(self, img, scale=0, interpolation=cv2.INTER_LINEAR, **params): + return A.scale(img, scale, interpolation) + + def apply_to_bbox(self, bbox, **params): + # Bounding box coordinates are scale invariant + return bbox + + def apply_to_keypoint(self, keypoint, scale=0, **params): + keypoint = A.keypoint_scale(keypoint, scale, scale) + return keypoint + + @property + def targets_as_params(self) -> list[str]: + return ["image"] + + def get_params_dependent_on_targets(self, params: dict[str, Any]) -> dict[str, Any]: + h, w, _ = params["image"].shape + if self.mode == "pad": + scale = min(self.height / h, self.width / w) + else: + scale = max(self.height / h, self.width / w) + + return {"scale": scale} + + def get_transform_init_args_names(self): + return "height", "width", "mode", "interpolation" diff --git a/deeplabcut/pose_estimation_pytorch/models/__init__.py b/deeplabcut/pose_estimation_pytorch/models/__init__.py index d0ca89cd01..38d38cc303 100644 --- a/deeplabcut/pose_estimation_pytorch/models/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/__init__.py @@ -1,10 +1,10 @@ -from deeplabcut.pose_estimation_pytorch.models.model import PoseModel -from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES +from deeplabcut.pose_estimation_pytorch.models.criterions import CRITERIONS +from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS -from deeplabcut.pose_estimation_pytorch.models.criterions import CRITERIONS +from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS from deeplabcut.pose_estimation_pytorch.models.target_generators import ( TARGET_GENERATORS, ) -from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py index 938a072192..fa92fcd507 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py @@ -1,11 +1,11 @@ +from deeplabcut.pose_estimation_pytorch.models.criterions.aggregators import ( + WeightedLossAggregator, +) from deeplabcut.pose_estimation_pytorch.models.criterions.base import ( - LOSS_AGGREGATORS, CRITERIONS, - BaseLossAggregator, + LOSS_AGGREGATORS, BaseCriterion, -) -from deeplabcut.pose_estimation_pytorch.models.criterions.aggregators import ( - WeightedLossAggregator, + BaseLossAggregator, ) from deeplabcut.pose_estimation_pytorch.models.criterions.weighted import ( WeightedBCECriterion, diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index b6d0086780..d98f8560d9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -15,7 +15,7 @@ import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg DETECTORS = Registry("detectors", build_func=build_from_cfg) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index cd847abe1d..3b93209ae6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -12,12 +12,12 @@ import torch import torchvision -from torchvision.models.detection import FasterRCNN_ResNet50_FPN_Weights +from torchvision.models.detection import FasterRCNN_ResNet50_FPN_V2_Weights from torchvision.models.detection.faster_rcnn import FastRCNNPredictor from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( - BaseDetector, DETECTORS, + BaseDetector, ) @@ -32,7 +32,9 @@ class FasterRCNN(BaseDetector): real-time object detection with region proposal networks." Advances in neural information processing systems 28 (2015). - See source: https://github.com/pytorch/vision/blob/main/torchvision/models/detection/generalized_rcnn.py + See source: + https://github.com/pytorch/vision/blob/main/torchvision/models/detection/generalized_rcnn.py + https://github.com/pytorch/vision/blob/main/torchvision/models/detection/faster_rcnn.py See tutorial: https://pytorch.org/tutorials/intermediate/torchvision_tutorial.html#defining-your-model See validation loss issue: @@ -40,14 +42,19 @@ class FasterRCNN(BaseDetector): - https://stackoverflow.com/a/65347721 """ - def __init__(self): - """Summary: - Constructor of the FasterRCNN object. - Loads the data. + def __init__( + self, + box_score_thresh: float = 0.01, + ): + """ + Args: + box_score_thresh: during inference, only return proposals with a + classification score greater than box_score_thresh """ super().__init__() - self.model = torchvision.models.detection.fasterrcnn_resnet50_fpn( - weights=FasterRCNN_ResNet50_FPN_Weights.COCO_V1 + self.model = torchvision.models.detection.fasterrcnn_resnet50_fpn_v2( + weights=FasterRCNN_ResNet50_FPN_V2_Weights.COCO_V1, + box_score_thresh=box_score_thresh, ) # Modify the base predictor to output the correct number of classes diff --git a/deeplabcut/pose_estimation_pytorch/runners/__init__.py b/deeplabcut/pose_estimation_pytorch/runners/__init__.py index 31e7bb1d3e..2883420f08 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/runners/__init__.py @@ -9,7 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # -from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner +from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner, Task from deeplabcut.pose_estimation_pytorch.runners.logger import LOGGER from deeplabcut.pose_estimation_pytorch.runners.pose import PoseRunner from deeplabcut.pose_estimation_pytorch.runners.top_down import DetectorRunner diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index bcb6196d9f..184de7141d 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -13,6 +13,7 @@ import logging from abc import ABC, abstractmethod from collections import defaultdict +from enum import Enum from pathlib import Path from typing import Any, Generic, Iterable, TypeVar @@ -23,13 +24,30 @@ from deeplabcut.pose_estimation_pytorch.data.postprocessor import Postprocessor from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor -from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry +from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger RUNNERS = Registry("runners", build_func=build_from_cfg) ModelType = TypeVar("ModelType", bound=nn.Module) +class Task(Enum): + """A task to solve""" + + BOTTOM_UP = "BU" + DETECT = "DT" + TOP_DOWN = "TD" + + @classmethod + def _missing_(cls, value): + if isinstance(value, str): + value = value.upper() + for member in cls: + if member.value == value: + return member + return None + + class Runner(ABC, Generic[ModelType]): """Runner base class @@ -197,6 +215,7 @@ def inference( image_predictions = self.predict(input_image) if self.postprocessor is not None: # TODO: Should we return context? + # TODO: typing update - the post-processor can remove a dict level image_predictions, _ = self.postprocessor(image_predictions, context) results.append(image_predictions) diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index 124558fb37..52efefe0e0 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -13,8 +13,9 @@ from pathlib import Path from typing import Any, Optional -import deeplabcut.pose_estimation_pytorch.registry as deeplabcut_pose_estimation_pytorch_registry import wandb as wb + +import deeplabcut.pose_estimation_pytorch.registry as deeplabcut_pose_estimation_pytorch_registry from deeplabcut.pose_estimation_pytorch.models.model import PoseModel LOGGER = deeplabcut_pose_estimation_pytorch_registry.Registry( diff --git a/deeplabcut/pose_estimation_pytorch/runners/pose.py b/deeplabcut/pose_estimation_pytorch/runners/pose.py index e18f7029be..8e3977b430 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/pose.py +++ b/deeplabcut/pose_estimation_pytorch/runners/pose.py @@ -14,11 +14,9 @@ import numpy as np import torch -from torch.utils.data import DataLoader from deeplabcut.pose_estimation_pytorch.models import model as models -from deeplabcut.pose_estimation_pytorch.runners.base import Runner, RUNNERS -from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger +from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner @RUNNERS.register_module diff --git a/deeplabcut/pose_estimation_pytorch/runners/top_down.py b/deeplabcut/pose_estimation_pytorch/runners/top_down.py index 01dd8e157c..6e91f12fc9 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/top_down.py +++ b/deeplabcut/pose_estimation_pytorch/runners/top_down.py @@ -14,10 +14,9 @@ import numpy as np import torch -from torch.utils.data import DataLoader import deeplabcut.pose_estimation_pytorch.models.detectors as detectors -from deeplabcut.pose_estimation_pytorch.runners.base import Runner, RUNNERS +from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner @RUNNERS.register_module @@ -86,7 +85,7 @@ def step( for item in target: # target is a list here for key in item: if item[key] is not None: - item[key] = torch.tensor(item[key]).to(self.device) + item[key] = item[key].to(self.device) losses, _ = self.model(images, target) losses["total_loss"] = sum(loss_part for loss_part in losses.values()) @@ -126,6 +125,11 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] "bboxes": item["boxes"][: self.max_individuals] .cpu() .numpy() + .reshape(-1, 4), + "scores": item["scores"][: self.max_individuals] + .cpu() + .numpy() + .reshape(-1), } } ) diff --git a/deeplabcut/pose_estimation_pytorch/runners/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py index 628d502267..4596b2567f 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/utils.py +++ b/deeplabcut/pose_estimation_pytorch/runners/utils.py @@ -21,6 +21,7 @@ import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions +from deeplabcut.pose_estimation_pytorch.runners import Task def verify_paths( @@ -499,7 +500,7 @@ def get_paths( model_prefix: str = "", cfg: dict = None, train_iterations: int = 99, - method: str = "bu", + task: Task = Task.BOTTOM_UP, ): dlc_scorer, dlc_scorer_legacy = get_dlc_scorer( project_path, cfg, train_fraction, shuffle, model_prefix, train_iterations @@ -515,7 +516,7 @@ def get_paths( model_path = get_model_path(model_folder, train_iterations) detector_path = None - if method.lower() == "td": + if task == Task.TOP_DOWN: detector_path = get_detector_path(model_folder, train_iterations) return { diff --git a/docs/pytorch/pytorch_config.md b/docs/pytorch/pytorch_config.md new file mode 100644 index 0000000000..f69bb1e096 --- /dev/null +++ b/docs/pytorch/pytorch_config.md @@ -0,0 +1,65 @@ +# The PyTorch Configuration file + +The `pytorch_config.yaml` file specifies everything about how you'll train a model for a +project. + +You can create "base" configurations using `deeplabcut.create_training_set` or +`deeplabcut.create_training_model_comparison`. + +## Model Architectures + +### Bottom-Up + +There are a few keys define your model and training run: + +- `batch_size`: the batch size to train with (inference always runs with batch size 1) +- `data`: the data augmentations you'll apply to images when training (during inference, +only normalization, rescaling and auto-padding are kept - this is subject to change) +- `device`: the device to use to train (such as `cpu`, `cuda`, `cuda:0`, ...) +- `epochs`: the number of epochs to train for +- `method`: either `bu` for bottom-up or `td` for `top-down` +- `model`: the architecture of the model you'll train (including loss criterions) +- `with_center_keypoints`: for models (like DEKR) that need to know the position of the +center of individuals +- `optimizer`: the optimizer to use (the name of any Torch optimizer works) +- `save_epochs`: the number of epochs between each model snapshot +- `scheduler`: learning rate scheduler + +### Top-Down + +Top-down models are configured in a very similar way to bottom up ones. The keys used to +configure the pose model are exactly the same, and a few additional keys are added to +configure how you want your detector to be trained: + +- `detector`: the configuration for the detector, with `model`, `optimizer`, +`scheduler`, `batch_size`, `epochs` and `save_epochs` options +- `data_detector`: the data augmentations to use to train the detector (in the same +format as the ones for the pose model) + +In this case, the `data` augmentations are applied to the pose model. + +## Data Augmentations + +### Resizing Images + +Resizes the images while preserving the aspect ratio (first resizes to the maximum +possible size, then adds padding for the missing pixels). + +```yaml +data: + resize: + width: 1300 + height: 800 + keep_ratio: true +``` + +### Logging Results + +Logs results to Weights & Biases. + +```yaml +logger: + type: 'WandbLogger' + project_name: 'dlc3-project' + run_name: 'dekr-w32-shuffle0' +``` diff --git a/tests/pose_estimation_pytorch/data/test_postprocessor.py b/tests/pose_estimation_pytorch/data/test_postprocessor.py new file mode 100644 index 0000000000..0587978de3 --- /dev/null +++ b/tests/pose_estimation_pytorch/data/test_postprocessor.py @@ -0,0 +1,139 @@ +"""Tests the pre-processors""" +import albumentations as A +import numpy as np +import pytest + +from deeplabcut.pose_estimation_pytorch.apis.utils import build_resize_transforms +from deeplabcut.pose_estimation_pytorch.data.preprocessor import AugmentImage +from deeplabcut.pose_estimation_pytorch.data.postprocessor import RescaleAndOffset + + +@pytest.mark.parametrize( + "data", + [ + { + "predictions": [[[0, 0, 0.95], [20, 30, 0.5]]], + "offsets": [(0, 0)], + "scales": [(1, 1)], + "rescaled": [[[0, 0, 0.95], [20, 30, 0.5]]], + }, + { + "predictions": [ + [[0, 0, 0.12], [1000, 0, 0.5]], # individual 1 + [[18, 2, 0.24], [0, 1000, 0.6]], # individual 2 + ], + "offsets": [(0, 0), (0, 0)], + "scales": [(1, 1), (0.5, 1.0)], + "rescaled": [ + [[0, 0, 0.12], [1000, 0, 0.5]], # individual 1 + [[9, 2, 0.24], [0, 1000, 0.6]], # individual 2 + ], + }, + { + "predictions": [ + [[0, 0, 0.95], [20, 30, 0.5]], # individual 1 + [[110, 5, 0.95], [60, 1200, 0.5]], # individual 2 + ], + "offsets": [(12, 5), (27, 10)], + "scales": [(0.5, 0.5), (0.2, 0.2)], + "rescaled": [ + [[12, 5, 0.95], [22, 20, 0.5]], # individual 1 + [[49, 11, 0.95], [39, 250, 0.5]], # individual 2 + ], + }, + ], +) +def test_rescale_topdown(data): + """expects x_processed = x * scale + offset""" + postprocessor = RescaleAndOffset( + keys_to_rescale=["bodyparts"], + mode=RescaleAndOffset.Mode.KEYPOINT_TD, + ) + context = {"scales": data["scales"], "offsets": data["offsets"]} + predictions = {"bodyparts": np.array(data["predictions"])} + predictions, context = postprocessor(predictions, context=context) + print(predictions["bodyparts"].tolist()) + print(data["rescaled"]) + np.testing.assert_array_equal(predictions["bodyparts"], np.array(data["rescaled"])) + + +@pytest.mark.parametrize( + "data", + [ + { + "predictions": [[[0, 0, 0.95], [20, 30, 0.5]]], + "offsets": (0, 0), + "scales": (1, 1), + "rescaled": [[[0, 0, 0.95], [20, 30, 0.5]]], + }, + { + "predictions": [ + [[0, 0, 0.12], [10, 0, 0.5]], # individual 1 + [[1000, 500, 0.24], [50, 250, 0.6]], # individual 2 + ], + "offsets": (5, 7), + "scales": (0.2, 0.5), + "rescaled": [ + [[5, 7, 0.12], [7, 7, 0.5]], # individual 1 + [[205, 257, 0.24], [15, 132, 0.6]], # individual 2 + ], + }, + ], +) +def test_rescale_bottom_up(data): + """expects x_processed = x * scale + offset""" + postprocessor = RescaleAndOffset( + keys_to_rescale=["bodyparts"], + mode=RescaleAndOffset.Mode.KEYPOINT, + ) + context = {"scales": data["scales"], "offsets": data["offsets"]} + predictions = {"bodyparts": np.array(data["predictions"])} + predictions, context = postprocessor(predictions, context=context) + print(predictions["bodyparts"].tolist()) + print(data["rescaled"]) + np.testing.assert_array_equal(predictions["bodyparts"], np.array(data["rescaled"])) + + +@pytest.mark.parametrize( + "data", + [ + { + "bboxes": [[222.0, 562.0, 721.0, 637.0]], + "offsets": (0, 0), + "scales": (1, 1), + "rescaled": [[222.0, 562.0, 721.0, 637.0]], + }, + { + "bboxes": [[386.71875, 219.53125, 281.640625, 248.828125]], + "offsets": (-768, 0), + "scales": (2.56, 2.56), + "rescaled": [[222.0, 562.0, 721.0, 637.0]], + }, + { + "bboxes": [ + [0, 0, 100, 100], + [5, 10, 100, 100], + [5, 10, 10, 20], + ], + "offsets": (3, 7), + "scales": (2, 0.5), + "rescaled": [ + [3, 7, 200, 50], + [13, 12, 200, 50], + [13, 12, 20, 10], + ], + }, + ], +) +def test_rescale_detector(data): + """expects x_processed = x * scale + offset""" + postprocessor = RescaleAndOffset( + keys_to_rescale=["bboxes"], + mode=RescaleAndOffset.Mode.BBOX_XYWH, + ) + context = {"scales": data["scales"], "offsets": data["offsets"]} + predictions = {"bboxes": np.array(data["bboxes"])} + predictions, context = postprocessor(predictions, context=context) + print(predictions["bboxes"].tolist()) + print(data["rescaled"]) + np.testing.assert_array_equal(predictions["bboxes"], np.array(data["rescaled"])) diff --git a/tests/pose_estimation_pytorch/data/test_preprocessor.py b/tests/pose_estimation_pytorch/data/test_preprocessor.py new file mode 100644 index 0000000000..e66e8fde7a --- /dev/null +++ b/tests/pose_estimation_pytorch/data/test_preprocessor.py @@ -0,0 +1,51 @@ +"""Tests the pre-processors""" +import albumentations as A +import numpy as np +import pytest + +from deeplabcut.pose_estimation_pytorch.apis.utils import build_resize_transforms +from deeplabcut.pose_estimation_pytorch.data.preprocessor import AugmentImage + + +@pytest.mark.parametrize( + "data", + [ + { + "image_shape": (2, 4, 4), + "resize_transform": {"height": 5, "width": 4, "keep_ratio": True}, + "output_shape": (2, 4, 4), + "padded_shape": (5, 4, 4), # single offset as not a batch + "output_context": {"offsets": (0, 0), "scales": (1, 1)} + }, + { + "image_shape": (1, 2, 4, 4), # as batch + "resize_transform": {"height": 10, "width": 4, "keep_ratio": True}, + "output_shape": (1, 2, 4, 4), + "padded_shape": (1, 10, 4, 4), + "output_context": {"offsets": [(0, 0)], "scales": [(1, 1)]} + }, + { + "image_shape": (2, 4, 3), + "resize_transform": {"height": 10, "width": 8, "keep_ratio": True}, + "output_shape": (4, 8, 3), + "padded_shape": (10, 8, 3), + "output_context": {"offsets": (0, 0), "scales": (0.5, 0.5)} + }, + ], +) +def test_augment_image_rescaling(data): + resize_transform = build_resize_transforms(data["resize_transform"]) + transform = A.Compose( + resize_transform, + keypoint_params=A.KeypointParams("xy", remove_invisible=False), + bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), + ) + preprocessor = AugmentImage(transform) + img = np.ones(data["image_shape"]) + transformed_image, context = preprocessor(img, context={}) + print() + print(transformed_image[:, :, 0]) # first channel + print(context) + assert np.sum(transformed_image) == np.sum(np.ones(data["output_shape"])) + assert context == data["output_context"] + assert transformed_image.shape == data["padded_shape"] diff --git a/tests/pose_estimation_pytorch/data/test_transforms.py b/tests/pose_estimation_pytorch/data/test_transforms.py new file mode 100644 index 0000000000..470b0a4569 --- /dev/null +++ b/tests/pose_estimation_pytorch/data/test_transforms.py @@ -0,0 +1,82 @@ +"""Tests the custom transforms""" +import albumentations as A +import numpy as np +import pytest + +from deeplabcut.pose_estimation_pytorch.data import transforms + + +@pytest.mark.parametrize( + "height, width, image_shapes", + [ + (200, 200, [(300, 300, 3), (1000, 1000, 3), (1024, 1024, 1)]), + (512, 512, [(1024, 1024, 3), (128, 128, 4), (300, 300, 1)]), + (1024, 512, [(600, 300, 3), (4096, 2048, 3), (50, 25, 1)]), + (800, 1300, [(80, 130, 3), (1600, 2600, 4), (1200, 1950, 1)]), + ], +) +def test_dlc_resize_pad_good_aspect_ratio(height, width, image_shapes): + aug = transforms.KeepAspectRatioResize(width=width, height=height, mode="pad") + for image_shape in image_shapes: + fake_image = np.zeros(image_shape) + transformed = aug(image=fake_image, keypoints=[]) + assert transformed["image"].shape[:2] == (height, width) + assert transformed["image"].shape[2] == fake_image.shape[2] + + +@pytest.mark.parametrize( + "data", + [ + { + "height": 200, + "width": 200, + "in_shapes": [(100, 50, 3), (50, 400, 3)], + "out_shapes": [(200, 100, 3), (25, 200, 3)], + }, + { + "height": 128, + "width": 256, + "in_shapes": [(100, 100, 3), (512, 256, 3)], + "out_shapes": [(128, 128, 3), (128, 64, 3)], + }, + ], +) +def test_dlc_resize_pad_bad_aspect_ratio(data): + aug = transforms.KeepAspectRatioResize(width=data["width"], height=data["height"], mode="pad") + for in_shape, out_shape in zip(data["in_shapes"], data["out_shapes"]): + fake_image = np.zeros(in_shape) + transformed = aug(image=fake_image, keypoints=[]) + assert transformed["image"].shape == out_shape + + +@pytest.mark.parametrize( + "data", + [ + { + "height": 200, + "width": 200, + "in_shape": (100, 50, 3), + "out_shape": (200, 100, 3), + "in_keypoints": [(50.0, 50.0), (25.0, 10.0)], + "out_keypoints": [(100.0, 100.0), (50.0, 20.0)], + }, + { + "height": 512, + "width": 256, + "in_shape": (1024, 1024, 3), + "out_shape": (256, 256, 3), + "in_keypoints": [(512.0, 512.0), (100.0, 10.0)], + "out_keypoints": [(128.0, 128.0), (25.0, 2.5)], + }, + ], +) +def test_dlc_resize_pad_bad_aspect_ratio_with_keypoints(data): + aug = transforms.KeepAspectRatioResize(width=data["width"], height=data["height"], mode="pad") + transform = A.Compose( + [aug], + keypoint_params=A.KeypointParams("xy", remove_invisible=False), + ) + fake_image = np.zeros(data["in_shape"]) + transformed = transform(image=fake_image, keypoints=data["in_keypoints"]) + assert transformed["image"].shape == data["out_shape"] + assert transformed["keypoints"] == data["out_keypoints"] diff --git a/tests/pose_estimation_pytorch/runners/test_task.py b/tests/pose_estimation_pytorch/runners/test_task.py new file mode 100644 index 0000000000..5e6340736d --- /dev/null +++ b/tests/pose_estimation_pytorch/runners/test_task.py @@ -0,0 +1,17 @@ +""" Tests the Task enum """ +import pytest + +from deeplabcut.pose_estimation_pytorch.runners.base import Task + + +@pytest.mark.parametrize( + "task, task_strings", + [ + (Task.BOTTOM_UP, ["bu", "BU", "bU", "Bu"]), + (Task.TOP_DOWN, ["TD", "tD"]), + (Task.DETECT, ["dt", "DT"]), + ], +) +def test_build_task(task: Task, task_strings: list[str]): + for s in task_strings: + assert task == Task(s) From 3f872de64006ad997db9ceda2d3583ff243a42b3 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 28 Nov 2023 18:25:15 +0100 Subject: [PATCH 056/293] new augmentations: ElasticTransform, Grayscale, CoarseDropout --- .../pose_estimation_pytorch/apis/utils.py | 37 ++-- .../data/transforms.py | 174 +++++++++++++++++- .../tests/test_transforms.py | 28 +++ 3 files changed, 220 insertions(+), 19 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 05fa7cbb16..97394f0c92 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -34,6 +34,9 @@ build_top_down_preprocessor, ) from deeplabcut.pose_estimation_pytorch.data.transforms import ( + CoarseDropout, + ElasticTransform, + Grayscale, KeepAspectRatioResize, KeypointAwareCrop, ) @@ -207,23 +210,21 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose transforms.append(A.Equalize(p=0.5)) if aug_cfg.get("motion_blur", False): transforms.append(A.MotionBlur(p=0.5)) - # TODO Coarse dropout can mask a keypoint which messes up the training, implement new augmentation - # if aug_cfg.get('covering', False): - # transforms.append( - # A.CoarseDropout( - # max_holes=10, - # max_height=0.05, - # min_height=0.01, - # max_width=0.05, - # min_width=0.01, - # p=0.5 - # ) - # ) - # TODO implement elastic transform apply_to_keypoints in albumentations - # if aug_cfg.get('elastic_transform', False): - # transforms.append(A.ElasticTransform(sigma=5, p=0.5)) - # TODO implement iia grayscale augmentation with albumentation - # if aug_cfg.get('grayscale', False): + if aug_cfg.get('covering', False): + transforms.append( + CoarseDropout( + max_holes=10, + max_height=0.05, + min_height=0.01, + max_width=0.05, + min_width=0.01, + p=0.5 + ) + ) + if aug_cfg.get('elastic_transform', False): + transforms.append(ElasticTransform(sigma=5, p=0.5)) + if aug_cfg.get('grayscale', False): + transforms.append(Grayscale(alpha=(0.5, 1.0))) if aug_cfg.get("gaussian_noise", False): opt = aug_cfg.get("gaussian_noise", False) # std # TODO inherit custom gaussian transform to support per_channel = 0.5 @@ -257,7 +258,7 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose return A.Compose( transforms, - keypoint_params=A.KeypointParams("xy", remove_invisible=False), + keypoint_params=A.KeypointParams("xy", remove_invisible=False, label_fields=["class_labels"]), bbox_params=bbox_params, ) diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 4ed579ba49..6fe961f94f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -1,10 +1,12 @@ from __future__ import annotations -from typing import Any +from typing import Any, Iterable, Sequence import albumentations as A import cv2 import numpy as np +import warnings +from albumentations.augmentations.geometric import functional as F from numpy.typing import NDArray from scipy.spatial.distance import pdist, squareform @@ -140,3 +142,173 @@ def get_params_dependent_on_targets(self, params: dict[str, Any]) -> dict[str, A def get_transform_init_args_names(self): return "height", "width", "mode", "interpolation" + + +class Grayscale(A.ToGray): + def __init__( + self, + alpha: float | int | tuple[float] = 1.0, + always_apply: bool = False, + p: float = 0.5, + ): + """ + Args: + alpha: int, float or tuple of floats, optional + The alpha value of the new colorspace when overlayed over the + old one. A value close to 1.0 means that mostly the new + colorspace is visible. A value close to 0.0 means that mostly the + old image is visible. + + * If a float, exactly that value will be used. + * If a tuple ``(a, b)``, a random value from the range + ``a <= x <= b`` will be sampled per image. + """ + super().__init__(always_apply, p) + if isinstance(alpha, (float, int)): + self._alpha = self._validate_alpha(alpha) + elif isinstance(alpha, tuple): + if len(alpha) != 2: + raise ValueError("`alpha` must be a tuple of two numbers.") + self._alpha = tuple([self._validate_alpha(val) for val in alpha]) + else: + raise ValueError("") + + @staticmethod + def _validate_alpha(val: float) -> float: + if not 0.0 <= val <= 1.0: + warnings.warn("`alpha` will be clipped to the interval [0.0, 1.0].") + return min(1.0, max(0.0, val)) + + @property + def alpha(self) -> float: + if isinstance(self._alpha, float): + return self._alpha + return np.random.uniform(*self._alpha) + + def apply(self, img: NDArray, **params) -> NDArray: + img_gray = super().apply(img, **params) + alpha = self.alpha + img_blend = img * (1 - alpha) + img_gray * alpha + return img_blend.astype(img.dtype) + + +class ElasticTransform(A.ElasticTransform): + def __init__( + self, + alpha: float = 20.0, + sigma: float = 5.0, # As in DLC TF + alpha_affine: float = 0.0, # Deactivate affine transformation prior to elastic deformation + interpolation: int = cv2.INTER_CUBIC, # As in imgaug + border_mode: int = cv2.BORDER_CONSTANT, # As in imgaug + value: float | None = None, + mask_value: float | None = None, + always_apply: bool = False, + approximate: bool = True, # Faster by a factor of 2 + same_dxdy: bool = True, # Here too + p: float = 0.5, + ): + super().__init__( + alpha, + sigma, + alpha_affine, + interpolation, + border_mode, + value, + mask_value, + always_apply, + approximate, + same_dxdy, + p, + ) + self._neighbor_dist = 3 + self._neighbor_dist_square = self._neighbor_dist ** 2 + + def apply_to_keypoints( + self, keypoints: Sequence[float], random_state: int | None = None, **params + ) -> list[float]: + heatmaps = np.zeros( + (params["rows"], params["cols"], len(keypoints)), dtype=np.float32 + ) + grid = np.mgrid[: params["rows"], : params["cols"]].transpose((1, 2, 0)) + kpts = np.array([(k[1], k[0]) for k in keypoints]) + valid_kpts = np.all(kpts > 0.0, axis=1) + dist = ((grid - kpts[:, None, None]) ** 2).sum(axis=3) + mask = (dist <= self._neighbor_dist_square) & valid_kpts[:, None, None] + heatmaps[mask.transpose(1, 2, 0)] = 1 + + heatmaps_aug = F.elastic_transform( + heatmaps, + self.alpha, + self.sigma, + self.alpha_affine, + cv2.INTER_NEAREST, + self.border_mode, + self.mask_value, + np.random.RandomState(random_state), + self.approximate, + self.same_dxdy, + ) + + inds = np.indices(heatmaps_aug.shape[:2])[::-1] + mask = np.transpose(heatmaps_aug == 1, (2, 0, 1)) + # Let's compute the average, rather than the median, coordinates + div = np.sum(mask, axis=(1, 2)) + sum_indices = np.sum(inds[:, None] * mask[None], axis=(2, 3)).T + xy = sum_indices / div[:, None] + new_keypoints = [] + for kp, new_coords in zip(keypoints, xy): + kp = list(kp) + kp[:2] = new_coords + new_keypoints.append(tuple(kp)) + return new_keypoints + + +class CoarseDropout(A.CoarseDropout): + def __init__( + self, + max_holes: int = 8, + max_height: int = 8, + max_width: int = 8, + min_holes: int | None = None, + min_height: int | None = None, + min_width: int | None = None, + fill_value: int = 0, + mask_fill_value: int | None = None, + always_apply: bool = False, + p: float = 0.5, + ): + super().__init__( + max_holes, + max_height, + max_width, + min_holes, + min_height, + min_width, + fill_value, + mask_fill_value, + always_apply, + p, + ) + + def apply_to_bboxes(self, bboxes: Sequence[float], **params) -> list[float]: + return list(bboxes) + + def apply_to_keypoints( + self, + keypoints: Sequence[float], + holes: Iterable[tuple[int, int, int, int]] = (), + **params, + ) -> list[float]: + new_keypoints = [] + for kp in keypoints: + in_hole = False + for hole in holes: + if self._keypoint_in_hole(kp, hole): + in_hole = True + break + if in_hole: + kp = list(kp) + kp[:2] = np.nan, np.nan + kp = tuple(kp) + new_keypoints.append(kp) + return new_keypoints diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py b/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py index 879d943f3d..69201a4d92 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py @@ -15,3 +15,31 @@ def test_keypoint_aware_cropping(width, height): assert transformed["image"].shape[:2] == (height, width) # Ensure at least a keypoint is visible in each crop assert len(transformed["keypoints"]) + + +def test_grayscale(): + fake_image = np.ones((600, 600, 3)) + fake_image *= np.random.uniform(0, 255, size=fake_image.shape) + fake_image = fake_image.astype(np.uint8) + gray = transforms.Grayscale(alpha=1, p=1) + aug_image = gray(image=fake_image)["image"] + assert aug_image.shape == fake_image.shape + + gray = transforms.Grayscale(alpha=0, p=1) + aug_image = gray(image=fake_image)["image"] + assert np.allclose(fake_image, aug_image) + + with pytest.warns(UserWarning, match="clipped"): + gray = transforms.Grayscale(alpha=1.5) + assert gray.alpha == 1 + + +def test_coarse_dropout(): + fake_image = np.ones((300, 300, 3)) + fake_image *= np.random.uniform(0, 255, size=fake_image.shape) + fake_image = fake_image.astype(np.uint8) + cd = transforms.CoarseDropout(max_height=0.9999, max_width=0.9999, p=1) + kpts = np.random.rand(10, 2) * 300 + aug_kpts = cd(image=fake_image, keypoints=kpts)["keypoints"] + assert len(aug_kpts) == kpts.shape[0] + assert np.isnan([c for kpt in aug_kpts for c in kpt]).all() From 72e409e49a92b6f2e9f06feecbaf3f27242bff9b Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Mon, 4 Dec 2023 17:54:21 +0100 Subject: [PATCH 057/293] individual re-identification Implements a head for re-indentification of individuals using visual features. --- benchmark/coco/evaluate.py | 1 + benchmark/train_benchmark.py | 1 + .../make_pytorch_config.py | 73 +++-- .../apis/analyze_videos.py | 160 ++++++---- .../apis/convert_detections_to_tracklets.py | 21 +- .../pose_estimation_pytorch/apis/evaluate.py | 19 +- .../pose_estimation_pytorch/apis/scoring.py | 123 ++++++++ .../pose_estimation_pytorch/apis/utils.py | 5 +- .../pose_estimation_pytorch/data/dataset.py | 4 + .../pose_estimation_pytorch/data/dlcloader.py | 5 + .../data/postprocessor.py | 111 +++++-- .../data/preprocessor.py | 3 +- .../pose_estimation_pytorch/default_config.py | 7 +- .../models/predictors/__init__.py | 3 + .../models/predictors/identity_predictor.py | 62 ++++ .../models/target_generators/__init__.py | 8 +- .../target_generators/gaussian_targets.py | 128 -------- .../target_generators/heatmap_targets.py | 278 ++++++++++++++++++ .../target_generators/plateau_targets.py | 137 --------- .../post_processing/identity.py | 36 +++ .../pose_estimation_pytorch/runners/base.py | 4 +- .../tests/test_gaussian_targets.py | 8 +- .../tests/test_plateau_targets.py | 15 +- .../tests/test_seq_targets.py | 7 +- .../apis/test_scoring.py | 194 ++++++++++++ .../data/test_postprocessor.py | 50 +++- .../target_generators/test_heatmap_targets.py | 127 ++++++++ .../target_generators/test_plateau_targets.py | 79 +++++ .../post_processing/test_identity.py | 51 ++++ 29 files changed, 1316 insertions(+), 404 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py delete mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py delete mode 100644 deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py create mode 100644 deeplabcut/pose_estimation_pytorch/post_processing/identity.py create mode 100644 tests/pose_estimation_pytorch/apis/test_scoring.py create mode 100644 tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py create mode 100644 tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py create mode 100644 tests/pose_estimation_pytorch/post_processing/test_identity.py diff --git a/benchmark/coco/evaluate.py b/benchmark/coco/evaluate.py index 9c92917a50..de31dd556e 100644 --- a/benchmark/coco/evaluate.py +++ b/benchmark/coco/evaluate.py @@ -80,6 +80,7 @@ def main( max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, num_unique_bodyparts=parameters.num_unique_bpts, + with_identity=False, transform=None, # Load transform from config detector_path=detector_path, detector_transform=None, diff --git a/benchmark/train_benchmark.py b/benchmark/train_benchmark.py index eebea13dfc..dec191f9fc 100644 --- a/benchmark/train_benchmark.py +++ b/benchmark/train_benchmark.py @@ -176,6 +176,7 @@ def run_inference_on_all_images( max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, num_unique_bodyparts=parameters.num_unique_bpts, + with_identity=False, # TODO: implement transform=transform, detector_path=None, # TODO: Fix for top-down models detector_transform=None, diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index 86bbdd31c0..d63cea4f5f 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -109,7 +109,7 @@ def make_pytorch_config( unique_bpts = auxiliaryfunctions.get_unique_bodyparts(project_config) num_unique_bpts = len(unique_bpts) compute_unique_bpts = num_unique_bpts > 0 - + identification_head = project_config.get("identity") pytorch_config = deepcopy(config_template) pytorch_config["device"] = "cuda" if torch.cuda.is_available() else "cpu" pytorch_config["method"] = "bu" @@ -141,6 +141,12 @@ def make_pytorch_config( num_unique_bpts, backbone_type ) + if identification_head: + pytorch_config["model"]["heads"]["identity"] = make_identity_head( + num_individuals, + backbone_out_channels=BACKBONE_OUT_CHANNELS[backbone_type], + ) + elif "resnet" in net_type: num_stages = pytorch_config.get("num_stages", 0) dim = BACKBONE_OUT_CHANNELS["resnet-50"] if num_stages == 0 else 2304 @@ -166,6 +172,11 @@ def make_pytorch_config( # TODO Set remaining params from config ) } + if identification_head: + pytorch_config["model"]["heads"]["identity"] = make_identity_head( + num_individuals, + backbone_out_channels=dim, + ) # pytorch_config["data"]["crop_sampling"] = { # "height": 400, # "width": 400, @@ -177,6 +188,11 @@ def make_pytorch_config( raise NotImplementedError( "Unique body parts are currently not handled by top down models" ) + if identification_head: + raise NotImplementedError( + "Identification heads are currently not handled by top down models" + ) + pytorch_config["method"] = "td" version = net_type.split("_")[-1] backbone_type = "hrnet_" + version @@ -265,42 +281,65 @@ def make_dlcrnet_head( def make_heatmap_head( - num_joints: int, heatmap_channels: list[int], locref_channels: list[int] + num_heatmaps: int, heatmap_channels: list[int], locref_channels: list[int] | None ) -> dict: n_deconv_heatmap = len(heatmap_channels) - 1 - n_deconv_locref = len(locref_channels) - 1 - return { + with_locref = (locref_channels is not None and len(locref_channels) > 0) + head_config = { "type": "HeatmapHead", "predictor": { "type": "SinglePredictor", - "location_refinement": True, + "location_refinement": with_locref, "locref_stdev": 7.2801, "num_animals": 1, }, "target_generator": { - "type": "PlateauGenerator", - "locref_stdev": 7.2801, - "num_joints": num_joints, + "type": "HeatmapPlateauGenerator", + "num_heatmaps": num_heatmaps, "pos_dist_thresh": 17, + "heatmap_mode": "KEYPOINT", + "generate_locref": with_locref, + "locref_std": 7.2801, }, "criterion": { "heatmap": {"type": "WeightedBCECriterion", "weight": 1.0}, - "locref": { - "type": "WeightedHuberCriterion", # or WeightedMSECriterion - "weight": 0.03, - }, }, "heatmap_config": { "channels": heatmap_channels, "kernel_size": [3] * n_deconv_heatmap, "strides": [2] * n_deconv_heatmap, }, - "locref_config": { + } + + if locref_channels: + n_deconv_locref = len(locref_channels) - 1 + head_config["locref_config"] = { "channels": locref_channels, "kernel_size": [3] * n_deconv_locref, "strides": [2] * n_deconv_locref, - }, + } + head_config["criterion"]["locref"] = { + "type": "WeightedHuberCriterion", # or WeightedMSECriterion + "weight": 0.05, + } + + return head_config + + +def make_identity_head( + num_individuals: int, backbone_out_channels: list[int] +) -> dict: + heatmap_head = make_heatmap_head( + num_individuals, + heatmap_channels=[backbone_out_channels, num_individuals], + locref_channels=None, + ) + heatmap_head["predictor"] = { + "type": "IdentityPredictor", + "apply_sigmoid": True, } + heatmap_head["target_generator"]["heatmap_mode"] = "INDIVIDUAL" + return heatmap_head def make_single_head_cfg(num_joints: int, net_type: str) -> dict: @@ -427,10 +466,10 @@ def make_token_pose_model_cfg(num_joints, backbone_type): "bodypart": { "type": "TransformerHead", "target_generator": { - "type": "PlateauGenerator", - "generate_locref": False, - "num_joints": num_joints, + "type": "HeatmapPlateauGenerator", + "num_heatmaps": num_joints, "pos_dist_thresh": 17, + "generate_locref": False, }, "criterion": {"type": "WeightedBCECriterion"}, "predictor": {"type": "HeatmapOnlyPredictor", "num_animals": 1}, diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 2945d26ad1..ee996451b9 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -14,11 +14,12 @@ import pickle import time from pathlib import Path -from typing import Any, List, Optional, Tuple, Union +from typing import Any import albumentations as A import numpy as np import pandas as pd +from tqdm import tqdm from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( convert_detections2tracklets, @@ -29,6 +30,8 @@ get_runners, list_videos_in_folder, ) +from deeplabcut.pose_estimation_pytorch.data import DLCLoader +from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity from deeplabcut.pose_estimation_pytorch.runners import Runner, Task from deeplabcut.refine_training_dataset.stitch import stitch_tracklets from deeplabcut.utils import VideoReader, auxfun_multianimal, auxiliaryfunctions @@ -85,7 +88,8 @@ def video_inference( task: Task, pose_runner: Runner, detector_runner: Runner | None = None, -) -> tuple[np.ndarray, np.ndarray | None]: + with_identity: bool = False, +) -> list[dict[str, np.ndarray]]: """Runs inference on a video""" video = VideoIterator(str(video_path)) n_frames = video.get_n_frames() @@ -102,33 +106,40 @@ def video_inference( if detector_runner is None: raise ValueError("Must use a detector for top-down video analysis") - bbox_predictions = detector_runner.inference(images=video) + print("Running Detector") + bbox_predictions = detector_runner.inference(images=tqdm(video)) video.set_context(bbox_predictions) - predictions = pose_runner.inference(images=video) - poses = np.stack([p["bodyparts"] for p in predictions]) - unique_poses = None - if len(predictions) > 0 and "unique_bodyparts" in predictions[0]: - unique_poses = np.stack([p["unique_bodyparts"] for p in predictions]) - return poses, unique_poses + print("Running Pose Prediction") + predictions = pose_runner.inference(images=tqdm(video)) + + if with_identity: + bodypart_predictions = assign_identity( + [p["bodyparts"] for p in predictions], + [p["identity_scores"] for p in predictions], + ) + for i, p_with_id in enumerate(bodypart_predictions): + predictions[i]["bodyparts"] = p_with_id + + return predictions def analyze_videos( config: str, - videos: Union[str, List[str]], - videotype: Optional[str] = None, + videos: str | list[str], + videotype: str | None = None, shuffle: int = 1, trainingsetindex: int = 0, - snapshotindex: Optional[int] = None, - device: Optional[str] = None, - destfolder: Optional[str] = None, - batchsize: Optional[int] = None, + snapshotindex: int | None = None, + device: str | None = None, + destfolder: str | None = None, + batchsize: int | None = None, modelprefix: str = "", - transform: Optional[A.Compose] = None, - auto_track: Optional[bool] = True, - identity_only: Optional[bool] = False, + transform: A.Compose | None = None, + auto_track: bool | None = True, + identity_only: bool | None = False, overwrite: bool = False, -) -> List[Tuple[str, pd.DataFrame]]: +) -> list[tuple[str, pd.DataFrame]]: """Makes prediction based on a trained network. # TODO: @@ -182,7 +193,7 @@ def analyze_videos( A list containing tuples (video_name, df_video_predictions) """ # Create the output folder - _create_output_folder(destfolder) + _validate_destfolder(destfolder) # Load the project configuration cfg = auxiliaryfunctions.read_config(config) @@ -223,6 +234,8 @@ def analyze_videos( # TODO: Choose which detector to use detector_path = _get_detector_path(model_folder, -1, cfg) + with_identity = DLCLoader.has_identity_head(pytorch_config) + print(f"Analyzing videos with {model_path}") pose_runner, detector_runner = get_runners( pytorch_config=pytorch_config, @@ -230,6 +243,7 @@ def analyze_videos( max_individuals=max_num_animals, num_bodyparts=len(bodyparts), num_unique_bodyparts=len(unique_bodyparts), + with_identity=with_identity, transform=transform, detector_path=detector_path, detector_transform=None, @@ -251,7 +265,7 @@ def analyze_videos( print(f"Video already analyzed at {output_pkl}!") else: runtime = [time.time()] - predictions, unique_predictions = video_inference( + predictions = video_inference( video_path=video, pose_runner=pose_runner, task=pose_task, @@ -259,6 +273,19 @@ def analyze_videos( ) runtime.append(time.time()) + bodyparts = np.stack([p["bodyparts"] for p in predictions]) + unique_bodyparts = None + if len(predictions) > 0 and "unique_bodyparts" in predictions[0]: + unique_bodyparts = np.stack([p["unique_bodyparts"] for p in predictions]) + bodypart_identities = None + if with_identity: + # reshape from (num_assemblies, num_bpts, num_individuals) + # to (num_assemblies, num_bpts) by taking the maximum + # likelihood individual for each bodypart + bodypart_identities = np.stack( + [np.argmax(p["identity_scores"], axis=2) for p in predictions] + ) + print(f"Inference is done for {video}! Saving results...") metadata = _generate_metadata( cfg=cfg, @@ -291,11 +318,11 @@ def analyze_videos( names=["scorer", "bodyparts", "coords"], ) df = pd.DataFrame( - predictions.reshape((len(predictions), -1)), + bodyparts.reshape((len(bodyparts), -1)), columns=results_df_index, - index=range(len(predictions)), + index=range(len(bodyparts)), ) - if unique_predictions is not None: + if unique_bodyparts is not None: coordinate_labels_unique = ["x", "y", "likelihood"] results_unique_df_index = pd.MultiIndex.from_product( [ @@ -306,9 +333,9 @@ def analyze_videos( names=["scorer", "bodyparts", "coords"], ) df_u = pd.DataFrame( - unique_predictions.reshape((len(unique_predictions), -1)), + unique_bodyparts.reshape((len(unique_bodyparts), -1)), columns=results_unique_df_index, - index=range(len(unique_predictions)), + index=range(len(unique_bodyparts)), ) df = df.join(df_u, how="outer") @@ -322,24 +349,25 @@ def analyze_videos( if cfg["multianimalproject"] and len(individuals) > 1: output_ass = output_path / f"{output_prefix}_assemblies.pickle" assemblies = {} - for i, prediction in enumerate(predictions): - extra_column = np.full( - (prediction.shape[0], prediction.shape[1], 1), - -1.0, - dtype=np.float32, - ) - ass = np.concatenate((prediction, extra_column), axis=-1) + for i, bpt in enumerate(bodyparts): + if with_identity: + extra_column = np.expand_dims(bodypart_identities[i], axis=-1) + else: + extra_column = np.full( + (bpt.shape[0], bpt.shape[1], 1), + -1.0, + dtype=np.float32, + ) + ass = np.concatenate((bpt, extra_column), axis=-1) assemblies[i] = ass - if unique_predictions is not None: + if unique_bodyparts is not None: assemblies["single"] = {} - for i, unique_prediction in enumerate(unique_predictions): + for i, unique_bpt in enumerate(unique_bodyparts): extra_column = np.full( - (unique_prediction.shape[1], 1), -1.0, dtype=np.float32 - ) - ass = np.concatenate( - (unique_prediction[0], extra_column), axis=-1 + (unique_bpt.shape[1], 1), -1.0, dtype=np.float32 ) + ass = np.concatenate((unique_bpt[0], extra_column), axis=-1) assemblies["single"][i] = ass with open(output_ass, "wb") as handle: @@ -383,9 +411,10 @@ def analyze_videos( return results -def _create_output_folder(output_folder: Optional[Path]) -> None: - if output_folder is not None: - output_folder = Path(output_folder) +def _validate_destfolder(destfolder: str | None) -> None: + """Checks that the destfolder for video analysis is valid""" + if destfolder is not None and destfolder != "": + output_folder = Path(destfolder) if not output_folder.exists(): print(f"Creating the output folder {output_folder}") output_folder.mkdir(parents=True) @@ -401,7 +430,7 @@ def _generate_metadata( dlc_scorer: str, train_fraction: int, batch_size: int, - runtime: Tuple[float, float], + runtime: tuple[float, float], video: VideoReader, ) -> dict: w, h = video.dimensions @@ -477,7 +506,10 @@ def _get_detector_path( return trained_models[snapshot_index] -def _generate_output_data(pose_config: dict, predictions: np.ndarray) -> dict: +def _generate_output_data( + pose_config: dict, + predictions: list[dict[str, np.ndarray]], +) -> dict: output = { "metadata": { "nms radius": pose_config.get("nmsradius"), @@ -499,25 +531,35 @@ def _generate_output_data(pose_config: dict, predictions: np.ndarray) -> dict: str_width = int(np.ceil(np.log10(len(predictions)))) for frame_num, frame_predictions in enumerate(predictions): - key = "frame" + str(frame_num).zfill(str_width) - output[key] = frame_predictions.squeeze() - # TODO: Do we want to keep the same format as in the TensorFlow version? # On the one hand, it's "more" backwards compatible. # On the other, might as well simplify the code. These files should only be loaded # by the PyTorch version, and only predictions made by PyTorch models should be # loaded using them - # p_bodypart_indv = np.transpose(frame_predictions.squeeze(), axes=[1, 0, 2]) - # coords = [ - # bodypart_predictions[:, :2] for bodypart_predictions in p_bodypart_indv - # ] - # scores = [ - # bodypart_predictions[:, 2:] for bodypart_predictions in p_bodypart_indv - # ] - # output[key] = { - # "coordinates": (coords,), - # "confidence": scores, - # "costs": None, - # } + + key = "frame" + str(frame_num).zfill(str_width) + bodyparts = frame_predictions["bodyparts"] # shape (num_assemblies, num_bpts, 3) + bodyparts = bodyparts.transpose((1, 0, 2)) # shape (num_bpts, num_assemblies, 3) + coordinates = [bpt[:, :2] for bpt in bodyparts] + scores = [bpt[:, 2:] for bpt in bodyparts] + + # full pickle has bodyparts and unique bodyparts in same array + if "unique_bodyparts" in frame_predictions: + unique_bpts = frame_predictions["unique_bodyparts"].transpose((1, 0, 2)) + coordinates += [bpt[:, :2] for bpt in unique_bpts] + scores += [bpt[:, 2:] for bpt in unique_bpts] + + output[key] = { + "coordinates": (coordinates,), + "confidence": scores, + "costs": None, + } + + if "identity_scores" in frame_predictions: + # Reshape id scores from (num_assemblies, num_bpts, num_individuals) + # to the original DLC full pickle format: (num_bpts, num_assem, num_ind) + id_scores = frame_predictions["identity_scores"] + id_scores = id_scores.transpose((1, 0, 2)) + output[key]["identity"] = [bpt_id_scores for bpt_id_scores in id_scores] return output diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index dd42dc80bf..cddb7e9a8c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -17,6 +17,7 @@ import numpy as np import pandas as pd +from scipy.optimize import linear_sum_assignment from tqdm import tqdm from deeplabcut import auxiliaryfunctions @@ -234,14 +235,14 @@ def convert_detections2tracklets( animals = np.stack([a for a in assemblies]) if identity_only: - raise ValueError("Identity Only is currently not implemented") # Optimal identity assignment based on soft voting - # mat = np.zeros((len(assemblies), inference_cfg["topktoretain"])) - # for row, a in enumerate(assemblies): - # for k, v in a.soft_identity.items(): - # mat[row, k] = v - # inds = linear_sum_assignment(mat, maximize=True) - # trackers = np.c_[inds][:, ::-1] + mat = np.zeros((len(assemblies), inference_cfg["topktoretain"])) + for row, a in enumerate(assemblies): + assembly = Assembly.from_array(a) + for k, v in assembly.soft_identity.items(): + mat[row, k] = v + inds = linear_sum_assignment(mat, maximize=True) + trackers = np.c_[inds][:, ::-1] else: if track_method == "box": xy = trackingutils.calc_bboxes_from_keypoints( @@ -251,9 +252,9 @@ def convert_detections2tracklets( xy = animals[:, keep_inds, :2] trackers = mot_tracker.track(xy) - trackingutils.fill_tracklets( - tracklets, trackers, animals, image_name - ) + trackingutils.fill_tracklets( + tracklets, trackers, animals, image_name + ) tracklets["header"] = df_index with open(track_filename, "wb") as f: diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 9e2803df3d..4ef38d1719 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -22,7 +22,7 @@ from deeplabcut.pose_estimation_pytorch.data import DLCLoader, Loader from deeplabcut.pose_estimation_pytorch.apis.scoring import ( get_scores, - pair_predicted_individuals_with_gt, + pair_predicted_individuals_with_gt, compute_identity_scores, ) from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_predictions_dataframe, @@ -137,6 +137,22 @@ def evaluate( unique_bodypart_gt=gt_unique_keypoints, ) + # TODO: Evaluate identity predictions + if DLCLoader.has_identity_head(loader.model_cfg): + pred_id_scores = { + filename: pred["identity_scores"] + for filename, pred in predictions.items() + } + id_scores = compute_identity_scores( + individuals=parameters.individuals, + bodyparts=parameters.bodyparts, + predictions=poses, + identity_scores=pred_id_scores, + ground_truth=gt_keypoints, + ) + for name, score in id_scores.items(): + results[f"id_head_{name}"] = score + # Updating poses to be aligned and padded for image, pose in poses.items(): predictions[image]["bodyparts"] = pose @@ -216,6 +232,7 @@ def evaluate_snapshot( max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, num_unique_bodyparts=parameters.num_unique_bpts, + with_identity=loader.with_identity, transform=transform, detector_path=detector_path, detector_transform=None, diff --git a/deeplabcut/pose_estimation_pytorch/apis/scoring.py b/deeplabcut/pose_estimation_pytorch/apis/scoring.py index 298d47260a..a2a8076e7e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/apis/scoring.py @@ -11,14 +11,20 @@ from __future__ import annotations import numpy as np +import pickle +from sklearn.metrics import accuracy_score from deeplabcut.pose_estimation_pytorch.post_processing import ( rmse_match_prediction_to_gt, ) +from deeplabcut.pose_estimation_tensorflow.core.evaluate_multianimal import ( + _find_closest_neighbors, +) from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( Assembly, evaluate_assembly, ) +from deeplabcut.utils.auxiliaryfunctions import read_config def get_scores( @@ -210,6 +216,123 @@ def compute_oks( ) +def compute_identity_scores( + individuals: list[str], + bodyparts: list[str], + predictions: dict[str, np.ndarray], + identity_scores: dict[str, np.ndarray], + ground_truth: dict[str, np.ndarray], +) -> dict[str, float]: + """ + FIXME: With DLCRNet all heatmap "peaks" above 0.01 were kept, with 1 keypoint and + 1 identity score map per peak. Then, for each ground truth keypoint, we selected + the prediction closest to it, and evaluated the identity score in that position. + This is no longer the case, as we're now evaluating after assembly. So we only + have num_individuals assemblies. + + Args: + individuals: + bodyparts: + predictions: (num_assemblies, num_bodyparts, 3) + identity_scores: (num_assemblies, num_bodyparts, num_individuals) + ground_truth: (num_individuals, num_bodyparts, 3) + + Returns: + + """ + if not len(predictions) == len(ground_truth): + raise ValueError("Mismatch between number of predictions and ground truth") + + all_bpts = np.asarray(len(individuals) * bodyparts) + ids = np.full((len(predictions), len(all_bpts), 2), np.nan) + for i, (image, pred) in enumerate(predictions.items()): + for j in range(len(individuals)): + for k in range(len(bodyparts)): + bpt_idx = len(bodyparts) * j + k + ids[i, bpt_idx, 0] = j + + gt = mask_invisible(ground_truth[image], mask_value=np.nan) + id_scores = identity_scores[image] + + # reorder to (bodypart, individual, ...) + gt = gt.transpose((1, 0, 2)) + pred = pred.transpose((1, 0, 2))[..., :2] + id_scores = id_scores.transpose((1, 0, 2)) + for bpt, bpt_gt, bpt_pred, bpt_id_scores in zip(bodyparts, gt, pred, id_scores): + # assign ground truth keypoints to the closest prediction, so the ID score + # is the closest possible to the ID score computed with "ground truth" + indices_gt = np.flatnonzero(np.all(~np.isnan(bpt_gt), axis=1)) + neighbors = _find_closest_neighbors(bpt_gt[indices_gt], bpt_pred, k=3) + found = neighbors != -1 + indices = np.flatnonzero(all_bpts == bpt) + # Get the predicted identity of each bodypart by taking the argmax + ids[i, indices[indices_gt[found]], 1] = np.argmax( + bpt_id_scores[neighbors[found]], axis=1 + ) + + ids = ids.reshape((len(predictions), len(individuals), len(bodyparts), 2)) + results = {} + for i, bpt in enumerate(bodyparts): + temp = ids[:, :, i].reshape((-1, 2)) + valid = np.isfinite(temp).all(axis=1) + y_true, y_pred = temp[valid].T + results[f"{bpt}_accuracy"] = accuracy_score(y_true, y_pred) + + return results + + +def _match_identity_preds_to_gt( + config_path: str, full_pickle_path: str +) -> tuple[np.ndarray, list]: + with open(full_pickle_path, "rb") as f: + data = pickle.load(f) + cfg = read_config(config_path) + all_ids = cfg["individuals"] + metadata = data.pop("metadata") + joints = metadata["all_joints_names"] + all_bpts = np.asarray(len(all_ids) * joints + cfg["uniquebodyparts"]) + ids = np.full((len(data), len(all_bpts), 2), np.nan) + for i, dict_ in enumerate(data.values()): + id_gt, _, df_gt = dict_["groundtruth"] + for j, id_ in enumerate(id_gt): + if id_.size: + ids[i, j, 0] = all_ids.index(id_) + + df = df_gt.unstack("coords").reindex(joints, level="bodyparts") + xy_pred = dict_["prediction"]["coordinates"][0] + for bpt, xy_gt in df.groupby(level="bodyparts"): + inds_gt = np.flatnonzero(np.all(~np.isnan(xy_gt), axis=1)) + n_joint = joints.index(bpt) + xy = xy_pred[n_joint] + if inds_gt.size and xy.size: + # Pick the predictions closest to ground truth, + # rather than the ones the model has most confident in + xy_gt_values = xy_gt.iloc[inds_gt].values + neighbors = _find_closest_neighbors(xy_gt_values, xy, k=3) + found = neighbors != -1 + inds = np.flatnonzero(all_bpts == bpt) + id_ = dict_["prediction"]["identity"][n_joint] + ids[i, inds[inds_gt[found]], 1] = np.argmax( + id_[neighbors[found]], axis=1 + ) + return ids, list(data) + + +def compute_id_accuracy(ids: np.ndarray, mask_test: np.ndarray) -> np.ndarray: + ids2 = ids.reshape((ids.shape[0], 2, -1, 2)) + nbpts = ids2.shape[2] + accu = np.empty((nbpts, 2)) + for i in range(nbpts): + temp = ids2[:, :, i].reshape((-1, 2)) + valid = np.isfinite(temp).all(axis=1) + y_true, y_pred = temp[valid].T + mask = np.repeat(mask_test, 2)[valid] + ac_train = accuracy_score(y_true[~mask], y_pred[~mask]) + ac_test = accuracy_score(y_true[mask], y_pred[mask]) + accu[i] = ac_train, ac_test + return accu + + def build_assemblies(poses: dict[str, np.ndarray]) -> dict[str, list[Assembly]]: """ Builds assemblies from a pose array diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 97394f0c92..f3798e9c12 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -532,6 +532,7 @@ def get_runners( max_individuals: int, num_bodyparts: int, num_unique_bodyparts: int, + with_identity: bool = False, transform: A.BaseCompose | None = None, detector_path: str | None = None, detector_transform: A.BaseCompose | None = None, @@ -544,8 +545,7 @@ def get_runners( max_individuals: the maximum number of individuals per image num_bodyparts: the number of bodyparts predicted by the model num_unique_bodyparts: the number of unique_bodyparts predicted by the model - pad_outputs_to_full_shape: if true, pose arrays are padded with -1s for missing - individuals + with_identity: whether the pose model has an identity head transform: the transform for pose estimation. if None, uses the transform defined in the config. detector_path: the path to the detector snapshot from which to load weights, @@ -571,6 +571,7 @@ def get_runners( max_individuals=max_individuals, num_bodyparts=num_bodyparts, num_unique_bodyparts=num_unique_bodyparts, + with_identity=with_identity, ) else: pose_preprocessor = build_top_down_preprocessor( diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 709c40b9b7..d3efdf5348 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -340,6 +340,10 @@ def apply_transform_all_keypoints( transformed["keypoints"] = keypoints transformed["keypoints_unique"] = keypoints_unique + transformed["bboxes"] = np.array(transformed["bboxes"]) + if len(transformed["bboxes"]) == 0: + transformed["bboxes"] = np.zeros((0, 4)) + return transformed @staticmethod diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index c50de71cbd..3f80efb77a 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -68,6 +68,7 @@ class DLCLoader(Loader, metaclass=CombinedPropertyMeta): def __post_init__(self): super().__init__(self.project_root, self.model_config_path) self.split, self.df_dlc, self.df_train, self.df_test = self._load_dlc_data() + self.with_identity = self.has_identity_head(self.model_cfg) def get_dataset_parameters(self) -> PoseDatasetParameters: """ @@ -175,3 +176,7 @@ def split_data( df_test = dlc_df.loc[test_images] return df_train, df_test + + @staticmethod + def has_identity_head(pytorch_config: dict) -> bool: + return "identity" in pytorch_config.get("model", {}).get("heads", {}) diff --git a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py index 7875944fe9..2e035fc4c9 100644 --- a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py @@ -34,6 +34,7 @@ def build_bottom_up_postprocessor( max_individuals: int, num_bodyparts: int, num_unique_bodyparts: int, + with_identity: bool = False, ) -> ComposePostprocessor: """Creates a postprocessor for bottom-up pose estimation (or object detection) @@ -41,6 +42,7 @@ def build_bottom_up_postprocessor( max_individuals: the maximum number of individuals in a single image num_bodyparts: the number of bodyparts output by the model num_unique_bodyparts: the number of unique_bodyparts output by the model + with_identity: whether the model has an identity head Returns: A default bottom-up Postprocessor @@ -48,30 +50,50 @@ def build_bottom_up_postprocessor( keys_to_concatenate = {"bodyparts": ("bodypart", "poses")} empty_shapes = {"bodyparts": (num_bodyparts, 3)} keys_to_rescale = ["bodyparts"] + if num_unique_bodyparts > 0: keys_to_concatenate["unique_bodyparts"] = ("unique_bodypart", "poses") - empty_shapes = {"bodyparts": (num_bodyparts, 3)} + empty_shapes["unique_bodyparts"] = (num_bodyparts, 3) keys_to_rescale.append("unique_bodyparts") - return ComposePostprocessor( - components=[ - ConcatenateOutputs( - keys_to_concatenate=keys_to_concatenate, - empty_shapes=empty_shapes, - create_empty_outputs=True, - ), - RescaleAndOffset( - keys_to_rescale=keys_to_rescale, - data=RescaleAndOffset.DataType.KEYPOINT, - ), - PadOutputs( - max_individuals={ - "bodyparts": max_individuals, - "unique_bodyparts": 0, # no need to pad - }, - pad_value=-1, - ), - ] - ) + + if with_identity: + # TODO: do we really want to return the heatmaps? + keys_to_concatenate["identity_heatmap"] = ("identity", "heatmap") + empty_shapes["identity_heatmap"] = (1, 1, max_individuals) + + components = [ + ConcatenateOutputs( + keys_to_concatenate=keys_to_concatenate, + empty_shapes=empty_shapes, + create_empty_outputs=True, + ), + ] + + if with_identity: + components.append( + PredictKeypointIdentities( + identity_key="identity_scores", + identity_map_key="identity_heatmap", + pose_key="bodyparts", + ) + ) + + components += [ + RescaleAndOffset( + keys_to_rescale=keys_to_rescale, + mode=RescaleAndOffset.Mode.KEYPOINT, + ), + PadOutputs( + max_individuals={ + "bodyparts": max_individuals, + "unique_bodyparts": 0, # no need to pad + "identity_heatmap": 0, # no need to pad + "identity_scores": max_individuals, + }, + pad_value=-1, + ), + ] + return ComposePostprocessor(components=components) def build_top_down_postprocessor( @@ -106,7 +128,7 @@ def build_top_down_postprocessor( ), RescaleAndOffset( keys_to_rescale=keys_to_rescale, - data=RescaleAndOffset.DataType.KEYPOINT_TD, + mode=RescaleAndOffset.Mode.KEYPOINT_TD, ), AddContextToOutput(keys=["bboxes", "bbox_scores"]), PadOutputs( @@ -139,7 +161,7 @@ def build_detector_postprocessor() -> Postprocessor: BboxToCoco(bounding_box_keys=["bboxes"]), RescaleAndOffset( keys_to_rescale=["bboxes"], - data=RescaleAndOffset.DataType.BBOX_XYWH, + mode=RescaleAndOffset.Mode.BBOX_XYWH, ), ] ) @@ -177,7 +199,7 @@ def __init__( if not all([k in self.empty_shapes for k in self.keys_to_concatenate]): raise ValueError( "You must provide the expected shape for all keys to concatenate" - f"when create_empty_outputs is true, found {self.empty_shapes}" + f" when create_empty_outputs is true, found {self.empty_shapes}" ) def __call__( @@ -339,3 +361,44 @@ def __call__( if k in context: predictions[k] = context[k].copy() return predictions, context + + +class PredictKeypointIdentities(Postprocessor): + """Assigns predicted identities to keypoints + + Attributes: + identity_key: + identity_map_key: shape (h, w, num_ids) + pose_key: + """ + + def __init__( + self, + identity_key: str, + identity_map_key: str, + pose_key: str, + ) -> None: + self.identity_key = identity_key + self.identity_map_key = identity_map_key + self.pose_key = pose_key + + def __call__( + self, predictions: dict[str, np.ndarray], context: Context + ) -> tuple[dict[str, np.ndarray], Context]: + individuals = predictions[self.pose_key] + identity_heatmap = predictions[self.identity_map_key] # (h, w, num_ids) + h, w, num_ids = identity_heatmap.shape + num_individuals, num_keypoints, _ = individuals.shape + + assembly_id_scores = [] + for individual_keypoints in individuals: + heatmap_indices = np.rint(individual_keypoints).astype(int) + xs = np.clip(heatmap_indices[:, 0], 0, w - 1) + ys = np.clip(heatmap_indices[:, 1], 0, h - 1) + id_scores = [] + for x, y in zip(xs, ys): + id_scores.append(identity_heatmap[y, x, :]) + assembly_id_scores.append(np.stack(id_scores)) + + predictions[self.identity_key] = np.stack(assembly_id_scores) + return predictions, context diff --git a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py index 8d0c7026a4..499abb3c92 100644 --- a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py @@ -34,7 +34,8 @@ def __call__(self, image: Image, context: Context) -> tuple[Image, Context]: image: an image (containing height, width and channel dimensions) or a batch of images linked to a single input (containing an extra batch dimension) - context: the context for this image or batch of images (such as ) + context: the context for this image or batch of images (such as bounding + boxes, conditional pose, ...) Returns: the pre-processed image (or batch of images) and their context diff --git a/deeplabcut/pose_estimation_pytorch/default_config.py b/deeplabcut/pose_estimation_pytorch/default_config.py index 99e73bb36e..4624bb0301 100644 --- a/deeplabcut/pose_estimation_pytorch/default_config.py +++ b/deeplabcut/pose_estimation_pytorch/default_config.py @@ -43,10 +43,11 @@ "strides": [2, 2], }, "target_generator": { - "type": "PlateauGenerator", - "locref_stdev": 7.2801, - "num_joints": -1, + "type": "HeatmapPlateauGenerator", + "num_heatmaps": -1, "pos_dist_thresh": 17, + "generate_locref": True, + "locref_std": 7.2801, }, "pose_model": {"stride": 8}, }, diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index 786ef049b6..66f0a0b90a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -15,6 +15,9 @@ from deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor import ( DEKRPredictor, ) +from deeplabcut.pose_estimation_pytorch.models.predictors.identity_predictor import ( + IdentityPredictor, +) from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import ( PartAffinityFieldPredictor, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py new file mode 100644 index 0000000000..ef9c562be2 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py @@ -0,0 +1,62 @@ +"""Predictor to generate identity maps from head outputs + + +""" +import torch +import torch.nn as nn +import torchvision.transforms.functional as F + +from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( + BasePredictor, + PREDICTORS, +) + + +@PREDICTORS.register_module +class IdentityPredictor(BasePredictor): + """Predictor to generate identity maps from head outputs + + Attributes: + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + """ + + def __init__(self, apply_sigmoid: bool = True): + """ + Args: + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + """ + super().__init__() + self.apply_sigmoid = apply_sigmoid + self.sigmoid = nn.Sigmoid() + + def forward( + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] + ) -> dict[str, torch.Tensor]: + """Forward pass of IdentityPredictor. + + Swaps the dimensions so the heatmap are (batch_size, h, w, num_individuals), + optionally applies a sigmoid to the heatmaps, and rescales it to be the size + of the original image (so that the identity scores of keypoints can be computed) + + Args: + inputs: the input images given to the model, of shape (b, c, h, w) + outputs: output of the model identity head, of shape (b, num_individuals, w', h') + + Returns: + A dictionary containing a "heatmap" key with the identity heatmap tensor as + value. + """ + heatmaps = outputs["heatmap"] + h_in, w_in = inputs.shape[2:] + heatmaps = F.resize( + heatmaps, + size=[h_in, w_in], + interpolation=F.InterpolationMode.BILINEAR, + antialias=True, + ) + if self.apply_sigmoid: + heatmaps = self.sigmoid(heatmaps) + + # permute to have shape (batch_size, h, w, num_individuals) + heatmaps = heatmaps.permute((0, 2, 3, 1)) + return {"heatmap": heatmaps} diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py index 2d33aad033..96a74f39e2 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py @@ -16,12 +16,10 @@ from deeplabcut.pose_estimation_pytorch.models.target_generators.dekr_targets import ( DEKRGenerator, ) -from deeplabcut.pose_estimation_pytorch.models.target_generators.gaussian_targets import ( - GaussianGenerator, +from deeplabcut.pose_estimation_pytorch.models.target_generators.heatmap_targets import ( + HeatmapGaussianGenerator, + HeatmapPlateauGenerator, ) from deeplabcut.pose_estimation_pytorch.models.target_generators.pafs_targets import ( PartAffinityFieldGenerator, ) -from deeplabcut.pose_estimation_pytorch.models.target_generators.plateau_targets import ( - PlateauGenerator, -) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py deleted file mode 100644 index ea270c0c1a..0000000000 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/gaussian_targets.py +++ /dev/null @@ -1,128 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from __future__ import annotations - -import numpy as np -import torch - -from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - BaseGenerator, - TARGET_GENERATORS, -) - - -@TARGET_GENERATORS.register_module -class GaussianGenerator(BaseGenerator): - """ - TODO: Remove code duplication with PlateauGenerator - - Generate gaussian heatmaps and locref targets from ground truth keypoints in order - to train baseline deeplabcut model (ResNet + Deconv) - """ - - def __init__( - self, locref_stdev: float, num_joints: int, pos_dist_thresh: int, **kwargs - ): - """ - Args: - locref_stdev: scaling factor - num_joints: number of keypoints - pos_dist_thresh: 3*std of the gaussian - - Examples: - input: - locref_stdev = 7.2801, default value in pytorch config - num_joints = 6 - po_dist_thresh = 17, default value in pytorch config - """ - super().__init__(**kwargs) - self.locref_scale = 1.0 / locref_stdev - self.num_joints = num_joints - self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh ** 2 - self.std = ( - 2 * self.dist_thresh / 3 - ) # We think of dist_thresh as a radius and std is a 'diameter' - - def forward( - self, inputs: torch.Tensor, outputs: torch.Tensor, labels: dict - ) -> dict[str, dict[str, torch.Tensor]]: - """Summary: - Given the annotations and predictions of your keypoints, this function returns the targets, - a dictionary containing the heatmaps, locref_maps and locref_masks. - - Args: - inputs: the input images given to the model, of shape (b, c, w, h) - outputs: output of each model head - labels: the labels for the inputs (each tensor should have shape (b, ...)) - - Returns: - The targets for the heatmap and locref heads: - { - "heatmap": { - "target": heatmaps, - "weights": heatmap_weights, - }, - "locref": { - "target": locref_map, - "weights": locref_weights, - } - } - - Examples: - input: - annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} - prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] - image_size = (256, 256) - output: - targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} - """ - batch_size, _, input_h, input_w = inputs.shape - height, width = outputs.shape[2:] - stride_y, stride_x = input_h / height, input_w / width - coords = labels[self.label_keypoint_key].cpu().numpy() - scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) - - locref_map = np.zeros( - (batch_size, height, width, self.num_joints * 2), dtype=np.float32 - ) - locref_mask = np.zeros_like(locref_map, dtype=int) - - grid = np.mgrid[:height, :width].transpose((1, 2, 0)) - grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 - grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 - - for b in range(batch_size): - for idx_animal, kpts_animal in enumerate(coords[b]): - for i, coord in enumerate(kpts_animal): - coord = np.array(coord)[::-1] - if np.any(coord <= 0.0): - continue - dist = np.linalg.norm(grid - coord, axis=2) ** 2 - scmap_j = np.exp(-dist / (2 * self.std ** 2)) - scmap[b, :, :, i] += scmap_j - locref_mask[b, dist <= self.dist_thresh_sq, i * 2 : i * 2 + 2] = 1 - dx = coord[1] - grid.copy()[:, :, 1] - dy = coord[0] - grid.copy()[:, :, 0] - locref_map[b, :, :, i * 2 + 0] += dx * self.locref_scale - locref_map[b, :, :, i * 2 + 1] += dy * self.locref_scale - scmap = scmap.transpose(0, 3, 1, 2) - locref_map = locref_map.transpose(0, 3, 1, 2) - locref_mask = locref_mask.transpose(0, 3, 1, 2) - return { - "heatmap": { - "target": torch.tensor(scmap, device=outputs["heatmap"].device) - }, - "locref": { - "target": torch.tensor(locref_map, device=outputs["locref"].device), - "weights": torch.tensor(locref_mask, device=outputs["locref"].device), - }, - } diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py new file mode 100644 index 0000000000..4133ef86ac --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py @@ -0,0 +1,278 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +from abc import abstractmethod +from enum import Enum + +import numpy as np +import torch + +from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( + BaseGenerator, + TARGET_GENERATORS, +) + + +class HeatmapGenerator(BaseGenerator): + """Abstract class to generate target heatmap targets (with/without locref) + + Can generate target heatmaps either for pose estimation (one keypoint), or for + individual identification. + + This class is abstract, and heatmap targets should be generated through its + subclasses (such as HeatmapPlateauGenerator) + """ + + class Mode(Enum): + """ + KEYPOINT generates one heatmap per type of keypoint (for pose estimation heads) + INDIVIDUAL generates one heatmap per individual (for identification heads) + """ + + INDIVIDUAL = "INDIVIDUAL" + KEYPOINT = "KEYPOINT" + + @classmethod + def _missing_(cls, value): + if isinstance(value, str): + value = value.upper() + for member in cls: + if member.value == value: + return member + return None + + def __init__( + self, + num_heatmaps: int, + pos_dist_thresh: int, + heatmap_mode: str | Mode = Mode.KEYPOINT, + generate_locref: bool = True, + locref_std: float = 7.2801, + **kwargs + ): + """ + Args: + num_heatmaps: the number of heatmaps to generate + pos_dist_thresh: 3*std of the gaussian. We think of dist_thresh as a radius + and std is a 'diameter'. + mode: the mode to generate heatmaps for + learned_id_target: whether to generate the heatmap for keypoints + or for learned IDs + generate_locref: whether to generate location refinement maps + locref_std: the STD for the location refinement maps, if defined + + Examples: + input: + locref_std = 7.2801, default value in pytorch config + num_joints = 6 + po_dist_thresh = 17, default value in pytorch config + """ + super().__init__(**kwargs) + self.num_heatmaps = num_heatmaps + self.dist_thresh = float(pos_dist_thresh) + self.dist_thresh_sq = self.dist_thresh ** 2 + self.std = 2 * self.dist_thresh / 3 + + if isinstance(heatmap_mode, str): + heatmap_mode = HeatmapGenerator.Mode(heatmap_mode) + self.heatmap_mode = heatmap_mode + + self.generate_locref = generate_locref + self.locref_scale = 1.0 / locref_std + + def forward( + self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict + ) -> dict[str, dict[str, torch.Tensor]]: + """ + Given the annotations and predictions of your keypoints, this function returns the targets, + a dictionary containing the heatmaps, locref_maps and locref_masks. + + Args: + inputs: the input images given to the model, of shape (b, c, w, h) + outputs: output of each model head + labels: the labels for the inputs (each tensor should have shape (b, ...)) + + Returns: + The targets for the heatmap and locref heads: + { + "heatmap": { + "target": heatmaps, + "weights": heatmap_weights, + }, + "locref": { # optional + "target": locref_map, + "weights": locref_weights, + } + } + + Examples: + input: + annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} + prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + image_size = (256, 256) + output: + targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} + """ + batch_size, _, input_h, input_w = inputs.shape + height, width = outputs["heatmap"].shape[2:] + stride_y, stride_x = input_h / height, input_w / width + coords = labels[self.label_keypoint_key].cpu().numpy() + if len(coords.shape) == 3: # for single animal: add individual dimension + coords = coords.reshape((batch_size, 1, *coords.shape[1:])) + + if self.heatmap_mode == HeatmapGenerator.Mode.KEYPOINT: + # transpose the individuals and keypoints to iterate over bodyparts + coords = coords.transpose((0, 2, 1, 3)) + + heatmap = np.zeros((batch_size, height, width, self.num_heatmaps), dtype=np.float32) + + locref_map, locref_mask = None, None + if self.generate_locref: + locref_map = np.zeros( + (batch_size, height, width, self.num_heatmaps * 2), dtype=np.float32 + ) + locref_mask = np.zeros_like(locref_map, dtype=int) + + grid = np.mgrid[:height, :width].transpose((1, 2, 0)) + grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 + grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 + + for b in range(batch_size): + for heatmap_idx, group_keypoints in enumerate(coords[b]): + for keypoint in group_keypoints: + keypoint = keypoint.copy()[::-1] + if np.any(keypoint <= 0.0): + continue + + self.update( + heatmap=heatmap[b, :, :, heatmap_idx], + grid=grid, + keypoint=keypoint, + locref_map=self.get_locref(locref_map, b, heatmap_idx), + locref_mask=self.get_locref(locref_mask, b, heatmap_idx), + ) + + heatmap = heatmap.transpose((0, 3, 1, 2)) + target = { + "heatmap": { + "target": torch.tensor(heatmap, device=outputs["heatmap"].device) + } + } + + if self.generate_locref: + locref_map = locref_map.transpose((0, 3, 1, 2)) + locref_mask = locref_mask.transpose((0, 3, 1, 2)) + target["locref"] = { + "target": torch.tensor(locref_map, device=outputs["locref"].device), + "weights": torch.tensor(locref_mask, device=outputs["locref"].device), + } + + return target + + def get_locref( + self, locref_map_or_mask: np.ndarray | None, batch_idx: int, heatmap_idx: int, + ) -> np.ndarray | None: + """ + Args: + locref_map_or_mask: the locref array to return (either the map or mask), of + shape (batch_size, height, width, num_heatmaps) + batch_idx: the index of the batch + heatmap_idx: the index of the heatmap for which we want the location + refinement maps or masks + + Returns: + the location refinement maps/masks of shape (height, width, 2) + """ + if not self.generate_locref: + return None + + start_idx = 2 * heatmap_idx + end_idx = start_idx + 2 + return locref_map_or_mask[batch_idx, :, :, start_idx:end_idx] + + @abstractmethod + def update( + self, + heatmap: np.ndarray, + grid: np.mgrid, + keypoint: np.ndarray, + locref_map: np.ndarray | None, + locref_mask: np.ndarray | None, + ) -> None: + """ + Updates the heatmap and locref targets in-place following an update rule (e.g., + Gaussian or Plateau). + + Args: + heatmap: the heatmap to update of shape (height, width) + grid: the grid for ??? + keypoint: the keypoint with which to update the maps + locref_map: the location refinement maps of shape (height, width, 2), if + self.generate_locref = True + locref_mask: the location refinement masks of shape (height, width, 2), if + self.generate_locref = True + """ + raise NotImplementedError + + +@TARGET_GENERATORS.register_module +class HeatmapGaussianGenerator(HeatmapGenerator): + """Generates gaussian heatmaps (and locref) targets from keypoints""" + + def update( + self, + heatmap: np.ndarray, + grid: np.mgrid, + keypoint: np.ndarray, + locref_map: np.ndarray | None, + locref_mask: np.ndarray | None, + ) -> None: + """Updates the heatmap (and locref if defined) with gaussian values""" + dist = np.linalg.norm(grid - keypoint, axis=2) ** 2 + heatmap_j = np.exp(-dist / (2 * self.std ** 2)) + heatmap[:, :] = np.maximum(heatmap, heatmap_j) + + if locref_map is not None: + dx = keypoint[1] - grid.copy()[:, :, 1] + dy = keypoint[0] - grid.copy()[:, :, 0] + locref_map[:, :, 0] = dx * self.locref_scale + locref_map[:, :, 1] = dy * self.locref_scale + + if locref_mask: + locref_mask[dist <= self.dist_thresh_sq] = 1 + + +@TARGET_GENERATORS.register_module +class HeatmapPlateauGenerator(HeatmapGenerator): + """Generates plateau heatmaps (and locref) targets from keypoints""" + + def update( + self, + heatmap: np.ndarray, + grid: np.mgrid, + keypoint: np.ndarray, + locref_map: np.ndarray | None, + locref_mask: np.ndarray | None, + ) -> None: + """Updates the heatmap (and locref if defined) with plateau values""" + dist = np.sum((grid - keypoint) ** 2, axis=2) + mask = dist <= self.dist_thresh_sq + heatmap[mask] = 1 + + if locref_map is not None: + dx = keypoint[1] - grid.copy()[:, :, 1] + dy = keypoint[0] - grid.copy()[:, :, 0] + locref_map[mask, 0] = (dx * self.locref_scale)[mask] + locref_map[mask, 1] = (dy * self.locref_scale)[mask] + + if locref_mask is not None: + locref_mask[mask] = 1 diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py deleted file mode 100644 index 2c131cce9e..0000000000 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/plateau_targets.py +++ /dev/null @@ -1,137 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from __future__ import annotations - -import numpy as np -import torch - -from deeplabcut.pose_estimation_pytorch.models.target_generators.base import ( - BaseGenerator, - TARGET_GENERATORS, -) - - -@TARGET_GENERATORS.register_module -class PlateauGenerator(BaseGenerator): - """ - TODO: Remove code duplication with GaussianGenerator - TODO: add WITHOUT_LOCREF - - Generate plateau heatmaps and locref targets from ground truth keypoints in order - to train baseline deeplabcut model (ResNet + Deconv) - """ - - def __init__( - self, - num_joints: int, - pos_dist_thresh: int, - generate_locref: bool = True, # TODO: Implement - locref_stdev: float = 7.2801, - **kwargs, - ): - """ - Args: - num_joints: number of keypoints - pos_dist_thresh: radius plateau on the heatmap - locref_stdev: scaling factor - - Examples: - input: - locref_stdev = 7.2801, default value in pytorch config - num_joints = 6 - pos_dist_thresh = 17, default value in pytorch config - """ - super().__init__(**kwargs) - self.num_joints = num_joints - self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh ** 2 - self.generate_locref = generate_locref - self.locref_scale = 1.0 / locref_stdev - - def forward( - self, inputs: torch.Tensor, outputs: torch.Tensor, labels: dict - ) -> dict[str, dict[str, torch.Tensor]]: - """Summary: - Given the annotations and predictions of your keypoints, this function returns the targets, - a dictionary containing the heatmaps, locref_maps and locref_masks. - - Args: - inputs: the input images given to the model, of shape (b, c, w, h) - outputs: output of each model head - labels: the labels for the inputs (each tensor should have shape (b, ...)) - - Returns: - The targets for the heatmap and locref heads: - { - "heatmap": { - "target": heatmaps, - "weights": heatmap_weights, - }, - "locref": { - "target": locref_map, - "weights": locref_weights, - } - } - - Examples: - input: - annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} - prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] - image_size = (256, 256) - output: - targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} - """ - batch_size, _, input_h, input_w = inputs.shape - height, width = outputs["heatmap"].shape[2:] - stride_y, stride_x = input_h / height, input_w / width - coords = labels[self.label_keypoint_key].cpu().numpy() - if len(coords.shape) == 3: # for single animal: add individual dimension - coords = coords.reshape((batch_size, 1, self.num_joints, 2)) - - scmap = np.zeros((batch_size, height, width, self.num_joints), dtype=np.float32) - - locref_map = np.zeros( - (batch_size, height, width, self.num_joints * 2), dtype=np.float32 - ) - locref_mask = np.zeros_like(locref_map, dtype=int) - - grid = np.mgrid[:height, :width].transpose((1, 2, 0)) - grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 - grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 - - for b in range(batch_size): - for _, kpts_animal in enumerate(coords[b]): - for i, coord in enumerate(kpts_animal): - coord = np.array(coord)[::-1] - if np.any(coord <= 0.0): - continue - dist = np.sum((grid - coord) ** 2, axis=2) - mask = dist <= self.dist_thresh_sq - scmap[b, mask, i] = 1 - locref_mask[b, mask, i * 2 : i * 2 + 2] = 1 - dx = coord[1] - grid.copy()[:, :, 1] - dy = coord[0] - grid.copy()[:, :, 0] - locref_map[b, mask, i * 2 + 0] = (dx * self.locref_scale)[mask] - locref_map[b, mask, i * 2 + 1] = (dy * self.locref_scale)[mask] - - scmap = scmap.transpose(0, 3, 1, 2) - targets = { - "heatmap": {"target": torch.tensor(scmap, device=outputs["heatmap"].device)} - } - if self.generate_locref: - locref_map = locref_map.transpose(0, 3, 1, 2) - locref_mask = locref_mask.transpose(0, 3, 1, 2) - targets["locref"] = { - "target": torch.tensor(locref_map, device=outputs["locref"].device), - "weights": torch.tensor(locref_mask, device=outputs["locref"].device), - } - - return targets diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/identity.py b/deeplabcut/pose_estimation_pytorch/post_processing/identity.py new file mode 100644 index 0000000000..d8852011f3 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/post_processing/identity.py @@ -0,0 +1,36 @@ +"""Functions to assign identity to predictions from an identity head""" +from __future__ import annotations + +import numpy as np +from scipy.optimize import linear_sum_assignment + + +def assign_identity( + predictions: list[np.ndarray], + identity_scores: list[np.ndarray], +) -> list[np.ndarray]: + """ + Args: + predictions: shape (num_individuals, num_bodyparts, 3) + identity_scores: shape (num_individuals, num_bodyparts, num_individuals) + + Returns: + predictions with assigned identity, of shape (num_individuals, num_bodyparts, 3) + """ + if not len(predictions) == len(identity_scores): + raise ValueError( + "There are not the same number of predictions as identity scores" + f" ({len(predictions)} != {len(identity_scores)}" + ) + + predictions_with_identity = [] + for pred, scores in zip(predictions, identity_scores): + cost_matrix = np.product(scores, axis=1) + row_ind, col_ind = linear_sum_assignment(cost_matrix, maximize=True) + new_order = np.zeros_like(row_ind) + for old_pos, new_pos in zip(row_ind, col_ind): + new_order[new_pos] = old_pos + + predictions_with_identity.append(pred[new_order]) + + return predictions_with_identity diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index 184de7141d..27f4b75c15 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -90,7 +90,7 @@ def __init__( self.starting_epoch = 0 if snapshot_path: - snapshot = torch.load(snapshot_path) + snapshot = torch.load(snapshot_path, map_location=device) self.model.load_state_dict(snapshot["model_state_dict"]) self.optimizer.load_state_dict(snapshot["optimizer_state_dict"]) self.starting_epoch = snapshot["epoch"] @@ -180,7 +180,7 @@ def inference( self, images: Iterable[str | np.ndarray] | Iterable[tuple[str | np.ndarray, dict[str, Any]]], - ) -> list[dict[str, dict[str, np.ndarray]]]: + ) -> list[dict[str, np.ndarray]]: """Run model inference on the given dataset TODO: Add an option to also return head outputs (such as heatmaps)? Can be diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py index 96b8692284..e61ddaf7f7 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py @@ -1,7 +1,7 @@ import pytest import torch -from deeplabcut.pose_estimation_pytorch.models.target_generators import gaussian_targets +from deeplabcut.pose_estimation_pytorch.models.target_generators import HeatmapGaussianGenerator @pytest.mark.parametrize( @@ -23,7 +23,11 @@ def test_gaussian_target_generation( ] # batch size, num keypoints , imageh, imagew # generate heatmap - output = gaussian_targets.GaussianGenerator(5.0, num_keypoints, 17) + output = HeatmapGaussianGenerator( + num_heatmaps=num_keypoints, + pos_dist_thresh=17, + locref_std=5.0, + ) output = torch.tensor( output(annotations, prediction, image_size)["heatmaps"].reshape( batch_size, num_keypoints, image_size[0] * image_size[1] diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py index 2620494a29..971a84bf6f 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py @@ -15,7 +15,7 @@ import pytest import torch -import deeplabcut.pose_estimation_pytorch.models.target_generators.plateau_targets as deeplabcut_torch_plateau_targets +from deeplabcut.pose_estimation_pytorch.models.target_generators import HeatmapPlateauGenerator def get_target( @@ -23,7 +23,7 @@ def get_target( num_animals: int, num_joints: int, image_size: Tuple[int, int], - locref_stdev: float, + locref_std: float, pos_dist_thresh: int, ): """Summary @@ -34,7 +34,7 @@ def get_target( num_animals (int): number of animals num_joints (int): number of bodyparts image_size (tuple): image size in pixels - locref_stdev (float): scaling factor + locref_std (float): scaling factor pos_dist_thresh (int): radius plateau on the heatmap Returns: @@ -58,8 +58,11 @@ def get_target( ) } # 2 for x,y coords prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] - generator = deeplabcut_torch_plateau_targets.PlateauLocrefGenerator( - locref_stdev, num_joints, pos_dist_thresh + generator = HeatmapPlateauGenerator( + num_heatmaps=num_joints, + pos_dist_thresh=pos_dist_thresh, + locref_std=locref_std, + generate_locref=True, ) targets_output = generator(annotations, prediction, image_size) @@ -82,7 +85,7 @@ def test_expected_output( pos_dist_thresh: int, ): """Summary: - Testing if plateau_targets.py returns the expected output. We take a target generator from + Testing if plateau targets return the expected output. We take a target generator from get_target function. Given a sequence of random numbers for batch_size, num_animals etc., we assert if it returns the expected heatmaps and locrefmaps, as well as checking if the output has the expected shape. diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py b/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py index 4b2b23dce0..97d11e7eea 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py @@ -18,10 +18,11 @@ def test_sequential_generator(): "type": "SequentialGenerator", "generators": [ { - "type": "PlateauGenerator", - "locref_stdev": 7.2801, - "num_joints": num_keypoints, + "type": "HeatmapPlateauGenerator", + "num_heatmaps": num_keypoints, "pos_dist_thresh": 17, + "generate_locref": True, + "locref_std": 7.2801, }, {"type": "PartAffinityFieldGenerator", "graph": graph, "width": 20}, ], diff --git a/tests/pose_estimation_pytorch/apis/test_scoring.py b/tests/pose_estimation_pytorch/apis/test_scoring.py new file mode 100644 index 0000000000..d27f378734 --- /dev/null +++ b/tests/pose_estimation_pytorch/apis/test_scoring.py @@ -0,0 +1,194 @@ +"""Tests for the scoring methods""" +import numpy as np +import pytest + +import deeplabcut.pose_estimation_pytorch.apis.scoring as scoring + + +@pytest.mark.parametrize( + "data", + [ + { + "individuals": ["i1", "i2"], + "bodyparts": ["arm"], + "predictions": { + "img0.png": [ # (num_assemblies, num_bodyparts, 3) + [[2.0, 2.0, 0.8]], [[1.0, 1.0, 0.7]], # x, y, score + ], + }, + "identity_scores": { + "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) + [[0.8, 0.5]], [[0.51, 0.49]], + ], + }, + "ground_truth": { + "img0.png": [ # (num_individuals, num_bodyparts, 3) + [[1.0, 1.0, 2]], [[0, 0, 0]] # x, y, visibility + ] + }, + "accuracy": { + "arm_accuracy": 1.0, + }, + }, + { + "individuals": ["i1", "i2"], + "bodyparts": ["arm"], + "predictions": { + "img0.png": [ # (num_assemblies, num_bodyparts, 3) + [[1.0, 1.0, 0.7]], [[2.0, 2.0, 0.7]], # x, y, score + ], + }, + "identity_scores": { + "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) + [[0.4, 0.6]], [[0.6, 0.4]] + ], + }, + "ground_truth": { + "img0.png": [ # (num_individuals, num_bodyparts, 3) + [[2.0, 2.0, 2]], [[1.0, 1.0, 2]], # x, y, visibility + ] + }, + "accuracy": { + "arm_accuracy": 1.0, + }, + }, + { + "individuals": ["i1", "i2"], + "bodyparts": ["arm"], + "predictions": { + "img0.png": [ # (num_assemblies, num_bodyparts, 3) + [[1.0, 1.0, 0.7]], [[2.0, 2.0, 0.7]], # x, y, score + ], + }, + "identity_scores": { + "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) + [[0.6, 0.4]], [[0.6, 0.4]] # both assemblies assigned to idv 1 + ], + }, + "ground_truth": { + "img0.png": [ # (num_individuals, num_bodyparts, 3) + [[2.0, 2.0, 2]], [[1.0, 1.0, 2]], # x, y, visibility + ] + }, + "accuracy": { + "arm_accuracy": 0.5, + }, + }, + { + "individuals": ["i1", "i2"], + "bodyparts": ["arm"], + "predictions": { + "img0.png": [ # (num_assemblies, num_bodyparts, 3) + [[1.0, 1.0, 0.7]], [[2.0, 2.0, 0.7]], # x, y, score + ], + }, + "identity_scores": { + "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) + [[0.6, 0.4]], [[0.4, 0.6]] # both assigned to wrong ID + ], + }, + "ground_truth": { + "img0.png": [ # (num_individuals, num_bodyparts, 3) + [[2.0, 2.0, 2]], # x, y, visibility + [[1.0, 1.0, 2]], + ] + }, + "accuracy": { + "arm_accuracy": 0.0, + }, + }, + { + "individuals": ["i1", "i2"], + "bodyparts": ["arm", "leg"], + "predictions": { + "img0.png": [ # (num_assemblies, num_bodyparts, 3) + [[1.0, 1.0, 0.7], [10.0, 10.0, 0.9]], + [[100.0, 100.0, 0.9], [90.0, 90.9, 0.8]], + ], + }, + "identity_scores": { + "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) + [[0.7, 0.3], [0.6, 0.2]], + [[0.6, 0.3], [0.6, 0.2]], # should not matter, not assigned to GT + ], + }, + "ground_truth": { + "img0.png": [ # (num_individuals, num_bodyparts, 3) + [[2.0, 2.0, 2], [8.0, 8.0, 2]], # x, y, visibility + [[-1, -1, 0.0], [-1, -1, 0.0]], # not visible + ] + }, + "accuracy": { + "arm_accuracy": 1.0, + "leg_accuracy": 1.0, + }, + }, + { + "individuals": ["i1", "i2", "i3"], + "bodyparts": ["arm", "leg"], + "predictions": { + "img0.png": [ # (num_assemblies, num_bodyparts, 3) + [[1.0, 1.0, 0.7], [10.0, 10.0, 0.9]], + [[100.0, 100.0, 0.9], [90.0, 90.9, 0.8]], + [[110.0, 110.0, 0.9], [98.0, 91.9, 0.8]], + ], + }, + "identity_scores": { + "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) + [[0.7, 0.3], [0.6, 0.2]], # assigned to correct ID + [[0.6, 0.3], [0.6, 0.2]], # should not matter, not assigned to GT + [[0.6, 0.3], [0.6, 0.2]], # should not matter, not assigned to GT + ], + }, + "ground_truth": { + "img0.png": [ # (num_individuals, num_bodyparts, 3) + [[2.0, 2.0, 2], [8.0, 8.0, 2]], # x, y, visibility + [[-1, -1, 0.0], [-1, -1, 0.0]], # not visible + [[-1, -1, 0.0], [-1, -1, 0.0]], # not visible + ] + }, + "accuracy": { + "arm_accuracy": 1.0, + "leg_accuracy": 1.0, + }, + }, + { + "individuals": ["i1", "i2", "i3"], + "bodyparts": ["arm", "leg"], + "predictions": { + "img0.png": [ # (num_assemblies, num_bodyparts, 3) + [[1.0, 1.0, 0.7], [10.0, 10.0, 0.9]], + [[100.0, 100.0, 0.9], [90.0, 90.9, 0.8]], + [[110.0, 110.0, 0.9], [98.0, 91.9, 0.8]], + ], + }, + "identity_scores": { + "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) + [[0.7, 0.3, 0.1], [0.6, 0.2, 0.1]], # assigned to correct ID + [[0.1, 0.2, 0.7], [0.4, 0.3, 0.2]], # 1st correct, 2nd wrong + [[0.6, 0.3, 0.5], [0.6, 0.2, 0.4]], # should not matter, not assigned to GT + ], + }, + "ground_truth": { + "img0.png": [ # (num_individuals, num_bodyparts, 3) + [[2.0, 2.0, 2], [8.0, 8.0, 2]], # x, y, visibility + [[-1, -1, 0.0], [-1, -1, 0.0]], # not visible + [[90.0, 90, 2], [80, 80, 2.0]], # x, y, visibility + ] + }, + "accuracy": { + "arm_accuracy": 1.0, + "leg_accuracy": 0.5, + }, + }, + ], +) +def test_id_accuracy(data) -> None: + scores = scoring.compute_identity_scores( + individuals=data["individuals"], + bodyparts=data["bodyparts"], + predictions={k: np.array(v) for k, v in data["predictions"].items()}, + identity_scores={k: np.array(v) for k, v in data["identity_scores"].items()}, + ground_truth={k: np.array(v) for k, v in data["ground_truth"].items()}, + ) + assert scores == data["accuracy"] diff --git a/tests/pose_estimation_pytorch/data/test_postprocessor.py b/tests/pose_estimation_pytorch/data/test_postprocessor.py index 0587978de3..853caab9f7 100644 --- a/tests/pose_estimation_pytorch/data/test_postprocessor.py +++ b/tests/pose_estimation_pytorch/data/test_postprocessor.py @@ -1,11 +1,11 @@ """Tests the pre-processors""" -import albumentations as A import numpy as np import pytest -from deeplabcut.pose_estimation_pytorch.apis.utils import build_resize_transforms -from deeplabcut.pose_estimation_pytorch.data.preprocessor import AugmentImage -from deeplabcut.pose_estimation_pytorch.data.postprocessor import RescaleAndOffset +from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( + PredictKeypointIdentities, + RescaleAndOffset, +) @pytest.mark.parametrize( @@ -137,3 +137,45 @@ def test_rescale_detector(data): print(predictions["bboxes"].tolist()) print(data["rescaled"]) np.testing.assert_array_equal(predictions["bboxes"], np.array(data["rescaled"])) + + +@pytest.mark.parametrize( + "data", + [ + { + "bodyparts": [ + [[3.1, 1, 0.8], [1, 0, 0.9]], # assembly 1 (x, y, score) + [[2.2, 1.6, 0.5], [3, 3, 0.4]], # assembly 2 (x, y, score) + ], + "id_heatmap": [ # id1, id2 score for each pixel + [[0.1, 0.1], [0.2, 0.1], [0.3, 0.1], [0.4, 0.1]], + [[0.1, 0.2], [0.2, 0.2], [0.3, 0.2], [0.4, 0.2]], + [[0.1, 0.3], [0.2, 0.3], [0.3, 0.3], [0.4, 0.3]], + [[0.1, 0.4], [0.2, 0.4], [0.3, 0.4], [0.4, 0.4]], + ], + "id_scores": [ # id1, id2 score for each bodypart + [[0.4, 0.2], [0.2, 0.1]], # assembly 1 (id_1 proba, id_2 proba) + [[0.3, 0.3], [0.4, 0.4]], # assembly 2 (id_1 proba, id_2 proba) + ], + }, + ], +) +def test_assign_id_scores(data): + p = PredictKeypointIdentities( + identity_key="keypoint_identity", + identity_map_key="identity_map", + pose_key="bodyparts", + ) + bodyparts = np.array(data["bodyparts"]) + id_heatmap = np.array(data["id_heatmap"]) + expected_ids = np.array(data["id_scores"]) + print() + print(bodyparts.shape) + print(id_heatmap.shape) + print(expected_ids.shape) + predictions_in = {"bodyparts": bodyparts, "identity_map": id_heatmap} + predictions, _ = p(predictions_in, {}) + np.testing.assert_array_equal( + predictions["keypoint_identity"], + expected_ids, + ) diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py new file mode 100644 index 0000000000..53c76af7f0 --- /dev/null +++ b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py @@ -0,0 +1,127 @@ +"""Tests the heatmap target generators (plateau and gaussian)""" +import numpy as np +import torch +import pytest + +from deeplabcut.pose_estimation_pytorch.models.target_generators.heatmap_targets import ( + HeatmapGaussianGenerator, +) + +@pytest.mark.parametrize( + "data", + [ + { + "dist_thresh": 3, + "num_heatmaps": 1, + "in_shape": (3, 3), + "out_shape": (3, 3), + "centers": [(1, 1)], + "expected_output": [ + [0.7788, 0.8825, 0.7788], + [0.8825, 1.0000, 0.8825], + [0.7788, 0.8825, 0.7788], + ], + }, + { + "dist_thresh": 3, + "num_heatmaps": 1, + "in_shape": (5, 5), + "out_shape": (5, 5), + "centers": [[1, 1], [2, 2]], + "expected_output": [ + [0.7788, 0.8825, 0.7788, 0.5353, 0.3679], + [0.8825, 1.0000, 0.8825, 0.7788, 0.5353], + [0.7788, 0.8825, 1.0000, 0.8825, 0.6065], + [0.5353, 0.7788, 0.8825, 0.7788, 0.5353], + [0.3679, 0.5353, 0.6065, 0.5353, 0.3679], + ], + }, + { + "dist_thresh": 1, + "num_heatmaps": 1, + "in_shape": (4, 4), + "out_shape": (4, 4), + "centers": [[1, 1]], + "expected_output": [ + [0.1054, 0.3247, 0.1054, 0.0036], + [0.3247, 1.0, 0.3247, 0.0111], + [0.1054, 0.3247, 0.1054, 0.0036], + [0.0036, 0.0111, 0.0036, 0.0001] + ], + }, + ], +) +def test_gaussian_heatmap_generation_single_keypoint(data): + dist_thresh = data["dist_thresh"] + generator = HeatmapGaussianGenerator( + num_heatmaps=data["num_heatmaps"], + pos_dist_thresh=dist_thresh, + heatmap_mode=HeatmapGaussianGenerator.Mode.INDIVIDUAL, + generate_locref=False, + ) + inputs = torch.zeros((1, 3, *data["in_shape"])) + outputs = torch.zeros((1, data["num_heatmaps"], *data["out_shape"])) + ann_shape = (1, len(data["centers"]), data["num_heatmaps"], 2) + annotations = { + "keypoints": torch.tensor(data["centers"]).reshape(ann_shape) # x, y + } + targets = generator(inputs, {"heatmap": outputs}, annotations) + + print("Targets") + print(targets["heatmap"]["target"]) + print() + np.testing.assert_almost_equal( + targets["heatmap"]["target"].cpu().numpy().reshape(data["out_shape"]), + np.array(data["expected_output"]), + decimal=3, + ) + + +@pytest.mark.parametrize( + "batch_size, num_keypoints, image_size", + [(2, 2, (64, 64)), (1, 5, (48, 64)), (15, 50, (64, 48))], +) +def test_random_gaussian_target_generation( + batch_size: int, num_keypoints: int, image_size: tuple, num_animals=1 +): + # generate annotations + annotations = { + "keypoints": torch.randint( + 1, min(image_size), (batch_size, num_animals, num_keypoints, 2) + ) + } # batch size, num animals, num keypoints, 2 for x,y + + # generate input images (batch_size, 3, h, w) + inputs = torch.zeros((batch_size, 3, *image_size)) + + # generate predictions + predicted_heatmaps = { + "heatmap": torch.zeros((batch_size, num_keypoints, *image_size)) + } + + # generate heatmap + generator = HeatmapGaussianGenerator( + num_heatmaps=num_keypoints, + pos_dist_thresh=17, + heatmap_mode=HeatmapGaussianGenerator.Mode.INDIVIDUAL, + generate_locref=False, + ) + targets = generator(inputs, predicted_heatmaps, annotations) + target_heatmap = targets["heatmap"]["target"].reshape( + batch_size, num_keypoints, image_size[0] * image_size[1] + ) + + # get coords of max value of the heatmap + gaus_max = torch.argmax(target_heatmap, dim=2) + + # get unraveled coords + x = gaus_max % image_size[1] + y = gaus_max // image_size[1] + + # get heatmap center tensor + predict_kp = torch.stack((x, y), dim=-1) + # Remove num_animals dimension - only one animal is supported + annotations["keypoints"] = torch.squeeze(annotations["keypoints"], dim=1) + + # compare heatmap center to annotation + assert torch.eq(annotations["keypoints"], predict_kp).all().item() diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py new file mode 100644 index 0000000000..42ff827484 --- /dev/null +++ b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py @@ -0,0 +1,79 @@ +"""Tests the heatmap target generators (plateau and gaussian)""" +import numpy as np +import torch +import pytest + +from deeplabcut.pose_estimation_pytorch.models.target_generators.heatmap_targets import ( + HeatmapGenerator, + HeatmapPlateauGenerator, +) + + +@pytest.mark.parametrize( + "data", + [ + { + "dist_thresh": 1, + "num_heatmaps": 1, + "in_shape": (3, 3), + "out_shape": (3, 3), + "centers": [(1, 1)], + "expected_output": [ + [0., 1., 0.], + [1., 1., 1.], + [0., 1., 0.], + ], + }, + { + "dist_thresh": 2, + "num_heatmaps": 1, + "in_shape": (5, 5), + "out_shape": (5, 5), + "centers": [[1, 1], [2, 2]], + "expected_output": [ + [1., 1., 1., 0., 0.], + [1., 1., 1., 1., 0.], + [1., 1., 1., 1., 1.], + [0., 1., 1., 1., 0.], + [0., 0., 1., 0., 0.], + ], + }, + { + "dist_thresh": 2, + "num_heatmaps": 1, + "in_shape": (4, 4), + "out_shape": (4, 4), + "centers": [[1, 1]], + "expected_output": [ + [1., 1., 1., 0.], + [1., 1., 1., 1.], + [1., 1., 1., 0.], + [0., 1., 0., 0.], + ], + }, + ], +) +def test_plateau_heatmap_generation_single_keypoint(data): + dist_thresh = data["dist_thresh"] + generator = HeatmapPlateauGenerator( + num_heatmaps=data["num_heatmaps"], + pos_dist_thresh=dist_thresh, + heatmap_mode=HeatmapGenerator.Mode.INDIVIDUAL, + generate_locref=False, + ) + inputs = torch.zeros((1, 3, *data["in_shape"])) + outputs = torch.zeros((1, data["num_heatmaps"], *data["out_shape"])) + ann_shape = (1, len(data["centers"]), data["num_heatmaps"], 2) + annotations = { + "keypoints": torch.tensor(data["centers"]).reshape(ann_shape) # x, y + } + targets = generator(inputs, {"heatmap": outputs}, annotations) + + print("Targets") + print(targets["heatmap"]["target"]) + print() + np.testing.assert_almost_equal( + targets["heatmap"]["target"].cpu().numpy().reshape(data["out_shape"]), + np.array(data["expected_output"]), + decimal=3, + ) diff --git a/tests/pose_estimation_pytorch/post_processing/test_identity.py b/tests/pose_estimation_pytorch/post_processing/test_identity.py new file mode 100644 index 0000000000..30f0467923 --- /dev/null +++ b/tests/pose_estimation_pytorch/post_processing/test_identity.py @@ -0,0 +1,51 @@ +""" Tests identity matching """ +import numpy as np +import pytest + +from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity + + +@pytest.mark.parametrize( + "prediction, identity_scores, output_order", + [ + ( + [ + [[0, 0, 1.0], [0, 0, 1.0]], # assembly 1 + [[5, 5, 1.0], [5, 5, 1.0]], # assembly 2 + [[9, 9, 1.0], [9, 9, 1.0]], # assembly 3 + ], + [ # a0 -> idv1, a1 -> idv2, a2 -> idv0 + [[0.1, 0.8, 0.3], [0.1, 0.7, 0.3]], # assembly 1 ID scores + [[0.2, 0.1, 0.6], [0.3, 0.1, 0.5]], # assembly 2 ID scores + [[0.7, 0.1, 0.1], [0.6, 0.2, 0.2]], # assembly 3 ID scores + ], + [2, 0, 1], + ), + ( + [ + [[0, 0, 1.0], [0, 0, 1.0]], # assembly 1 + [[1, 1, 1.0], [5, 5, 1.0]], # assembly 2 + [[0, 0, 1.0], [9, 9, 1.0]], # assembly 3 + ], + [ # a0 -> idv0, a1 -> idv1, a2 -> idv2 + [[0.4, 0.4, 0.3], [0.5, 0.3, 0.3]], # assembly 1 ID scores + [[0.4, 0.4, 0.3], [0.3, 0.5, 0.4]], # assembly 2 ID scores + [[0.2, 0.2, 0.4], [0.2, 0.2, 0.3]], # assembly 3 ID scores + ], + [0, 1, 2], + ), + ], +) +def test_single_identity_assignment(prediction, identity_scores, output_order): + predictions = np.array(prediction) + identity_scores = np.array(identity_scores) + predictions_with_id = assign_identity([predictions], [identity_scores]) + + print() + print(predictions.shape) + print(identity_scores.shape) + + np.testing.assert_equal( + predictions[output_order], + predictions_with_id[0], + ) From 749c52fc780238b96bd2a5936bc3a3690bda41d9 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:59:19 +0100 Subject: [PATCH 058/293] Some bug fixes & improvements regarding DLCRNet and PAFPredictor --- .../make_pytorch_config.py | 38 +++- .../apis/analyze_videos.py | 30 +-- .../pose_estimation_pytorch/apis/scoring.py | 2 +- .../pose_estimation_pytorch/apis/train.py | 2 +- .../pose_estimation_pytorch/apis/utils.py | 14 +- .../data/transforms.py | 16 ++ .../pose_estimation_pytorch/data/utils.py | 3 +- .../models/predictors/paf_predictor.py | 93 ++++---- .../models/predictors/utils.py | 208 ++++++++++++++++++ .../pose_estimation_pytorch/runners/base.py | 1 + 10 files changed, 336 insertions(+), 71 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/utils.py diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index d63cea4f5f..e5273b10bc 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -154,7 +154,13 @@ def make_pytorch_config( list(edge) for edge in combinations(range(num_joints), 2) ] # TODO Parse from config num_limbs = len(graph) - pytorch_config["model"]["backbone"] = {"type": "ResNet"} + pytorch_config["optimizer"]["params"]["lr"] = 5e-4 + pytorch_config["scheduler"]["params"].update({"lr_list": [[1e-4], [5e-5]]}), + pytorch_config["model"]["backbone"]["type"] = "ResNet" + _init_output_stride(pytorch_config, output_stride=16) + heatmap_channels, locref_channels, paf_channels = _configure_channels( + dim, num_joints, num_limbs, pytorch_config + ) pytorch_config["model"]["heads"] = { "bodypart": make_dlcrnet_head( num_joints, @@ -164,10 +170,9 @@ def make_pytorch_config( edges_to_keep=list( pytorch_config.get("paf_best", list(range(num_limbs))) ), - # TODO Below is hardcoded for output stride 32; - heatmap_channels=[dim, dim // 2, num_joints], - locref_channels=[dim, dim // 2, 2 * num_joints], - paf_channels=[dim, dim // 2, 2 * num_limbs], + heatmap_channels=heatmap_channels, + locref_channels=locref_channels, + paf_channels=paf_channels, num_stages=num_stages, # TODO Set remaining params from config ) @@ -230,6 +235,29 @@ def make_pytorch_config( return pytorch_config +def _init_output_stride(config: dict, output_stride: int): + backbone_config = config["model"]["backbone"] + if backbone_config.get("output_stride") is None: + backbone_config["output_stride"] = output_stride + + +def _configure_channels(dim: int, num_joints: int, num_limbs: int, config: dict): + """Configure the channels for heatmap, locref, and paf based on the backbone's output stride.""" + output_stride = config["model"]["backbone"]["output_stride"] + if output_stride not in (16, 32): + raise ValueError("`output_stride` must be 16 or 32.") + + base_channels = [dim, dim // 2] + if output_stride == 16: + base_channels.pop(1) + + heatmap_channels = base_channels + [num_joints] + locref_channels = base_channels + [2 * num_joints] + paf_channels = base_channels + [2 * num_limbs] + + return heatmap_channels, locref_channels, paf_channels + + def make_dlcrnet_head( num_joints: int, num_unique_joints: int, diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index ee996451b9..bfafe504e4 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -272,6 +272,7 @@ def analyze_videos( detector_runner=detector_runner, ) runtime.append(time.time()) + predictions = predictions[..., :3] bodyparts = np.stack([p["bodyparts"] for p in predictions]) unique_bodyparts = None @@ -297,26 +298,17 @@ def analyze_videos( video=VideoReader(str(video)), ) - coordinate_labels = ["x", "y", "likelihood"] + cols = [ + [dlc_scorer], + auxiliaryfunctions.get_bodyparts(cfg), + ["x", "y", "likelihood"], + ] + cols_names = ["scorer", "bodyparts", "coords"] if len(individuals) > 1: - print("Extracting ", len(individuals), "instances per bodypart") - # first has empty suffix for backwards compatibility - individual_suffixes = [str(s + 1) for s in range(len(individuals))] - individual_suffixes[0] = "" - coordinate_labels = [ - coord_label + s - for s in individual_suffixes - for coord_label in coordinate_labels - ] - - results_df_index = pd.MultiIndex.from_product( - [ - [dlc_scorer], - auxiliaryfunctions.get_bodyparts(cfg), - coordinate_labels, - ], - names=["scorer", "bodyparts", "coords"], - ) + cols.insert(1, individuals) + cols_names.insert(1, "individuals") + + results_df_index = pd.MultiIndex.from_product(cols, names=cols_names) df = pd.DataFrame( bodyparts.reshape((len(bodyparts), -1)), columns=results_df_index, diff --git a/deeplabcut/pose_estimation_pytorch/apis/scoring.py b/deeplabcut/pose_estimation_pytorch/apis/scoring.py index a2a8076e7e..8513e43fdd 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/apis/scoring.py @@ -77,7 +77,7 @@ def get_scores( """ if not len(poses) == len(ground_truth): raise ValueError( - "The prediction an ground truth dicts must contain the same number of " + "The prediction and ground truth dicts must contain the same number of " f"images (poses={len(poses)}, gt={len(ground_truth)})" ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 117e2f2410..9a66187bce 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -102,7 +102,7 @@ def train( f" for testing" ) train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) - valid_dataloader = DataLoader(valid_dataset, batch_size=batch_size, shuffle=False) + valid_dataloader = DataLoader(valid_dataset, batch_size=1, shuffle=False) runner.fit( train_dataloader, valid_dataloader, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index f3798e9c12..ec3029f7d0 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -198,14 +198,16 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose # ) # ) scale_jitter_lo, scale_jitter_up = aug_cfg.get("scale_jitter", (1, 1)) - rotation = aug_cfg.get("rotation", 0) transforms.append( - A.Affine( - scale=(scale_jitter_lo, scale_jitter_up), - rotate=(-rotation, rotation), - p=0.5, - ) + A.Affine(scale=(scale_jitter_lo, scale_jitter_up), p=1) ) + if rotation := aug_cfg.get("rotation", 0) != 0: + transforms.append( + A.Affine( + rotate=(-rotation, rotation), + p=0.5, + ) + ) if aug_cfg.get("hist_eq", False): transforms.append(A.Equalize(p=0.5)) if aug_cfg.get("motion_blur", False): diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 6fe961f94f..504cb7ecfe 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -85,6 +85,22 @@ def get_params_dependent_on_targets(self, params: dict[str, Any]) -> dict[str, A center = np.clip(center, 0, np.nextafter(1, 0)) # Clip to 1 exclusive return {"h_start": center[1], "w_start": center[0]} + def apply_to_keypoints( + self, + keypoints, + **params, + ) -> list[float]: + keypoints = super().apply_to_keypoints(keypoints, **params) + new_keypoints = [] + for kp in keypoints: + x, y = kp[:2] + if not (0 <= x < self.width and 0 <= y < self.height): + kp = list(kp) + kp[:2] = np.nan, np.nan + kp = tuple(kp) + new_keypoints.append(kp) + return new_keypoints + def get_transform_init_args_names(self) -> tuple[str, ...]: return "width", "height", "max_shift", "crop_sampling" diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 6cfcea09b8..ca94c5b15f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -292,7 +292,8 @@ def _compute_crop_bounds( bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - np.spacing(0.0) bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) - return bboxes + squashed_bbox_mask = np.logical_or(bboxes[:, 2] <= 0, bboxes[:, 3] <= 0) + return bboxes[~squashed_bbox_mask] def _extract_keypoints_and_bboxes( diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index 456c0dea6d..9c0684495b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -13,6 +13,7 @@ import numpy as np import torch import torch.nn.functional as F +from numpy.typing import NDArray from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( BasePredictor, @@ -21,6 +22,9 @@ from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils +Graph = list[tuple[int, int]] + + @PREDICTORS.register_module class PartAffinityFieldPredictor(BasePredictor): """Predictor class for multiple animal pose estimation with part affinity fields. @@ -52,7 +56,7 @@ def __init__( num_animals: int, num_multibodyparts: int, num_uniquebodyparts: int, - graph: list[tuple[int, int]], + graph: Graph, edges_to_keep: list[int], locref_stdev: float, nms_radius: int, @@ -60,19 +64,21 @@ def __init__( min_affinity: float, add_discarded: bool = False, force_fusion: bool = False, + return_preds: bool = False, ): """Initialize the PartAffinityFieldPredictor class. Args: - num_animals: Number of animals in the project. - num_multibodyparts: Number of animal's body parts (ignoring unique body parts). - num_uniquebodyparts: Number of unique body parts. - graph: Part affinity field graph edges. - edges_to_keep: List of indices in `graph` of the edges to keep. - locref_stdev: Standard deviation for location refinement. - nms_radius: Radius of the Gaussian kernel. - sigma: Width of the 2D Gaussian distribution. - min_affinity: Minimal edge affinity to add a body part to an Assembly. + num_animals: Number of animals in the project. + num_multibodyparts: Number of animal's body parts (ignoring unique body parts). + num_uniquebodyparts: Number of unique body parts. + graph: Part affinity field graph edges. + edges_to_keep: List of indices in `graph` of the edges to keep. + locref_stdev: Standard deviation for location refinement. + nms_radius: Radius of the Gaussian kernel. + sigma: Width of the 2D Gaussian distribution. + min_affinity: Minimal edge affinity to add a body part to an Assembly. + return_preds: Whether to return predictions alongside the animals' poses Returns: None @@ -85,6 +91,7 @@ def __init__( self.edges_to_keep = edges_to_keep self.locref_stdev = locref_stdev self.nms_radius = nms_radius + self.return_preds = return_preds self.sigma = sigma self.sigmoid = torch.nn.Sigmoid() self.assembler = inferenceutils.Assembler.empty( @@ -158,33 +165,43 @@ def forward( scale_factors, n_id_channels=0, # FIXME Handle identity training ) - poses = torch.empty((batch_size, self.num_animals, self.num_multibodyparts, 4)) + poses = torch.empty((batch_size, self.num_animals, self.num_multibodyparts, 5)) poses_unique = torch.empty((batch_size, 1, self.num_uniquebodyparts, 4)) for i, data_dict in enumerate(preds): assemblies, unique = self.assembler._assemble(data_dict, ind_frame=0) for j, assembly in enumerate(assemblies): - poses[i, j] = torch.from_numpy(assembly.data) + poses[i, j, :, :4] = torch.from_numpy(assembly.data) + poses[i, j, :, 4] = assembly.affinity if unique is not None: - poses_unique[i, 0] = torch.from_numpy(unique) + poses_unique[i, 0, :, :4] = torch.from_numpy(unique) - # FIXME Handle unique bodyparts in a separate HeatmapHead - return {"poses": poses} + out = {"poses": poses} + if self.return_preds: + out["preds"] = preds + return out @staticmethod - def find_local_peak_indices_maxpool_nms(input_, radius, threshold): + def find_local_peak_indices_maxpool_nms( + input_: torch.Tensor, radius: int, threshold: float + ) -> torch.Tensor: pooled = F.max_pool2d(input_, kernel_size=radius, stride=1, padding=radius // 2) maxima = input_ * torch.eq(input_, pooled).float() peak_indices = torch.nonzero(maxima >= threshold, as_tuple=False) return peak_indices.int() @staticmethod - def make_2d_gaussian_kernel(sigma, size): + def make_2d_gaussian_kernel(sigma: float, size: int) -> torch.Tensor: k = torch.arange(-size // 2 + 1, size // 2 + 1, dtype=torch.float32) ** 2 k = F.softmax(-k / (2 * (sigma ** 2)), dim=0) return torch.einsum("i,j->ij", k, k) @staticmethod - def calc_peak_locations(locrefs, peak_inds_in_batch, strides, n_decimals=3): + def calc_peak_locations( + locrefs: torch.Tensor, + peak_inds_in_batch: torch.Tensor, + strides: tuple[float, float], + n_decimals: int = 3, + ) -> torch.Tensor: s, b, r, c = peak_inds_in_batch.T stride_y, stride_x = strides strides = torch.Tensor((stride_x, stride_y)).to(locrefs.device) @@ -192,16 +209,16 @@ def calc_peak_locations(locrefs, peak_inds_in_batch, strides, n_decimals=3): loc = strides * peak_inds_in_batch[:, [3, 2]] + strides // 2 + off return torch.round(loc, decimals=n_decimals) + @staticmethod def compute_edge_costs( - self, - pafs, - peak_inds_in_batch, - graph, - paf_inds, - n_bodyparts, - n_points=10, - n_decimals=3, - ): + pafs: NDArray, + peak_inds_in_batch: NDArray, + graph: Graph, + paf_inds: list[int], + n_bodyparts: int, + n_points: int = 10, + n_decimals: int = 3, + ) -> list[dict[int, NDArray]]: # Clip peak locations to PAFs dimensions h, w = pafs.shape[-2:] peak_inds_in_batch[:, 2] = np.clip(peak_inds_in_batch[:, 2], 0, h - 1) @@ -289,17 +306,17 @@ def _linspace(start: torch.Tensor, stop: torch.Tensor, num: int) -> torch.Tensor def compute_peaks_and_costs( self, - heatmaps, - locrefs, - pafs, - peak_inds_in_batch, - graph, - paf_inds, - strides, - n_id_channels, - n_points=10, - n_decimals=3, - ): + heatmaps: torch.Tensor, + locrefs: torch.Tensor, + pafs: torch.Tensor, + peak_inds_in_batch: torch.Tensor, + graph: Graph, + paf_inds: list[int], + strides: tuple[float, float], + n_id_channels: int, + n_points: int = 10, + n_decimals: int = 3, + ) -> list[dict[str, NDArray]]: n_samples, n_channels = heatmaps.shape[:2] n_bodyparts = n_channels - n_id_channels pos = self.calc_peak_locations(locrefs, peak_inds_in_batch, strides, n_decimals) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py new file mode 100644 index 0000000000..fb0f11dbb3 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py @@ -0,0 +1,208 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +from collections import defaultdict + +import albumentations as A +import networkx as nx +import numpy as np +import torch +from numpy.typing import ArrayLike, NDArray +from scipy.spatial import cKDTree +from torch.utils.data import DataLoader +from tqdm import tqdm + +from deeplabcut.pose_estimation_pytorch import Loader +from deeplabcut.pose_estimation_pytorch.apis.scoring import ( + get_scores, + align_predicted_individuals_to_gt, +) +from deeplabcut.pose_estimation_pytorch.models import PoseModel +from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import Graph + + +def _find_closest_neighbors(query: NDArray, ref: NDArray, k: int = 3) -> NDArray: + n_preds = ref.shape[0] + tree = cKDTree(ref) + dist, inds = tree.query(query, k=k) + idx = np.argsort(dist[:, 0]) + neighbors = np.full(len(query), -1, dtype=int) + picked = set() + for i, ind in enumerate(inds[idx]): + for j in ind: + if j not in picked: + picked.add(j) + neighbors[idx[i]] = j + break + if len(picked) == n_preds: + break + return neighbors + + +def _calc_separability( + vals_left: ArrayLike, + vals_right: ArrayLike, + n_bins: int = 101, + metric: str = "jeffries", + max_sensitivity: bool = False, +) -> tuple[float, float]: + if metric not in ("jeffries", "auc"): + raise ValueError("`metric` should be either 'jeffries' or 'auc'.") + + bins = np.linspace(0, 1, n_bins) + hist_left = np.histogram(vals_left, bins=bins)[0] + hist_left = hist_left / hist_left.sum() + hist_right = np.histogram(vals_right, bins=bins)[0] + hist_right = hist_right / hist_right.sum() + tpr = np.cumsum(hist_right) + if metric == "jeffries": + sep = np.sqrt( + 2 * (1 - np.sum(np.sqrt(hist_left * hist_right))) + ) # Jeffries-Matusita distance + else: + sep = np.trapz(np.cumsum(hist_left), tpr) + if max_sensitivity: + threshold = bins[max(1, np.argmax(tpr > 0))] + else: + threshold = bins[np.argmin(1 - np.cumsum(hist_left) + tpr)] + return sep, threshold + + +def get_n_best_paf_graphs( + model: PoseModel, + train_dataloader: DataLoader, + full_graph: Graph, + root_edges: list[int] | None = None, + n_graphs: int = 10, + metric: str = "auc", + device: str = "cuda", +) -> tuple[list[list[int]], dict[int, float]]: + within_train, between_train = compute_within_between_paf_costs( + model, train_dataloader, device + ) + existing_edges = list(set(k for k, v in within_train.items() if v)) + + scores, _ = zip( + *[ + _calc_separability(between_train[n], within_train[n], metric=metric) + for n in existing_edges + ] + ) + + # Find minimal skeleton + G = nx.Graph() + for edge, score in zip(existing_edges, scores): + if np.isfinite(score): + G.add_edge(*full_graph[edge], weight=score) + + order = np.asarray(existing_edges)[np.argsort(scores)[::-1]] + if root_edges is None: + root_edges = [] + for edge in nx.maximum_spanning_edges(G, data=False): + root_edges.append(full_graph.index(sorted(edge))) + + n_edges = len(existing_edges) - len(root_edges) + lengths = np.linspace(0, n_edges, min(n_graphs, n_edges + 1), dtype=int)[1:] + order = order[np.isin(order, root_edges, invert=True)] + best_edges = [root_edges] + for length in lengths: + best_edges.append(root_edges + list(order[:length])) + return best_edges, dict(zip(existing_edges, scores)) + + +def compute_within_between_paf_costs( + model: PoseModel, dataloader: DataLoader, device: str = "cuda" +) -> tuple[defaultdict[list]]: + model.to(device) + predictor = model.heads.bodypart.predictor + within = defaultdict(list) + between = defaultdict(list) + with torch.no_grad(): + for batch in tqdm(dataloader): + inputs = batch["image"].to(device) + preds = model.get_predictions(inputs, model(inputs))["bodypart"] + + for coords_gt, preds_ in zip( + batch["annotations"]["keypoints"], preds["preds"] + ): + coords_gt = coords_gt.permute(1, 0, 2).detach().cpu().numpy() + if np.isnan(coords_gt).all(): + continue + + coords_pred = preds_["coordinates"][0] + costs_pred = preds_["costs"] + + # Get animal IDs and corresponding indices in the arrays of detections + lookup = dict() + for i, (coord_pred, coord_gt) in enumerate(zip(coords_pred, coords_gt)): + inds = np.flatnonzero(np.all(~np.isnan(coord_pred), axis=1)) + inds_gt = np.flatnonzero(np.all(~np.isnan(coord_gt), axis=1)) + if inds.size and inds_gt.size: + neighbors = _find_closest_neighbors( + coord_gt[inds_gt], coord_pred[inds], k=3 + ) + found = neighbors != -1 + lookup[i] = dict(zip(inds_gt[found], inds[neighbors[found]])) + + for k, v in costs_pred.items(): + paf = v["m1"] + mask_within = np.zeros(paf.shape, dtype=bool) + s, t = predictor.graph[k] + if s not in lookup or t not in lookup: + continue + lu_s = lookup[s] + lu_t = lookup[t] + common_id = set(lu_s).intersection(lu_t) + for id_ in common_id: + mask_within[lu_s[id_], lu_t[id_]] = True + within_vals = paf[mask_within] + between_vals = paf[~mask_within] + within[k].extend(within_vals) + between[k].extend(between_vals) + return within, between + + +def benchmark_paf_graphs( + model: PoseModel, + loader: Loader, + transform: A.BaseCompose, + batch_size: int = 8, + device: str = "cuda", +) -> tuple[list[dict[str, float]], list[dict[str, NDArray]], list[list[int]]]: + predictor = model.heads.bodypart.predictor + train_dataset = loader.create_dataset(mode="train", task="BU", transform=transform) + valid_dataset = loader.create_dataset(mode="test", task="BU", transform=transform) + train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=False) + valid_dataloader = DataLoader(valid_dataset, batch_size=batch_size, shuffle=False) + best_paf_edges, _ = get_n_best_paf_graphs( + model, train_dataloader, predictor.graph, device=device + ) + poses_gt = loader.ground_truth_keypoints("test") + results = [] + poses = [] + for edges in best_paf_edges: + predictor.edges_to_keep = predictor.assembler.paf_inds = edges + paths = [] + poses_ = [] + with torch.no_grad(): + for batch in tqdm(valid_dataloader): + paths.extend(batch["path"]) + inputs = batch["image"].to(device) + # FIXME We can do better than the repetition below + preds = model.get_predictions(inputs, model(inputs))["bodypart"] + poses_.extend(preds["poses"]) + poses_ = torch.stack(poses_).detach().cpu().numpy() + poses_ = dict(zip(paths, poses_)) + poses_ = align_predicted_individuals_to_gt(poses_, poses_gt) + poses.append(poses_) + results.append(get_scores(poses_, poses_gt)) + return results, poses, best_paf_edges diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index 27f4b75c15..58c9f624d6 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -21,6 +21,7 @@ import torch import torch.nn as nn from torch.utils.data import DataLoader +from tqdm import tqdm from deeplabcut.pose_estimation_pytorch.data.postprocessor import Postprocessor from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor From a5d07bd650fb65faee59811d95ddb3eaa3ba5f1c Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 8 Dec 2023 15:57:15 +0100 Subject: [PATCH 059/293] refactor make_pytorch_pose_config * improves the way model configuration files are created * makes it possible to create configuration files for all models --- benchmark/coco/make_config.py | 38 +- .../make_pytorch_config.py | 545 ------------------ ...ple_individuals_trainingsetmanipulation.py | 64 +- .../trainingsetmanipulation.py | 64 +- .../apis/convert_detections_to_tracklets.py | 6 +- .../config/__init__.py | 1 + .../config/backbones/hrnet_w18.yaml | 11 + .../config/backbones/hrnet_w32.yaml | 11 + .../config/backbones/hrnet_w48.yaml | 11 + .../config/backbones/resnet_101.yaml | 6 + .../config/backbones/resnet_50.yaml | 6 + .../config/base/base.yaml | 34 ++ .../config/base/detector.yaml | 22 + .../config/base/head_bodyparts.yaml | 41 ++ .../config/base/head_bodyparts_with_paf.yaml | 64 ++ .../config/base/head_identity.yaml | 25 + .../config/dekr/dekr_w18.yaml | 45 ++ .../config/dekr/dekr_w32.yaml | 45 ++ .../config/dekr/dekr_w48.yaml | 45 ++ .../config/dlcrnet/dlcrnet_stride16_ms5.yaml | 70 +++ .../config/dlcrnet/dlcrnet_stride32_ms5.yaml | 75 +++ .../config/make_pose_config.py | 362 ++++++++++++ .../config/tokenpose/tokenpose_base.yaml | 48 ++ .../pose_estimation_pytorch/config/utils.py | 170 ++++++ .../pose_estimation_pytorch/data/base.py | 34 +- .../pose_estimation_pytorch/models/model.py | 2 +- .../models/predictors/__init__.py | 2 +- .../models/predictors/paf_predictor.py | 2 +- .../models/predictors/single_predictor.py | 212 +------ .../pose_estimation_pytorch/runners/base.py | 1 - .../tests/test_get_predictions.py | 12 +- deeplabcut/utils/auxfun_models.py | 4 - deeplabcut/utils/auxiliaryfunctions.py | 10 +- .../config/test_make_pose_config.py | 319 ++++++++++ .../runners/bottum_up.py | 4 +- 35 files changed, 1568 insertions(+), 843 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/config/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/base/base.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/base/detector.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/make_pose_config.py create mode 100644 deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/utils.py create mode 100644 tests/pose_estimation_pytorch/config/test_make_pose_config.py diff --git a/benchmark/coco/make_config.py b/benchmark/coco/make_config.py index b70eda37ce..dcd8abb042 100644 --- a/benchmark/coco/make_config.py +++ b/benchmark/coco/make_config.py @@ -9,29 +9,31 @@ import torch import deeplabcut.utils.auxiliaryfunctions as af -from deeplabcut.generate_training_dataset import MakeInference_yaml, make_pytorch_config +from deeplabcut.generate_training_dataset import MakeInference_yaml from deeplabcut.pose_estimation_pytorch import COCOLoader +from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config def get_base_config( - dlc_path: str, + project_path: str, + pose_config_path: str, model_architecture: str, bodyparts: list[str], unique_bodyparts: list[str], individuals: list[str], ) -> dict: - pytorch_cfg_template = af.read_plainconfig( - str(Path(dlc_path) / "pose_estimation_pytorch" / "apis" / "pytorch_config.yaml") - ) cfg = { + "project_path": project_path, + "multianimalproject": True, "bodyparts": bodyparts, - "unique_bodyparts": unique_bodyparts, + "multianimalbodyparts": bodyparts, + "uniquebodyparts": unique_bodyparts, "individuals": individuals, } - return make_pytorch_config( - cfg, - model_architecture, - config_template=pytorch_cfg_template, + return make_pytorch_pose_config( + project_config=cfg, + pose_config_path=pose_config_path, + net_type=model_architecture, ) @@ -61,19 +63,21 @@ def main(project_root: str, train_file: str, output: str, model_arch: str): train_dict = COCOLoader.load_json(project_root, train_file) num_individuals, bodyparts = COCOLoader.get_project_parameters(train_dict) dlc_path = af.get_deeplabcut_path() - pytorch_cfg = get_base_config( - dlc_path=dlc_path, - model_architecture=model_arch, - bodyparts=bodyparts, - unique_bodyparts=[], - individuals=[f"individual{i}" for i in range(num_individuals)], - ) output_path.mkdir(parents=True) train_dir = output_path / "train" test_dir = output_path / "test" train_dir.mkdir() test_dir.mkdir() + pose_config_path = str(train_dir / "pytorch_config.yaml") + pytorch_cfg = get_base_config( + project_path=project_root, + pose_config_path=pose_config_path, + model_architecture=model_arch, + bodyparts=bodyparts, + unique_bodyparts=[], + individuals=[f"individual{i}" for i in range(num_individuals)], + ) af.write_plainconfig(str(train_dir / "pytorch_config.yaml"), pytorch_cfg) make_inference_config( dlc_path, diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py index e5273b10bc..e69de29bb2 100644 --- a/deeplabcut/generate_training_dataset/make_pytorch_config.py +++ b/deeplabcut/generate_training_dataset/make_pytorch_config.py @@ -1,545 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from __future__ import annotations - -import torch -from copy import deepcopy -from itertools import combinations - -from deeplabcut.utils import auxiliaryfunctions - - -BACKBONE_OUT_CHANNELS = { - "resnet-50": 2048, - "mobilenet_v2_1.0": 1280, - "mobilenet_v2_0.75": 1280, - "mobilenet_v2_0.5": 1280, - "mobilenet_v2_0.35": 1280, - "efficientnet-b0": 1280, - "efficientnet-b1": 1280, - "efficientnet-b2": 1408, - "efficientnet-b3": 1536, - "efficientnet-b4": 1792, - "efficientnet-b5": 2048, - "efficientnet-b6": 2304, - "efficientnet-b7": 2560, - "efficientnet-b8": 2816, - "hrnet_w18": 270, - "hrnet_w32": 480, - "hrnet_w48": 720, -} -SUPPORTED_MODELS = ( - "resnet_50", - "mobilenet_v2_1.0", - "mobilenet_v2_0.75", - "mobilenet_v2_0.5", - "mobilenet_v2_0.35", - "efficientnet-b0", - "efficientnet-b1", - "efficientnet-b2", - "efficientnet-b3", - "efficientnet-b4", - "efficientnet-b5", - "efficientnet-b6", - "efficientnet-b7", - "efficientnet-b8", - "hrnet_w18", - "hrnet_w32", - "hrnet_w48", - "dekr_w18", - "dekr_w32", - "dekr_w48", - "token_pose_w18", - "token_pose_w32", - "token_pose_w48", -) - - -def make_pytorch_config( - project_config: dict, - net_type: str, - augmenter_type: str = "default", - config_template: dict = None, -): - """ - Currently supported net types : - Single Animal : - - resnet-50 - - mobilenet_v2_1.0 - - mobilenet_v2_0.75 - - mobilenet_v2_0.5 - - mobilenet_v2_0.35 - - efficientnet-b0 - - efficientnet-b1 - - efficientnet-b2 - - efficientnet-b3 - - efficientnet-b4 - - efficientnet-b5 - - efficientnet-b6 - - efficientnet-b7 - - efficientnet-b8 - - hrnet_w18 - - hrnet_w32 - - hrnet_w48 - - Multi Animal: - - dekr_w18 - - dekr_w32 - - dekr_w48 - - Multi Animal top-down models: - - token_pose_w18 - - token_pose_w32 - - token_pose_w48 - - """ - if net_type not in SUPPORTED_MODELS: - raise ValueError(f"Unsupported network {net_type}.") - - bodyparts = auxiliaryfunctions.get_bodyparts(project_config) - num_joints = len(bodyparts) - unique_bpts = auxiliaryfunctions.get_unique_bodyparts(project_config) - num_unique_bpts = len(unique_bpts) - compute_unique_bpts = num_unique_bpts > 0 - identification_head = project_config.get("identity") - pytorch_config = deepcopy(config_template) - pytorch_config["device"] = "cuda" if torch.cuda.is_available() else "cpu" - pytorch_config["method"] = "bu" - if project_config.get("multianimalproject", False): - num_individuals = len(project_config.get("individuals", [0])) - if "dekr" in net_type: - version = net_type.split("_")[-1] - backbone_type = "hrnet_" + version - num_offset_per_kpt = 15 - pytorch_config["data"]["auto_padding"] = { - "min_height": None, - "min_width": None, - "pad_width_divisor": 32, - "pad_height_divisor": 32, - } - pytorch_config["model"]["backbone"] = { - "type": "HRNet", - "model_name": "hrnet_" + version, - } - pytorch_config["model"]["heads"] = { - "bodypart": make_dekr_head_cfg( - num_individuals, num_joints, backbone_type, num_offset_per_kpt - ), - } - pytorch_config["with_center_keypoints"] = True - - if compute_unique_bpts: - pytorch_config["model"]["heads"]["unique_bodypart"] = make_unique_bodyparts_head( - num_unique_bpts, backbone_type - ) - - if identification_head: - pytorch_config["model"]["heads"]["identity"] = make_identity_head( - num_individuals, - backbone_out_channels=BACKBONE_OUT_CHANNELS[backbone_type], - ) - - elif "resnet" in net_type: - num_stages = pytorch_config.get("num_stages", 0) - dim = BACKBONE_OUT_CHANNELS["resnet-50"] if num_stages == 0 else 2304 - graph = [ - list(edge) for edge in combinations(range(num_joints), 2) - ] # TODO Parse from config - num_limbs = len(graph) - pytorch_config["optimizer"]["params"]["lr"] = 5e-4 - pytorch_config["scheduler"]["params"].update({"lr_list": [[1e-4], [5e-5]]}), - pytorch_config["model"]["backbone"]["type"] = "ResNet" - _init_output_stride(pytorch_config, output_stride=16) - heatmap_channels, locref_channels, paf_channels = _configure_channels( - dim, num_joints, num_limbs, pytorch_config - ) - pytorch_config["model"]["heads"] = { - "bodypart": make_dlcrnet_head( - num_joints, - num_unique_bpts, - num_individuals, - graph, - edges_to_keep=list( - pytorch_config.get("paf_best", list(range(num_limbs))) - ), - heatmap_channels=heatmap_channels, - locref_channels=locref_channels, - paf_channels=paf_channels, - num_stages=num_stages, - # TODO Set remaining params from config - ) - } - if identification_head: - pytorch_config["model"]["heads"]["identity"] = make_identity_head( - num_individuals, - backbone_out_channels=dim, - ) - # pytorch_config["data"]["crop_sampling"] = { - # "height": 400, - # "width": 400, - # "max_shift": 0.4, - # "method": "hybrid", - # } - elif "token_pose" in net_type: - if compute_unique_bpts: - raise NotImplementedError( - "Unique body parts are currently not handled by top down models" - ) - if identification_head: - raise NotImplementedError( - "Identification heads are currently not handled by top down models" - ) - - pytorch_config["method"] = "td" - version = net_type.split("_")[-1] - backbone_type = "hrnet_" + version - pytorch_config["data_detector"] = make_detector_data_aug() - pytorch_config["detector"] = make_detector_cfg(num_individuals) - pytorch_config["model"] = make_token_pose_model_cfg( - num_joints, backbone_type - ) - pytorch_config["criterion"] = {"type": "HeatmapOnlyCriterion"} - pytorch_config["runner"] = {"type": "PoseRunner"} - pytorch_config["with_center_keypoints"] = False - else: - raise NotImplementedError( - "Currently no other model than dlcrnet, dekr, and token_pose are implemented" - ) - else: - pytorch_config["model"]["heads"] = { - "bodypart": make_single_head_cfg(num_joints, net_type) - } - - if "efficientnet" in net_type: - raise NotImplementedError("efficientnet config not yet implemented") - elif "mobilenetv2" in net_type: - raise NotImplementedError("mobilenet config not yet implemented") - elif "hrnet" in net_type: - raise NotImplementedError("hrnet config not yet implemented") - - if augmenter_type == None: - pytorch_config["data"] = {} - elif augmenter_type != "default" and augmenter_type != None: - raise NotImplementedError( - "Other augmentations than default are not implemented" - ) - - return pytorch_config - - -def _init_output_stride(config: dict, output_stride: int): - backbone_config = config["model"]["backbone"] - if backbone_config.get("output_stride") is None: - backbone_config["output_stride"] = output_stride - - -def _configure_channels(dim: int, num_joints: int, num_limbs: int, config: dict): - """Configure the channels for heatmap, locref, and paf based on the backbone's output stride.""" - output_stride = config["model"]["backbone"]["output_stride"] - if output_stride not in (16, 32): - raise ValueError("`output_stride` must be 16 or 32.") - - base_channels = [dim, dim // 2] - if output_stride == 16: - base_channels.pop(1) - - heatmap_channels = base_channels + [num_joints] - locref_channels = base_channels + [2 * num_joints] - paf_channels = base_channels + [2 * num_limbs] - - return heatmap_channels, locref_channels, paf_channels - - -def make_dlcrnet_head( - num_joints: int, - num_unique_joints: int, - num_animals: int, - graph: list[tuple[int, int]], - edges_to_keep: list[int], - heatmap_channels: list[int], - locref_channels: list[int], - paf_channels: list[int], - locref_weight: float = 0.05, - paf_weight: float = 0.1, - paf_width: int = 20, - nms_radius: int = 5, - sigma: float = 1.0, - min_affinity: float = 0.05, - num_stages: int = 5, -) -> dict: - dict_ = make_heatmap_head(num_joints, heatmap_channels, locref_channels) - dict_["type"] = "DLCRNetHead" - dict_["criterion"]["locref"]["weight"] = locref_weight - dict_["criterion"]["paf"] = {"type": "WeightedHuberCriterion", "weight": paf_weight} - n_deconv_layers = len(paf_channels) - 1 - dict_["paf_config"] = { - "channels": paf_channels, - "kernel_size": [3] * n_deconv_layers, - "strides": [2] * n_deconv_layers, - } - dict_["num_stages"] = num_stages - dict_["target_generator"] = { - "type": "SequentialGenerator", - "generators": [ - dict_["target_generator"], - {"type": "PartAffinityFieldGenerator", "graph": graph, "width": paf_width}, - ], - } - dict_["predictor"] = { - "type": "PartAffinityFieldPredictor", - "num_animals": num_animals, - "num_multibodyparts": num_joints, - "num_uniquebodyparts": num_unique_joints, - "nms_radius": nms_radius, - "sigma": sigma, - "locref_stdev": 7.2801, - "min_affinity": min_affinity, - "graph": graph, - "edges_to_keep": edges_to_keep, - } - return dict_ - - -def make_heatmap_head( - num_heatmaps: int, heatmap_channels: list[int], locref_channels: list[int] | None -) -> dict: - n_deconv_heatmap = len(heatmap_channels) - 1 - with_locref = (locref_channels is not None and len(locref_channels) > 0) - head_config = { - "type": "HeatmapHead", - "predictor": { - "type": "SinglePredictor", - "location_refinement": with_locref, - "locref_stdev": 7.2801, - "num_animals": 1, - }, - "target_generator": { - "type": "HeatmapPlateauGenerator", - "num_heatmaps": num_heatmaps, - "pos_dist_thresh": 17, - "heatmap_mode": "KEYPOINT", - "generate_locref": with_locref, - "locref_std": 7.2801, - }, - "criterion": { - "heatmap": {"type": "WeightedBCECriterion", "weight": 1.0}, - }, - "heatmap_config": { - "channels": heatmap_channels, - "kernel_size": [3] * n_deconv_heatmap, - "strides": [2] * n_deconv_heatmap, - }, - } - - if locref_channels: - n_deconv_locref = len(locref_channels) - 1 - head_config["locref_config"] = { - "channels": locref_channels, - "kernel_size": [3] * n_deconv_locref, - "strides": [2] * n_deconv_locref, - } - head_config["criterion"]["locref"] = { - "type": "WeightedHuberCriterion", # or WeightedMSECriterion - "weight": 0.05, - } - - return head_config - - -def make_identity_head( - num_individuals: int, backbone_out_channels: list[int] -) -> dict: - heatmap_head = make_heatmap_head( - num_individuals, - heatmap_channels=[backbone_out_channels, num_individuals], - locref_channels=None, - ) - heatmap_head["predictor"] = { - "type": "IdentityPredictor", - "apply_sigmoid": True, - } - heatmap_head["target_generator"]["heatmap_mode"] = "INDIVIDUAL" - return heatmap_head - - -def make_single_head_cfg(num_joints: int, net_type: str) -> dict: - """ - Args: - num_joints: the number of keypoints to predict - net_type: the type of neural net to make the head for - - Raises: - NotImplementedError if unique bodyparts are not implemented for backbone_type - - Returns: - the head configuration - """ - if "resnet" in net_type: - return make_heatmap_head( - num_joints, - heatmap_channels=[2048, 1024, num_joints], - locref_channels=[2048, 1024, 2 * num_joints], - ) - - raise NotImplementedError( - f"Heads for single animals are not yet implemented with a {net_type} " - f"backbone" - ) - - -def make_unique_bodyparts_head(num_unique_bodyparts: int, backbone_type: str) -> dict: - """Creates a deconvolutional head to predict unique bodyparts - - Args: - num_unique_bodyparts: number of unique bodyparts - backbone_type: type of the backbone - - Raises: - NotImplementedError if unique bodyparts are not implemented for backbone_type - - Returns: - The configs for the unique bodyparts heatmap and locref heads - """ - if "hrnet" in backbone_type: - # Only one deconvolutional layer since hrnet stride is 1/4 - heatmap_in_channels = BACKBONE_OUT_CHANNELS[backbone_type] - head = make_heatmap_head( - num_unique_bodyparts, - heatmap_channels=[heatmap_in_channels, num_unique_bodyparts], - locref_channels=[heatmap_in_channels, 2 * num_unique_bodyparts], - ) - head["target_generator"]["label_keypoint_key"] = "keypoints_unique" - return head - - raise NotImplementedError( - f"Unique bodyparts prediction is not implemented yet for backbone {backbone_type}" - ) - - -def make_dekr_head_cfg( - num_individuals: int, num_joints: int, backbone_type: str, num_offset_per_kpt: int -): - return { - "type": "DEKRHead", - "target_generator": { - "type": "DEKRGenerator", - "num_joints": num_joints, - "pos_dist_thresh": 17, - "bg_weight": 0.1, - }, - "criterion": { - "heatmap": {"type": "WeightedBCECriterion", "weight": 1}, - "offset": { - "type": "WeightedHuberCriterion", # or WeightedMSECriterion - "weight": 0.03, - }, - }, - "predictor": { - "type": "DEKRPredictor", - "num_animals": num_individuals, - "keypoint_score_type": "combined", - "max_absorb_distance": 75, - }, - "heatmap_config": { - "channels": [ - BACKBONE_OUT_CHANNELS[backbone_type], - 64, - num_joints + 1, - ], # +1 since we need center - "num_blocks": 1, - "dilation_rate": 1, - "final_conv_kernel": 1, - }, - "offset_config": { - "channels": [ - BACKBONE_OUT_CHANNELS[backbone_type], - num_offset_per_kpt * num_joints, - num_joints, - ], - "num_offset_per_kpt": num_offset_per_kpt, - "num_blocks": 2, - "dilation_rate": 1, - "final_conv_kernel": 1, - }, - } - - -def make_token_pose_model_cfg(num_joints, backbone_type): - return { - "backbone": { - "type": "HRNet", - "model_name": backbone_type, - "pretrained": True, - "only_high_res": True, - }, - "neck": { - "type": "Transformer", - "feature_size": [64, 64], - "patch_size": [4, 4], - "num_keypoints": num_joints, - "channels": 32, - "dim": 192, - "heads": 8, - "depth": 6, - }, - "heads": { - "bodypart": { - "type": "TransformerHead", - "target_generator": { - "type": "HeatmapPlateauGenerator", - "num_heatmaps": num_joints, - "pos_dist_thresh": 17, - "generate_locref": False, - }, - "criterion": {"type": "WeightedBCECriterion"}, - "predictor": {"type": "HeatmapOnlyPredictor", "num_animals": 1}, - "dim": 192, - "hidden_heatmap_dim": 384, - "heatmap_dim": 4096, - "apply_multi": True, - "heatmap_size": [64, 64], - "apply_init": True, - } - }, - "pose_model": {"stride": 4}, - } - - -def make_detector_cfg(num_individuals: int): - return { - "model": {"type": "FasterRCNN"}, - "optimizer": {"type": "AdamW", "params": {"lr": 1e-4}}, - "scheduler": { - "type": "LRListScheduler", - "params": {"milestones": [90], "lr_list": [[1e-5]]}, - }, - "runner": { - "type": "DetectorRunner", - "max_individuals": num_individuals, - }, - "batch_size": 1, - "epochs": 500, - "save_epochs": 100, - "display_iters": 500, - } - - -def make_detector_data_aug() -> dict: - return { - "covering": False, - "gaussian_noise": False, - "hist_eq": False, - "hflip": True, - "motion_blur": False, - "normalize_images": True, - "rotation": 30, - "scale_jitter": [0.5, 1.25], - } diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index 6564f17c88..9045086b3e 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -27,7 +27,6 @@ MakeTest_pose_yaml, MakeInference_yaml, pad_train_test_indices, - make_pytorch_config, ) from deeplabcut.utils import ( auxiliaryfunctions, @@ -211,15 +210,20 @@ def create_multianimaltraining_dataset( if net_type is None: # loading & linking pretrained models net_type = cfg.get("default_net_type", "dlcrnet_ms5") - elif not any( - net in net_type for net in ("resnet", "eff", "dlc", "mob", "dekr", "token_pose") - ): + if any(net in net_type for net in ("resnet", "eff", "dlc", "mob")): + pass + elif cfg.get("engine", "pytorch").lower() == "pytorch": + pass # TODO: Change default to tensorflow + else: raise ValueError(f"Unsupported network {net_type}.") multi_stage = False ### dlcnet_ms5: backbone resnet50 + multi-fusion & multi-stage module ### dlcr101_ms5/dlcr152_ms5: backbone resnet101/152 + multi-fusion & multi-stage module - if all(net in net_type for net in ("dlcr", "_ms5")): + if ( + all(net in net_type for net in ("dlcr", "_ms5")) + and cfg.get("engine", "pytorch").lower() != "pytorch" + ): # TODO: Change default to tensorflow num_layers = re.findall("dlcr([0-9]*)", net_type)[0] if num_layers == "": num_layers = 50 @@ -276,9 +280,14 @@ def create_multianimaltraining_dataset( # Loading the encoder (if necessary downloading from TF) dlcparent_path = auxiliaryfunctions.get_deeplabcut_path() defaultconfigfile = os.path.join(dlcparent_path, "pose_cfg.yaml") - model_path = auxfun_models.check_for_weights( - net_type, Path(dlcparent_path) - ) + + # TODO: Clean this + if cfg.get("engine", "pytorch") == "pytorch": + model_path = dlcparent_path + else: + model_path = auxfun_models.check_for_weights( + net_type, Path(dlcparent_path) + ) if Shuffles is None: Shuffles = range(1, num_shuffles + 1, 1) @@ -406,6 +415,7 @@ def create_multianimaltraining_dataset( jointnames.extend([str(bpt) for bpt in uniquebodyparts]) items2change = { "dataset": datafilename, + "engine": cfg.get("engine", "pytorch"), # TODO: Default to tensorflow "metadataset": metadatafilename, "num_joints": len(multianimalbodyparts) + len(uniquebodyparts), # cfg["uniquebodyparts"]), @@ -413,7 +423,7 @@ def create_multianimaltraining_dataset( [i] for i in range(len(multianimalbodyparts) + len(uniquebodyparts)) ], # cfg["uniquebodyparts"]))], "all_joints_names": jointnames, - "init_weights": model_path, + "init_weights": str(model_path), "project_path": str(cfg["project_path"]), "net_type": net_type, "multi_stage": multi_stage, @@ -483,25 +493,23 @@ def create_multianimaltraining_dataset( ) # Populate the pytorch config yaml file - pytorch_config_path = os.path.join( - dlcparent_path, - "pose_estimation_pytorch", - "apis", - "pytorch_config.yaml", - ) - pytorch_cfg_template = auxiliaryfunctions.read_plainconfig( - pytorch_config_path - ) - pytorch_cfg = make_pytorch_config( - cfg, net_type, config_template=pytorch_cfg_template - ) - pytorch_cfg["project_path"] = os.path.dirname(config) - pytorch_cfg["pose_cfg_path"] = path_train_config - pytorch_cfg["cfg_path"] = config - auxiliaryfunctions.write_plainconfig( - path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml"), - pytorch_cfg, - ) + # TODO: Add switch for PyTorch projects + if cfg.get("engine", "pytorch").lower() == "pytorch": + from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config + + top_down = False + if net_type.startswith("top_down_"): + top_down = True + net_type = net_type[len("top_down_"):] + + pose_cfg_path = path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml") + pytorch_cfg = make_pytorch_pose_config( + project_config=cfg, + pose_config_path=path_train_config, + net_type=net_type, + top_down=top_down, + ) + auxiliaryfunctions.write_plainconfig(pose_cfg_path, pytorch_cfg) print( "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!" diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index e7e3d6f625..2ef8797cbe 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -24,7 +24,6 @@ import yaml from deeplabcut.pose_estimation_tensorflow import training -from deeplabcut.generate_training_dataset.make_pytorch_config import make_pytorch_config from deeplabcut.utils import ( auxiliaryfunctions, conversioncode, @@ -902,18 +901,10 @@ def create_training_dataset( # loading & linking pretrained models if net_type is None: # loading & linking pretrained models net_type = cfg.get("default_net_type", "resnet_50") + elif cfg.get("engine", "pytorch").lower() == "pytorch": # TODO: Change default to tensorflow + pass else: - if ( - "resnet" in net_type - or "mobilenet" in net_type - or "efficientnet" in net_type - or "dlcrnet" in net_type - or "dekr" in net_type - or "token_pose" in net_type - ): - pass - else: - raise ValueError("Invalid network type:", net_type) + raise ValueError("Invalid network type:", net_type) if augmenter_type is None: augmenter_type = cfg.get("default_augmenter", "imgaug") @@ -946,9 +937,14 @@ def create_training_dataset( defaultconfigfile = os.path.join(dlcparent_path, "pose_cfg.yaml") elif posecfg_template: defaultconfigfile = posecfg_template - model_path = auxfun_models.check_for_weights( - net_type, Path(dlcparent_path) - ) + + # TODO: Clean this + if cfg.get("engine", "pytorch") == "pytorch": + model_path = dlcparent_path + else: + model_path = auxfun_models.check_for_weights( + net_type, Path(dlcparent_path) + ) if Shuffles is None: Shuffles = range(1, num_shuffles + 1) @@ -1083,6 +1079,7 @@ def create_training_dataset( # str(cfg['proj_path']+'/'+Path(modelfoldername) / 'test' / 'pose_cfg.yaml') items2change = { "dataset": datafilename, + "engine": cfg.get("engine", "pytorch"), # TODO: Default to tensorflow "metadataset": metadatafilename, "num_joints": len(bodyparts), "all_joints": [[i] for i in range(len(bodyparts))], @@ -1123,25 +1120,24 @@ def create_training_dataset( ) # Populate the pytorch config yaml file - pytorch_config_path = os.path.join( - dlcparent_path, - "pose_estimation_pytorch", - "apis", - "pytorch_config.yaml", - ) - pytorch_cfg_template = auxiliaryfunctions.read_plainconfig( - pytorch_config_path - ) - pytorch_cfg = make_pytorch_config( - cfg, net_type, config_template=pytorch_cfg_template - ) - pytorch_cfg["project_path"] = os.path.dirname(config) - pytorch_cfg["pose_cfg_path"] = path_train_config - pytorch_cfg["cfg_path"] = config - auxiliaryfunctions.write_plainconfig( - path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml"), - pytorch_cfg, - ) + # TODO: Add switch for PyTorch projects + if cfg.get("engine", "pytorch").lower() == "pytorch": + from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config + + top_down = False + if net_type.startswith("top_down_"): + top_down = True + net_type = net_type[len("top_down_"):] + + pose_cfg_path = path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml") + pytorch_cfg = make_pytorch_pose_config( + project_config=cfg, + pose_config_path=path_train_config, + net_type=net_type, + top_down=top_down, + ) + auxiliaryfunctions.write_plainconfig(pose_cfg_path, pytorch_cfg) + return splits diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index cddb7e9a8c..a64e720fab 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -20,7 +20,8 @@ from scipy.optimize import linear_sum_assignment from tqdm import tqdm -from deeplabcut import auxiliaryfunctions +import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions +import deeplabcut.utils.auxfun_multianimal as auxfun_multianimal from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_model_snapshots, list_videos_in_folder, @@ -28,7 +29,6 @@ from deeplabcut.pose_estimation_tensorflow import load_config from deeplabcut.pose_estimation_tensorflow.lib import trackingutils from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly -from deeplabcut.utils import auxfun_multianimal, read_pickle def convert_detections2tracklets( @@ -204,7 +204,7 @@ def convert_detections2tracklets( "analyzing the video!" ) - ass = read_pickle(ass_filename) + ass = auxiliaryfunctions.read_pickle(ass_filename) # Initialize storage of the 'single' individual track if cfg["uniquebodyparts"]: diff --git a/deeplabcut/pose_estimation_pytorch/config/__init__.py b/deeplabcut/pose_estimation_pytorch/config/__init__.py new file mode 100644 index 0000000000..a5c9248ed9 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/__init__.py @@ -0,0 +1 @@ +from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml new file mode 100644 index 0000000000..3265f068e5 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml @@ -0,0 +1,11 @@ +data: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 +model: + backbone: + type: HRNet + model_name: hrnet_w18 + pretrained: true + only_high_res: false + backbone_output_channels: 270 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml new file mode 100644 index 0000000000..d563abc805 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml @@ -0,0 +1,11 @@ +data: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 +model: + backbone: + type: HRNet + model_name: hrnet_w32 + pretrained: true + only_high_res: false + backbone_output_channels: 480 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml new file mode 100644 index 0000000000..e6c2d5d6b9 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml @@ -0,0 +1,11 @@ +data: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 +model: + backbone: + type: HRNet + model_name: hrnet_w48 + pretrained: true + only_high_res: false + backbone_output_channels: 720 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml new file mode 100644 index 0000000000..9581dcfe09 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml @@ -0,0 +1,6 @@ +model: + backbone: + type: ResNet + model_name: resnet101 + pretrained: true + backbone_output_channels: 2048 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml new file mode 100644 index 0000000000..6582b7aab0 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml @@ -0,0 +1,6 @@ +model: + backbone: + type: ResNet + model_name: resnet50 + pretrained: true + backbone_output_channels: 2048 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml new file mode 100644 index 0000000000..fe6205540b --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -0,0 +1,34 @@ +batch_size: 1 +cfg_path: +colormode: RGB +data: + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true + rotation: 30 + scale_jitter: + - 0.5 + - 1.25 +device: cuda +display_iters: 50 +epochs: 200 +optimizer: + params: + lr: 0.0001 + type: AdamW +runner: + type: PoseRunner +save_epochs: 50 +scheduler: + params: + lr_list: + - - 0.00001 + - - 0.000001 + milestones: + - 90 + - 120 + type: LRListScheduler +seed: 42 +method: bu diff --git a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml new file mode 100644 index 0000000000..7c7c1e798d --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml @@ -0,0 +1,22 @@ +data_detector: + hflip: true + normalize_images: true +detector: + model: + type: "FasterRCNN" + optimizer: + type: "AdamW" + params: + lr: 1e-4 + scheduler: + type: "LRListScheduler" + params: + milestones: [90] + lr_list: [[1e-5]] + runner: + type: "DetectorRunner" + max_individuals: "num_individuals" + batch_size: 1 + epochs: 500 + save_epochs: 100 + display_iters: 500 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml new file mode 100644 index 0000000000..28c9c84b4d --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml @@ -0,0 +1,41 @@ +type: HeatmapHead +predictor: + type: HeatmapPredictor + location_refinement: true + locref_std: 7.2801 +target_generator: + type: HeatmapPlateauGenerator + num_heatmaps: "num_bodyparts" + pos_dist_thresh: 17 + heatmap_mode: KEYPOINT + generate_locref: true + locref_std: 7.2801 +criterion: + heatmap: + type: WeightedBCECriterion + weight: 1.0 + locref: + type: WeightedHuberCriterion + weight: 0.05 +heatmap_config: + channels: + - "backbone_output_channels" + - 1024 + - "num_bodyparts" + kernel_size: + - 3 + - 3 + strides: + - 2 + - 2 +locref_config: + channels: + - "backbone_output_channels" + - 1024 + - "num_bodyparts x 2" + kernel_size: + - 3 + - 3 + strides: + - 2 + - 2 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml new file mode 100644 index 0000000000..ba4475b3e3 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml @@ -0,0 +1,64 @@ +type: DLCRNetHead +predictor: + type: PartAffinityFieldPredictor + num_animals: "num_individuals" + num_multibodyparts: "num_bodyparts" + num_uniquebodyparts: 0 + nms_radius: 5 + sigma: 1.0 + locref_stdev: 7.2801 + min_affinity: 0.05 + graph: "paf_graph" + edges_to_keep: "paf_edges_to_keep" +target_generator: + type: SequentialGenerator + generators: + - type: HeatmapPlateauGenerator + num_heatmaps: "num_bodyparts" + pos_dist_thresh: 17 + heatmap_mode: KEYPOINT + generate_locref: true + locref_std: 7.2801 + - type: PartAffinityFieldGenerator + graph: "paf_graph" + width: 20 +criterion: + heatmap: + type: WeightedBCECriterion + weight: 1.0 + locref: + type: WeightedHuberCriterion + weight: 0.05 + paf: + type: WeightedHuberCriterion + weight: 0.1 +heatmap_config: + channels: + - "backbone_output_channels" + - 1024 + - "num_bodyparts" + kernel_size: + - 3 + - 3 + strides: + - 2 + - 2 +locref_config: + channels: + - "backbone_output_channels" + - 1024 + - "num_bodyparts x 2" + kernel_size: + - 3 + strides: + - 2 +paf_config: + channels: + - "backbone_output_channels" + - 1024 + - "num_limbs x 2" # num_limbs = len(graph) + kernel_size: + - 3 + strides: + - 2 +num_stages: 5 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml new file mode 100644 index 0000000000..9ac9910342 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml @@ -0,0 +1,25 @@ +type: HeatmapHead +predictor: + type: HeatmapPredictor + location_refinement: false +target_generator: + type: HeatmapPlateauGenerator + num_heatmaps: "num_individuals" + pos_dist_thresh: 17 + heatmap_mode: INDIVIDUAL + generate_locref: false +criterion: + heatmap: + type: WeightedBCECriterion + weight: 1.0 +heatmap_config: + channels: + - "backbone_output_channels" + - 1024 + - "num_individuals" + kernel_size: + - 3 + - 3 + strides: + - 2 + - 2 diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml new file mode 100644 index 0000000000..9c06df3812 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml @@ -0,0 +1,45 @@ +with_center_keypoints: true +model: + backbone: + type: HRNet + model_name: hrnet_w18 + pretrained: true + only_high_res: false + backbone_output_channels: 270 + heads: + bodypart: + type: DEKRHead + target_generator: + type: DEKRGenerator + num_joints: "num_bodyparts" + pos_dist_thresh: 17 + bg_weight: 0.1 + criterion: + heatmap: + type: WeightedBCECriterion + weight: 1 + offset: + type: WeightedHuberCriterion + weight: 0.03 + predictor: + type: DEKRPredictor + num_animals: "num_individuals" + keypoint_score_type: combined + max_absorb_distance: 75 + heatmap_config: + channels: + - 270 + - 64 + - "num_bodyparts + 1" # num_bodyparts + center keypoint + num_blocks: 1 + dilation_rate: 1 + final_conv_kernel: 1 + offset_config: + channels: + - 270 + - "num_bodyparts x 15" # num_bodyparts * num_offset_per_kpt + - "num_bodyparts" + num_offset_per_kpt: 15 + num_blocks: 2 + dilation_rate: 1 + final_conv_kernel: 1 diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml new file mode 100644 index 0000000000..2acabf6f3f --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml @@ -0,0 +1,45 @@ +with_center_keypoints: true +model: + backbone: + type: HRNet + model_name: hrnet_w32 + pretrained: true + only_high_res: false + backbone_output_channels: 480 + heads: + bodypart: + type: DEKRHead + target_generator: + type: DEKRGenerator + num_joints: "num_bodyparts" + pos_dist_thresh: 17 + bg_weight: 0.1 + criterion: + heatmap: + type: WeightedBCECriterion + weight: 1 + offset: + type: WeightedHuberCriterion + weight: 0.03 + predictor: + type: DEKRPredictor + num_animals: "num_individuals" + keypoint_score_type: combined + max_absorb_distance: 75 + heatmap_config: + channels: + - 480 + - 64 + - "num_bodyparts + 1" # num_bodyparts + center keypoint + num_blocks: 1 + dilation_rate: 1 + final_conv_kernel: 1 + offset_config: + channels: + - 480 + - "num_bodyparts x 15" # num_bodyparts * num_offset_per_kpt + - "num_bodyparts" + num_offset_per_kpt: 15 + num_blocks: 2 + dilation_rate: 1 + final_conv_kernel: 1 diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml new file mode 100644 index 0000000000..f10aadf600 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml @@ -0,0 +1,45 @@ +with_center_keypoints: true +model: + backbone: + type: HRNet + model_name: hrnet_w32 + pretrained: true + only_high_res: false + backbone_output_channels: 720 + heads: + bodypart: + type: DEKRHead + target_generator: + type: DEKRGenerator + num_joints: "num_bodyparts" + pos_dist_thresh: 17 + bg_weight: 0.1 + criterion: + heatmap: + type: WeightedBCECriterion + weight: 1 + offset: + type: WeightedHuberCriterion + weight: 0.03 + predictor: + type: DEKRPredictor + num_animals: "num_individuals" + keypoint_score_type: combined + max_absorb_distance: 75 + heatmap_config: + channels: + - 720 + - 64 # TODO: Check channels + - "num_bodyparts + 1" # num_bodyparts + center keypoint + num_blocks: 1 + dilation_rate: 1 + final_conv_kernel: 1 + offset_config: + channels: + - 720 + - "num_bodyparts x 15" # num_bodyparts * num_offset_per_kpt + - "num_bodyparts" + num_offset_per_kpt: 15 + num_blocks: 2 + dilation_rate: 1 + final_conv_kernel: 1 diff --git a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml new file mode 100644 index 0000000000..e1660ddf4a --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml @@ -0,0 +1,70 @@ +model: + backbone: + type: DLCRNet + model_name: resnet50 + pretrained: true + output_stride: 16 + backbone_output_channels: 2304 + pose_model: + stride: 8 + heads: + bodypart: + type: DLCRNetHead + predictor: + type: PartAffinityFieldPredictor + num_animals: "num_individuals" + num_multibodyparts: "num_bodyparts" + num_uniquebodyparts: 0 + nms_radius: 5 + sigma: 1.0 + locref_stdev: 7.2801 + min_affinity: 0.05 + graph: "paf_graph" + edges_to_keep: "paf_edges_to_keep" + target_generator: + type: SequentialGenerator + generators: + - type: HeatmapPlateauGenerator + num_heatmaps: "num_bodyparts" + pos_dist_thresh: 17 + heatmap_mode: KEYPOINT + generate_locref: true + locref_std: 7.2801 + - type: PartAffinityFieldGenerator + graph: "paf_graph" + width: 20 + criterion: + heatmap: + type: WeightedBCECriterion + weight: 1.0 + locref: + type: WeightedHuberCriterion + weight: 0.05 + paf: + type: WeightedHuberCriterion + weight: 0.1 + heatmap_config: + channels: + - 2304 + - "num_bodyparts" + kernel_size: + - 3 + strides: + - 2 + locref_config: + channels: + - 2304 + - "num_bodyparts x 2" + kernel_size: + - 3 + strides: + - 2 + paf_config: + channels: + - 2304 + - "num_limbs x 2" # num_limbs = len(graph) + kernel_size: + - 3 + strides: + - 2 + num_stages: 5 diff --git a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml new file mode 100644 index 0000000000..841b9391b8 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml @@ -0,0 +1,75 @@ +model: + backbone: + type: DLCRNet + model_name: resnet50 + pretrained: true + output_stride: 32 + backbone_output_channels: 2304 + pose_model: + stride: 8 + heads: + bodypart: + type: DLCRNetHead + predictor: + type: PartAffinityFieldPredictor + num_animals: "num_individuals" + num_multibodyparts: "num_bodyparts" + num_uniquebodyparts: 0 + nms_radius: 5 + sigma: 1.0 + locref_stdev: 7.2801 + min_affinity: 0.05 + graph: "paf_graph" + edges_to_keep: "paf_edges_to_keep" + target_generator: + type: SequentialGenerator + generators: + - type: HeatmapPlateauGenerator + num_heatmaps: "num_bodyparts" + pos_dist_thresh: 17 + heatmap_mode: KEYPOINT + generate_locref: true + locref_std: 7.2801 + - type: PartAffinityFieldGenerator + graph: "paf_graph" + width: 20 + criterion: + heatmap: + type: WeightedBCECriterion + weight: 1.0 + locref: + type: WeightedHuberCriterion + weight: 0.05 + paf: + type: WeightedHuberCriterion + weight: 0.1 + heatmap_config: + channels: + - 2304 + - 1152 + - "num_bodyparts" + kernel_size: + - 3 + - 3 + strides: + - 2 + - 2 + locref_config: + channels: + - 2304 + - 1152 + - "num_bodyparts x 2" + kernel_size: + - 3 + strides: + - 2 + paf_config: + channels: + - 2304 + - 1152 + - "num_limbs x 2" # num_limbs = len(graph) + kernel_size: + - 3 + strides: + - 2 + num_stages: 5 diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py new file mode 100644 index 0000000000..3c8a34dea3 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -0,0 +1,362 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Methods to create the configuration files for PyTorch DeepLabCut models""" +from __future__ import annotations + +import copy +from pathlib import Path + +from deeplabcut.pose_estimation_pytorch.config.utils import ( + load_backbones, + load_config_dir_and_base_config, + read_config_as_dict, + replace_default_values, + update_config, +) +from deeplabcut.utils import auxiliaryfunctions + + +def make_pytorch_pose_config( + project_config: dict, + pose_config_path: str, + net_type: str | None = None, + top_down: bool = False, +) -> dict: + """Creates a PyTorch pose configuration file for a DeepLabCut project + + The base/ folder contains default configurations, such as data augmentations or + heatmap heads (that can be used to predict pose or identity based on visual + features). These files are used to create pose model configurations. + + All available backbone configurations are stored in the backbones/ folder. + - any backbone can be a single animal model with a heatmap head added on top + - any backbone can be a top-down model with a detector and a heatmap head + - any backbone can be a bottom-up model with a detector and a heatmap + PAF head + + All other model architectures have their own folders, with different variants + available. Top-down model architectures must specify `method: TD` in their + configuration files, from which this method adds a backbone configuration. + + Placeholder values (such as `num_bodyparts` or `num_individuals`) are filled in + based on the project config file. + + Args: + project_config: the DeepLabCut project config + pose_config_path: the path where the pytorch pose configuration will be saved + net_type: the architecture of the desired pose estimation model + top_down: when the net_type is a backbone, whether to create a top-down model + by associating a detector to the pose model. Required for multi-animal + projects when net_type is a backbone (as a backbone + heatmap head can only + predict pose for single individuals). + + Returns: + the PyTorch pose configuration file + """ + multianimal_project = project_config.get("multianimalproject", False) + individuals = project_config.get("individuals", ["single"]) + with_identity = project_config.get("identity") + bodyparts = auxiliaryfunctions.get_bodyparts(project_config) + unique_bpts = auxiliaryfunctions.get_unique_bodyparts(project_config) + + if net_type is None: + net_type = project_config.get("default_net_type", "resnet_50") + + configs_dir, pose_config = load_config_dir_and_base_config() + pose_config = add_metadata(project_config, pose_config, pose_config_path) + + backbones = load_backbones(configs_dir) + if net_type in backbones: + if multianimal_project: + model_cfg = create_backbone_with_paf_model( + configs_dir=configs_dir, + net_type=net_type, + num_individuals=len(individuals), + bodyparts=bodyparts, + paf_parameters=_get_paf_parameters(project_config, bodyparts) + ) + + else: + model_cfg = create_backbone_with_heatmap_model( + configs_dir=configs_dir, + net_type=net_type, + multianimal_project=multianimal_project, + bodyparts=bodyparts, + top_down=top_down, + ) + else: + architecture = net_type.split("_")[0] + default_value_kwargs = {} + if architecture == "dlcrnet": + default_value_kwargs.update(_get_paf_parameters(project_config, bodyparts)) + + cfg_path = configs_dir / architecture / f"{net_type}.yaml" + model_cfg = read_config_as_dict(cfg_path) + model_cfg = replace_default_values( + model_cfg, + num_bodyparts=len(bodyparts), + num_individuals=len(individuals), + **default_value_kwargs, + ) + + is_top_down = model_cfg.get("method", "BU").upper() == "TD" + if is_top_down: + model_cfg = add_detector(configs_dir, model_cfg, num_individuals=len(individuals)) + + # add the model to the config + pose_config = update_config(pose_config, model_cfg) + + # add a unique bodypart head if needed + if len(unique_bpts) > 0: + if is_top_down: + raise ValueError( + f"You selected a top-down model architecture ({net_type}), but you have" + f" unique bodyparts, which is not yet implemented for top-down models." + " Please select a bottom-up architecture such as `resnet_50` for single" + " animal projects or `dlcrnet_50` for multi-animal projects." + ) + + pose_config = add_unique_bodypart_head( + configs_dir, + pose_config, + num_unique_bodyparts=len(unique_bpts), + backbone_output_channels=pose_config["model"]["backbone_output_channels"], + ) + + # add an identity head if needed + if with_identity: + if is_top_down: + raise ValueError( + f"You selected a top-down model architecture ({net_type}), but you have" + f" set `identity: true`, which is not yet implemented for top-down" + f" models. Please select a bottom-up architecture such as `dlcrnet_50`" + f" to train with identity, or set `identity: false`." + ) + + pose_config = add_identity_head( + configs_dir, + pose_config, + num_individuals=len(individuals), + backbone_output_channels=pose_config["model"]["backbone_output_channels"], + ) + + # sort first-level keys to make it prettier + return dict(sorted(pose_config.items())) + + +def add_metadata(project_config: dict, config: dict, pose_config_path: str) -> dict: + """Adds metadata to a pytorch pose configuration + + Args: + project_config: the project configuration + config: the pytorch pose configuration + pose_config_path: the path where the pytorch pose configuration will be saved + + Returns: + the configuration with a `meta` key added + """ + config = copy.deepcopy(config) + config["pose_config_path"] = pose_config_path + config["project_path"] = project_config["project_path"] + return config + + +def create_backbone_with_heatmap_model( + configs_dir: Path, + net_type: str, + multianimal_project: bool, + bodyparts: list[str], + top_down: bool, +) -> dict: + """ + Creates a simple heatmap pose estimation model, composed of a backbone and a head + predicting heatmaps and location refinement maps + + Args: + configs_dir: path to the DeepLabCut "configs" directory + net_type: the type of backbone to create the model with (e.g., resnet_50) + multianimal_project: whether this model is created for a multi-animal project + bodyparts: the bodyparts to detect + top_down: whether the model will be associated to a detector to form a top-down + pose estimation model + + Returns: + the backbone + heatmap model configuration + + Raises: + ValueError: if the model is being created for a multi-animal project but the + head won't be associated with a detector (heatmaps can only predict + bodyparts for a single individual). + """ + if multianimal_project and not top_down: + raise ValueError( + "A pose model formed of a backbone and simple heatmap + location refinement" + " head can only be used for single animal projects. As you're working with" + " a multi-animal project, please select a multi-individual model instead of" + f" {net_type} or use a detector to create a top-down model (create your" + f" configuration with `make_pytorch_pose_config(..., top_down=True)`)." + ) + + # add the backbone to the config + model_config = read_config_as_dict(configs_dir / "backbones" / f"{net_type}.yaml") + backbone_output_channels = model_config["model"]["backbone_output_channels"] + + model_config["method"] = "bu" + if top_down: + model_config["method"] = "td" + + # add a bodypart head + bodypart_head_config = read_config_as_dict(configs_dir / "base" / f"head_bodyparts.yaml") + model_config["model"]["heads"] = { + "bodypart": replace_default_values( + bodypart_head_config, + num_bodyparts=len(bodyparts), + backbone_output_channels=backbone_output_channels, + ) + } + return model_config + + +def create_backbone_with_paf_model( + configs_dir: Path, + net_type: str, + num_individuals: int, + bodyparts: list[str], + paf_parameters: dict, +) -> dict: + """ + Creates a pose estimation model, composed of a backbone and a head predicting + heatmaps, location refinement maps and part affinity fields for multi-animal pose + estimation. + + Args: + configs_dir: path to the DeepLabCut "configs" directory + net_type: the type of backbone to create the model with (e.g., resnet_50) + num_individuals: the maximum number of individuals in a frame + bodyparts: the bodyparts to detect + paf_parameters: the parameters for the PAF + + Returns: + the backbone + heatmap, location refinement, PAF model configuration + """ + # add the backbone to the config + model_config = read_config_as_dict(configs_dir / "backbones" / f"{net_type}.yaml") + backbone_output_channels = model_config["model"]["backbone_output_channels"] + + # add a bodypart head + bodypart_head_config = read_config_as_dict( + configs_dir / "base" / f"head_bodyparts_with_paf.yaml" + ) + model_config["model"]["heads"] = { + "bodypart": replace_default_values( + bodypart_head_config, + num_bodyparts=len(bodyparts), + num_individuals=num_individuals, + backbone_output_channels=backbone_output_channels, + **paf_parameters, + ) + } + return model_config + + +def add_detector(configs_dir: Path, config: dict, num_individuals: int) -> dict: + """Adds a detector to a model + + Args: + configs_dir: path to the DeepLabCut "configs" directory + config: model configuration to update + num_individuals: the maximum number of individuals the model should detect + + Returns: + the model configuration with an added detector config + """ + config = copy.deepcopy(config) + detector_config = read_config_as_dict(configs_dir / "base" / "detector.yaml") + detector_config = replace_default_values( + detector_config, + num_individuals=num_individuals, + ) + config = update_config(config, detector_config) + return config + + +def add_unique_bodypart_head( + configs_dir: Path, + config: dict, + num_unique_bodyparts: int, + backbone_output_channels: int, +) -> dict: + """Adds a unique bodypart head to a model + + Args: + configs_dir: path to the DeepLabCut "configs" directory + config: model configuration to update + num_unique_bodyparts: the number of unique bodyparts to detect + backbone_output_channels: the number of channels output by the model backbone + + Returns: + the configuration with an added unique bodypart head + """ + config = copy.deepcopy(config) + unique_bodypart_head_config = read_config_as_dict( + configs_dir / "base" / "head_bodyparts.yaml" + ) + config["model"]["heads"]["unique_bodypart"] = replace_default_values( + unique_bodypart_head_config, + num_bodyparts=num_unique_bodyparts, + backbone_output_channels=backbone_output_channels, + ) + return config + + +def add_identity_head( + configs_dir: Path, + config: dict, + num_individuals: int, + backbone_output_channels: int, +) -> dict: + """Adds an identity head to a model + + Args: + configs_dir: path to the DeepLabCut "configs" directory + config: model configuration to update + num_individuals: the number of individuals to re-identify + backbone_output_channels: the number of channels output by the model backbone + + Returns: + the configuration with an added identity head + """ + config = copy.deepcopy(config) + id_head_config = read_config_as_dict( + configs_dir / "base" / "head_identity.yaml" + ) + config["model"]["heads"]["identity"] = replace_default_values( + id_head_config, + num_individuals=num_individuals, + backbone_output_channels=backbone_output_channels, + ) + return config + + +def _get_paf_parameters(project_config: dict, bodyparts: list[str]) -> dict: + """Gets values for PAF parameters from the project configuration""" + paf_graph = [ + [i, j] + for i in range(len(bodyparts)) + for j in range(i + 1, len(bodyparts)) + ] + num_limbs = len(paf_graph) + return { + "paf_graph": paf_graph, + "num_limbs": num_limbs, + "paf_edges_to_keep": project_config.get( + "paf_best", list(range(num_limbs)) + ), + } diff --git a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml b/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml new file mode 100644 index 0000000000..55ddd4c553 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml @@ -0,0 +1,48 @@ +# TODO: MATCH https://github.com/leeyegy/TokenPose/blob/main/experiments/coco/tokenpose/tokenpose_b_256_192_patch43_dim192_depth12_heads8.yaml +# See https://arxiv.org/pdf/2104.03516.pdf +data: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 +method: TD # Need to add a detector +model: + backbone: + type: HRNet + model_name: hrnet_w32 + pretrained: true + only_high_res: true # only uses high-resolution output + backbone_output_channels: 480 + neck: + type: Transformer + feature_size: + - 64 + - 64 + patch_size: + - 4 + - 4 + num_keypoints: "num_bodyparts" + channels: 32 + dim: 192 + heads: 8 + depth: 6 + heads: + bodypart: + type: TransformerHead + target_generator: + type: PlateauGenerator + generate_locref: false + num_joints: "num_bodyparts" + pos_dist_thresh: 17 + criterion: + type: WeightedBCECriterion + predictor: + type: HeatmapPredictor + location_refinement: false + dim: 192 + hidden_heatmap_dim: 384 + heatmap_dim: 4096 + apply_multi: true + heatmap_size: + - 64 + - 64 + apply_init: true diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py new file mode 100644 index 0000000000..e126a9ca53 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -0,0 +1,170 @@ +"""Util functions to create pytorch pose configuration files""" +from __future__ import annotations + +import copy +from pathlib import Path +from ruamel.yaml import YAML + +from deeplabcut.utils import auxiliaryfunctions + + +def replace_default_values( + config: dict | list, + num_bodyparts: int | None = None, + num_individuals: int | None = None, + backbone_output_channels: int | None = None, + **kwargs, +) -> dict: + """Replaces placeholder values in a model configuration with their actual values. + + This method allows to create template PyTorch configurations for models with values + such as "num_bodyparts", which are replaced with the number of bodyparts for a + project when making its Pytorch configuration. + + This code can also do some basic arithmetic. You can write "num_bodyparts x 2" (or + any factor other than 2) for location refinement channels, and the number of + channels will be twice the number of bodyparts. You can write "num_bodyparts + 1" + (such as for DEKR heatmaps, where a "center" bodypart is added). + + The three base placeholder values that can be computed are "num_bodyparts", + "num_individuals" and "backbone_output_channels". You can add more through the + keyword arguments (such as "paf_graph": list[tuple[int, int]] or + "paf_edges_to_keep": list[int] for DLCRNet models). + + Args: + config: the configuration in which to replace default values + num_bodyparts: the number of bodyparts + num_individuals: the number of individuals + backbone_output_channels: the number of backbone output channels + kwargs: other placeholder values to fill in + + Returns: + the configuration with placeholder values replaced + + Raises: + ValueError: if there is a placeholder value who's "updated" value was not + given to the method + """ + def get_updated_value(variable: str) -> int | list[int]: + var_parts = variable.strip().split(" ") + var_name = var_parts[0] + if updated_values[var_name] is None: + raise ValueError( + f"Found {variable} in the configuration file, but there is no default " + f"value for this variable." + ) + + if len(var_parts) == 1: + return updated_values[var_name] + elif len(var_parts) == 3: + operator, factor = var_parts[1], var_parts[2] + if not factor.isdigit(): + raise ValueError(f"F must be an integer in variable: {variable}") + + factor = int(factor) + if operator == "+": + return updated_values[var_name] + factor + elif operator == "x": + return updated_values[var_name] * factor + else: + raise ValueError(f"Unknown operator for variable: {variable}") + + raise ValueError( + f"Found {variable} in the configuration file, but cannot parse it." + ) + + updated_values = { + "num_bodyparts": num_bodyparts, + "num_individuals": num_individuals, + "backbone_output_channels": backbone_output_channels, + **kwargs, + } + + config = copy.deepcopy(config) + if isinstance(config, dict): + keys_to_update = list(config.keys()) + elif isinstance(config, list): + keys_to_update = range(len(config)) + else: + raise ValueError(f"Config to update must be dict or list, found {type(config)}") + + for k in keys_to_update: + if isinstance(config[k], (list, dict)): + config[k] = replace_default_values( + config[k], + num_bodyparts, + num_individuals, + backbone_output_channels, + **kwargs, + ) + elif ( + isinstance(config[k], str) + and config[k].strip().split(" ")[0] in updated_values.keys() + ): + config[k] = get_updated_value(config[k]) + + return config + + +def update_config(config: dict, updates: dict, copy_original: bool = True) -> dict: + """Updates items in the configuration file + + The configuration dict should only be composed of primitive Python types + (dict, list and values). This is the case when reading the file using + `read_config_as_dict`. + + Args: + config: the configuration dict to update + updates: the updates to make to the configuration dict + copy_original: whether to copy the original dict before updating it + + Returns: + the updated dictionary + """ + if copy_original: + config = copy.deepcopy(config) + + for k, v in updates.items(): + if k in config and isinstance(config[k], dict) and isinstance(v, dict): + config[k] = update_config(config[k], v, copy_original=False) + else: + config[k] = copy.deepcopy(v) + return config + + +def load_config_dir_and_base_config() -> tuple[Path, dict]: + """ + Returns: + the Path to the folder containing the "configs" for PyTorch DeepLabCut + the base configuration for all PyTorch DeepLabCut models + """ + dlc_parent_path = Path(auxiliaryfunctions.get_deeplabcut_path()) + configs_dir = dlc_parent_path / "pose_estimation_pytorch" / "config" + base_dir = configs_dir / "base" + base_config = read_config_as_dict(base_dir / "base.yaml") + return configs_dir, base_config + + +def load_backbones(configs_dir: Path) -> list[str]: + """ + Args: + configs_dir: the Path to the folder containing the "configs" for PyTorch + DeepLabCut + + Returns: + all backbones with default configurations that can be used + """ + backbone_dir = configs_dir / "backbones" + backbones = [p.stem for p in backbone_dir.iterdir() if p.suffix == ".yaml"] + return backbones + + +def read_config_as_dict(config_path: Path) -> dict: + """ + Args: + config_path: the path to the configuration file to load + + Returns: + The configuration file with pure Python classes + """ + return YAML(typ='safe', pure=True).load(config_path) diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index fd024ef996..56aed73fe6 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -15,7 +15,6 @@ import albumentations as A import numpy as np -from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.data.dataset import ( PoseDataset, PoseDatasetParameters, @@ -25,7 +24,7 @@ map_id_to_annotations, ) from deeplabcut.pose_estimation_pytorch.runners import Task -from deeplabcut.utils.auxiliaryfunctions import get_bodyparts, get_unique_bodyparts +from deeplabcut.utils import auxiliaryfunctions class Loader(ABC): @@ -48,10 +47,10 @@ def __init__(self, project_root: str, model_config_path: str) -> None: self.project_root = project_root self.model_config_path = model_config_path self.model_cfg = auxiliaryfunctions.read_plainconfig(model_config_path) - self._loaded_data: dict[str, dict[str, dict]] = {} + self._loaded_data: dict[str, dict[str, list[dict]]] = {} @abstractmethod - def load_data(self, mode: str = "train") -> dict[str, dict]: + def load_data(self, mode: str = "train") -> dict[str, list[dict]]: """Abstract method to convert the project configuration to a standard coco format. Raises: @@ -107,7 +106,7 @@ def ground_truth_keypoints( self._loaded_data[mode] = self.load_data(mode) data = self._loaded_data[mode] - annotations = self.filter_annotations(data["annotations"]) + annotations = self.filter_annotations(data["annotations"], task=Task.BOTTOM_UP) img_to_ann_map = map_id_to_annotations(annotations) ground_truth_dict = {} @@ -145,7 +144,7 @@ def ground_truth_bboxes(self, mode: str = "train") -> dict[str, np.ndarray]: self._loaded_data[mode] = self.load_data(mode) data = self._loaded_data[mode] - annotations = self.filter_annotations(data["annotations"]) + annotations = self.filter_annotations(data["annotations"], task=Task.DETECT) img_to_ann_map = map_id_to_annotations(annotations) ground_truth_dict = {} @@ -181,7 +180,7 @@ def create_dataset( """ parameters = self.get_dataset_parameters() data = self.load_data(mode) - data["annotations"] = self.filter_annotations(data["annotations"]) + data["annotations"] = self.filter_annotations(data["annotations"], task) dataset = PoseDataset( images=data["images"], annotations=data["annotations"], @@ -203,21 +202,30 @@ def get_dataset_parameters(self) -> PoseDatasetParameters: raise NotImplementedError @staticmethod - def filter_annotations(annotations: list[dict]) -> list[dict]: - """Filters annotations based on the keypoints, removing empty annotations + def filter_annotations(annotations: list[dict], task: Task) -> list[dict]: + """Filters annotations based on the task, removing empty annotations + + For pose estimation tasks, annotations with empty keypoints are removed. For + detection task, annotations with no bounding boxes are removed Args: - annotations: A list of annotations. + annotations: the annotations to filter + task: the task for which to filter Returns: - list: A list of filtered annotations. + list: the filtered annotations """ filtered_annotations = [] for annotation in annotations: keypoints = annotation["keypoints"].reshape(-1, 3) - annotation["bbox"].reshape(-1, 4) - if np.all(keypoints[:, :2] <= 0): + if ( + task == Task.DETECT and + (annotation["bbox"][2] <= 0 or annotation["bbox"][3] <= 0) + ): + continue + elif task != Task.DETECT and np.all(keypoints[:, :2] <= 0): continue + filtered_annotations.append(annotation) return filtered_annotations diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 90e1caa5b4..883c86fb50 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -173,5 +173,5 @@ def from_cfg(cfg: dict) -> "PoseModel": heads[name] = HEADS.build(head_cfg) return PoseModel( - cfg=cfg, backbone=backbone, neck=neck, heads=heads, **cfg["pose_model"] + cfg=cfg, backbone=backbone, neck=neck, heads=heads, **cfg.get("pose_model", {}) ) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index 66f0a0b90a..4318d086f5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -22,7 +22,7 @@ PartAffinityFieldPredictor, ) from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import ( - SinglePredictor, + HeatmapPredictor, ) from deeplabcut.pose_estimation_pytorch.models.predictors.top_down_prediction import ( TopDownPredictor, diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index 9c0684495b..389621962e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -32,7 +32,7 @@ class PartAffinityFieldPredictor(BasePredictor): Args: num_animals: Number of animals in the project. num_multibodyparts: Number of animal's body parts (ignoring unique body parts). - num_uniquebodyparts: Number of unique body parts. + num_uniquebodyparts: Number of unique body parts. # FIXME - should not be needed here if we separate the unique bodypart head graph: Part affinity field graph edges. edges_to_keep: List of indices in `graph` of the edges to keep. locref_stdev: Standard deviation for location refinement. diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index 49b03d7693..295dfd7b74 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -8,6 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations from typing import Tuple @@ -20,57 +21,35 @@ @PREDICTORS.register_module -class SinglePredictor(BasePredictor): - """Predictor class for single animal pose estimation. - - TODO: Refactor to include HeatmapOnlyPredictor +class HeatmapPredictor(BasePredictor): + """Predictor class for pose estimation from heatmaps (and optionally locrefs). Args: - num_animals: Number of animals in the project. location_refinement: Enable location refinement. - locref_stdev: Standard deviation for location refinement. + locref_std: Standard deviation for location refinement. apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. Returns: Regressed keypoints from heatmaps and locref_maps of baseline DLC model (ResNet + Deconv). """ - default_init = { - "location_refinement": True, - "locref_stdev": 7.2801, - "apply_sigmoid": True, - } - def __init__( self, - num_animals: int, - location_refinement: bool, - locref_stdev: float, apply_sigmoid: bool = True, + location_refinement: bool = True, + locref_std: float = 7.2801, ): """ Args: - num_animals: Number of animals in the project. - location_refinement : Enable location refinement. - locref_stdev: Standard deviation for location refinement. apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. - - Returns: - None - - Notes: - TODO: add num_animals in pytorch_cfg automatically + location_refinement : Enable location refinement. + locref_std: Standard deviation for location refinement. """ super().__init__() - self.num_animals = num_animals - assert ( - self.num_animals == 1, - "SinglePredictor must only be used for single animal predictions", - ) - self.location_refinement = location_refinement - self.locref_stdev = locref_stdev self.apply_sigmoid = apply_sigmoid self.sigmoid = torch.nn.Sigmoid() + self.location_refinement = location_refinement + self.locref_std = locref_std def forward( self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] @@ -85,27 +64,32 @@ def forward( A dictionary containing a "poses" key with the output tensor as value. Example: - >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) - >>> output = (torch.rand(32, 17, 64, 64), torch.rand(32, 17, 64, 64)) - >>> scale_factors = (0.5, 0.5) - >>> poses = predictor.forward(output, scale_factors) + >>> predictor = HeatmapPredictor(location_refinement=True, locref_std=7.2801) + >>> inputs = torch.rand((1, 3, 256, 256)) + >>> output = {"heatmap": torch.rand(32, 17, 64, 64), "locref": torch.rand(32, 17, 64, 64)} + >>> poses = predictor.forward(inputs, output) """ - heatmaps, locrefs = outputs["heatmap"], outputs["locref"] + heatmaps = outputs["heatmap"] h_in, w_in = inputs.shape[2:] h_out, w_out = heatmaps.shape[2:] scale_factors = h_in / h_out, w_in / w_out if self.apply_sigmoid: heatmaps = self.sigmoid(heatmaps) + heatmaps = heatmaps.permute(0, 2, 3, 1) batch_size, height, width, num_joints = heatmaps.shape - locrefs = locrefs.permute(0, 2, 3, 1).reshape( - batch_size, height, width, num_joints, 2 - ) + locrefs = None + if self.location_refinement: + locrefs = outputs["locref"] + locrefs = locrefs.permute(0, 2, 3, 1).reshape( + batch_size, height, width, num_joints, 2 + ) + locrefs = locrefs * self.locref_std poses = self.get_pose_prediction( - heatmaps, locrefs * self.locref_stdev, scale_factors + heatmaps, locrefs, scale_factors ) return {"poses": poses} @@ -121,20 +105,20 @@ def get_top_values( Y and X indices of the top values. Example: - >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) + >>> predictor = HeatmapPredictor(location_refinement=True, locref_std=7.2801) >>> heatmap = torch.rand(32, 17, 64, 64) >>> Y, X = predictor.get_top_values(heatmap) """ batchsize, ny, nx, num_joints = heatmap.shape heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints) - heatmap_top = torch.argmax(heatmap_flat, axis=1) + heatmap_top = torch.argmax(heatmap_flat, dim=1) Y, X = heatmap_top // nx, heatmap_top % nx return Y, X def get_pose_prediction( - self, heatmap: torch.Tensor, locref: torch.Tensor, scale_factors + self, heatmap: torch.Tensor, locref: torch.Tensor | None, scale_factors ) -> torch.Tensor: """Gets the pose prediction given the heatmaps and locref. @@ -147,7 +131,7 @@ def get_pose_prediction( Pose predictions of the format: (batch_size, num_people = 1, num_joints, 3) Example: - >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) + >>> predictor = HeatmapPredictor(location_refinement=True, locref_std=7.2801) >>> heatmap = torch.rand(32, 17, 64, 64) >>> locref = torch.rand(32, 17, 64, 64, 2) >>> scale_factors = (0.5, 0.5) @@ -156,148 +140,12 @@ def get_pose_prediction( Y, X = self.get_top_values(heatmap) batch_size, num_joints = X.shape - DZ = torch.zeros((batch_size, 1, num_joints, 3)).to(X.device) - for b in range(batch_size): - for j in range(num_joints): - DZ[b, 0, j, :2] = locref[b, Y[b, j], X[b, j], j, :] - DZ[b, 0, j, 2] = heatmap[b, Y[b, j], X[b, j], j] - - X, Y = torch.unsqueeze(X, 1), torch.unsqueeze(Y, 1) - - X = X * scale_factors[1] + 0.5 * scale_factors[1] + DZ[:, :, :, 0] - Y = Y * scale_factors[0] + 0.5 * scale_factors[0] + DZ[:, :, :, 1] - # P = DZ[:, :, 2] - - pose = torch.empty((batch_size, 1, num_joints, 3)) - pose[:, :, :, 0] = X - pose[:, :, :, 1] = Y - pose[:, :, :, 2] = DZ[:, :, :, 2] - - return pose - - -@PREDICTORS.register_module -class HeatmapOnlyPredictor(BasePredictor): - """Predictor only intended for single animal pose estimation, without locref. - - Args: - num_animals: Number of animals in the project. - apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. - - Returns: - Regressed keypoints from heatmaps. - """ - - default_init = { - "location_refinement": True, - "locref_stdev": 7.2801, - "apply_sigmoid": True, - } - - def __init__(self, num_animals: int, apply_sigmoid: bool = True): - """Initializes the HeatmapOnlyPredictor class. - - Args: - num_animals: Number of animals in the project. - apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. - - Returns: - None - - Notes: - TODO: add num_animals in pytorch_cfg automatically - """ - super().__init__() - self.num_animals = num_animals - assert ( - self.num_animals == 1, - "SinglePredictor must only be used for single animal predictions", - ) - self.apply_sigmoid = apply_sigmoid - self.sigmoid = torch.nn.Sigmoid() - - def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] - ) -> dict[str, torch.Tensor]: - """Forward pass of SinglePredictor. Gets predictions from model output. - - Args: - inputs: the input images given to the model, of shape (b, c, w, h) - outputs: output of the model heads (heatmap, locref) - - Returns: - A dictionary containing a "poses" key with the output tensor as value. - - Example: - >>> predictor = SinglePredictor(num_animals=1, location_refinement=True, locref_stdev=7.2801) - >>> output = (torch.rand(32, 17, 64, 64), torch.rand(32, 17, 64, 64)) - >>> scale_factors = (0.5, 0.5) - >>> poses = predictor.forward(output, scale_factors) - """ - heatmaps = outputs["heatmap"] - h_in, w_in = inputs.shape[2:] - h_out, w_out = heatmaps.shape[2:] - scale_factors = h_in / h_out, w_in / w_out - - if self.apply_sigmoid: - heatmaps = self.sigmoid(heatmaps) - heatmaps = heatmaps.permute(0, 2, 3, 1) - - poses = self.get_pose_prediction(heatmaps, scale_factors) - return {"poses": poses} - - def get_top_values( - self, heatmap: torch.Tensor - ) -> Tuple[torch.Tensor, torch.Tensor]: - """Get the top values from the heatmap. - - Args: - heatmap: Heatmap tensor. - - Returns: - Y and X indices of the top values. - - Example: - >>> predictor = HeatmapOnlyPredictor(num_animals=1, apply_sigmoid=True) - >>> heatmap = torch.rand(32, 17, 64, 64) - >>> Y, X = predictor.get_top_values(heatmap) - """ - batchsize, ny, nx, num_joints = heatmap.shape - heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints) - - heatmap_top = torch.argmax(heatmap_flat, axis=1) - - Y, X = heatmap_top // nx, heatmap_top % nx - return Y, X - - def get_pose_prediction( - self, heatmap: torch.Tensor, scale_factors: Tuple[float, float] - ) -> torch.Tensor: - """Get the pose prediction from heatmaps. - - Args: - heatmap: Heatmap tensor with shape (batch_size, height, width, num_joints) - scale_factors: Scale factors for the poses. - - Returns: - Pose predictions following the format: (batch_size, num_people = 1, num_joints, 3) - - Notes: - TODO: optimize that so DZ looks right - - Example: - >>> predictor = HeatmapOnlyPredictor(num_animals=1, apply_sigmoid=True) - >>> heatmap = torch.rand(32, 17, 64, 64) - >>> scale_factors = (0.5, 0.5) - >>> poses = predictor.get_pose_prediction(heatmap, scale_factors) - """ - Y, X = self.get_top_values(heatmap) - batch_size, num_joints = X.shape - DZ = torch.zeros((batch_size, 1, num_joints, 3)).to(X.device) for b in range(batch_size): for j in range(num_joints): DZ[b, 0, j, 2] = heatmap[b, Y[b, j], X[b, j], j] + if locref is not None: + DZ[b, 0, j, :2] = locref[b, Y[b, j], X[b, j], j, :] X, Y = torch.unsqueeze(X, 1), torch.unsqueeze(Y, 1) diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index 58c9f624d6..27f4b75c15 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -21,7 +21,6 @@ import torch import torch.nn as nn from torch.utils.data import DataLoader -from tqdm import tqdm from deeplabcut.pose_estimation_pytorch.data.postprocessor import Postprocessor from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py index e49184f35c..af31c98db0 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py +++ b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py @@ -1,11 +1,11 @@ from itertools import product import pytest +import torch from torchvision.transforms import Resize as TorchResize -from deeplabcut.generate_training_dataset.make_pytorch_config import * +from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config from deeplabcut.pose_estimation_pytorch.apis import inference, utils -from deeplabcut.pose_estimation_pytorch.default_config import * from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector, DETECTORS from deeplabcut.pose_estimation_pytorch.models.predictors import ( BasePredictor, @@ -43,8 +43,8 @@ def test_get_predictions_bottom_up( if cfg["multianimalproject"] else len(cfg["bodyparts"]) ) - pytorch_config = make_pytorch_config( - cfg, net_type, config_template=pytorch_cfg_template.copy() + pytorch_config = make_pytorch_pose_config( + project_config=cfg, pose_config_path="my/pytorch_config.yaml", net_type=net_type ) # Pretrained set to False to initialize model without using a snapshot pytorch_config["model"]["backbone"]["pretrained"] = False @@ -77,8 +77,8 @@ def test_get_predicitons_top_down( images = torch.rand((batch_size, 3, image_shape[1], image_shape[0])) * 100 # Create config and pytorch_config dicts cfg = write_config(multianimal) - pytorch_config = make_pytorch_config( - cfg, net_type, config_template=pytorch_cfg_template.copy() + pytorch_config = make_pytorch_pose_config( + project_config=cfg, pose_config_path="my/pytorch_config.yaml", net_type=net_type ) # Pretrained set to False to initialize model without using a snapshot pytorch_config["model"]["backbone"]["pretrained"] = False diff --git a/deeplabcut/utils/auxfun_models.py b/deeplabcut/utils/auxfun_models.py index 31cb6de3a1..3d7f83cb9d 100644 --- a/deeplabcut/utils/auxfun_models.py +++ b/deeplabcut/utils/auxfun_models.py @@ -46,10 +46,6 @@ def check_for_weights(modeltype, parent_path): """gets local path to network weights and checks if they are present. If not, downloads them from tensorflow.org""" - # TODO: Adapt code for all PyTorch models - if any([torch_fam in modeltype for torch_fam in ["dekr", "token_pose"]]): - return str(parent_path), num_shuffles - if modeltype not in MODELTYPE_FILEPATH_MAP.keys(): print( "Currently ResNet (50, 101, 152), MobilenetV2 (1, 0.75, 0.5 and 0.35) and EfficientNet (b0-b6) are supported, please change 'resnet' entry in config.yaml!" diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 6200558274..a3d5924dbd 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -645,7 +645,9 @@ def get_scorer_name( ) ) # ABBREVIATE NETWORK NAMES -- esp. for mobilenet! - if "resnet" in dlc_cfg["net_type"]: + if dlc_cfg.get("engine", "pytorch") == "pytorch": # TODO: default should be TF + netname = "".join([p.capitalize() for p in dlc_cfg["net_type"].split("_")]) + elif "resnet" in dlc_cfg["net_type"]: if dlc_cfg.get("multi_stage", False): netname = "dlcrnetms5" else: @@ -654,10 +656,8 @@ def get_scorer_name( netname = "mobnet_" + str(int(float(dlc_cfg["net_type"].split("_")[-1]) * 100)) elif "efficientnet" in dlc_cfg["net_type"]: netname = "effnet_" + dlc_cfg["net_type"].split("-")[1] - elif "dekr" in dlc_cfg["net_type"]: - netname = "dekr_" + dlc_cfg["net_type"].split("_")[-1] - elif "token_pose" in dlc_cfg["net_type"]: - netname = "token_pose" + dlc_cfg["net_type"].split("_")[-1] + else: + raise ValueError(f"Failed to abbreviate network name: {dlc_cfg['net_type']}") scorer = ( "DLC_" diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py new file mode 100644 index 0000000000..000759c346 --- /dev/null +++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py @@ -0,0 +1,319 @@ +"""Tests the pre-processors""" +import pytest + +from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config + + +@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]]) +@pytest.mark.parametrize( + "net_type", ["resnet_50", "resnet_101", "hrnet_w18", "hrnet_w32", "hrnet_w48"] +) +def test_make_single_animal_config(bodyparts: list[str], net_type: str): + # Single animal projects can't have unique bodyparts + project_config = _make_project_config( + project_path="my/little/project", + multianimal=False, + identity=False, + individuals=[], + bodyparts=bodyparts, + unique_bodyparts=[], + ) + pytorch_pose_config = make_pytorch_pose_config( + project_config, + "pytorch_config.yaml", + net_type=net_type, + ) + _print_pose_config(pytorch_pose_config) + + # check heads are there + assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() + # check that the bodypart head has locref and heatmaps and the correct output shapes + bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"] + for name, output_channels in [ + ("heatmap_config", len(bodyparts)), + ("locref_config", 2 * len(bodyparts)), + ]: + assert name in bodypart_head + assert bodypart_head[name]["channels"][-1] == output_channels + + +@pytest.mark.parametrize("multianimal", [True]) +@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]]) +@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]]) +@pytest.mark.parametrize("identity", [False, True]) +@pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]]) +@pytest.mark.parametrize( + "net_type", ["resnet_50", "resnet_101", "hrnet_w18", "hrnet_w32", "hrnet_w48"] +) +def test_backbone_plus_paf_config( + multianimal: bool, + individuals: list[str], + bodyparts: list[str], + identity: bool, + unique_bodyparts: list[str], + net_type: str, +): + # Single animal projects can't have unique bodyparts + project_config = _make_project_config( + project_path="my/little/project", + multianimal=multianimal, + identity=identity, + individuals=individuals, + bodyparts=bodyparts, + unique_bodyparts=unique_bodyparts, + ) + pytorch_pose_config = make_pytorch_pose_config( + project_config, + "pytorch_config.yaml", + net_type=net_type, + ) + _print_pose_config(pytorch_pose_config) + + graph = [ + [i, j] + for i in range(len(bodyparts)) + for j in range(i + 1, len(bodyparts)) + ] + num_limbs = len(graph) * 2 + + # check heads are there + assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() + bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"] + + # check PAF head + assert bodypart_head["type"] == "DLCRNetHead" + assert bodypart_head["predictor"]["type"] == "PartAffinityFieldPredictor" + + for name, output_channels in [ + ("heatmap_config", len(bodyparts)), + ("locref_config", len(bodyparts) * 2), + ("paf_config", num_limbs) + ]: + print(name, bodypart_head[name]["channels"]) + assert name in bodypart_head + assert bodypart_head[name]["channels"][-1] == output_channels + + if len(unique_bodyparts) > 0: + assert "unique_bodypart" in pytorch_pose_config["model"]["heads"].keys() + unique_bodypart_head = pytorch_pose_config["model"]["heads"]["unique_bodypart"] + for name, output_channels in [ + ("heatmap_config", len(unique_bodyparts)), + ("locref_config", 2 * len(unique_bodyparts)), + ]: + assert name in unique_bodypart_head + assert unique_bodypart_head[name]["channels"][-1] == output_channels + assert unique_bodypart_head["target_generator"]["heatmap_mode"] == "KEYPOINT" + + if identity: + assert "identity" in pytorch_pose_config["model"]["heads"].keys() + id_head = pytorch_pose_config["model"]["heads"]["identity"] + assert "heatmap_config" in id_head + assert id_head["heatmap_config"]["channels"][-1] == len(individuals) + assert "locref_config" not in id_head + assert id_head["target_generator"]["heatmap_mode"] == "INDIVIDUAL" + + +@pytest.mark.parametrize("multianimal", [True]) +@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]]) +@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]]) +@pytest.mark.parametrize("identity", [False, True]) +@pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]]) +@pytest.mark.parametrize("net_type", ["dekr_w18", "dekr_w32", "dekr_w48"]) +def test_make_dekr_config( + multianimal: bool, + individuals: list[str], + bodyparts: list[str], + identity: bool, + unique_bodyparts: list[str], + net_type: str, +): + project_config = _make_project_config( + project_path="my/little/project", + multianimal=multianimal, + identity=identity, + individuals=individuals, + bodyparts=bodyparts, + unique_bodyparts=unique_bodyparts + ) + pytorch_pose_config = make_pytorch_pose_config( + project_config, + "pytorch_config.yaml", + net_type=net_type, + ) + _print_pose_config(pytorch_pose_config) + + # check heads are there + assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() + bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"] + for name, output_channels in [ + ("heatmap_config", len(bodyparts) + 1), + ("offset_config", len(bodyparts)), + ]: + print(name, bodypart_head[name]["channels"]) + assert name in bodypart_head + assert bodypart_head[name]["channels"][-1] == output_channels + + if len(unique_bodyparts) > 0: + assert "unique_bodypart" in pytorch_pose_config["model"]["heads"].keys() + unique_bodypart_head = pytorch_pose_config["model"]["heads"]["unique_bodypart"] + for name, output_channels in [ + ("heatmap_config", len(unique_bodyparts)), + ("locref_config", 2 * len(unique_bodyparts)), + ]: + assert name in unique_bodypart_head + assert unique_bodypart_head[name]["channels"][-1] == output_channels + assert unique_bodypart_head["target_generator"]["heatmap_mode"] == "KEYPOINT" + + if identity: + assert "identity" in pytorch_pose_config["model"]["heads"].keys() + id_head = pytorch_pose_config["model"]["heads"]["identity"] + assert "heatmap_config" in id_head + assert id_head["heatmap_config"]["channels"][-1] == len(individuals) + assert "locref_config" not in id_head + assert id_head["target_generator"]["heatmap_mode"] == "INDIVIDUAL" + + +@pytest.mark.parametrize("multianimal", [True]) +@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]]) +@pytest.mark.parametrize("bodyparts", [["nose", "ears"], ["nose", "ear", "eye"]]) +@pytest.mark.parametrize("identity", [False, True]) +@pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]]) +@pytest.mark.parametrize("net_type", ["dlcrnet_stride16_ms5", "dlcrnet_stride32_ms5"]) +def test_make_dlcrnet_config( + multianimal: bool, + individuals: list[str], + bodyparts: list[str], + identity: bool, + unique_bodyparts: list[str], + net_type: str, +): + project_config = _make_project_config( + project_path="my/little/project", + multianimal=multianimal, + identity=identity, + individuals=individuals, + bodyparts=bodyparts, + unique_bodyparts=unique_bodyparts + ) + pytorch_pose_config = make_pytorch_pose_config( + project_config, + "pytorch_config.yaml", + net_type=net_type, + ) + _print_pose_config(pytorch_pose_config) + + paf_graph = [ + [i, j] + for i in range(len(bodyparts)) + for j in range(i + 1, len(bodyparts)) + ] + num_limbs = len(paf_graph) + + # check heads are there + assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() + bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"] + for name, output_channels in [ + ("heatmap_config", len(bodyparts)), + ("locref_config", 2 * len(bodyparts)), + ("paf_config", 2 * num_limbs), + ]: + print(name, bodypart_head[name]["channels"]) + assert name in bodypart_head + assert bodypart_head[name]["channels"][-1] == output_channels + + if len(unique_bodyparts) > 0: + assert "unique_bodypart" in pytorch_pose_config["model"]["heads"].keys() + unique_bodypart_head = pytorch_pose_config["model"]["heads"]["unique_bodypart"] + for name, output_channels in [ + ("heatmap_config", len(unique_bodyparts)), + ("locref_config", 2 * len(unique_bodyparts)), + ]: + assert name in unique_bodypart_head + assert unique_bodypart_head[name]["channels"][-1] == output_channels + assert unique_bodypart_head["target_generator"]["heatmap_mode"] == "KEYPOINT" + + if identity: + assert "identity" in pytorch_pose_config["model"]["heads"].keys() + id_head = pytorch_pose_config["model"]["heads"]["identity"] + assert "heatmap_config" in id_head + assert id_head["heatmap_config"]["channels"][-1] == len(individuals) + assert "locref_config" not in id_head + assert id_head["target_generator"]["heatmap_mode"] == "INDIVIDUAL" + + +@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]]) +@pytest.mark.parametrize("bodyparts", [["nose", "eyes"], ["nose", "ear", "eye"]]) +@pytest.mark.parametrize("identity", [False, True]) +@pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]]) +@pytest.mark.parametrize("net_type", ["tokenpose_base"]) +def test_make_tokenpose_config( + individuals: list[str], + bodyparts: list[str], + identity: bool, + unique_bodyparts: list[str], + net_type: str, +): + project_config = _make_project_config( + project_path="my/little/project", + multianimal=True, + identity=identity, + individuals=individuals, + bodyparts=bodyparts, + unique_bodyparts=unique_bodyparts + ) + + if identity or len(unique_bodyparts) > 0: + with pytest.raises(ValueError) as err_info: + # Not yet implemented! + pytorch_pose_config = make_pytorch_pose_config( + project_config, + "pytorch_config.yaml", + net_type=net_type, + ) + else: + pytorch_pose_config = make_pytorch_pose_config( + project_config, + "pytorch_config.yaml", + net_type=net_type, + ) + _print_pose_config(pytorch_pose_config) + # check detector is there + assert "detector" in pytorch_pose_config + assert "data_detector" in pytorch_pose_config + + +def _make_project_config( + project_path: str, + multianimal: bool, + identity: bool, + individuals: list[str], + bodyparts: list[str], + unique_bodyparts: list[str], +) -> dict: + project_config = { + "project_path": project_path, + "multianimalproject": multianimal, + "identity": identity, + "uniquebodyparts": unique_bodyparts, + } + + if multianimal: + project_config["multianimalbodyparts"] = bodyparts + project_config["bodyparts"] = "MULTI!" + project_config["individuals"] = individuals + else: + project_config["bodyparts"] = bodyparts + + return project_config + + +def _print_pose_config(pose_config: dict, indent: int = 0) -> None: + if indent == 0: + print() + print("Pose config") + for k, v in pose_config.items(): + if isinstance(v, dict): + print(f"{indent * ' '}{k}:") + _print_pose_config(v, indent + 2) + else: + print(f"{indent * ' '}{k}: {v}") diff --git a/tests/pose_estimation_pytorch/runners/bottum_up.py b/tests/pose_estimation_pytorch/runners/bottum_up.py index d27aca6dab..a7709f9552 100644 --- a/tests/pose_estimation_pytorch/runners/bottum_up.py +++ b/tests/pose_estimation_pytorch/runners/bottum_up.py @@ -4,8 +4,8 @@ import pytest import torch +from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config -from deeplabcut.generate_training_dataset import make_pytorch_config from deeplabcut.pose_estimation_pytorch.models import PoseModel, LOSSES, PREDICTORS from deeplabcut.pose_estimation_pytorch.models.criterion import WeightedAggregateLoss from deeplabcut.pose_estimation_pytorch.runners import RUNNERS @@ -45,7 +45,7 @@ def test_build_bottom_up_runner( root_path = Path(auxiliaryfunctions.get_deeplabcut_path()) template_path = root_path / "pose_estimation_pytorch" / "apis" / "pytorch_config.yaml" template = auxiliaryfunctions.read_plainconfig(str(template_path)) - pytorch_cfg = make_pytorch_config(project_cfg, net_type, config_template=template) + pytorch_cfg = make_pytorch_pose_config(project_cfg, str(template_path), net_type) print_dict(pytorch_cfg) pose_model = PoseModel.from_cfg(pytorch_cfg["model"]) From fcd718213e167a3c4c37b8cc59926ddadc7ce5b4 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Mon, 11 Dec 2023 10:22:21 +0100 Subject: [PATCH 060/293] add MOT evaluation functions --- deeplabcut/benchmark/mot.py | 148 ++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 deeplabcut/benchmark/mot.py diff --git a/deeplabcut/benchmark/mot.py b/deeplabcut/benchmark/mot.py new file mode 100644 index 0000000000..095469bae3 --- /dev/null +++ b/deeplabcut/benchmark/mot.py @@ -0,0 +1,148 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +from __future__ import annotations + +import motmetrics as mm +import numpy as np +import pandas as pd +import warnings +from deeplabcut.pose_estimation_tensorflow.lib import trackingutils +from numpy.typing import NDArray + + +def _convert_bboxes_to_xywh(bboxes: NDArray, inplace: bool = False) -> NDArray: + w = bboxes[:, 2] - bboxes[:, 0] + h = bboxes[:, 3] - bboxes[:, 1] + if not inplace: + new_bboxes = bboxes.copy() + new_bboxes[:, 2] = w + new_bboxes[:, 3] = h + return new_bboxes + bboxes[:, 2] = w + bboxes[:, 3] = h + + +def reconstruct_bboxes_from_bodyparts( + data: pd.DataFrame, margin: float, to_xywh: bool = False +) -> NDArray: + x = data.xs("x", axis=1, level="coords") + y = data.xs("y", axis=1, level="coords") + p = data.xs("likelihood", axis=1, level="coords") + xy = np.stack([x, y], axis=2) + bboxes = np.full((data.shape[0], 5), np.nan) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + bboxes[:, :2] = np.nanmin(xy, axis=1) - margin + bboxes[:, 2:4] = np.nanmax(xy, axis=1) + margin + bboxes[:, 4] = np.nanmean(p, axis=1) + if to_xywh: + _convert_bboxes_to_xywh(bboxes, inplace=True) + return bboxes + + +def reconstruct_all_bboxes( + data: pd.DataFrame, margin: float, to_xywh: bool = False +) -> NDArray: + animals = data.columns.get_level_values("individuals").unique().tolist() + try: + animals.remove("single") + except ValueError: + pass + bboxes = np.full((len(animals), data.shape[0], 5), np.nan) + for n, animal in enumerate(animals): + bboxes[n] = reconstruct_bboxes_from_bodyparts( + data.xs(animal, axis=1, level="individuals"), margin, to_xywh + ) + return bboxes + + +def compute_mot_metrics( + h5_file_gt: str, + h5_file_pred: str, + tracker_type: str = "bbox", + **kwargs, +) -> mm.MOTAccumulator: + df_gt = pd.read_hdf(h5_file_gt) + df = pd.read_hdf(h5_file_pred) + if tracker_type == "bbox": + func = reconstruct_all_bboxes + elif tracker_type == "ellipse": + func = trackingutils.reconstruct_all_ellipses + else: + raise ValueError(f"Unrecognized tracker type {tracker_type}.") + + trackers_gt = func(df_gt, **kwargs) + trackers = func(df, **kwargs) + return _compute_mot_metrics( + trackers_gt, trackers, tracker_type, + ) + + +def _compute_mot_metrics( + trackers_ground_truth: NDArray, + trackers: NDArray, + tracker_type: str = "bbox", +) -> mm.MOTAccumulator: + if trackers_ground_truth.shape != trackers.shape: + raise ValueError( + "Dimensions mismatch. There must be as many `trackers_ground_truth` as there are `trackers`." + ) + + if tracker_type == "bbox": + sl = slice(0, 4) + cost_func = mm.distances.iou_matrix + elif tracker_type == "ellipse": + sl = slice(0, 5) + + def cost_func(ellipses_gt, ellipses_hyp): + cost_matrix = np.zeros((len(ellipses_gt), len(ellipses_hyp))) + gt_el = [trackingutils.Ellipse(*e[:5]) for e in ellipses_gt] + hyp_el = [trackingutils.Ellipse(*e[:5]) for e in ellipses_hyp] + for i, el in enumerate(gt_el): + for j, tracker in enumerate(hyp_el): + cost_matrix[i, j] = 1 - el.calc_similarity_with(tracker) + return cost_matrix + + else: + raise ValueError(f"Unrecognized tracker type {tracker_type}.") + + ids = np.arange(trackers_ground_truth.shape[0]) + acc = mm.MOTAccumulator(auto_id=True) + for i in range(trackers_ground_truth.shape[1]): + trackers_gt = trackers_ground_truth[:, i, sl] + trackers_hyp = trackers[:, i, sl] + empty_gt = np.isnan(trackers_gt).any(axis=1) + empty_hyp = np.isnan(trackers_hyp).any(axis=1) + trackers_gt = trackers_gt[~empty_gt] + trackers_hyp = trackers_hyp[~empty_hyp] + cost = cost_func(trackers_gt, trackers_hyp) + acc.update(ids[~empty_gt], ids[~empty_hyp], cost) + return acc + + +def print_all_metrics( + accumulators: list[mm.MOTAccumulator], all_params: list[str] | None = None +): + if not all_params: + names = [f"iter{i + 1}" for i in range(len(accumulators))] + else: + s = "_".join("{}" for _ in range(len(all_params[0]))) + names = [s.format(*params.values()) for params in all_params] + mh = mm.metrics.create() + summary = mh.compute_many( + accumulators, metrics=mm.metrics.motchallenge_metrics, names=names + ) + strsummary = mm.io.render_summary( + summary, formatters=mh.formatters, namemap=mm.io.motchallenge_metric_names + ) + print(strsummary) + return summary From 73496e10a7aee7f464d2aab948e6d5387ee21c22 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 14 Dec 2023 11:41:05 +0100 Subject: [PATCH 061/293] benchmarking code improvement + small bug fixes --- benchmark/benchmark_create_shuffle.py | 98 ++++ benchmark/benchmark_train.py | 242 ++++++++++ benchmark/coco/make_config.py | 10 +- benchmark/create_train_test_splits.py | 89 ++++ benchmark/madlc_evaluation.py | 162 +++++++ benchmark/madlc_test_inference.py | 209 +++++++++ benchmark/projects.py | 44 ++ benchmark/train_benchmark.py | 434 ------------------ benchmark/utils.py | 302 ++++++++++++ .../pose_estimation_pytorch/apis/config.yaml | 40 -- .../pose_estimation_pytorch/apis/train.py | 14 +- .../config/__init__.py | 5 + .../config/base/head_bodyparts_with_paf.yaml | 4 + .../config/dlcrnet/dlcrnet_stride32_ms5.yaml | 4 + .../config/make_pose_config.py | 3 +- .../pose_estimation_pytorch/config/utils.py | 22 +- .../data/cocoloader.py | 122 ++++- .../pose_estimation_pytorch/default_config.py | 69 --- .../pose_estimation_pytorch/runners/utils.py | 4 +- setup.py | 18 +- .../config/test_make_pose_config.py | 53 ++- 21 files changed, 1371 insertions(+), 577 deletions(-) create mode 100644 benchmark/benchmark_create_shuffle.py create mode 100644 benchmark/benchmark_train.py create mode 100644 benchmark/create_train_test_splits.py create mode 100644 benchmark/madlc_evaluation.py create mode 100644 benchmark/madlc_test_inference.py create mode 100644 benchmark/projects.py delete mode 100644 benchmark/train_benchmark.py create mode 100644 benchmark/utils.py delete mode 100644 deeplabcut/pose_estimation_pytorch/apis/config.yaml delete mode 100644 deeplabcut/pose_estimation_pytorch/default_config.py diff --git a/benchmark/benchmark_create_shuffle.py b/benchmark/benchmark_create_shuffle.py new file mode 100644 index 0000000000..bd75ff57dd --- /dev/null +++ b/benchmark/benchmark_create_shuffle.py @@ -0,0 +1,98 @@ +"""Training models on DLC benchmark datasets + +In a first step, shuffles can be created for your projects (pass an empty list and no +shuffles are created). + +Then you can train models using RunParameters. I usually create the shuffles first, +modify the PyTorch configuration files to add a logger and modify the data augmentation +for whatever I'm doing, and then start my training runs. A logger can be added with: +``` +logger: + type: 'WandbLogger' + project_name: 'dlc3-ff5f2af-fish' + run_name: 'dekr-w32-shuffle3' +``` + +Which specifies to log the run to wandb, (including the project and with which name each +shuffle should be logged). + +For single animal projects, benchmark splits were created using the +`create_train_test_splits.py` file. This script creates a JSON file for DLC projects +specifying train/test indices, which can then be passed in the ShuffleCreationParameters +to create new shuffles with the splits. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import deeplabcut + +from projects import MA_DLC_BENCHMARKS, MA_DLC_DATA_ROOT, SA_DLC_BENCHMARKS, SA_DLC_DATA_ROOT +from utils import Project, create_shuffles + + +@dataclass +class ShuffleCreationParameters: + """Parameters to create a shuffle + + Attributes: + project: the project for which to create shuffles + train_fraction: the training fraction to use to create the shuffles + net_types: the architectures to create + num_shuffles: the number of shuffles to create for each net type + splits_file: if you have specific train/test splits to use for your project, + they can be used by passing the path to the file containing the splits. + See the create_train_test_splits.py file for more information about this. + """ + project: Project + train_fraction: float + net_types: tuple[str, ...] | list[str] + num_shuffles: int = 1 + splits_file: Path | None = None + + def __post_init__(self): + self.trainset_index = self.project.cfg["TrainingFraction"].index(self.train_fraction) + + +def main(shuffles_to_create: list[ShuffleCreationParameters]) -> None: + """Creates new shuffles for DeepLabCut projects + + Args: + shuffles_to_create: the shuffles to create + """ + for m in shuffles_to_create: + m.project.update_iteration_in_config() + if m.splits_file is not None: + for net_type in m.net_types: + create_shuffles( + project=m.project, + splits_file=m.splits_file, + trainset_index=m.trainset_index, + net_type=net_type, + ) + else: + deeplabcut.create_training_model_comparison( + str(m.project.config_path()), + trainindex=m.trainset_index, + num_shuffles=m.num_shuffles, + net_types=list(m.net_types), + ) + + +if __name__ == "__main__": + main( + shuffles_to_create=[ + ShuffleCreationParameters( + project=MA_DLC_BENCHMARKS["fish"], + train_fraction=0.95, + net_types=("top_down_hrnet_w18", "dekr_w32", "dlcrnet_stride32_ms5"), + ), + ShuffleCreationParameters( + project=SA_DLC_BENCHMARKS["fly"], + train_fraction=0.8, + net_types=("resnet_50", "hrnet_w18", "hrnet_w32"), + splits_file=(SA_DLC_DATA_ROOT / "saDLC_benchmarking_splits.json"), + ), + ] + ) diff --git a/benchmark/benchmark_train.py b/benchmark/benchmark_train.py new file mode 100644 index 0000000000..a2caa53ced --- /dev/null +++ b/benchmark/benchmark_train.py @@ -0,0 +1,242 @@ +"""Training models on DLC benchmark datasets + +In a first step, shuffles can be created for your projects (pass an empty list and no +shuffles are created). + +Then you can train models using RunParameters. I usually create the shuffles first, +modify the PyTorch configuration files to add a logger and modify the data augmentation +for whatever I'm doing, and then start my training runs. A logger can be added with: +``` +logger: + type: 'WandbLogger' + project_name: 'dlc3-ff5f2af-fish' + run_name: 'dekr-w32-shuffle3' +``` + +Which specifies to log the run to wandb, (including the project and with which name each +shuffle should be logged). + +For single animal projects, benchmark splits were created using the +`create_train_test_splits.py` file. This script creates a JSON file for DLC projects +specifying train/test indices, which can then be passed in the ShuffleCreationParameters +to create new shuffles with the splits. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import wandb + +import deeplabcut +import deeplabcut.pose_estimation_pytorch.apis as api + +from projects import MA_DLC_BENCHMARKS, SA_DLC_BENCHMARKS +from utils import Shuffle + + +@dataclass +class TrainParameters: + """Parameters to train models""" + batch_size: int = 16 + display_iters: int = 500 + epochs: int | None = 100 + save_epochs: int | None = 25 + snapshot_path: Path | None = None + detector_max_epochs: int | None = None + detector_save_epochs: int | None = None + + def train_kwargs(self) -> dict: + kwargs = { + "batch_size": self.batch_size, + "display_iters": self.display_iters, + } + if self.epochs is not None: + kwargs["epochs"] = self.epochs + if self.save_epochs is not None: + kwargs["save_epochs"] = self.save_epochs + if self.snapshot_path is not None: + kwargs["snapshot_path"] = str(self.snapshot_path) + if self.detector_max_epochs is not None: + kwargs["detector_max_epochs"] = self.detector_max_epochs + if self.detector_save_epochs is not None: + kwargs["detector_save_epochs"] = self.detector_save_epochs + return kwargs + + +@dataclass +class EvalParameters: + """Parameters for evaluation""" + snapshotindex: int | list[int] | str | None = (None,) + plotting: str | bool = False + show_errors: bool = True + + def eval_kwargs(self) -> dict: + return { + "plotting": self.plotting, + "show_errors": self.show_errors, + } + + +@dataclass +class VideoAnalysisParameters: + """Parameters to run video analysis""" + videos: list[str] + videotype: str + output_folder: str = "" + + +@dataclass +class RunParameters: + """Parameters on what to run for each shuffle""" + shuffle: Shuffle + train: bool = False + evaluate: bool = False + analyze_videos: bool = False + track: bool = False + create_labeled_video: bool = False + device: str = "cuda:0" + train_params: TrainParameters | None = None + eval_params: EvalParameters | None = None + video_analysis_params: VideoAnalysisParameters | None = None + + def __post_init__(self): + if ( + (self.analyze_videos is None or self.track or self.create_labeled_video) + and self.video_analysis_params is None + ): + raise ValueError(f"Must specify video_analysis_params") + + +def run_dlc(parameters: RunParameters) -> None: + """Runs DeepLabCut 3.0 API methods + + Args: + parameters: the parameters specifying what to run, and which parameters to use + """ + if parameters.train: + api.train_network( + str(parameters.shuffle.project.config_path()), + shuffle=parameters.shuffle.index, + trainingsetindex=parameters.shuffle.trainset_index, + transform=None, + modelprefix=parameters.shuffle.model_prefix, + device=parameters.device, + **parameters.train_params.train_kwargs(), + ) + + if parameters.evaluate: + snapshot_indices = parameters.eval_params.snapshotindex + if isinstance(snapshot_indices, int) or isinstance(snapshot_indices, str): + snapshot_indices = [snapshot_indices] + + for idx in snapshot_indices: + api.evaluate_network( + config=str(parameters.shuffle.project.config_path()), + shuffles=[parameters.shuffle.index], + trainingsetindex=parameters.shuffle.trainset_index, + snapshotindex=idx, + device=parameters.device, + transform=None, + modelprefix=parameters.shuffle.model_prefix, + **parameters.eval_params.eval_kwargs(), + ) + + if parameters.analyze_videos: + destfolder = parameters.shuffle.project.path / parameters.video_analysis_params.output_folder + api.analyze_videos( + config=str(parameters.shuffle.project.config_path()), + videos=parameters.video_analysis_params.videos, + videotype=parameters.video_analysis_params.videotype, + trainingsetindex=parameters.shuffle.trainset_index, + destfolder=str(destfolder), + snapshotindex=5, + device=parameters.device, + modelprefix=parameters.shuffle.model_prefix, + batchsize=parameters.train_params.batch_size, + transform=None, + overwrite=False, + auto_track=False, + ) + + if parameters.track: + destfolder = parameters.shuffle.project.path / parameters.video_analysis_params.output_folder + api.convert_detections2tracklets( + config=str(parameters.shuffle.project.config_path()), + videos=parameters.video_analysis_params.videos, + videotype=".mp4", + modelprefix=parameters.shuffle.model_prefix, + destfolder=str(destfolder), + track_method="box", + ) + deeplabcut.stitch_tracklets( + str(parameters.shuffle.project.config_path()), + videos=parameters.video_analysis_params.videos, + shuffle=1, + trainingsetindex=parameters.shuffle.trainset_index, + destfolder=str(destfolder), + modelprefix=parameters.shuffle.model_prefix, + save_as_csv=True, + track_method="box", + ) + + if parameters.create_labeled_video: + destfolder = parameters.shuffle.project.path / parameters.video_analysis_params.output_folder + deeplabcut.create_labeled_video( + config=str(parameters.shuffle.project.config_path()), + videos=parameters.video_analysis_params.videos, + videotype="mp4", + trainingsetindex=parameters.shuffle.trainset_index, + color_by="individual", # bodypart, individual + modelprefix=parameters.shuffle.model_prefix, + destfolder=str(destfolder), + track_method="box", + ) + return + + +def main(runs: list[RunParameters]) -> None: + """Runs benchmarking scripts for DeepLabCut + + Args: + runs: + """ + for run in runs: + run.shuffle.project.update_iteration_in_config() + + if wandb.run is not None: # TODO: Finish wandb run in DLC + wandb.finish() + + print(f"Running {run.shuffle}") + try: + run_dlc(run) + except Exception as err: + print(f"Failed to run {run}: {err}") + raise err + + +if __name__ == "__main__": + main( + runs=[ + RunParameters( + shuffle=Shuffle( + project=SA_DLC_BENCHMARKS["fly"], + train_fraction=0.8, + index=1, + model_prefix="", + ), + train=True, + evaluate=True, + analyze_videos=False, + track=False, + create_labeled_video=False, + device="cuda:0", + train_params=TrainParameters( + batch_size=8, epochs=125, save_epochs=25, + ), + eval_params=EvalParameters( + snapshotindex="all", plotting=False + ) + ), + ] + ) diff --git a/benchmark/coco/make_config.py b/benchmark/coco/make_config.py index dcd8abb042..6314d18753 100644 --- a/benchmark/coco/make_config.py +++ b/benchmark/coco/make_config.py @@ -10,9 +10,8 @@ import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.generate_training_dataset import MakeInference_yaml -from deeplabcut.pose_estimation_pytorch import COCOLoader from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config - +from deeplabcut.pose_estimation_pytorch.data import COCOLoader def get_base_config( project_path: str, @@ -30,10 +29,17 @@ def get_base_config( "uniquebodyparts": unique_bodyparts, "individuals": individuals, } + + top_down = False + if model_architecture.startswith("top_down_"): + top_down = True + model_architecture = model_architecture[len("top_down_"):] + return make_pytorch_pose_config( project_config=cfg, pose_config_path=pose_config_path, net_type=model_architecture, + top_down=top_down, ) diff --git a/benchmark/create_train_test_splits.py b/benchmark/create_train_test_splits.py new file mode 100644 index 0000000000..96f3f3264c --- /dev/null +++ b/benchmark/create_train_test_splits.py @@ -0,0 +1,89 @@ +"""Creates train/test splits for DeepLabCut Single Animal benchmarks""" +from __future__ import annotations + +import json +from pathlib import Path + +import torch +import deeplabcut +import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions +import numpy as np + +from projects import SA_DLC_BENCHMARKS +from utils import Project + + +def create_splits( + seed: int, + num_samples: int, + train_fractions: list[float], + num_splits: int, +) -> dict[float, list[dict[str, list[int]]]]: + splits = {} + gen = np.random.default_rng(seed=seed) + for train_frac in train_fractions: + splits[train_frac] = [] + print(f"Percentage of samples used for training: {train_frac}") + for i in range(num_splits): + num_train_indices = int(np.floor(train_frac * num_samples)) + samples = gen.choice(num_samples, size=num_train_indices, replace=False) + train_indices = np.sort(samples).tolist() + test_indices = [i for i in range(num_samples) if i not in train_indices] + splits[train_frac].append({ + "train": train_indices, + "test": test_indices, + }) + print(f" Split {i}:") + print(f" train: {train_indices}") + print(f" test: {test_indices}") + return splits + + +def main( + projects: list[Project], + seeds: list[int], + num_splits: int, + train_fractions: list[float], + output_file: Path, +) -> None: + output_file = output_file.resolve() + splits_data = {} + for project, seed in zip(projects, seeds): + save_dir = output_file.parent / f"data-splits-{project.name}" + save_dir.mkdir(exist_ok=True) + + cfg = auxiliaryfunctions.read_config(str(project.config_path())) + + # saves .h5 and .csv files containing the full dataframe used + df = deeplabcut.generate_training_dataset.merge_annotateddatasets(cfg, save_dir) + num_samples = len(df) + + splits_data[project.name] = create_splits( + seed=seed, + num_samples=num_samples, + train_fractions=train_fractions, + num_splits=num_splits, + ) + + for k, v in splits_data.items(): + print(f"Dataset: {k}") + for fraction, splits in v.items(): + print(f" Percentage of samples used for training: {fraction}") + for i, s in enumerate(splits): + print(f" Split {i}:") + print(f" train ({len(s['train'])}): {s['train']}") + print(f" test ({len(s['test'])}): {s['test']}") + print() + + with open(output_file, "w") as f: + json.dump(splits_data, f, indent=2) + + +if __name__ == "__main__": + main( + projects=[SA_DLC_BENCHMARKS["fly"], SA_DLC_BENCHMARKS["openfield"]], + seeds=[0, 1], + num_splits=3, + train_fractions=[0.8, 0.95], + output_file=Path("saDLC_benchmarking_splits.json"), + ) diff --git a/benchmark/madlc_evaluation.py b/benchmark/madlc_evaluation.py new file mode 100644 index 0000000000..434a1ecde5 --- /dev/null +++ b/benchmark/madlc_evaluation.py @@ -0,0 +1,162 @@ +from collections.abc import Iterable +from pathlib import Path + +import numpy as np +import pandas as pd + +from deeplabcut.benchmark.benchmarks import ( + FishBenchmark, + MarmosetBenchmark, + ParentingMouseBenchmark, + TriMouseBenchmark, +) + + +def check_bodyparts(gt_bodyparts: set[str], predicted_bodyparts: set[str]) -> None: + """Needed for the fish: dfin1 and dfin2 are not predicted""" + valid_bodyparts = set() + missing_bodyparts = set() + for bpt in gt_bodyparts: + if bpt in predicted_bodyparts: + valid_bodyparts.add(bpt) + else: + missing_bodyparts.add(bpt) + + extra_bodyparts = predicted_bodyparts - valid_bodyparts + if len(extra_bodyparts) > 0: + print( + "WARNING: Found bodyparts in predictions that are not in ground truth:" + f"{list(extra_bodyparts)}" + ) + if len(missing_bodyparts) > 0: + print( + f"WARNING: Some GT bodyparts have no predictions: {list(missing_bodyparts)}" + ) + + +def parse_dlc_df( + df: pd.DataFrame, + ground_truth_bodyparts: set[str], +) -> dict[str, list[dict]]: + scorers = set(df.columns.get_level_values(0)) + if len(scorers) > 1: + raise ValueError( + f"There should only be 1 scorer in the predictions DF. Found {scorers}" + ) + scorer = scorers.pop() + + individuals = set(df.columns.get_level_values(1)) + bodyparts = set(df.columns.get_level_values(2)) + check_bodyparts(ground_truth_bodyparts, bodyparts) + + data = {} + for row, df_image in df.iterrows(): + if isinstance(row, str): + image_path = row + elif isinstance(row, Iterable): + image_path = str(Path(*row)) + else: + raise ValueError(f"Cannot parse row {row}") + + data[image_path] = [] + df_all_individuals = df_image.loc[scorer] + for idv in individuals: + df_idv = df_all_individuals.loc[idv] + keypoints = {} + scores = [] + for bpt in ground_truth_bodyparts: + if bpt in df_idv.index.get_level_values(0): + df_bpt = df_idv.loc[bpt] + scores.append(df_bpt.likelihood) + keypoints[bpt] = (df_bpt.x, df_bpt.y) + else: + keypoints[bpt] = (np.nan, np.nan) + + if len(keypoints) > 0: + data[image_path].append( + { + "pose": keypoints, + "score": np.mean(scores), + } + ) + + return data + + +class DLC3Benchmark: + """A benchmark for DLC3 Models""" + + def __init__(self, models: dict[str, str]) -> None: + super().__init__() + self._names = list(models.keys()) + self.data = {} + for name, predictions in models.items(): + df_predictions = pd.read_hdf(predictions) + if not isinstance(df_predictions, pd.DataFrame): + raise ValueError( + f"Failed to parse {predictions} - not a dataframe: {df_predictions}" + ) + self.data[name] = parse_dlc_df(df_predictions, set(self.keypoints)) + + def names(self): + """An iterable of model names to evaluate.""" + return self._names + + def get_predictions(self, name: str): + return self.data[name] + + +class DLC3FishBenchmark(DLC3Benchmark, FishBenchmark): + code = "link/to/your/code.git" + + +class DLC3MarmosetBenchmark(DLC3Benchmark, MarmosetBenchmark): + code = "link/to/your/code.git" + + +class DLC3ParentingBenchmark(DLC3Benchmark, ParentingMouseBenchmark): + code = "link/to/your/code.git" + + +class DLC3TrimouseBenchmark(DLC3Benchmark, TriMouseBenchmark): + code = "link/to/your/code.git" + + +def name_to_snapshot_index(filename: str) -> int: + return int(Path(filename).stem.split("-")[-1]) + + +def main(output_dir: Path, test_hash: str): + experiments = [p for p in (output_dir / test_hash).iterdir() if p.is_dir()] + experiments = sorted(experiments, key=lambda s: int(s.stem.split("shuffle")[-1])) + for exp in experiments: + benchmark_name = exp.name.split("-")[0] + benchmark_factory = BENCHMARKS[benchmark_name] + + print(120 * "-") + print(f"Results for {exp}") + models = {p.name: p for p in exp.iterdir() if p.suffix == ".h5"} + + b = benchmark_factory(models=models) + for model in sorted(models.keys(), key=lambda k: name_to_snapshot_index(k)): + result = b.evaluate(model) + print( + f"{result.method_name}, {result.benchmark_name}: " + f"{result.mean_avg_precision:.4f} mAP, " + f"{result.root_mean_squared_error:.2f} RMSE" + ) + + +BENCHMARKS = { + "fishMay7": DLC3FishBenchmark, + "pupsMar24": DLC3ParentingBenchmark, + "trimiceJun22": DLC3TrimouseBenchmark, + "marmosetMay7": DLC3MarmosetBenchmark, +} + + +if __name__ == "__main__": + main( + output_dir=Path("outputs"), + test_hash="2023_12_07_fc2f00e2", + ) diff --git a/benchmark/madlc_test_inference.py b/benchmark/madlc_test_inference.py new file mode 100644 index 0000000000..914f0cb52b --- /dev/null +++ b/benchmark/madlc_test_inference.py @@ -0,0 +1,209 @@ +""" Benchmarking maDLC datasets - inference + +This script can be used to run inference on the test images of a DeepLabCut project. +""" +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +from ruamel.yaml import YAML +from tqdm import tqdm + +from deeplabcut.pose_estimation_pytorch import DLCLoader +from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.utils.visualization import make_labeled_images_from_dataframe + +from projects import MA_DLC_BENCHMARKS +from utils import Project, Shuffle + + +def run_inference_on_all_images( + project: Project, + snapshot: Path, + plot: bool, + detector_snapshot: Path | None = None, +) -> None: + pytorch_config_path = snapshot.parent / "pytorch_config.yaml" + with open(pytorch_config_path, "r") as file: + pytorch_config = YAML(typ='safe', pure=True).load(pytorch_config_path) + + loader = DLCLoader( + project_root=str(project.path), + model_config_path=str(pytorch_config_path), + ) + parameters = loader.get_dataset_parameters() + shuffle_name = snapshot.parent.parent.name + test_data_dir = project.root / "test-images" / project.name / "labeled-data" + video_folders = [ + p + for p in test_data_dir.iterdir() + if p.is_dir() + ] + images = [] + for video_folder in video_folders: + images += [ + p # f"labeled-data/{video_folder.name}/{p.name}" + for p in video_folder.iterdir() + if p.suffix == ".png" + ] + + runner, detector_runner = get_runners( + pytorch_config=pytorch_config, + snapshot_path=str(snapshot), + max_individuals=parameters.max_num_animals, + num_bodyparts=parameters.num_joints, + num_unique_bodyparts=parameters.num_unique_bpts, + with_identity=False, # TODO: implement + transform=None, + detector_path=detector_snapshot, + detector_transform=None, + ) + + pose_inputs = [str(i) for i in images] + if detector_runner is not None: + print("Running detection") + bbox_predictions = detector_runner.inference(images=tqdm(pose_inputs)) + pose_inputs = list(zip(pose_inputs, bbox_predictions)) + + print("Running pose prediction") + predictions = runner.inference(tqdm(pose_inputs)) + poses = np.array([p["bodyparts"] for p in predictions]) + poses = poses[..., :3] + + if detector_snapshot is None: + scorer = f"{shuffle_name}-{snapshot.stem}" + else: + scorer = f"{shuffle_name}-{detector_snapshot.stem}-{snapshot.stem}" + + output_path = ( + project.root + / "test-images" + / project.name + / "evaluation-results" + / f"iteration-{project.iteration}" + / shuffle_name + / "benchmark" + / f"{scorer}.h5" + ) + output_path.parent.mkdir(exist_ok=True, parents=True) + + index = pd.MultiIndex.from_tuples( + [(f"labeled-data", f"{i.parent.name}", f"{i.name}") for i in images], + names=["dir", "video", "image"], + ) + columns = pd.MultiIndex.from_product( + [ + [scorer], + project.cfg["individuals"], + project.cfg["multianimalbodyparts"], + ["x", "y", "likelihood"], + ], + names=["scorer", "individuals", "bodyparts", "coords"], + ) + poses = poses.reshape(len(images), -1) + if parameters.num_unique_bpts > 0: + unique_columns = pd.MultiIndex.from_product( + [ + [shuffle_name], + ["single"], + parameters.unique_bpts, + ["x", "y", "likelihood"], + ], + names=["scorer", "individuals", "bodyparts", "coords"], + ) + columns = columns.append(unique_columns) + unique_poses = np.array([p["unique_bodyparts"] for p in predictions]) + unique_poses = unique_poses[..., :3] + unique_poses = unique_poses.reshape(len(images), -1) + poses = np.concatenate([poses, unique_poses], axis=1) + + df = pd.DataFrame(poses, index=index, columns=columns) + df.to_hdf(output_path, "df_with_missing") + + if plot: + test_config_path = str( + project.root + / "test-images" + / project.name + / "config.yaml" + ) + with open(test_config_path, "r") as file: + test_config = YAML(typ='safe', pure=True).load(file) + + image_output_folder = output_path.parent / "images" + image_output_folder.mkdir(exist_ok=True) + for video in video_folders: + index_filter = [v == video.name for v in df.index.get_level_values("video")] + col_filter = [ + c in ("x", "y") and bpt not in ("dfin1", "dfin2") + for c, bpt in zip( + df.columns.get_level_values("coords"), + df.columns.get_level_values("bodyparts"), + ) + ] + df_video = df.loc[index_filter, col_filter] + plot_output_folder = image_output_folder / video.name + make_labeled_images_from_dataframe( + df_video, + test_config, + destfolder=str(plot_output_folder), + scale=1.0, + dpi=200, + keypoint="+", + draw_skeleton=False, + color_by="bodypart", + ) + + +def main( + shuffle: Shuffle, + snapshot_indices: int | list[int] | None = None, + detector_snapshot_indices: int | list[int] | None = None, + plot: bool = False, +) -> None: + """ + + Args: + shuffle: + snapshot_indices: + detector_snapshot_indices: + plot: + + Returns: + + """ + if isinstance(snapshot_indices, int): + snapshot_indices = [snapshot_indices] + if isinstance(detector_snapshot_indices, int): + detector_snapshot_indices = [detector_snapshot_indices] + + detectors = [None] + if shuffle.pytorch_cfg.get("method", "bu").lower() == "td": + detectors = shuffle.snapshots(detector=True) + if detector_snapshot_indices is not None: + detectors = [detectors[idx] for idx in detector_snapshot_indices] + print(f"Running inference with detectors: {[s.name for s in detectors]}") + + snapshots = shuffle.snapshots() + if snapshot_indices is not None: + snapshots = [snapshots[idx] for idx in snapshot_indices] + print(f"Running inference with snapshots: {[s.name for s in snapshots]}") + + for detector in detectors: + for snapshot in snapshots: + run_inference_on_all_images(shuffle.project, snapshot, plot, detector) + + +if __name__ == "__main__": + main( + shuffle=Shuffle( + project=MA_DLC_BENCHMARKS["trimouse"], + index=0, + train_fraction=0.95, + ), + snapshot_indices=None, + detector_snapshot_indices=-1, + plot=False, + ) diff --git a/benchmark/projects.py b/benchmark/projects.py new file mode 100644 index 0000000000..21947eb270 --- /dev/null +++ b/benchmark/projects.py @@ -0,0 +1,44 @@ +"""DeepLabCut projects to benchmark""" +from __future__ import annotations + +from pathlib import Path +from utils import Project + +MA_DLC_DATA_ROOT = Path("/home/niels/datasets/ma_dlc") +SA_DLC_DATA_ROOT = Path("/home/niels/datasets/single_animal_dlc") + +MA_DLC_BENCHMARKS = { + "trimouse": Project( + root=MA_DLC_DATA_ROOT, + name="trimice-dlc-2021-06-22", + iteration=1, + ), + "fish": Project( + root=MA_DLC_DATA_ROOT, + name="fish-dlc-2021-05-07", + iteration=1, + ), + "parenting": Project( + root=MA_DLC_DATA_ROOT, + name="pups-dlc-2021-03-24", + iteration=1, + ), + "marmoset": Project( + root=MA_DLC_DATA_ROOT, + name="marmoset-dlc-2021-05-07", + iteration=1, + ), +} + +SA_DLC_BENCHMARKS = { + "fly": Project( + root=SA_DLC_DATA_ROOT, + name="Fly-Kevin-2019-03-16", + iteration=2, + ), + "openfield": Project( + root=SA_DLC_DATA_ROOT, + name="openfield-Pranav-2018-08-20", + iteration=2, + ), +} diff --git a/benchmark/train_benchmark.py b/benchmark/train_benchmark.py deleted file mode 100644 index dec191f9fc..0000000000 --- a/benchmark/train_benchmark.py +++ /dev/null @@ -1,434 +0,0 @@ -""" Benchmarking maDLC datasets - -TODO: Document data format - -In a first step, create_dataset=True was used to create the training dataset files and -the pytorch configurations for the models. The data augmentation parameters were then -updated for the shuffle that I wanted to train. This also included adding the following: - -``` -logger: - type: 'WandbLogger' - project_name: 'dlc3-ff5f2af-fish' - run_name: 'dekr-w32-shuffle3' -``` - -Which specifies to log the run to wandb, (including the project and with which name each -shuffle should be logged). - -Then run with `create_dataset=False, train=True` to train the models. -""" -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from pathlib import Path - -import numpy as np -import pandas as pd -import ruamel.yaml as yaml -import wandb - -import deeplabcut -import deeplabcut.pose_estimation_pytorch.apis as api -from deeplabcut.pose_estimation_pytorch import DLCLoader -from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_inference_transform, - get_runners, -) -from deeplabcut.utils.visualization import make_labeled_images_from_dataframe - - -@dataclass -class ProjectConfig: - data_root: Path - name: str - iteration: int - shuffle_prefix: str - - def snapshot_path( - self, - train_percentage: int, - shuffle: int, - num_epochs: int, - ) -> Path: - return ( - self.data_root - / self.name - / "dlc-models" - / f"iteration-{self.iteration}" - / f"{self.shuffle_prefix}-trainset{train_percentage}shuffle{shuffle}" - / "train" - / f"snapshot-{num_epochs}.pt" - ) - - -@dataclass -class DataParameters: - model_prefix: str = "" - output_folder: str = "videos" - shuffle: int = 0 - trainset_index: int = 0 - net_types: tuple[str, ...] = ("dekr_w18", "dekr_w32", "dekr_w48") - - -@dataclass -class RunParameters: - create_dataset: bool = False - train: bool = False - evaluate: bool = False - analyze_videos: bool = False - track: bool = False - create_labeled_video: bool = False - device: str = "cuda:0" - - -@dataclass -class TrainParameters: - batch_size: int = 16 - display_iters: int = 500 - epochs: int | None = None - save_epochs: int | None = None - snapshot_path: Path | None = None - detector_max_epochs: int | None = None - detector_save_epochs: int | None = None - - def train_kwargs(self) -> dict: - kwargs = { - "batch_size": self.batch_size, - "display_iters": self.display_iters, - } - if self.epochs is not None: - kwargs["epochs"] = self.epochs - if self.save_epochs is not None: - kwargs["save_epochs"] = self.save_epochs - if self.snapshot_path is not None: - kwargs["snapshot_path"] = str(self.snapshot_path) - if self.detector_max_epochs is not None: - kwargs["detector_max_epochs"] = self.detector_max_epochs - if self.detector_save_epochs is not None: - kwargs["detector_save_epochs"] = self.detector_save_epochs - return kwargs - - -@dataclass -class EvalParameters: - snapshotindex: int | list[int] | str | None = (None,) - plotting: str | bool = False - show_errors: bool = True - - def eval_kwargs(self) -> dict: - return { - "plotting": self.plotting, - "show_errors": self.show_errors, - } - - -def run_inference_on_all_images( - project: ProjectConfig, - snapshot: Path, - plot: bool, -) -> None: - warnings.simplefilter("ignore", yaml.error.UnsafeLoaderWarning) - - with open(project.data_root / project.name / "config.yaml", "r") as file: - config = yaml.load(file) - - pytorch_config_path = snapshot.parent / "pytorch_config.yaml" - with open(pytorch_config_path, "r") as file: - pytorch_config = yaml.load(file) - - loader = DLCLoader( - project_root=str(project.data_root / project.name), - model_config_path=str(pytorch_config_path), - ) - parameters = loader.get_dataset_parameters() - - shuffle_name = snapshot.parent.parent.name - - video_folders = [ - p - for p in ( - project.data_root / (project.name + "-test-images") / "labeled-data" - ).iterdir() - if p.is_dir() - ] - images = [] - for video_folder in video_folders: - images += [ - p # f"labeled-data/{video_folder.name}/{p.name}" - for p in video_folder.iterdir() - if p.suffix == ".png" - ] - - transform_cfg = { - "auto_padding": { - "pad_width_divisor": 32, - "pad_height_divisor": 32, - }, - "normalize_images": True, - "resize": False, - } - transform = build_inference_transform(transform_cfg, augment_bbox=True) - runner, _ = get_runners( - pytorch_config=pytorch_config, - snapshot_path=str(snapshot), - max_individuals=parameters.max_num_animals, - num_bodyparts=parameters.num_joints, - num_unique_bodyparts=parameters.num_unique_bpts, - with_identity=False, # TODO: implement - transform=transform, - detector_path=None, # TODO: Fix for top-down models - detector_transform=None, - ) - predictions = runner.inference([str(i) for i in images]) - poses = np.array([p["bodyparts"] for p in predictions]) - - output_path = ( - project.data_root - / (project.name + "-test-images") - / "evaluation-results" - / f"iteration-{project.iteration}" - / shuffle_name - / "benchmark" - / f"{shuffle_name}-{snapshot.stem}.h5" - ) - output_path.parent.mkdir(exist_ok=True, parents=True) - - index = pd.MultiIndex.from_tuples( - [(f"labeled-data", f"{i.parent.name}", f"{i.name}") for i in images], - names=["dir", "video", "image"], - ) - columns = pd.MultiIndex.from_product( - [ - [shuffle_name], - config["individuals"], - config["multianimalbodyparts"], - ["x", "y", "likelihood"], - ], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - df = pd.DataFrame(poses.reshape(len(images), -1), index=index, columns=columns) - df.to_hdf(output_path, "df_with_missing") - if plot: - image_output_folder = output_path.parent / "images" - image_output_folder.mkdir(exist_ok=True) - for video in video_folders: - index_filter = [v == video.name for v in df.index.get_level_values("video")] - col_filter = [ - c in ("x", "y") and bpt not in ("dfin1", "dfin2") - for c, bpt in zip( - df.columns.get_level_values("coords"), - df.columns.get_level_values("bodyparts"), - ) - ] - df_video = df.loc[index_filter, col_filter] - plot_output_folder = image_output_folder / video.name - make_labeled_images_from_dataframe( - df_video, - config, - destfolder=str(plot_output_folder), - scale=1.0, - dpi=200, - keypoint="+", - draw_skeleton=False, - color_by="bodypart", - ) - - -def main( - project_root: Path, - iteration: int, - data_parameters: DataParameters, - run_parameters: RunParameters, - train_parameters: TrainParameters, - eval_parameters: EvalParameters, -) -> None: - project = project_root.name - cfg = project_root / "config.yaml" - - print("Training on dataset:") - print(f" Project {project}") - print(f" Iteration {iteration}") - print(f" Shuffle {data_parameters.shuffle}") - print(f" Model prefix {data_parameters.model_prefix}") - - # Configuration - videos = str(project_root / "videos") - deeplabcut.auxiliaryfunctions.edit_config( - str(cfg), - {"iteration": iteration}, - ) - - if run_parameters.create_dataset: - deeplabcut.create_training_model_comparison( - str(cfg), - trainindex=data_parameters.trainset_index, - num_shuffles=1, - net_types=list(data_parameters.net_types), - ) - - if run_parameters.train: - api.train_network( - str(cfg), - shuffle=data_parameters.shuffle, - trainingsetindex=data_parameters.trainset_index, - transform=None, - modelprefix=data_parameters.model_prefix, - device=run_parameters.device, - **train_parameters.train_kwargs(), - ) - - if run_parameters.evaluate: - snapshot_indices = eval_parameters.snapshotindex - if isinstance(snapshot_indices, int) or isinstance(snapshot_indices, str): - snapshot_indices = [snapshot_indices] - - for idx in snapshot_indices: - api.evaluate_network( - config=str(cfg), - shuffles=[data_parameters.shuffle], - trainingsetindex=data_parameters.trainset_index, - snapshotindex=idx, - device=run_parameters.device, - transform=None, - modelprefix=data_parameters.model_prefix, - **eval_parameters.eval_kwargs(), - ) - - if run_parameters.analyze_videos: - api.analyze_videos( - config=str(cfg), - videos=videos, - videotype=".mp4", - trainingsetindex=data_parameters.trainset_index, - destfolder=str(project_root / data_parameters.output_folder), - snapshotindex=5, - device=run_parameters.device, - modelprefix=data_parameters.model_prefix, - batchsize=train_parameters.batch_size, - transform=None, - overwrite=False, - auto_track=False, - ) - - if run_parameters.track: - api.convert_detections2tracklets( - config=str(cfg), - videos=videos, - videotype=".mp4", - modelprefix=data_parameters.model_prefix, - destfolder=str(project_root / data_parameters.output_folder), - track_method="box", - ) - deeplabcut.stitch_tracklets( - str(cfg), - [videos], - shuffle=1, - trainingsetindex=data_parameters.trainset_index, - destfolder=str(project_root / data_parameters.output_folder), - modelprefix=data_parameters.model_prefix, - save_as_csv=True, - track_method="box", - ) - - if run_parameters.create_labeled_video: - deeplabcut.create_labeled_video( - config=str(cfg), - videos=[videos], - videotype="mp4", - trainingsetindex=data_parameters.trainset_index, - color_by="individual", # bodypart, individual - modelprefix=data_parameters.model_prefix, - destfolder=str(project_root / data_parameters.output_folder), - track_method="box", - ) - - -if __name__ == "__main__": - benchmarks = { - "trimouse": ProjectConfig( - data_root=Path("/home/datasets"), - name="trimice-dlc-2021-06-22", - iteration=1, - shuffle_prefix="trimiceJun22", - ), - "fish": ProjectConfig( - data_root=Path("/home/datasets"), - name="fish-dlc-2021-05-07", - iteration=4, - shuffle_prefix="fishMay7", - ), - "parenting": ProjectConfig( - data_root=Path("/home/datasets"), - name="pups-dlc-2021-03-24", - iteration=1, - shuffle_prefix="pupsMar24", - ), - } - - for name, project in benchmarks.items(): - if wandb.run is not None: # TODO: Finish wandb run in DLC - wandb.finish() - - print(f"Running {name}") - data_parameters = DataParameters( - model_prefix="", - output_folder=f"videos-iter{project.iteration}", - shuffle=0, - trainset_index=0, - net_types=( - "dekr_w18", - "dekr_w18", - "dekr_w18", - "dekr_w32", - "dekr_w32", - "dekr_w32", - ), - ) - run_parameters = RunParameters( - create_dataset=False, - train=True, - evaluate=True, - analyze_videos=False, - track=False, - create_labeled_video=False, - device="cuda:0", - ) - train_parameters = TrainParameters( - batch_size=2, - epochs=125, - save_epochs=25, - ) - - try: - main( - project_root=(project.data_root / project.name), - iteration=project.iteration, - data_parameters=data_parameters, - run_parameters=run_parameters, - train_parameters=train_parameters, - eval_parameters=EvalParameters( - snapshotindex="all", - plotting=False, - ), - ) - - if run_parameters.train: - for num_epochs in range( - train_parameters.save_epochs, - train_parameters.epochs + 1, - train_parameters.save_epochs, - ): - snapshot = project.snapshot_path( - train_percentage=95, - shuffle=data_parameters.shuffle, - num_epochs=num_epochs, - ) - run_inference_on_all_images( - project, - snapshot=snapshot, - plot=(num_epochs == train_parameters.epochs), - ) - except Exception as err: - print(f"Failed to run {project}: {err}") diff --git a/benchmark/utils.py b/benchmark/utils.py new file mode 100644 index 0000000000..130a0c6457 --- /dev/null +++ b/benchmark/utils.py @@ -0,0 +1,302 @@ +"""Util methods and classes for DeepLabCut Benchmarking""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +import pandas as pd + +import deeplabcut as dlc +import deeplabcut.pose_estimation_pytorch.apis.utils as api_utils +import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils +import deeplabcut.utils.auxiliaryfunctions as af + + +@dataclass +class Project: + """ + Attributes: + root: the path where the project folder is stored + name: the name of the project + iteration: the iteration of the project + """ + root: Path + name: str + iteration: int + + def __post_init__(self) -> None: + self._cfg = None + + @property + def cfg(self) -> dict: + if self._cfg is None: + self._cfg = dlc.utils.auxiliaryfunctions.read_config( + self.config_path() + ) + return self._cfg + + @property + def date(self) -> str: + return self.cfg["date"] + + @property + def path(self) -> Path: + return self.root / self.name + + @property + def shuffle_prefix(self) -> str: + return self.task + self.date + + @property + def task(self) -> str: + return self.cfg["Task"] + + def config_path(self) -> str: + return str(self.root / self.name / "config.yaml") + + def update_iteration_in_config(self) -> None: + dlc.auxiliaryfunctions.edit_config( + self.config_path(), + {"iteration": self.iteration}, + ) + + def get_shuffle_folder(self, model_prefix: str | None = None): + base_dir = self.root / self.name + if model_prefix is not None: + base_dir = base_dir / model_prefix + + model_dir = base_dir / "dlc-models" / f"iteration-{self.iteration}" + return model_dir + + def get_shuffle_path( + self, + shuffle_index: int, + trainset_index: int, + model_prefix: str | None = None + ) -> Path: + base_dir = self.get_shuffle_folder(model_prefix=model_prefix) + train_fraction = (100 * self.cfg["TrainingFraction"][trainset_index]) + shuffle_name = f"{self.shuffle_prefix}-trainset{train_fraction}shuffle{shuffle_index}" + return base_dir / shuffle_name + + +@dataclass +class Shuffle: + project: Project + train_fraction: float + index: int + model_prefix: str | None = None + + def __post_init__(self): + self.model_prefix_ = self.model_prefix if self.model_prefix is not None else "" + self.model_folder = self.project.path / af.get_model_folder( + self.train_fraction, self.index, self.project.cfg, modelprefix=self.model_prefix_ + ) + self.trainset_folder = af.get_training_set_folder(self.project.cfg) + self._metadata = None + self._pytorch_cfg = None + + @property + def pytorch_cfg(self) -> dict: + if self._pytorch_cfg is None: + cfg_path = self.model_folder / "train" / "pytorch_config.yaml" + self._pytorch_cfg = af.read_plainconfig(str(cfg_path)) + + return self._pytorch_cfg + + @property + def test_indices(self): + self._lazy_load_metadata() + return self._metadata[2] + + @property + def train_indices(self): + self._lazy_load_metadata() + return self._metadata[1] + + @property + def trainset_index(self) -> int: + return self.project.cfg["TrainingFraction"].index(self.train_fraction) + + def snapshots(self, detector: bool = False) -> list[Path]: + start_str = "snapshot" + if detector: + start_str = "detector-snapshot" + + all_snapshots = [ + p for p in (self.model_folder / "train").iterdir() + if p.name.startswith(start_str) and p.suffix == ".pt" + ] + return sorted(all_snapshots, key=lambda s: int(s.stem.split("-")[-1])) + + def scorer(self, index: int | None = None, epochs: int | None = None) -> str: + if (index is None and epochs is None) or (index is not None and epochs is not None): + raise ValueError(f"Exactly one of (index, epochs) must be given: had {index}, {epochs}") + + if index is None: + index = self.epochs_to_snapshot_index(epochs) + + dlc_scorer, _ = runner_utils.get_dlc_scorer( + str(self.project.path), + self.project.cfg, + self.train_fraction, + self.index, + self.model_prefix_, + index, + ) + return dlc_scorer + + def ground_truth(self) -> pd.DataFrame: + path_gt = self.project.path / self.trainset_folder / f"CollectedData_{self.project.cfg['scorer']}.h5" + df_ground_truth = pd.read_hdf(path_gt) + if not isinstance(df_ground_truth, pd.DataFrame): + raise ValueError(f"Ground truth data did not contain a dataframe: {df_ground_truth}") + + return api_utils.ensure_multianimal_df_format(df_ground_truth) + + def predictions(self, index: int | None = None, epochs: int | None = None) -> pd.DataFrame: + if (index is None and epochs is None) or (index is not None and epochs is not None): + raise ValueError(f"Exactly one of (index, epochs) must be given: had {index}, {epochs}") + + if index is None: + index = self.epochs_to_snapshot_index(epochs) + + path_eval = ( + self.project.path / + "evaluation-results" / + f"iteration-{self.project.iteration}" / + self.model_folder.name + ) + scorer = self.scorer(index=index, epochs=epochs) + epochs = scorer.split("_")[-1] + path_predictions = path_eval / f"{scorer}-snapshot-{epochs}.h5" + df_predictions = pd.read_hdf(path_predictions) + if not isinstance(df_predictions, pd.DataFrame): + raise ValueError(f"Predictions data did not contain a dataframe: {df_predictions}") + + return df_predictions + + def epochs_to_snapshot_index(self, epochs: int) -> int: + paths = self.snapshots() + snapshot_epochs = [int(s.stem.split("-")[-1]) for s in paths] + try: + index = snapshot_epochs.index(epochs) + except ValueError: + raise ValueError( + f"Could not find a snapshot trained for {epochs} epochs in {self}." + f" Found the following snapshots: {[s.name for s in paths]}" + ) + + return index + + def _lazy_load_metadata(self) -> None: + if self._metadata is None: + self._metadata = _get_model_folder( + project_path=self.project.path, + project_config=self.project.cfg, + trainset_folder=self.trainset_folder, + train_fraction=self.train_fraction, + shuffle_index=self.index, + ) + + +def create_shuffles( + project: Project, + splits_file: Path, + trainset_index: int, + net_type: str, +) -> None: + """Creates shuffles for a project using predefined train/test splits + + Creates train/test splits according to what is defined in a file (can be created + with `create_train_test_splits.py`). If there are already shuffles for this + iteration of the project, the index of the first shuffle created will be 1 more + than the current max (i.e., if shuffle1 and shuffle2 already exist, the first + shuffle created will be called ...-shuffle3). + + The splits file must have format: + { + "project_name": { + "train_fraction": [ + {"train": list[int], "test": list[int]} # image indices in the train and test set + } + } + + Example file: + { + "openfield-Pranav-2018-08-20": { + 0.8: [ + {"train": [0, 1, 3, 4], "test": [2]}, # split 1 + {"train": [0, 1, 2, 3], "test": [4]}, # split 2 + {"train": [0, 1, 2, 3], "test": [4]}, # split 3 + ] + }, + "Fly-Kevin-2019-03-16": { + 0.8: [ + {"train": [0, 1, 3, 4, 5, 6, 7, 8], "test": [2, 9]}, + {"train": [0, 1, 2, 3, 6, 7, 8, 9], "test": [4, 5]} + ] + 0.9: [ + {"train": [0, 1, 2, 3, 5, 6, 7, 8, 9], "test": [4]}, + ] + } + } + + Args: + project: the project to create shuffles for + splits_file: the splits containing the train and test indices + trainset_index: the index of the training fractions to create the shuffles with + net_type: the type of neural net to create the shuffles with + """ + shuffle_folder = project.get_shuffle_folder(model_prefix=None) + shuffle_indices = [] + if shuffle_folder.exists(): + existing_shuffles = [ + p + for p in project.get_shuffle_folder(model_prefix=None).iterdir() + if p.is_dir() + ] + shuffle_indices = [int(s.name.split("shuffle")[1]) for s in existing_shuffles] + + if len(shuffle_indices) == 0: + next_index = 1 + else: + next_index = max(*shuffle_indices) + 1 + + train_fraction = project.cfg["TrainingFraction"][trainset_index] + with open(splits_file, "r") as f: + raw_data = json.load(f) + + splits = raw_data[project.name][str(train_fraction)] + train_indices = [s["train"] for s in splits] + test_indices = [s["test"] for s in splits] + shuffles_to_create = [i for i in range(next_index, next_index + len(train_indices))] + + print(f"Creating training datasets with indices {shuffles_to_create} and splits:") + for s in splits: + print(f" Train: {s['train']}") + print(f" Test: {s['test']}") + + dlc.create_training_dataset( + project.config_path(), + Shuffles=shuffles_to_create, + trainIndices=train_indices, + testIndices=test_indices, + net_type=net_type, + augmenter_type="imgaug", + ) + + +def _get_model_folder( + project_path: Path, + project_config: dict, + trainset_folder: str, + train_fraction: float, + shuffle_index: int, +) -> tuple[dict, list[int], list[int]]: + _, metadata_filename = af.get_data_and_metadata_filenames( + trainset_folder, train_fraction, shuffle_index, project_config, + ) + metadata = af.load_metadata(str(project_path / metadata_filename)) + return metadata[0], [int(i) for i in metadata[1]], [int(i) for i in metadata[2]] diff --git a/deeplabcut/pose_estimation_pytorch/apis/config.yaml b/deeplabcut/pose_estimation_pytorch/apis/config.yaml deleted file mode 100644 index 9565c95546..0000000000 --- a/deeplabcut/pose_estimation_pytorch/apis/config.yaml +++ /dev/null @@ -1,40 +0,0 @@ -batch_size: 1 -cfg_path: /project/config.yaml -colormode: 'RGB' -data: - covering: true - gaussian_noise: 12.75 - hist_eq: true - motion_blur: true - normalize_images: true - rotation: 30 - scale_jitter: - - 0.5 - - 1.25 -device: cuda:0 -display_iters: 50 -epochs: 200 -model: - backbone: - pretrained: https://download.pytorch.org/models/resnet50-19c8e357.pth - type: ResNet - pose_model: - stride: 8 -optimizer: - params: - lr: 0.0001 - type: AdamW -save_epochs: 50 -scheduler: - params: - lr_list: - - - 0.00001 - - - 0.000001 - milestones: - - 90 - - 120 - type: LRListScheduler -seed: 42 -runner: - type: PoseRunner -with_center: false diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 9a66187bce..1d5dda8ac2 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -25,7 +25,11 @@ build_inference_transform, build_runner, build_transforms, - update_config_parameters, +) +from deeplabcut.pose_estimation_pytorch.config import ( + pretty_print_config, + read_config_as_dict, + update_config, ) from deeplabcut.pose_estimation_pytorch.data import Loader, DLCLoader from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel @@ -74,6 +78,7 @@ def train( logger = None if logger_config is not None: logger = LOGGER.build(dict(**logger_config, model=model)) + logger.log_config(run_config) runner = build_runner( run_cfg=run_config, @@ -166,9 +171,12 @@ def train_network( model_config_path = str(train_folder / "pytorch_config.yaml") setup_file_logging(log_path) - pytorch_config = auxiliaryfunctions.read_plainconfig(model_config_path) - update_config_parameters(pytorch_config=pytorch_config, **kwargs) # TODO: improve + pytorch_config = read_config_as_dict(model_config_path) + pytorch_config = update_config(pytorch_config, kwargs) + print("Training with configuration:") + pretty_print_config(pytorch_config) + if transform is None: logging.info("No transform specified... using default") transform = build_transforms(dict(pytorch_config["data"]), augment_bbox=True) diff --git a/deeplabcut/pose_estimation_pytorch/config/__init__.py b/deeplabcut/pose_estimation_pytorch/config/__init__.py index a5c9248ed9..8450c8a8c4 100644 --- a/deeplabcut/pose_estimation_pytorch/config/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/config/__init__.py @@ -1 +1,6 @@ from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config +from deeplabcut.pose_estimation_pytorch.config.utils import ( + pretty_print_config, + read_config_as_dict, + update_config, +) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml index ba4475b3e3..d3c1007d6e 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml @@ -50,8 +50,10 @@ locref_config: - "num_bodyparts x 2" kernel_size: - 3 + - 3 strides: - 2 + - 2 paf_config: channels: - "backbone_output_channels" @@ -59,6 +61,8 @@ paf_config: - "num_limbs x 2" # num_limbs = len(graph) kernel_size: - 3 + - 3 strides: - 2 + - 2 num_stages: 5 diff --git a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml index 841b9391b8..ea5a3aad1b 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml @@ -61,8 +61,10 @@ model: - "num_bodyparts x 2" kernel_size: - 3 + - 3 strides: - 2 + - 2 paf_config: channels: - 2304 @@ -70,6 +72,8 @@ model: - "num_limbs x 2" # num_limbs = len(graph) kernel_size: - 3 + - 3 strides: - 2 + - 2 num_stages: 5 diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index 3c8a34dea3..506f2c65b1 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -74,7 +74,7 @@ def make_pytorch_pose_config( backbones = load_backbones(configs_dir) if net_type in backbones: - if multianimal_project: + if not top_down and multianimal_project: model_cfg = create_backbone_with_paf_model( configs_dir=configs_dir, net_type=net_type, @@ -82,7 +82,6 @@ def make_pytorch_pose_config( bodyparts=bodyparts, paf_parameters=_get_paf_parameters(project_config, bodyparts) ) - else: model_cfg = create_backbone_with_heatmap_model( configs_dir=configs_dir, diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py index e126a9ca53..c8007740d9 100644 --- a/deeplabcut/pose_estimation_pytorch/config/utils.py +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -159,7 +159,7 @@ def load_backbones(configs_dir: Path) -> list[str]: return backbones -def read_config_as_dict(config_path: Path) -> dict: +def read_config_as_dict(config_path: str | Path) -> dict: """ Args: config_path: the path to the configuration file to load @@ -167,4 +167,22 @@ def read_config_as_dict(config_path: Path) -> dict: Returns: The configuration file with pure Python classes """ - return YAML(typ='safe', pure=True).load(config_path) + with open(config_path, "r") as f: + cfg = YAML(typ='safe', pure=True).load(f) + + return cfg + + +def pretty_print_config(config: dict, indent: int = 0) -> None: + """Prints a model configuration in a pretty and readable way + + Args: + config: the config to print + indent: the base indent on all keys + """ + for k, v in config.items(): + if isinstance(v, dict): + print(f"{indent * ' '}{k}:") + pretty_print_config(v, indent + 2) + else: + print(f"{indent * ' '}{k}: {v}") diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py index 8175a12578..b4b8e27631 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py @@ -11,7 +11,9 @@ import json import os +import warnings from dataclasses import dataclass +from pathlib import Path import numpy as np @@ -35,6 +37,7 @@ class COCOLoader(Loader): Examples: loader = COCOLoader( project_root='/path/to/project/', + model_config_path='/path/to/project/experiments/train/pytorch_config.yaml' train_json_filename="train.json", test_json_filename="test.json", ) @@ -100,6 +103,120 @@ def load_json(project_root: str, filename: str) -> dict: return json_obj + @staticmethod + def validate_categories(coco_json: dict) -> dict: + """Checks that the categories for the COCO project are valid. + + Checks that there is no category with ID 0 in the dataset, as this causes issues + with torchvision object detectors (label 0 is reserved for background + detections). If that's the case, all category IDs are shifted by 1 such that + there is no longer a category 0. + + Currently, detectors can only be trained with a single category. This also + ensures that all annotations have `category_id` set to 1. + + Args: + coco_json: the COCO dictionary containing the annotations + + Returns: + the validated COCO object + """ + cat_0 = False + for cat in coco_json["categories"]: + if cat["id"] == 0: + cat_0 = cat + warnings.warn( + f"Found a category with ID 0 ({cat}) in the COCO dataset. This is not" + f" allowed, as category ID 0 is reserved as the background ID for" + f" torchvision detectors. All category IDs have been shifted by 1." + ) + + if len(coco_json["categories"]) > 1: + warnings.warn( + f"Found more than 1 category in the project. This is currently not" + f" supported in DeepLabCut. All annotations will be given category 1" + ) + + if cat_0: + for cat in coco_json["categories"]: + cat["id"] = 1 + + if cat_0 or len(coco_json["categories"]) > 1: + for ann in coco_json["annotations"]: + ann["category_id"] = 1 + + return coco_json + + @staticmethod + def validate_images(project_root: str, coco_json: dict) -> dict: + """Goes over images and annotations to look for potential errors + + This code tries to ensure that training a model on this project does not crash + down the line + + Completes relative image filepaths to '/project_root/images/file_name'. Absolute + filepaths are not updated (which allows storing images to be stored in a folder + other than the project root) Then checks that all images files exist in the file + system. + + Args: + project_root: the root path of the COCO project + coco_json: the COCO dictionary containing the annotations + + Returns: + the validated COCO object + """ + image_ids = set() + missing_images = {} + validated_images = [] + for image in coco_json["images"]: + image_filename = Path(image["file_name"]) + if image_filename.is_absolute(): + image_path = image_filename + else: + image_path = Path(project_root) / "images" / image["file_name"] + image["file_name"] = str(image_path) + + if not image_path.exists(): + missing_images[image["id"]] = image["file_name"] + else: + validated_images.append(image) + image_ids.add(image["id"]) + + if len(missing_images) > 0: + warnings.warn( + f"There are {len(missing_images)} images that cannot be found (here" + " are some):" + ) + for img_id, file_name in missing_images.items(): + print(f" * {img_id}: {file_name}") + + coco_json["images"] = validated_images + + if len(missing_images) > 0: + validated_annotations = [] + for ann in coco_json["annotations"]: + if ann["image_id"] not in missing_images: + validated_annotations.append(ann) + + coco_json["annotations"] = validated_annotations + + validated_annotations = [] + for ann in coco_json["annotations"]: + if ann["image_id"] in image_ids: + validated_annotations.append(ann) + + if len(coco_json["annotations"]) < len(validated_annotations): + warnings.warn( + f"Found some annotations for which the image ID was not in the images." + f" Removing them from the dataset." + ) + print(f" All annotations: {len(coco_json['annotations'])}") + print(f" Annotations with correct image IDs: {len(validated_annotations)}") + coco_json["annotations"] = validated_annotations + + return coco_json + def load_data(self, mode: str = "train") -> dict: """Convert data from JSON object to dictionary. Args: @@ -116,9 +233,8 @@ def load_data(self, mode: str = "train") -> dict: else: raise AttributeError(f"Unknown mode: {mode}") - for image in data["images"]: - image_path = image["file_name"] - image["file_name"] = os.path.join(self.project_root, "images", image_path) + data = COCOLoader.validate_categories(data) + data = COCOLoader.validate_images(self.project_root, data) for annotation in data["annotations"]: annotation["keypoints"] = np.array(annotation["keypoints"], dtype=float) diff --git a/deeplabcut/pose_estimation_pytorch/default_config.py b/deeplabcut/pose_estimation_pytorch/default_config.py deleted file mode 100644 index 4624bb0301..0000000000 --- a/deeplabcut/pose_estimation_pytorch/default_config.py +++ /dev/null @@ -1,69 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from typing import Any - -pytorch_cfg_template: dict[str, Any] = { - "cfg_path": "/data/quentin/datasets/daniel3mouse/config.yaml", - "seed": 42, - "device": "cuda:0", - "display_iters": 1000, - "save_epochs": 50, - "data": { - "scale_jitter": [0.5, 1.25], - "rotation": 30, - "hist_eq": True, - "motion_blur": True, - "covering": True, - "gaussian_noise": 0.05 * 255, - "normalize_images": True, - }, - "model": { - "backbone": { - "type": "ResNet", - "pretrained": "https://download.pytorch.org/models/resnet50-19c8e357.pth", - }, - "heatmap_head": { - "type": "SimpleHead", - "channels": [2048, 1024, -1], # -1 acts as undefined here - "kernel_size": [2, 2], - "strides": [2, 2], - }, - "locref_head": { - "type": "SimpleHead", - "channels": [2048, 1024, -1], # -1 acts as undefined here - "kernel_size": [2, 2], - "strides": [2, 2], - }, - "target_generator": { - "type": "HeatmapPlateauGenerator", - "num_heatmaps": -1, - "pos_dist_thresh": 17, - "generate_locref": True, - "locref_std": 7.2801, - }, - "pose_model": {"stride": 8}, - }, - "optimizer": {"type": "AdamW", "params": {"lr": 1e-4}}, - "scheduler": { - "type": "LRListScheduler", - "params": {"milestones": [90, 120], "lr_list": [[1e-5], [1e-6]]}, - }, - "runner": {"type": "PoseRunner"}, - "with_center_keypoints": False, - "batch_size": 1, - "epochs": 200, -} - -if __name__ == "__main__": - import yaml - - with open("pytorch_config.yaml", "w") as f: - yaml.safe_dump(pytorch_cfg_template, f) diff --git a/deeplabcut/pose_estimation_pytorch/runners/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py index 4596b2567f..a4c2ad305a 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/utils.py +++ b/deeplabcut/pose_estimation_pytorch/runners/utils.py @@ -516,8 +516,8 @@ def get_paths( model_path = get_model_path(model_folder, train_iterations) detector_path = None - if task == Task.TOP_DOWN: - detector_path = get_detector_path(model_folder, train_iterations) + if task == Task.TOP_DOWN: # always take the last detector + detector_path = get_detector_path(model_folder, -1) return { "dlc_scorer": dlc_scorer, diff --git a/setup.py b/setup.py index c293d31b83..776c68ea41 100644 --- a/setup.py +++ b/setup.py @@ -8,13 +8,28 @@ https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS Licensed under GNU Lesser General Public License v3.0 """ +from __future__ import annotations import setuptools +from pathlib import Path + with open("README.md", encoding="utf-8", errors="replace") as fh: long_description = fh.read() +def pytorch_config_paths() -> list[str]: + pytorch_configs = [] + config_dir = Path("deeplabcut") / "pose_estimation_pytorch" / "config" + config_subdirs = [p for p in config_dir.iterdir() if p.is_dir()] + for subdir in config_subdirs: + for p in subdir.iterdir(): + if p.suffix == ".yaml": + pytorch_configs.append(str(p)) + + return pytorch_configs + + setuptools.setup( name="deeplabcut", version="2.3.8", @@ -75,7 +90,6 @@ "deeplabcut/pose_cfg.yaml", "deeplabcut/inference_cfg.yaml", "deeplabcut/reid_cfg.yaml", - "deeplabcut/pose_estimation_pytorch/apis/pytorch_config.yaml", "deeplabcut/pose_estimation_tensorflow/models/pretrained/pretrained_model_urls.yaml", "deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml", "deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml", @@ -92,7 +106,7 @@ "deeplabcut/gui/assets/icons/open.png", "deeplabcut/gui/assets/icons/open2.png", "deeplabcut/modelzoo/models.json", - ], + ] + pytorch_config_paths(), ) ], include_package_data=True, diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py index 000759c346..8387dd4c1b 100644 --- a/tests/pose_estimation_pytorch/config/test_make_pose_config.py +++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py @@ -2,6 +2,7 @@ import pytest from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config +from deeplabcut.pose_estimation_pytorch.config.utils import pretty_print_config, update_config @pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]]) @@ -23,7 +24,7 @@ def test_make_single_animal_config(bodyparts: list[str], net_type: str): "pytorch_config.yaml", net_type=net_type, ) - _print_pose_config(pytorch_pose_config) + pretty_print_config(pytorch_pose_config) # check heads are there assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() @@ -67,7 +68,7 @@ def test_backbone_plus_paf_config( "pytorch_config.yaml", net_type=net_type, ) - _print_pose_config(pytorch_pose_config) + pretty_print_config(pytorch_pose_config) graph = [ [i, j] @@ -140,7 +141,7 @@ def test_make_dekr_config( "pytorch_config.yaml", net_type=net_type, ) - _print_pose_config(pytorch_pose_config) + pretty_print_config(pytorch_pose_config) # check heads are there assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() @@ -200,8 +201,7 @@ def test_make_dlcrnet_config( "pytorch_config.yaml", net_type=net_type, ) - _print_pose_config(pytorch_pose_config) - + pretty_print_config(pytorch_pose_config) paf_graph = [ [i, j] for i in range(len(bodyparts)) @@ -276,12 +276,41 @@ def test_make_tokenpose_config( "pytorch_config.yaml", net_type=net_type, ) - _print_pose_config(pytorch_pose_config) + pretty_print_config(pytorch_pose_config) # check detector is there assert "detector" in pytorch_pose_config assert "data_detector" in pytorch_pose_config +@pytest.mark.parametrize("data", [ + { + "config": {"a": 0, "b": 0}, + "updates": {"b": 1}, + "expected_result": {"a": 0, "b": 1}, + }, + { + "config": {"a": 0, "b": {"i0": 1, "i1": 2}}, + "updates": {"b": 1}, + "expected_result": {"a": 0, "b": 1}, + }, + { + "config": {"a": 0, "b": {"i0": 1, "i1": 2}}, + "updates": {"b": {"i0": [1, 2, 3]}}, + "expected_result": {"a": 0, "b": {"i0": [1, 2, 3], "i1": 2}}, + }, + { + "config": {"detector": {"batch_size": 1, "epochs": 10, "save_epochs": 5}}, + "updates": {"batch_size": 1, "detector": {"batch_size": 8, "save_epochs": 1}}, + "expected_result": {"batch_size": 1, "detector": {"batch_size": 8, "epochs": 10, "save_epochs": 1}}, + }, +]) +def test_update_config(data: dict): + result = update_config(config=data["config"], updates=data["updates"]) + print("\nResult") + pretty_print_config(result) + assert result == data["expected_result"] + + def _make_project_config( project_path: str, multianimal: bool, @@ -305,15 +334,3 @@ def _make_project_config( project_config["bodyparts"] = bodyparts return project_config - - -def _print_pose_config(pose_config: dict, indent: int = 0) -> None: - if indent == 0: - print() - print("Pose config") - for k, v in pose_config.items(): - if isinstance(v, dict): - print(f"{indent * ' '}{k}:") - _print_pose_config(v, indent + 2) - else: - print(f"{indent * ' '}{k}: {v}") From 6023c87faeb0f526b503e9a1100659f9b742f3ef Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 19 Dec 2023 10:40:53 +0100 Subject: [PATCH 062/293] runner improvements * split runners into train and test * fixed runner building and imports * added missing future type annotations * bug fix in video analysis --- .../apis/analyze_videos.py | 10 +- .../pose_estimation_pytorch/apis/evaluate.py | 13 +- .../pose_estimation_pytorch/apis/inference.py | 397 ------------------ .../pose_estimation_pytorch/apis/train.py | 19 +- .../pose_estimation_pytorch/apis/utils.py | 118 +----- .../pose_estimation_pytorch/models/model.py | 8 +- .../models/predictors/base.py | 1 + .../models/predictors/identity_predictor.py | 2 + .../runners/__init__.py | 16 +- .../pose_estimation_pytorch/runners/base.py | 242 ++--------- .../runners/inference.py | 244 +++++++++++ .../pose_estimation_pytorch/runners/pose.py | 114 ----- .../runners/schedulers.py | 25 ++ .../runners/scoring.py | 254 ----------- .../runners/top_down.py | 137 ------ .../pose_estimation_pytorch/runners/train.py | 370 ++++++++++++++++ .../pose_estimation_pytorch/runners/utils.py | 82 +--- .../runners/bottum_up.py | 2 +- 18 files changed, 725 insertions(+), 1329 deletions(-) delete mode 100644 deeplabcut/pose_estimation_pytorch/apis/inference.py create mode 100644 deeplabcut/pose_estimation_pytorch/runners/inference.py delete mode 100644 deeplabcut/pose_estimation_pytorch/runners/pose.py delete mode 100644 deeplabcut/pose_estimation_pytorch/runners/scoring.py delete mode 100644 deeplabcut/pose_estimation_pytorch/runners/top_down.py create mode 100644 deeplabcut/pose_estimation_pytorch/runners/train.py diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index bfafe504e4..1e5bbb0e89 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -32,7 +32,7 @@ ) from deeplabcut.pose_estimation_pytorch.data import DLCLoader from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity -from deeplabcut.pose_estimation_pytorch.runners import Runner, Task +from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner, Task from deeplabcut.refine_training_dataset.stitch import stitch_tracklets from deeplabcut.utils import VideoReader, auxfun_multianimal, auxiliaryfunctions @@ -86,8 +86,8 @@ def __next__(self) -> np.ndarray | tuple[str, dict[str, Any]]: def video_inference( video_path: str | Path, task: Task, - pose_runner: Runner, - detector_runner: Runner | None = None, + pose_runner: InferenceRunner, + detector_runner: InferenceRunner | None = None, with_identity: bool = False, ) -> list[dict[str, np.ndarray]]: """Runs inference on a video""" @@ -272,9 +272,9 @@ def analyze_videos( detector_runner=detector_runner, ) runtime.append(time.time()) - predictions = predictions[..., :3] - bodyparts = np.stack([p["bodyparts"] for p in predictions]) + # poses must have shape (x, y, score, ...) + bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions]) unique_bodyparts = None if len(predictions) > 0 and "unique_bodyparts" in predictions[0]: unique_bodyparts = np.stack([p["unique_bodyparts"] for p in predictions]) diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 4ef38d1719..060c838f49 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -21,25 +21,26 @@ import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils from deeplabcut.pose_estimation_pytorch.data import DLCLoader, Loader from deeplabcut.pose_estimation_pytorch.apis.scoring import ( + compute_identity_scores, get_scores, - pair_predicted_individuals_with_gt, compute_identity_scores, + pair_predicted_individuals_with_gt, ) from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_predictions_dataframe, ensure_multianimal_df_format, get_runners, ) -from deeplabcut.pose_estimation_pytorch.runners import Runner, Task +from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner, Task from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.visualization import plot_evaluation_results def predict( pose_task: Task, - pose_runner: Runner, + pose_runner: InferenceRunner, loader: Loader, mode: str, - detector_runner: Runner | None = None, + detector_runner: InferenceRunner | None = None, ) -> dict[str, dict[str, np.ndarray]]: """Predicts poses on data contained in a loader @@ -85,10 +86,10 @@ def predict( def evaluate( pose_task: Task, - pose_runner: Runner, + pose_runner: InferenceRunner, loader: Loader, mode: str, - detector_runner: Runner | None = None, + detector_runner: InferenceRunner | None = None, pcutoff: float = 1, ) -> tuple[dict[str, float], dict[str, dict[str, np.ndarray]]]: """ diff --git a/deeplabcut/pose_estimation_pytorch/apis/inference.py b/deeplabcut/pose_estimation_pytorch/apis/inference.py deleted file mode 100644 index 928c1489a2..0000000000 --- a/deeplabcut/pose_estimation_pytorch/apis/inference.py +++ /dev/null @@ -1,397 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from typing import List, Optional, Tuple - -import numpy as np -import torch -from torchvision.ops import box_convert -from torchvision.transforms import Resize as TorchResize - -from deeplabcut.pose_estimation_pytorch.models import PREDICTORS, PoseModel -from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector -from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor -from deeplabcut.pose_estimation_pytorch.post_processing import ( - rmse_match_prediction_to_gt, -) - - -def get_predictions_bottom_up( - model: PoseModel, predictor: BasePredictor, images: torch.Tensor -) -> Tuple[np.array, Optional[np.ndarray]]: - """Gets the predicted coordinates tensor for a bottom_up approach - - Model and images should already be on the same device - - Args: - model (PoseModel): bottom-up model - predictor (BasePredictor): predictor used to regress keypoints coordinates and scores - images (torch.Tensor): input images (should already be normalised and formatted if needed), - shape (batch_size, 3, height, width) - - Returns: - array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions - If there are unique bodyparts, array of shape (batch_size, num_unique_keypoints, 3) - """ - output = model(images) - shape_image = images.shape - scale_factor = ( - shape_image[2] / output[0].shape[2], - shape_image[3] / output[0].shape[3], - ) - pred_dict = predictor(output, scale_factor) - predictions = pred_dict["poses"] - unique_bodyparts = pred_dict.get("unique_bodyparts", None) - if unique_bodyparts is not None: - return predictions.cpu().numpy(), unique_bodyparts["poses"].cpu().numpy() - else: - return predictions.cpu().numpy(), None - - -def get_predictions_top_down( - detector: BaseDetector, - model: PoseModel, - predictor: BasePredictor, - top_down_predictor: BasePredictor, - images: torch.Tensor, - max_num_animals: int, - num_keypoints: int, - resize_object: TorchResize, - ground_truth_bboxes: Optional[torch.Tensor] = None, -) -> Tuple[np.array, Optional[np.ndarray]]: - """ - TODO probably quite bad design, most arguments could be stored somewhere else - Gets the predicted coordinates tensor for a bottom_up approach - - Detector, Model and images should already be on the same device - - Args: - detector (BaseDetector): detector used to detect bboxes, should be in eval mode - model (PoseModel): pose model - predictor (BasePredictor): predictor used to regress keypoints coordinates and - scores in the cropped images - top_down_predictor (BasePredictor): Given the bboxes and the cropped keypoints - coordinates, outputs the regressed keypoints - images (torch.Tensor): input images (should already be normalized and formatted - if needed), shape (batch_size, 3, height, width) - max_num_animals (int) : maximum number of animals to predict - num_keypoints (int) : number of keypoints per animal in the dataset - resize_object: a torch resize transform to resize the cropped images - ground_truth_bboxes: if defined, the detector is ignored and the predicted - bboxes are taken from this list. If defined, must be of shape (batch_size, - max_num_animals, xyxy). - - Returns: - array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions - None as unique bodyparts is currently not supported by top down but still returned by the function - for coherence over the repo - """ - batch_size = images.shape[0] - - if ground_truth_bboxes is not None: - boxes = ground_truth_bboxes - else: - output_detector = detector(images) - boxes = torch.zeros((batch_size, max_num_animals, 4)) - for b, item in enumerate(output_detector): - boxes[b][: min(max_num_animals, len(item["boxes"]))] = item["boxes"][ - :max_num_animals - ] # Boxes should be sorted by scores, only keep the maximum number allowed - - boxes = boxes.int() - cropped_kpts_total = torch.full( - (batch_size, max_num_animals, num_keypoints, 3), -1.0 - ) - - for b in range(batch_size): - for j, box in enumerate(boxes[b]): - if (box == 0.0).all(): - continue - cropped_image = images[b][:, box[1] : box[3] + 1, box[0] : box[2] + 1] - cropped_image = resize_object(cropped_image).unsqueeze(0) - heatmaps = model(cropped_image) - - scale_factors_cropped = ( - cropped_image.shape[2] / heatmaps[0].shape[2], - cropped_image.shape[3] / heatmaps[0].shape[3], - ) - - pred_dict = predictor(heatmaps, scale_factors_cropped) - cropped_kpts = pred_dict["poses"] - cropped_kpts_total[b, j, :] = cropped_kpts[0, 0] - - final_pred_dict = top_down_predictor(boxes, cropped_kpts_total) - final_predictions = final_pred_dict["poses"] - return final_predictions.cpu().numpy(), None - - -def get_detections_batch( - detector: BaseDetector, images: torch.Tensor, max_num_animals: int -) -> torch.Tensor: - """Given a batch of images, outputs the predicted bboxes. - - Args: - detector: detector model - images: batch of images, shape (batch_size, 3, height, width) - max_num_animals: maximum number of accepted detections - - Returns: - The coordinates of the bounding boxes shape (batch_size, max_num_animals, 4) - """ - batch_size = images.shape[0] - - output_detector = detector(images) - - boxes = torch.zeros((batch_size, max_num_animals, 4)) - for b, item in enumerate(output_detector): - boxes[b][: min(max_num_animals, len(item["boxes"]))] = item["boxes"][ - :max_num_animals - ] # Boxes should be sorted by scores, only keep the maximum number allowed - boxes = boxes.int() - - return boxes - - -def get_pose_batch( - pose_model: PoseModel, predictor: BasePredictor, cropped_images: torch.Tensor -) -> torch.Tensor: - """Given a batch of cropped images, outputs a batch of predicted pose coordinates. - Coordinates are still in cropped image space and needs to be handled accordingly to - be back in input space. - - Should only be used for top down with a predictor for single animal - - Args: - pose_model: pose_estimation model - predictor: regresses the coordinates of the keypoints inside the cropped images - Must be a single animal predictor - cropped_images: Batch of cropped images for the top down pose_estimation - - Returns: - Tensor of the estimated poses (inside the cropped image), shape (batch_size, num_joints, 3) - """ - outputs = pose_model(cropped_images) - - scale_factors_cropped = ( - cropped_images.shape[2] / outputs[0].shape[2], - cropped_images.shape[3] / outputs[0].shape[3], - ) - - # Predictor always returns num_animals as 2nd dimension even for single animal ones - # Hence the slicing - pred_dict = predictor(outputs, scale_factors_cropped) - poses = pred_dict["poses"][:, 0] - - return poses - - -def match_predicted_individuals_to_annotations( - predictions: np.ndarray, ground_truth: List[np.ndarray], max_individuals: int -) -> None: - """ - Uses RMSE to match predicted individuals to frame annotations for a batch of - frames. This method is preferred to OKS, as OKS needs at least 2 annotated - keypoints per animal (to compute area) - - The prediction arrays are modified in-place, where the order of elements are - swapped in 2nd dimension (individuals) such that the keypoints in predictions[b][i] - is matched to the ground truth annotations of ground_truth[b][i] - - Args: - predictions: (batch, individual, keypoints, 3) predicted keypoints - ground_truth: list containing "batch" (individual, keypoints, 2) ground truth - keypoint arrays - max_individuals: the maximum number of individuals in a frame - """ - if max_individuals > 1: - for b in range(predictions.shape[0]): - match_individuals = rmse_match_prediction_to_gt( - predictions[b], ground_truth[b] - ) - predictions[b] = predictions[b][match_individuals] - - -def resize_batch_predictions( - predictions: np.ndarray, original_sizes: np.ndarray, image_shape: Tuple[int, int] -) -> None: - """ - Converts keypoint coordinates to their values in the original image. Call if the - image was resized during the image augmentation pipeline. - - Modifies the prediction array in-place. - - Args: - predictions: (batch, individual, keypoints, 3) predicted keypoints - original_sizes: shape (batch, 3); the original (w, h, c) for images - image_shape: the (width, height) for the image given to the model - """ - for b in range(predictions.shape[0]): - resizing_factor = ( - (original_sizes[b][0] / image_shape[0]).item(), - (original_sizes[b][1] / image_shape[1]).item(), - ) - predictions[b, :, :, 0] = ( - predictions[b, :, :, 0] * resizing_factor[1] + resizing_factor[1] / 2 - ) - predictions[b, :, :, 1] = ( - predictions[b, :, :, 1] * resizing_factor[0] + resizing_factor[0] / 2 - ) - - -def inference( - dataloader: torch.utils.data.DataLoader, - model: PoseModel, - predictor: BasePredictor, - method: str, - max_individuals: int, - num_keypoints: int, - device: str, - align_predictions_to_ground_truth: bool, - images_resized_with_transform: bool, - detector: Optional[BaseDetector] = None, - use_ground_truth_bboxes: bool = False, -) -> Tuple[np.ndarray, Optional[np.ndarray]]: - """ - Runs inference for a pose estimation model. - - Args: - dataloader: contains the data to run inference on - model: the pose estimation model to use for inference - predictor: predictor used to obtain keypoints from the model output - method: either `"td"` (top-down) or `"bu"` (bottom-up) - max_individuals: the maximum number of individuals detected in a frame - num_keypoints: the number of keypoints per individual - device: the device on which to run inference - align_predictions_to_ground_truth: whether to align predictions to ground truth - individuals in the output predictions (prediction i is closest to ground - truth individual i) - images_resized_with_transform: whether the image is resized by the transform - detector: None when `method="bu"`. The detector to use when `method="td"`. - use_ground_truth_bboxes: For top-down models, whether to make pose predictions - using ground truth bbox annotations (which the dataset must contain). - - Returns: - array of shape (batch_size, num_animals, num_keypoints, 3) for pose predictions - None if there are no unique bodyparts, otherwise array of shape (batch_size, num_keypoints, 3) - for unique bodypart predictions - """ - if method.lower() == "td": - if detector is None: - raise ValueError( - f"A detector must be provided when running inference for a top-down " - f"pose estimator!" - ) - - detector.eval() - detector.to(device) - elif method.lower() == "bu": - if detector is not None: - raise ValueError( - f"A detector was provided when running inference for a bottom-up " - f"which is not possible!" - ) - else: - raise ValueError(f"Unknown method: {method}. Choose 'td' or 'bu'.") - - model.eval() - model.to(device) - predictor.eval() - predictor.to(device) - - top_down_predictor = None - resize_object = None - if hasattr(predictor, "unique_bodyparts"): - compute_unique_bpts = predictor.unique_bodyparts - else: - compute_unique_bpts = False - if method == "td": - top_down_predictor = PREDICTORS.build( - {"type": "TopDownPredictor", "format_bbox": "xyxy"} - ) - top_down_predictor.eval() - top_down_predictor.to(device) - - resize_object = TorchResize((256, 256)) # TODO hardcoded 256 - - predicted_poses = [] - unique_poses = [] - with torch.no_grad(): - for item in dataloader: - item["image"] = item["image"].to(device) - image_shape = item["image"].shape # b, c, w, h - if method == "td": - # TODO unique_bodyparts not supported by top down, it is None here - gt_bboxes = None - if use_ground_truth_bboxes: - boxes_xywh = item.get("annotations", {}).get("boxes") - if boxes_xywh is None: - raise ValueError( - f"Using ground truth bboxes for inference, but there are none defined" - ) - gt_bboxes = box_convert(boxes_xywh.reshape(-1, 4), "xywh", "xyxy") - gt_bboxes = gt_bboxes.reshape(boxes_xywh.shape) - - predictions, unique_pred = get_predictions_top_down( - detector=detector, - model=model, - predictor=predictor, - top_down_predictor=top_down_predictor, - images=item["image"], - max_num_animals=max_individuals, - num_keypoints=num_keypoints, - resize_object=resize_object, - ground_truth_bboxes=gt_bboxes, - ) - else: - predictions, unique_pred = get_predictions_bottom_up( - model=model, predictor=predictor, images=item["image"] - ) - - if align_predictions_to_ground_truth: - match_predicted_individuals_to_annotations( - predictions=predictions, - ground_truth=[ - kpts.cpu().numpy() for kpts in item["annotations"]["keypoints"] - ], - max_individuals=max_individuals, - ) - - if images_resized_with_transform: - original_sizes = torch.stack(item["original_size"], dim=1) - resize_batch_predictions( - predictions=predictions, - original_sizes=original_sizes.cpu().numpy(), - image_shape=(image_shape[2], image_shape[3]), - ) - if compute_unique_bpts: - resize_batch_predictions( - predictions=unique_pred, - original_sizes=original_sizes.cpu().numpy(), - image_shape=(image_shape[2], image_shape[3]), - ) - predicted_poses.append(predictions) - if compute_unique_bpts: - unique_poses.append(unique_pred) - - if len(predicted_poses) > 0: - predicted_poses = np.concatenate(predicted_poses, axis=0) - else: - predicted_poses = np.zeros((0, max_individuals, num_keypoints, 3)) - - if compute_unique_bpts: - num_unique_bpts = unique_poses[0].shape[2] - if len(unique_poses) > 0: - unique_poses = np.concatenate(unique_poses, axis=0) - else: - unique_poses = np.zeros((0, 1, num_unique_bpts, 3)) - else: - unique_poses = None - - return predicted_poses, unique_poses diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 1d5dda8ac2..fbed4da79f 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -23,9 +23,10 @@ from deeplabcut import auxiliaryfunctions from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_inference_transform, - build_runner, + build_optimizer, build_transforms, ) +from deeplabcut.pose_estimation_pytorch.runners.schedulers import build_scheduler from deeplabcut.pose_estimation_pytorch.config import ( pretty_print_config, read_config_as_dict, @@ -33,7 +34,7 @@ ) from deeplabcut.pose_estimation_pytorch.data import Loader, DLCLoader from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel -from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.runners import Task, build_training_runner from deeplabcut.pose_estimation_pytorch.runners.logger import ( LOGGER, destroy_file_logging, @@ -71,19 +72,23 @@ def train( if task == Task.DETECT: model = DETECTORS.build(run_config["model"]) else: - model = PoseModel.from_cfg(run_config["model"]) + model = PoseModel.build(run_config["model"]) - # TODO: Log the configuration file - # Model should not be needed when building the logger logger = None if logger_config is not None: logger = LOGGER.build(dict(**logger_config, model=model)) logger.log_config(run_config) - runner = build_runner( - run_cfg=run_config, + model.to(device) # Move model before giving its parameters to the optimizer + optimizer = build_optimizer(run_config["optimizer"], model) + scheduler = build_scheduler(run_config["scheduler"], optimizer) + + runner = build_training_runner( + task=task, model=model, + optimizer=optimizer, device=device, + scheduler=scheduler, snapshot_path=snapshot_path, logger=logger, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index ec3029f7d0..e57ff59d11 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -23,13 +23,11 @@ from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( - Postprocessor, build_bottom_up_postprocessor, build_detector_postprocessor, build_top_down_postprocessor, ) from deeplabcut.pose_estimation_pytorch.data.preprocessor import ( - Preprocessor, build_bottom_up_preprocessor, build_top_down_preprocessor, ) @@ -41,9 +39,11 @@ KeypointAwareCrop, ) from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel -from deeplabcut.pose_estimation_pytorch.runners import RUNNERS, Runner, Task -from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger -from deeplabcut.pose_estimation_pytorch.runners.schedulers import LRListScheduler +from deeplabcut.pose_estimation_pytorch.runners import ( + InferenceRunner, + Task, + build_inference_runner, +) from deeplabcut.utils import auxfun_videos @@ -61,83 +61,6 @@ def build_optimizer(optimizer_cfg: dict, model: nn.Module) -> torch.optim.Optimi return get_optimizer(params=model.parameters(), **optimizer_cfg["params"]) -def build_scheduler( - scheduler_cfg: dict | None, optimizer: torch.optim.Optimizer -) -> torch.optim.lr_scheduler.LRScheduler | None: - """Builds a scheduler from a configuration, if defined - - Args: - scheduler_cfg: the configuration of the scheduler to build - optimizer: the optimizer the scheduler will be built for - - Returns: - None if scheduler_cfg is None, otherwise the scheduler - """ - if scheduler_cfg is None: - return None - - if scheduler_cfg["type"] == "LRListScheduler": - scheduler = LRListScheduler - else: - scheduler = getattr(torch.optim.lr_scheduler, scheduler_cfg["type"]) - - return scheduler(optimizer=optimizer, **scheduler_cfg["params"]) - - -def build_pose_model(pytorch_cfg: dict) -> PoseModel: - """ - TODO: Deprecated but still used in analyze_videos - - Args: - pytorch_cfg : entire pytorch config - - Returns a pytorch pose model based on pytorch config - """ - return PoseModel.from_cfg(pytorch_cfg["model"]) - - -def build_runner( - run_cfg: dict, - model: nn.Module, - device: str, - snapshot_path: str | None, - logger: BaseLogger | None = None, - preprocessor: Preprocessor | None = None, - postprocessor: Postprocessor | None = None, -) -> Runner: - """ - Build a runner object according to a pytorch configuration file - - Args: - run_cfg: config dictionary to build the runner - model: the model to run - device: the device to run on - snapshot_path: the snapshot from which to load the weights - logger: the logger to use, if any - preprocessor: the preprocessor to use on images before inference - postprocessor: the postprocessor to use on images after inference - - Returns: - the runner - """ - model.to(device) # Move model before giving its parameters to the optimizer - optimizer = build_optimizer(run_cfg["optimizer"], model) - scheduler = build_scheduler(run_cfg["scheduler"], optimizer) - return RUNNERS.build( - dict( - **run_cfg["runner"], - model=model, - optimizer=optimizer, - device=device, - snapshot_path=snapshot_path, - scheduler=scheduler, - logger=logger, - preprocessor=preprocessor, - postprocessor=postprocessor, - ) - ) - - def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose: """ Returns the transformation pipeline based on config @@ -366,21 +289,6 @@ def list_videos_in_folder( return [video_path] -def update_config_parameters(pytorch_config: dict, **kwargs) -> None: - """ - Overwrites the pytorch config dictionary to correspond to the command line input keys - - Args: - pytorch_config - **kwargs : any arguments that can be found as entry for the pytorch config - - Return: - None - """ - for key in kwargs.keys(): - pytorch_config[key] = kwargs[key] - - def build_auto_padding( min_height: int | None = None, min_width: int | None = None, @@ -538,7 +446,7 @@ def get_runners( transform: A.BaseCompose | None = None, detector_path: str | None = None, detector_transform: A.BaseCompose | None = None, -) -> tuple[Runner, Runner | None]: +) -> tuple[InferenceRunner, InferenceRunner | None]: """Builds the runners for pose estimation Args: @@ -593,12 +501,11 @@ def get_runners( pytorch_config["data_detector"] ) - detector_runner = build_runner( - run_cfg=pytorch_config["detector"], + detector_runner = build_inference_runner( + task=Task.DETECT, model=DETECTORS.build(pytorch_config["detector"]["model"]), device=device, - snapshot_path=detector_path, - logger=None, # No logging for evaluation + snapshot_path=snapshot_path, preprocessor=build_bottom_up_preprocessor( color_mode="RGB", # TODO: read from Loader transform=detector_transform, @@ -606,12 +513,11 @@ def get_runners( postprocessor=build_detector_postprocessor(), ) - pose_runner = build_runner( - run_cfg=pytorch_config, - model=PoseModel.from_cfg(pytorch_config["model"]), + pose_runner = build_inference_runner( + task=pose_task, + model=PoseModel.build(pytorch_config["model"]), device=device, snapshot_path=snapshot_path, - logger=None, # No logging for evaluation preprocessor=pose_preprocessor, postprocessor=pose_postprocessor, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 883c86fb50..ac1b14bb1b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -140,7 +140,7 @@ def get_predictions( } @staticmethod - def from_cfg(cfg: dict) -> "PoseModel": + def build(cfg: dict) -> "PoseModel": backbone = BACKBONES.build(dict(cfg["backbone"])) neck = None @@ -173,5 +173,9 @@ def from_cfg(cfg: dict) -> "PoseModel": heads[name] = HEADS.build(head_cfg) return PoseModel( - cfg=cfg, backbone=backbone, neck=neck, heads=heads, **cfg.get("pose_model", {}) + cfg=cfg, + backbone=backbone, + neck=neck, + heads=heads, + **cfg.get("pose_model", {}) ) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index 3f29f5991d..a5fe617368 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -8,6 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations from abc import ABC, abstractmethod diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py index ef9c562be2..8b0196f03f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py @@ -2,6 +2,8 @@ """ +from __future__ import annotations + import torch import torch.nn as nn import torchvision.transforms.functional as F diff --git a/deeplabcut/pose_estimation_pytorch/runners/__init__.py b/deeplabcut/pose_estimation_pytorch/runners/__init__.py index 2883420f08..5a5b1b86a9 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/runners/__init__.py @@ -9,7 +9,17 @@ # Licensed under GNU Lesser General Public License v3.0 # -from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner, Task +from deeplabcut.pose_estimation_pytorch.runners.base import Runner, Task from deeplabcut.pose_estimation_pytorch.runners.logger import LOGGER -from deeplabcut.pose_estimation_pytorch.runners.pose import PoseRunner -from deeplabcut.pose_estimation_pytorch.runners.top_down import DetectorRunner +from deeplabcut.pose_estimation_pytorch.runners.inference import ( + build_inference_runner, + DetectorInferenceRunner, + InferenceRunner, + PoseInferenceRunner, +) +from deeplabcut.pose_estimation_pytorch.runners.train import ( + build_training_runner, + DetectorTrainingRunner, + PoseTrainingRunner, + TrainingRunner, +) \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index 27f4b75c15..6d2f03ec93 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -10,24 +10,15 @@ # from __future__ import annotations -import logging -from abc import ABC, abstractmethod -from collections import defaultdict +from abc import ABC from enum import Enum from pathlib import Path -from typing import Any, Generic, Iterable, TypeVar +from typing import Generic, TypeVar -import numpy as np import torch import torch.nn as nn -from torch.utils.data import DataLoader -from deeplabcut.pose_estimation_pytorch.data.postprocessor import Postprocessor -from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger -RUNNERS = Registry("runners", build_func=build_from_cfg) ModelType = TypeVar("ModelType", bound=nn.Module) @@ -57,224 +48,39 @@ class Runner(ABC, Generic[ModelType]): def __init__( self, model: ModelType, - optimizer: torch.optim.Optimizer, device: str = "cpu", - snapshot_prefix: str = "snapshot", - snapshot_path: str | None = None, - scheduler: torch.optim.lr_scheduler.LRScheduler | None = None, - logger: BaseLogger | None = None, - preprocessor: Preprocessor | None = None, - postprocessor: Postprocessor | None = None, + snapshot_path: str | Path | None = None, ): """ Args: - model: the model to run actions on - optimizer: the optimizer to use when fitting the model - device: one of {'cpu', 'cuda', 'mps'}; the device to use for training/inference - snapshot_prefix: the prefix with which to save snapshots - snapshot_path: if defined, the path of a snapshot from which to load pretrained weights - scheduler: Scheduler for adjusting the lr of the optimizer. - logger: logger to monitor training (e.g WandB logger) - preprocessor: the preprocessor to use on images before inference - postprocessor: the postprocessor to use on images after inference + model: the model to run + device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) + snapshot_path: the path of a snapshot from which to load model weights """ self.model = model self.device = device - self.optimizer = optimizer - self.scheduler = scheduler - self.history: dict[str, list] = {"train_loss": [], "eval_loss": []} - self.snapshot_prefix = snapshot_prefix - self.logger = logger - self.preprocessor = preprocessor - self.postprocessor = postprocessor + self.snapshot_path = snapshot_path - self.starting_epoch = 0 - if snapshot_path: - snapshot = torch.load(snapshot_path, map_location=device) - self.model.load_state_dict(snapshot["model_state_dict"]) - self.optimizer.load_state_dict(snapshot["optimizer_state_dict"]) - self.starting_epoch = snapshot["epoch"] - - @abstractmethod - def step( - self, batch: dict[str, Any], mode: str = "train" - ) -> dict[str, torch.Tensor]: - """Perform a single epoch gradient update or validation step""" - - @abstractmethod - def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: - """Makes predictions from a model input and output - - Args: - the inputs to the model, of shape (batch_size, ...) - - Returns: - the predictions for each of the 'batch_size' inputs - """ - - def fit( - self, - train_loader: DataLoader, - valid_loader: DataLoader, - model_folder: str, - epochs: int, - save_epochs: int, - display_iters: int, - *args, - **kwargs, - ) -> None: - """Train model for the specified number of steps. - - Args: - train_loader: Data loader, which is an iterator over train instances. - Each batch contains image tensor and heat maps tensor input samples. - valid_loader: Data loader used for validation of the model. - model_folder: the folder to which logs should be written and snapshots saved - epochs: The number of training epochs. - save_epochs: The epoch step at which to save models - display_iters: The number of iterations between each loss print - - Example: - runner = Runner(model, optimizer, cfg, device='cuda') - runner.fit(train_loader, valid_loader, "example/models" epochs=50) - """ - Path(model_folder).mkdir(exist_ok=True, parents=True) - self.model.to(self.device) - - for i in range(self.starting_epoch, epochs): - train_loss = self._epoch( - train_loader, mode="train", step=i + 1, display_iters=display_iters - ) - if self.scheduler: - self.scheduler.step() - - logging.info( - f"Training for epoch {i + 1} done, starting eval on validation data" - ) - valid_loss = self._epoch( - valid_loader, mode="eval", step=i + 1, display_iters=display_iters - ) - - if (i + 1) % save_epochs == 0: - logging.info(f"Finished epoch {i + 1}; saving model") - torch.save( - { - "model_state_dict": self.model.state_dict(), - "epoch": i + 1, - "optimizer_state_dict": self.optimizer.state_dict(), - "train_loss": train_loss, - "validation_loss": valid_loss, - }, - f"{model_folder}/train/{self.snapshot_prefix}-{i + 1}.pt", - ) - - logging.info( - f"Epoch {i + 1}/{epochs}, " - f"train loss {float(train_loss):.5f}, " - f"valid loss {float(valid_loss):.5f}, " - f'lr {self.optimizer.param_groups[0]["lr"]}' - ) - - @torch.no_grad() - def inference( - self, - images: Iterable[str | np.ndarray] - | Iterable[tuple[str | np.ndarray, dict[str, Any]]], - ) -> list[dict[str, np.ndarray]]: - """Run model inference on the given dataset - - TODO: Add an option to also return head outputs (such as heatmaps)? Can be - super useful for debugging - - Args: - images: the images to run inference on, optionally with context - - Returns: - a dict containing head predictions for each image - [ - { - "bodypart": {"poses": np.array}, - "unique_bodypart": "poses": np.array}, - } - ] + @staticmethod + def load_snapshot( + snapshot_path: str, + device: str, + model: ModelType, + optimizer: torch.optim.Optimizer | None = None, + ) -> int: """ - self.model.to(self.device) - self.model.eval() - - results = [] - for data in images: - if isinstance(data, (str, np.ndarray)): - input_image, context = data, {} - else: - input_image, context = data - - if self.preprocessor is not None: - # TODO: input batch should also be able to be a dict[str, torch.Tensor] - input_image, context = self.preprocessor(input_image, context) - - image_predictions = self.predict(input_image) - if self.postprocessor is not None: - # TODO: Should we return context? - # TODO: typing update - the post-processor can remove a dict level - image_predictions, _ = self.postprocessor(image_predictions, context) - - results.append(image_predictions) - - return results - - def _epoch( - self, - loader: torch.utils.data.DataLoader, - mode: str = "train", - step: int | None = None, - display_iters: int = 500, - ) -> float: - """Facilitates training over an epoch. Returns the loss over the batches. - Args: - loader: Data loader, which is an iterator over instances. - Each batch contains image tensor and heat maps tensor input samples. - mode: str identifier to instruct the Runner whether to train or evaluate. - Possible values are: "train" or "eval". - step: the global step in processing, used to log metrics. Defaults to None. - display_iters: the number of iterations between each loss print - - Raises: - ValueError: When the given mode is invalid + snapshot_path: the path containing the model weights to load + device: the device on which to load the model + model: the model for which the weights are loaded + optimizer: if defined, the optimizer weights to load Returns: - epoch_loss: Average of the loss over the batches. + the number of epochs the model was trained for """ - if mode == "train": - self.model.train() - elif mode == "eval" or mode == "inference": - self.model.eval() - else: - raise ValueError(f"Runner mode must be train or eval, found mode={mode}.") - - epoch_loss = [] - metrics = defaultdict(list) - for i, batch in enumerate(loader): - losses_dict = self.step(batch, mode) - epoch_loss.append(losses_dict["total_loss"]) - - for key in losses_dict.keys(): - metrics[key].append(losses_dict[key]) - - if (i + 1) % display_iters == 0: - logging.info( - f"Number of iterations: {i + 1}, " - f"loss: {losses_dict['total_loss']:.5f}, " - f"lr: {self.optimizer.param_groups[0]['lr']}" - ) - - epoch_loss = np.mean(epoch_loss).item() - self.history[f"{mode}_loss"].append(epoch_loss) - - if self.logger: - for key in metrics: - self.logger.log( - f"{mode} {key}", np.nanmean(metrics[key]).item(), step=step - ) + snapshot = torch.load(snapshot_path, map_location=device) + model.load_state_dict(snapshot["model_state_dict"]) + if optimizer is not None: + optimizer.load_state_dict(snapshot["optimizer_state_dict"]) - return epoch_loss + return snapshot["epoch"] diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py new file mode 100644 index 0000000000..d46b1962e7 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -0,0 +1,244 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Any, Generic, Iterable + +import numpy as np +import torch +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.data.postprocessor import Postprocessor +from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor +from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner, Task + + +class InferenceRunner(Runner, Generic[ModelType], metaclass=ABCMeta): + """Base class for inference runners + + A runner takes a model and runs actions on it, such as training or inference + """ + + def __init__( + self, + model: ModelType, + device: str = "cpu", + snapshot_path: str | None = None, + preprocessor: Preprocessor | None = None, + postprocessor: Postprocessor | None = None, + ): + """ + Args: + model: the model to run actions on + device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) + snapshot_path: if defined, the path of a snapshot from which to load pretrained weights + preprocessor: the preprocessor to use on images before inference + postprocessor: the postprocessor to use on images after inference + """ + super().__init__(model=model, device=device, snapshot_path=snapshot_path) + self.preprocessor = preprocessor + self.postprocessor = postprocessor + + if self.snapshot_path is not None and len(self.snapshot_path) > 0: + self.load_snapshot(self.snapshot_path, self.device, self.model) + + @abstractmethod + def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: + """Makes predictions from a model input and output + + Args: + the inputs to the model, of shape (batch_size, ...) + + Returns: + the predictions for each of the 'batch_size' inputs + """ + + @torch.no_grad() + def inference( + self, + images: Iterable[str | np.ndarray] + | Iterable[tuple[str | np.ndarray, dict[str, Any]]], + ) -> list[dict[str, np.ndarray]]: + """Run model inference on the given dataset + + TODO: Add an option to also return head outputs (such as heatmaps)? Can be + super useful for debugging + + Args: + images: the images to run inference on, optionally with context + + Returns: + a dict containing head predictions for each image + [ + { + "bodypart": {"poses": np.array}, + "unique_bodypart": "poses": np.array}, + } + ] + """ + self.model.to(self.device) + self.model.eval() + + results = [] + for data in images: + if isinstance(data, (str, np.ndarray)): + input_image, context = data, {} + else: + input_image, context = data + + if self.preprocessor is not None: + # TODO: input batch should also be able to be a dict[str, torch.Tensor] + input_image, context = self.preprocessor(input_image, context) + + image_predictions = self.predict(input_image) + if self.postprocessor is not None: + # TODO: Should we return context? + # TODO: typing update - the post-processor can remove a dict level + image_predictions, _ = self.postprocessor(image_predictions, context) + + results.append(image_predictions) + + return results + + +class PoseInferenceRunner(InferenceRunner[PoseModel]): + """Runner for pose estimation inference""" + + def __init__(self, model: PoseModel, **kwargs): + super().__init__(model, **kwargs) + + def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: + """Makes predictions from a model input and output + + Args: + the inputs to the model, of shape (batch_size, ...) + + Returns: + predictions for each of the 'batch_size' inputs, made by each head, e.g. + [ + { + "bodypart": {"poses": np.ndarray}, + "unique_bodypart": "poses": np.ndarray}, + ] + """ + # TODO: iterates over batch one element at a time + batch_size = 1 + batch_predictions = [] + for i in range(0, len(inputs), batch_size): + batch_inputs = inputs[i : i + batch_size] + batch_inputs = batch_inputs.to(self.device) + batch_outputs = self.model(batch_inputs) + raw_predictions = self.model.get_predictions(batch_inputs, batch_outputs) + + for b in range(batch_size): + image_predictions = {} + for head, head_outputs in raw_predictions.items(): + image_predictions[head] = {} + for pred_name, pred in head_outputs.items(): + image_predictions[head][pred_name] = pred[b].cpu().numpy() + batch_predictions.append(image_predictions) + + return batch_predictions + + +class DetectorInferenceRunner(InferenceRunner[BaseDetector]): + """Runner for object detection inference""" + + def __init__(self, model: BaseDetector, max_individuals: int, **kwargs): + """ + Args: + model: The detector to use for inference. + max_individuals: The maximum number of detections to make for a single + frame. When calling predict, at most `max_individuals` bounding boxes + will be returned. + **kwargs: Inference runner kwargs. + """ + super().__init__(model, **kwargs) + self.max_individuals = max_individuals + + def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: + """Makes predictions from a model input and output + + Args: + the inputs to the model, of shape (batch_size, ...) + + Returns: + predictions for each of the 'batch_size' inputs, made by each head, e.g. + [ + { + "bodypart": {"poses": np.ndarray}, + "unique_bodypart": "poses": np.ndarray}, + ] + """ + # TODO: iterates over batch one element at a time + batch_size = 1 + batch_predictions = [] + for i in range(0, len(inputs), batch_size): + batch_inputs = inputs[i : i + batch_size] + batch_inputs = batch_inputs.to(self.device) + _, raw_predictions = self.model(batch_inputs) + + for b, item in enumerate(raw_predictions): + # take the top-k bounding boxes as individuals + batch_predictions.append( + { + "detection": { + "bboxes": item["boxes"][: self.max_individuals] + .cpu() + .numpy() + .reshape(-1, 4), + "scores": item["scores"][: self.max_individuals] + .cpu() + .numpy() + .reshape(-1), + } + } + ) + + return batch_predictions + + +def build_inference_runner( + task: Task, + model: nn.Module, + device: str, + snapshot_path: str, + preprocessor: Preprocessor | None = None, + postprocessor: Postprocessor | None = None, +) -> InferenceRunner: + """ + Build a runner object according to a pytorch configuration file + + Args: + task: the inference task to run + model: the model to run + device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) + snapshot_path: the snapshot from which to load the weights + preprocessor: the preprocessor to use on images before inference + postprocessor: the postprocessor to use on images after inference + + Returns: + the inference runner + """ + kwargs = dict( + model=model, + device=device, + snapshot_path=snapshot_path, + preprocessor=preprocessor, + postprocessor=postprocessor, + ) + if task == Task.DETECT: + return DetectorInferenceRunner(**kwargs) + + return PoseInferenceRunner(**kwargs) diff --git a/deeplabcut/pose_estimation_pytorch/runners/pose.py b/deeplabcut/pose_estimation_pytorch/runners/pose.py deleted file mode 100644 index 8e3977b430..0000000000 --- a/deeplabcut/pose_estimation_pytorch/runners/pose.py +++ /dev/null @@ -1,114 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from __future__ import annotations - -from typing import Any - -import numpy as np -import torch - -from deeplabcut.pose_estimation_pytorch.models import model as models -from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner - - -@RUNNERS.register_module -class PoseRunner(Runner[models.PoseModel]): - """Runner for pose estimation""" - - def __init__( - self, model: models.PoseModel, optimizer: torch.optim.Optimizer, **kwargs - ): - """TODO: Update doc to generic (not pose) runner. Constructor of the Runner class. - Args: - model: The neural network for solving pose estimation task. - optimizer: A PyTorch optimizer for updating model parameters. - kwargs: Runner kwargs - - Returns: - None - - Notes/TODO: - Read stride from config file - """ - super().__init__(model, optimizer, **kwargs) - - def step( - self, batch: dict[str, Any], mode: str = "train" - ) -> dict[str, torch.Tensor]: - """Perform a single epoch gradient update or validation step. - - Args: - batch: Tuple of input image(s) and target(s) for train or valid single step. - mode: `train` or `eval`. Defaults to "train". - - Raises: - ValueError: "Runner must be in train or eval mode, but {mode} was found." - - Returns: - dict: { - "total_loss": aggregate_loss, - "aux_loss_1": loss_value, - ..., - } - """ - if mode not in ["train", "eval"]: - raise ValueError( - f"BottomUpSolver must be in train or eval mode, but {mode} was found." - ) - - if mode == "train": - self.optimizer.zero_grad() - - batch_inputs = batch["image"] - batch_inputs = batch_inputs.to(self.device) - head_outputs = self.model(batch_inputs) - - target = self.model.get_target(batch_inputs, head_outputs, batch["annotations"]) - - losses_dict = self.model.get_loss(head_outputs, target) - if mode == "train": - losses_dict["total_loss"].backward() - self.optimizer.step() - - return {k: v.detach().cpu().numpy() for k, v in losses_dict.items()} - - def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: - """Makes predictions from a model input and output - - Args: - the inputs to the model, of shape (batch_size, ...) - - Returns: - predictions for each of the 'batch_size' inputs, made by each head, e.g. - [ - { - "bodypart": {"poses": np.ndarray}, - "unique_bodypart": "poses": np.ndarray}, - ] - """ - # TODO: iterates over batch one element at a time - batch_size = 1 - batch_predictions = [] - for i in range(0, len(inputs), batch_size): - batch_inputs = inputs[i : i + batch_size] - batch_inputs = batch_inputs.to(self.device) - batch_outputs = self.model(batch_inputs) - raw_predictions = self.model.get_predictions(batch_inputs, batch_outputs) - - for b in range(batch_size): - image_predictions = {} - for head, head_outputs in raw_predictions.items(): - image_predictions[head] = {} - for pred_name, pred in head_outputs.items(): - image_predictions[head][pred_name] = pred[b].cpu().numpy() - batch_predictions.append(image_predictions) - - return batch_predictions diff --git a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py index 52b2ca1832..4727deda08 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py +++ b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py @@ -8,7 +8,9 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations +import torch from torch.optim.lr_scheduler import _LRScheduler @@ -65,3 +67,26 @@ def get_lr(self): if self.last_epoch not in self.milestones: return [group["lr"] for group in self.optimizer.param_groups] return [lr for lr in self.lr_list[self.milestones.index(self.last_epoch)]] + + +def build_scheduler( + scheduler_cfg: dict | None, optimizer: torch.optim.Optimizer +) -> torch.optim.lr_scheduler.LRScheduler | None: + """Builds a scheduler from a configuration, if defined + + Args: + scheduler_cfg: the configuration of the scheduler to build + optimizer: the optimizer the scheduler will be built for + + Returns: + None if scheduler_cfg is None, otherwise the scheduler + """ + if scheduler_cfg is None: + return None + + if scheduler_cfg["type"] == "LRListScheduler": + scheduler = LRListScheduler + else: + scheduler = getattr(torch.optim.lr_scheduler, scheduler_cfg["type"]) + + return scheduler(optimizer=optimizer, **scheduler_cfg["params"]) diff --git a/deeplabcut/pose_estimation_pytorch/runners/scoring.py b/deeplabcut/pose_estimation_pytorch/runners/scoring.py deleted file mode 100644 index 72014fd9e6..0000000000 --- a/deeplabcut/pose_estimation_pytorch/runners/scoring.py +++ /dev/null @@ -1,254 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from __future__ import annotations - -import numpy as np -import pandas as pd - -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( - Assembly, - evaluate_assembly, -) - - -def get_scores( - prediction: pd.DataFrame, - target: pd.DataFrame, - pcutoff: float | None = None, - bodyparts: list[str] | None = None, -) -> dict[str, float]: - """Computes for the different scores given the grount truth and the predictions. - - The different scores computed are based on the COCO metrics: https://cocodataset.org/#keypoints-eval - RMSE (Root Mean Square Error) - OKS mAP (Mean Average Precision) - OKS mAR (Mean Average Recall) - - Args: - prediction: prediction df, should already be matched to ground truth using - Hungarian Algorithm (Ref: https://brilliant.org/wiki/hungarian-matching/) - target: ground truth dataframe - pcutoff: the value used to compute the pcutoff scores - bodyparts: names of the bodyparts. Defaults to None. - - Returns: - scores: A dictionary of scores containign the following keys: - ['rmse', 'rmse_pcutoff', 'mAP', 'mAR', 'mAP_pcutoff', 'mAR_pcutoff'] - - Examples: - >>> # Define the p-cutoff, prediction, and target DataFrames - >>> pcutoff = 0.5 - >>> prediction = pd.DataFrame(...) # Your DataFrame here - >>> target = pd.DataFrame(...) # Your DataFrame here - >>> # Compute the scores - >>> scores = get_scores(prediction, target, pcutoff) - >>> print(scores) - { - 'rmse': 0.156, - 'rmse_pcutoff': 0.115, - 'mAP': 84.2, - 'mAR': 74.5, - 'mAP_pcutoff': 91.3, - 'mAR_pcutoff': 82.5 - } # Sample output scores - """ - if pcutoff is None: - pcutoff = -1 - - rmse, rmse_p = get_rmse(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) - oks, oks_p = get_oks(prediction, target, pcutoff=pcutoff, bodyparts=bodyparts) - return { - "rmse": np.nanmean(rmse), - "rmse_pcutoff": np.nanmean(rmse_p), - "mAP": 100 * oks["mAP"], - "mAR": 100 * oks["mAR"], - "mAP_pcutoff": 100 * oks_p["mAP"], - "mAR_pcutoff": 100 * oks_p["mAR"], - } - - -def get_rmse( - prediction: pd.DataFrame, - target: pd.DataFrame, - pcutoff: float = -1, - bodyparts: list[str] | None = None, -) -> tuple[float, float]: - """Computes the root mean square error (rmse) for predictions vs the ground truth labels - - Assumes hungarian algorithm matching (https://brilliant.org/wiki/hungarian-matching/)) - has already be applied to match predicted animals and ground truth ones. - - Args: - prediction: prediction dataframe - target: target dataframe - pcutoff: Confidence lower bound for a keypoint to be considered as detected. - Defaults to -1. - bodyparts: list of the bodyparts names. Defaults to None. - - Returns: - rmse: rmse without cutoff - rmse_p : rmse with cutoff - - Example: - >>> # Define the prediction and target DataFrames - >>> prediction = pd.DataFrame(...) # Your DataFrame here - >>> target = pd.DataFrame(...) # Your DataFrame here - >>> # Compute the RMSE values - >>> rmse, rmse_pcutoff = get_rmse(prediction, target, pcutoff=0.5) - >>> print(rmse, rmse_pcutoff) - 0.145 0.105 # Sample output RMSE values - """ - scorer_pred = prediction.columns[0][0] - scorer_target = target.columns[0][0] - mask = prediction[scorer_pred].xs("likelihood", level=2, axis=1) >= pcutoff - if bodyparts: - diff = ( - target[scorer_target][bodyparts] - prediction[scorer_pred][bodyparts] - ) ** 2 - else: - diff = (target[scorer_target] - prediction[scorer_pred]) ** 2 - mse = diff.xs("x", level=2, axis=1) + diff.xs("y", level=2, axis=1) - rmse = np.sqrt(mse) - rmse_p = np.sqrt(mse[mask]) - - return rmse, rmse_p - - -def get_oks( - prediction: pd.DataFrame, - target: pd.DataFrame, - oks_sigma=0.1, - margin=0, - symmetric_kpts=None, - pcutoff: float = -1, - bodyparts: list[str] | None = None, -) -> tuple[dict, dict]: - """Computes the object keypoint similarity (OKS) scores for predictions. - - OKS is defined in https://cocodataset.org/#keypoints-eval - - Args: - prediction: prediction dataframe - target: target dataframe - oks_sigma: Sigma for oks conputation. Defaults to 0.1. - margin: margin used for bbox computation. Defaults to 0. - symmetric_kpts: Not supported yet. Defaults to None. - pcutoff: Confidence lower bound for a keypoint to be considered as detected. - Defaults to -1. - bodyparts: list of the bodyparts names. Defaults to None. - - Returns: - oks_raw: oks scores without p_cutoff - oks_pcutoff: oks scores with pcutoff - - Examples: - >>> # Define the prediction and target DataFrames - >>> prediction = pd.DataFrame(...) # Your DataFrame here - >>> target = pd.DataFrame(...) # Your DataFrame here - >>> # Compute the OKS scores - >>> oks, oks_pcutoff = get_oks(prediction, target, oks_sigma=0.2, pcutoff=0.5) - >>> print(oks, oks_pcutoff) - {'mAP': 0.842, 'mAR': 0.745} {'mAP': 0.913, 'mAR': 0.825} # Sample output OKS scores - """ - - scorer_pred = prediction.columns[0][0] - scorer_target = target.columns[0][0] - - if bodyparts is not None: - idx_slice = pd.IndexSlice[:, :, bodyparts, :] - prediction = prediction.loc[:, idx_slice] - target = target.loc[:, idx_slice] - mask = prediction[scorer_pred].xs("likelihood", level=2, axis=1) >= pcutoff - - # Convert predictions to DLC assemblies - assemblies_pred_raw, unique_pred_raw = conv_df_to_assemblies( - prediction[scorer_pred] - ) - assemblies_gt_raw, unique_gt_raw = conv_df_to_assemblies(target[scorer_target]) - - assemblies_pred_masked, unique_pred_masked = conv_df_to_assemblies( - prediction[scorer_pred][mask] - ) - oks_assemblies_raw = evaluate_assembly( - assemblies_pred_raw, - assemblies_gt_raw, - oks_sigma, - margin=margin, - symmetric_kpts=symmetric_kpts, - ) - if unique_pred_raw is not None and unique_gt_raw is not None: - oks_unique_raw = evaluate_assembly( - unique_pred_raw, - unique_gt_raw, - oks_sigma, - margin=margin, - symmetric_kpts=symmetric_kpts, - ) - - oks_pcutoff = evaluate_assembly( - assemblies_pred_masked, - assemblies_gt_raw, - oks_sigma, - margin=margin, - symmetric_kpts=symmetric_kpts, - ) - if unique_pred_masked is not None and unique_gt_raw is not None: - oks_unique_masked = evaluate_assembly( - unique_pred_masked, - unique_gt_raw, - oks_sigma, - margin=margin, - symmetric_kpts=symmetric_kpts, - ) - - return oks_assemblies_raw, oks_pcutoff - - -def conv_df_to_assemblies(df: pd.DataFrame) -> tuple[dict, dict | None]: - """ - Convert a dataframe to an assemblies dictionary - - Args: - df : dataframe of coordinates/predictions, - df is expected to have a multi_index of shape (num_animals, num_keypoints, 2 or 3) - Returns: - assemblies: dictionary of the assemblies of keypoints - if there are unique bodyparts, a dictionary containing unique bodyparts - """ - individuals = df.columns.get_level_values(0) - df_bodyparts = df.loc[:, individuals != "single"] - assemblies = _df_to_dict(df_bodyparts) - - unique_keypoints = None - if "single" in individuals: - df_unique = df.loc[:, individuals == "single"] - unique_keypoints = _df_to_dict(df_unique) - - return assemblies, unique_keypoints - - -def _df_to_dict(df: pd.DataFrame) -> dict: - data = {} - num_animals = len(df.columns.get_level_values(0).unique()) - num_kpts = len(df.columns.get_level_values(1).unique()) - for image_path in df.index: - row = df.loc[image_path].to_numpy() - row = row.reshape(num_animals, num_kpts, -1) - - kpt_lst = [] - for i in range(num_animals): - ass = Assembly.from_array(row[i]) - if len(ass): - kpt_lst.append(ass) - - data[image_path] = kpt_lst - - return data diff --git a/deeplabcut/pose_estimation_pytorch/runners/top_down.py b/deeplabcut/pose_estimation_pytorch/runners/top_down.py deleted file mode 100644 index 6e91f12fc9..0000000000 --- a/deeplabcut/pose_estimation_pytorch/runners/top_down.py +++ /dev/null @@ -1,137 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from __future__ import annotations - -from typing import Any - -import numpy as np -import torch - -import deeplabcut.pose_estimation_pytorch.models.detectors as detectors -from deeplabcut.pose_estimation_pytorch.runners.base import RUNNERS, Runner - - -@RUNNERS.register_module -class DetectorRunner(Runner[detectors.BaseDetector]): - """Runner for object detection""" - - def __init__( - self, - model: detectors.BaseDetector, - optimizer: torch.optim.Optimizer, - max_individuals: int, - snapshot_prefix: str = "detector-snapshot", - **kwargs, - ): - """ - - Args: - model: - optimizer: - max_individuals: - **kwargs: Runner kwargs - """ - super().__init__(model, optimizer, snapshot_prefix=snapshot_prefix, **kwargs) - self.max_individuals = max_individuals - - def step( - self, batch: dict[str, Any], mode: str = "train" - ) -> dict[str, torch.Tensor]: - """Perform a single epoch gradient update or validation step. - - Args: - batch: Tuple of input image(s) and target(s) for train or valid single step. - mode: `train` or `eval`. Defaults to "train". - - Raises: - ValueError: "Runner must be in train or eval mode, but {mode} was found." - - Returns: - dict: { - 'total_loss': torch.Tensor, - 'aux_loss_1': torch.Tensor, - ..., - } - """ - if mode not in ["train", "eval"]: - raise ValueError( - f"DetectorSolver must be in train or eval mode, but {mode} was found." - ) - - if mode == "train": - self.optimizer.zero_grad() - else: - # Override base class - # No losses returned in train mode; - # see https://stackoverflow.com/a/65347721 - # Should be safe as BN is frozen; - # see https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12 - self.model.train() - - images = batch["image"] - images = images.to(self.device) - - target = self.model.get_target( - batch["annotations"] - ) # (batch_size, channels, h, w) - for item in target: # target is a list here - for key in item: - if item[key] is not None: - item[key] = item[key].to(self.device) - - losses, _ = self.model(images, target) - losses["total_loss"] = sum(loss_part for loss_part in losses.values()) - if mode == "train": - losses["total_loss"].backward() - self.optimizer.step() - - return {k: v.detach().cpu().numpy() for k, v in losses.items()} - - def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: - """Makes predictions from a model input and output - - Args: - the inputs to the model, of shape (batch_size, ...) - - Returns: - predictions for each of the 'batch_size' inputs, made by each head, e.g. - [ - { - "bodypart": {"poses": np.ndarray}, - "unique_bodypart": "poses": np.ndarray}, - ] - """ - # TODO: iterates over batch one element at a time - batch_size = 1 - batch_predictions = [] - for i in range(0, len(inputs), batch_size): - batch_inputs = inputs[i : i + batch_size] - batch_inputs = batch_inputs.to(self.device) - _, raw_predictions = self.model(batch_inputs) - - for b, item in enumerate(raw_predictions): - # take the top-k bounding boxes as individuals - batch_predictions.append( - { - "detection": { - "bboxes": item["boxes"][: self.max_individuals] - .cpu() - .numpy() - .reshape(-1, 4), - "scores": item["scores"][: self.max_individuals] - .cpu() - .numpy() - .reshape(-1), - } - } - ) - - return batch_predictions diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py new file mode 100644 index 0000000000..f0746b7159 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -0,0 +1,370 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import logging +from abc import ABCMeta, abstractmethod +from collections import defaultdict +from pathlib import Path +from typing import Any, Generic + +import numpy as np +import torch +import torch.nn as nn +from torch.utils.data import DataLoader + +from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner, Task +from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger + + +class TrainingRunner(Runner, Generic[ModelType], metaclass=ABCMeta): + """Runner base class + + A runner takes a model and runs actions on it, such as training or inference + """ + + def __init__( + self, + model: ModelType, + optimizer: torch.optim.Optimizer, + device: str = "cpu", + snapshot_prefix: str = "snapshot", + snapshot_path: str | None = None, + scheduler: torch.optim.lr_scheduler.LRScheduler | None = None, + logger: BaseLogger | None = None, + ): + """ + Args: + model: the model to run actions on + optimizer: the optimizer to use when fitting the model + device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) + snapshot_prefix: the prefix with which to save snapshots + snapshot_path: if defined, the path of a snapshot from which to load pretrained weights + scheduler: Scheduler for adjusting the lr of the optimizer. + logger: logger to monitor training (e.g WandB logger) + """ + super().__init__(model=model, device=device, snapshot_path=snapshot_path) + self.optimizer = optimizer + self.scheduler = scheduler + self.history: dict[str, list] = {"train_loss": [], "eval_loss": []} + self.snapshot_prefix = snapshot_prefix + self.logger = logger + self.starting_epoch = 0 + + if self.snapshot_path is not None and len(self.snapshot_path) > 0: + self.starting_epoch = self.load_snapshot( + self.snapshot_path, + self.device, + self.model, + self.optimizer, + ) + + @abstractmethod + def step( + self, batch: dict[str, Any], mode: str = "train" + ) -> dict[str, torch.Tensor]: + """Perform a single epoch gradient update or validation step + + Args: + batch: the batch data on which to run a step + mode: "train" or "eval". Defaults to "train". + + Raises: + ValueError: if mode is not in {"train", "eval"} + + Returns: + A dictionary containing the different losses for the step + """ + + def fit( + self, + train_loader: DataLoader, + valid_loader: DataLoader, + model_folder: str, + epochs: int, + save_epochs: int, + display_iters: int, + ) -> None: + """Train model for the specified number of steps. + + Args: + train_loader: Data loader, which is an iterator over train instances. + Each batch contains image tensor and heat maps tensor input samples. + valid_loader: Data loader used for validation of the model. + model_folder: The folder to which logs should be written and snapshots saved + epochs: The number of training epochs. + save_epochs: The epoch step at which to save models + display_iters: The number of iterations between each loss print + + Example: + runner = Runner(model, optimizer, cfg, device='cuda') + runner.fit(train_loader, valid_loader, "example/models" epochs=50) + """ + Path(model_folder).mkdir(exist_ok=True, parents=True) + self.model.to(self.device) + + for i in range(self.starting_epoch, epochs): + train_loss = self._epoch( + train_loader, mode="train", step=i + 1, display_iters=display_iters + ) + if self.scheduler: + self.scheduler.step() + + logging.info( + f"Training for epoch {i + 1} done, starting eval on validation data" + ) + valid_loss = self._epoch( + valid_loader, mode="eval", step=i + 1, display_iters=display_iters + ) + + if (i + 1) % save_epochs == 0: + logging.info(f"Finished epoch {i + 1}; saving model") + torch.save( + { + "model_state_dict": self.model.state_dict(), + "epoch": i + 1, + "optimizer_state_dict": self.optimizer.state_dict(), + "train_loss": train_loss, + "validation_loss": valid_loss, + }, + f"{model_folder}/train/{self.snapshot_prefix}-{i + 1}.pt", + ) + + logging.info( + f"Epoch {i + 1}/{epochs}, " + f"train loss {float(train_loss):.5f}, " + f"valid loss {float(valid_loss):.5f}, " + f'lr {self.optimizer.param_groups[0]["lr"]}' + ) + + def _epoch( + self, + loader: torch.utils.data.DataLoader, + mode: str = "train", + step: int | None = None, + display_iters: int = 500, + ) -> float: + """Facilitates training over an epoch. Returns the loss over the batches. + + Args: + loader: Data loader, which is an iterator over instances. + Each batch contains image tensor and heat maps tensor input samples. + mode: str identifier to instruct the Runner whether to train or evaluate. + Possible values are: "train" or "eval". + step: the global step in processing, used to log metrics. Defaults to None. + display_iters: the number of iterations between each loss print + + Raises: + ValueError: When the given mode is invalid + + Returns: + epoch_loss: Average of the loss over the batches. + """ + if mode == "train": + self.model.train() + elif mode == "eval" or mode == "inference": + self.model.eval() + else: + raise ValueError(f"Runner mode must be train or eval, found mode={mode}.") + + epoch_loss = [] + metrics = defaultdict(list) + for i, batch in enumerate(loader): + losses_dict = self.step(batch, mode) + epoch_loss.append(losses_dict["total_loss"]) + + for key in losses_dict.keys(): + metrics[key].append(losses_dict[key]) + + if (i + 1) % display_iters == 0: + logging.info( + f"Number of iterations: {i + 1}, " + f"loss: {losses_dict['total_loss']:.5f}, " + f"lr: {self.optimizer.param_groups[0]['lr']}" + ) + + epoch_loss = np.mean(epoch_loss).item() + self.history[f"{mode}_loss"].append(epoch_loss) + + if self.logger: + for key in metrics: + self.logger.log( + f"{mode} {key}", np.nanmean(metrics[key]).item(), step=step + ) + + return epoch_loss + + +class PoseTrainingRunner(TrainingRunner[PoseModel]): + """Runner to train pose estimation models""" + + def __init__( + self, model: PoseModel, optimizer: torch.optim.Optimizer, **kwargs + ): + """ + Args: + model: The neural network for solving pose estimation task. + optimizer: A PyTorch optimizer for updating model parameters. + **kwargs: TrainingRunner kwargs + """ + super().__init__(model, optimizer, **kwargs) + + def step( + self, batch: dict[str, Any], mode: str = "train" + ) -> dict[str, torch.Tensor]: + """Perform a single epoch gradient update or validation step. + + Args: + batch: Tuple of input image(s) and target(s) for train or valid single step. + mode: `train` or `eval`. Defaults to "train". + + Raises: + ValueError: "Runner must be in train or eval mode, but {mode} was found." + + Returns: + dict: { + "total_loss": aggregate_loss, + "aux_loss_1": loss_value, + ..., + } + """ + if mode not in ["train", "eval"]: + raise ValueError( + f"BottomUpSolver must be in train or eval mode, but {mode} was found." + ) + + if mode == "train": + self.optimizer.zero_grad() + + batch_inputs = batch["image"] + batch_inputs = batch_inputs.to(self.device) + head_outputs = self.model(batch_inputs) + + target = self.model.get_target(batch_inputs, head_outputs, batch["annotations"]) + + losses_dict = self.model.get_loss(head_outputs, target) + if mode == "train": + losses_dict["total_loss"].backward() + self.optimizer.step() + + return {k: v.detach().cpu().numpy() for k, v in losses_dict.items()} + + +class DetectorTrainingRunner(TrainingRunner[BaseDetector]): + """Runner to train object detection models""" + + def __init__( + self, + model: BaseDetector, + optimizer: torch.optim.Optimizer, + snapshot_prefix: str = "detector-snapshot", + **kwargs, + ): + """ + Args: + model: The detector model to train. + optimizer: The optimizer to use to train the model. + **kwargs: TrainingRunner kwargs + """ + super().__init__(model, optimizer, snapshot_prefix=snapshot_prefix, **kwargs) + + def step( + self, batch: dict[str, Any], mode: str = "train" + ) -> dict[str, torch.Tensor]: + """Perform a single epoch gradient update or validation step. + + Args: + batch: Tuple of input image(s) and target(s) for train or valid single step. + mode: `train` or `eval`. Defaults to "train". + + Raises: + ValueError: "Runner must be in train or eval mode, but {mode} was found." + + Returns: + dict: { + 'total_loss': torch.Tensor, + 'aux_loss_1': torch.Tensor, + ..., + } + """ + if mode not in ["train", "eval"]: + raise ValueError( + f"DetectorSolver must be in train or eval mode, but {mode} was found." + ) + + if mode == "train": + self.optimizer.zero_grad() + else: + # Override base class + # No losses returned in train mode; + # see https://stackoverflow.com/a/65347721 + # Should be safe as BN is frozen; + # see https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12 + self.model.train() + + images = batch["image"] + images = images.to(self.device) + + target = self.model.get_target( + batch["annotations"] + ) # (batch_size, channels, h, w) + for item in target: # target is a list here + for key in item: + if item[key] is not None: + item[key] = item[key].to(self.device) + + losses, _ = self.model(images, target) + losses["total_loss"] = sum(loss_part for loss_part in losses.values()) + if mode == "train": + losses["total_loss"].backward() + self.optimizer.step() + + return {k: v.detach().cpu().numpy() for k, v in losses.items()} + + +def build_training_runner( + task: Task, + model: nn.Module, + optimizer: torch.optim.Optimizer, + device: str, + scheduler: torch.optim.lr_scheduler.LRScheduler | None = None, + snapshot_path: str | None = None, + logger: BaseLogger | None = None, +) -> TrainingRunner: + """ + Build a runner object according to a pytorch configuration file + + Args: + task: the task the runner will perform + model: the model to run + optimizer: the optimizer to use to train the model + scheduler: the scheduler to use to train the model + device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) + snapshot_path: the snapshot from which to load the weights + logger: the logger to use, if any + + Returns: + the runner that was built + """ + kwargs = dict( + model=model, + optimizer=optimizer, + device=device, + snapshot_path=snapshot_path, + scheduler=scheduler, + logger=logger, + ) + if task == Task.DETECT: + return DetectorTrainingRunner(**kwargs) + + return PoseTrainingRunner(**kwargs) diff --git a/deeplabcut/pose_estimation_pytorch/runners/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py index a4c2ad305a..8f01eec460 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/utils.py +++ b/deeplabcut/pose_estimation_pytorch/runners/utils.py @@ -14,14 +14,11 @@ import re import warnings from pathlib import Path -from typing import List, Optional, Tuple - -import numpy as np -import pandas as pd +from typing import List, Tuple import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils -import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.runners.base import Task +from deeplabcut.utils import auxiliaryfunctions def verify_paths( @@ -420,79 +417,6 @@ def get_evaluation_folder( return evaluation_folder -def build_predictions_df( - dlc_scorer: str, - individuals: List[str], - bodyparts: List[str], - df_index: pd.Index, - predictions: np.ndarray, -) -> pd.DataFrame: - """Builds a predictions dataframe in the DLC format - - Builds a DataFrame in the DeepLabCut format, with MultiIndex columns. If there is - only one individual, the column levels are ("scorer", "bodyparts", "coords"). If - there are multiple individuals, the column levels are ("scorer", "individuals", - "bodyparts", "coords"). - - Args: - dlc_scorer: the DLC scorer that generated the predictions - individuals: the names of individuals in the project - bodyparts: the names of bodyparts in the project - df_index: the index to apply to the dataframe - predictions: the predictions made by the scorer. should be of shape - (len(df_index), len(bodyparts), 3) if len(individuals) == 1, and otherwise - (len(df_index), len(individuals), len(bodyparts), 3) - - Returns: - the dataframe containing the predictions in DLC format - """ - num_individuals = len(individuals) - if num_individuals == 1: - # Single animal prediction dataframe - index = pd.MultiIndex.from_product( - [[dlc_scorer], bodyparts, ["x", "y", "likelihood"]], - names=["scorer", "bodyparts", "coords"], - ) - else: - # Multi-animal prediction dataframe - index = pd.MultiIndex.from_product( - [[dlc_scorer], individuals, bodyparts, ["x", "y", "likelihood"]], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - - return pd.DataFrame(predictions, columns=index, index=df_index) - - -def build_entire_pred_df( - dlc_scorer: str, - individuals: List[str], - bodyparts: List[str], - df_index: pd.Index, - predictions: np.ndarray, - unique_bodyparts: List[str], - unique_predictions: Optional[np.ndarray], -) -> pd.DataFrame: - num_individuals = len(individuals) - if num_individuals == 1 or len(unique_bodyparts) == 0 or unique_predictions is None: - return build_predictions_df( - dlc_scorer, individuals, bodyparts, df_index, predictions - ) - - animals_df = build_predictions_df( - dlc_scorer, individuals, bodyparts, df_index, predictions - ) - unique_df = build_predictions_df( - dlc_scorer, ["single"], unique_bodyparts, df_index, unique_predictions - ) - new_cols = pd.MultiIndex.from_tuples( - [(col[0], "single", col[1], col[2]) for col in unique_df.columns], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - unique_df.columns = new_cols - predictions_df = animals_df.merge(unique_df, left_index=True, right_index=True) - return predictions_df - - def get_paths( project_path: str, train_fraction: float = 0.95, diff --git a/tests/pose_estimation_pytorch/runners/bottum_up.py b/tests/pose_estimation_pytorch/runners/bottum_up.py index a7709f9552..1028bfa4ff 100644 --- a/tests/pose_estimation_pytorch/runners/bottum_up.py +++ b/tests/pose_estimation_pytorch/runners/bottum_up.py @@ -48,7 +48,7 @@ def test_build_bottom_up_runner( pytorch_cfg = make_pytorch_pose_config(project_cfg, str(template_path), net_type) print_dict(pytorch_cfg) - pose_model = PoseModel.from_cfg(pytorch_cfg["model"]) + pose_model = PoseModel.build(pytorch_cfg["model"]) head_criterions = [] for head_cfg in pytorch_cfg["model"]["heads"]: From 2f5723c8bb7677388573a445f7b6193f2a26fb81 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 21 Dec 2023 15:25:14 +0100 Subject: [PATCH 063/293] fixed detector postprocessor --- .../pose_estimation_pytorch/apis/utils.py | 4 ++- .../data/postprocessor.py | 34 +++++++++++++++++-- .../runners/inference.py | 10 ++---- .../data/test_postprocessor.py | 28 +++++++++++++++ 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index e57ff59d11..82e2fccade 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -510,7 +510,9 @@ def get_runners( color_mode="RGB", # TODO: read from Loader transform=detector_transform, ), - postprocessor=build_detector_postprocessor(), + postprocessor=build_detector_postprocessor( + max_individuals=max_individuals, + ), ) pose_runner = build_inference_runner( diff --git a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py index 2e035fc4c9..e298467189 100644 --- a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py @@ -144,9 +144,12 @@ def build_top_down_postprocessor( ) -def build_detector_postprocessor() -> Postprocessor: +def build_detector_postprocessor(max_individuals: int) -> Postprocessor: """Creates a postprocessor for top-down pose estimation + Args: + max_individuals: the maximum number of detections to keep in a single image + Returns: A default top-down Postprocessor """ @@ -158,6 +161,12 @@ def build_detector_postprocessor() -> Postprocessor: "bbox_scores": ("detection", "scores"), } ), + TrimOutputs( + max_individuals={ + "bboxes": max_individuals, + "bbox_scores": max_individuals, + }, + ), BboxToCoco(bounding_box_keys=["bboxes"]), RescaleAndOffset( keys_to_rescale=["bboxes"], @@ -241,12 +250,33 @@ def __call__( if len(output) < self.max_individuals[name]: pad_size = self.max_individuals[name] - len(output) tail_shape = output.shape[1:] - padding = -np.ones((pad_size, *tail_shape)) + padding = self.pad_value * np.ones((pad_size, *tail_shape)) predictions[name] = np.concatenate([output, padding]) return predictions, context +class TrimOutputs(Postprocessor): + """Ensures all outputs have at most `max_individuals` detections + + Assumes that the outputs are sorted by decreasing score, such that the first + `max_individuals` predictions are the ones to keep. + """ + + def __init__(self, max_individuals: dict[str, int]): + self.max_individuals = max_individuals + + def __call__( + self, predictions: dict[str, np.ndarray], context: Context + ) -> tuple[dict[str, np.ndarray], Context]: + for name in predictions: + output = predictions[name] + if len(output) > self.max_individuals[name]: + predictions[name] = output[:self.max_individuals[name]] + + return predictions, context + + class RescaleAndOffset(Postprocessor): """Rescales and offsets predictions back to their position in the original image diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py index d46b1962e7..49fe909e0e 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -155,17 +155,13 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] class DetectorInferenceRunner(InferenceRunner[BaseDetector]): """Runner for object detection inference""" - def __init__(self, model: BaseDetector, max_individuals: int, **kwargs): + def __init__(self, model: BaseDetector, **kwargs): """ Args: model: The detector to use for inference. - max_individuals: The maximum number of detections to make for a single - frame. When calling predict, at most `max_individuals` bounding boxes - will be returned. **kwargs: Inference runner kwargs. """ super().__init__(model, **kwargs) - self.max_individuals = max_individuals def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: """Makes predictions from a model input and output @@ -194,11 +190,11 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] batch_predictions.append( { "detection": { - "bboxes": item["boxes"][: self.max_individuals] + "bboxes": item["boxes"] .cpu() .numpy() .reshape(-1, 4), - "scores": item["scores"][: self.max_individuals] + "scores": item["scores"] .cpu() .numpy() .reshape(-1), diff --git a/tests/pose_estimation_pytorch/data/test_postprocessor.py b/tests/pose_estimation_pytorch/data/test_postprocessor.py index 853caab9f7..236bf4b8bb 100644 --- a/tests/pose_estimation_pytorch/data/test_postprocessor.py +++ b/tests/pose_estimation_pytorch/data/test_postprocessor.py @@ -5,6 +5,7 @@ from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( PredictKeypointIdentities, RescaleAndOffset, + TrimOutputs, ) @@ -57,6 +58,33 @@ def test_rescale_topdown(data): np.testing.assert_array_equal(predictions["bodyparts"], np.array(data["rescaled"])) +@pytest.mark.parametrize( + "data", + [ + { + "bboxes": [[0, 0, 0, 0], [1, 1, 1, 1]], + "bbox_scores": [0, 0], + "max_individuals": {"bboxes": 1, "bbox_scores": 1}, + }, + { + "bboxes": [[0, 0, 0, 0], [1, 1, 1, 1]], + "bbox_scores": [0, 0], + "max_individuals": {"bboxes": 2, "bbox_scores": 2}, + }, + ], +) +def test_trim_outputs(data): + """expects x_processed = x * scale + offset""" + postprocessor = TrimOutputs(max_individuals=data["max_individuals"]) + context = {} + predictions = {"bboxes": np.array(data["bboxes"]), "bbox_scores": np.array(data["bbox_scores"])} + predictions, context = postprocessor(predictions, context=context) + print(predictions["bboxes"].tolist()) + print(predictions["bbox_scores"].tolist()) + assert len(predictions["bboxes"]) == data["max_individuals"]["bboxes"] + assert len(predictions["bbox_scores"]) == data["max_individuals"]["bbox_scores"] + + @pytest.mark.parametrize( "data", [ From 8c3f95d4ee6804f3b36dcabecfccaceb2b8c1bd8 Mon Sep 17 00:00:00 2001 From: Anastasiia Filippova Date: Fri, 22 Dec 2023 14:39:43 +0100 Subject: [PATCH 064/293] Anastasiia/modelzoo * Add model_zoo inference * add configs for superanimal * Refactor apis videos, add model zoo inference * Modify topview config * Black style * Change model folder for tf * bug fixes in pytorch video inference * add warning if video adapt with pytorch * increased box_score_thresh * Save adapt weights in video folder --------- Co-authored-by: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Co-authored-by: Mackenzie Mathis Co-authored-by: shaokaiyeah Co-authored-by: n-poulsen <45132115+n-poulsen@users.noreply.github.com> --- .pre-commit-config.yaml | 18 ++ deeplabcut/__init__.py | 4 +- deeplabcut/gui/tabs/modelzoo.py | 6 +- .../model_configs/dlcrnet.yaml} | 115 ++++---- .../modelzoo/model_configs/hrnetw32.yaml | 100 +++++++ deeplabcut/modelzoo/models.json | 4 - deeplabcut/modelzoo/models_to_framework.json | 4 + .../superanimal_quadruped.yaml | 87 ++++++ .../superanimal_topviewmouse.yaml | 73 +++++ deeplabcut/modelzoo/utils.py | 56 +++- deeplabcut/modelzoo/video_inference.py | 141 +++++++++ deeplabcut/modelzoo/webapp/inference.py | 117 ++++++++ .../apis/analyze_videos.py | 228 ++++++++------ .../pose_estimation_pytorch/apis/utils.py | 20 +- .../modelzoo/__init__.py | 10 + .../modelzoo/_mmpose_to_dlc3.py | 132 +++++++++ .../modelzoo/inference.py | 161 ++++++++++ .../pose_estimation_pytorch/modelzoo/utils.py | 105 +++++++ .../tests/test_modelzoo.py | 38 +++ .../pose_estimation_tensorflow/__init__.py | 4 - .../core/train_multianimal.py | 37 ++- .../modelzoo/api/__init__.py | 0 .../modelzoo/api/spatiotemporal_adapt.py | 94 +++--- .../modelzoo/api/superanimal_inference.py | 157 ++++++++-- .../predict_supermodel.py | 110 ------- .../superanimal_configs/superquadruped.yaml | 150 ---------- deeplabcut/utils/make_labeled_video.py | 27 +- pyproject.toml | 20 ++ setup.py | 2 +- .../modelzoo/test_download.py} | 3 +- .../modelzoo/test_utils.py | 29 ++ .../modelzoo/test_webapp.py | 71 +++++ .../pose_estimation_pytorch/modelzoo_test.py | 65 ++++ ...se_estimation_pytorch_solvers_inference.py | 79 ----- ...t_pose_estimation_pytorch_solvers_utils.py | 279 ------------------ 35 files changed, 1650 insertions(+), 896 deletions(-) create mode 100644 .pre-commit-config.yaml rename deeplabcut/{pose_estimation_tensorflow/superanimal_configs/supertopview.yaml => modelzoo/model_configs/dlcrnet.yaml} (53%) create mode 100644 deeplabcut/modelzoo/model_configs/hrnetw32.yaml delete mode 100644 deeplabcut/modelzoo/models.json create mode 100644 deeplabcut/modelzoo/models_to_framework.json create mode 100644 deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml create mode 100644 deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml create mode 100644 deeplabcut/modelzoo/video_inference.py create mode 100644 deeplabcut/modelzoo/webapp/inference.py create mode 100644 deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py create mode 100644 deeplabcut/pose_estimation_pytorch/modelzoo/inference.py create mode 100644 deeplabcut/pose_estimation_pytorch/modelzoo/utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_modelzoo.py rename deeplabcut/{ => pose_estimation_tensorflow}/modelzoo/api/__init__.py (100%) rename deeplabcut/{ => pose_estimation_tensorflow}/modelzoo/api/spatiotemporal_adapt.py (77%) rename deeplabcut/{ => pose_estimation_tensorflow}/modelzoo/api/superanimal_inference.py (73%) delete mode 100644 deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml create mode 100644 pyproject.toml rename tests/{tests_modelzoo.py => pose_estimation_pytorch/modelzoo/test_download.py} (99%) create mode 100644 tests/pose_estimation_pytorch/modelzoo/test_utils.py create mode 100644 tests/pose_estimation_pytorch/modelzoo/test_webapp.py create mode 100644 tests/pose_estimation_pytorch/modelzoo_test.py delete mode 100644 tests/test_pose_estimation_pytorch_solvers_inference.py delete mode 100644 tests/test_pose_estimation_pytorch_solvers_utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..a21cfeebbd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: check-yaml + - id: end-of-file-fixer + - id: name-tests-test + - id: trailing-whitespace + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + language_version: python3 diff --git a/deeplabcut/__init__.py b/deeplabcut/__init__.py index 2da4b6a9f5..c1e7b02700 100644 --- a/deeplabcut/__init__.py +++ b/deeplabcut/__init__.py @@ -60,6 +60,9 @@ dropduplicatesinannotatinfiles, dropunlabeledframes, ) + +from deeplabcut.modelzoo.video_inference import video_inference_superanimal + from deeplabcut.utils import ( create_labeled_video, create_video_with_all_detections, @@ -107,7 +110,6 @@ visualize_paf, extract_save_all_maps, export_model, - video_inference_superanimal, ) diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index 2607fab362..6b24a357de 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -23,7 +23,7 @@ ) from deeplabcut.gui import BASE_DIR from deeplabcut.gui.utils import move_to_separate_thread -from deeplabcut.modelzoo.utils import parse_available_supermodels +from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS class RegExpValidator(QRegularExpressionValidator): @@ -58,8 +58,8 @@ def _set_page(self): model_combo_text = QtWidgets.QLabel("Supermodel name") self.model_combo = QtWidgets.QComboBox() - supermodels = parse_available_supermodels() - self.model_combo.addItems(supermodels.keys()) + supermodels = [model for model in MODELOPTIONS if "superanimal" in model] + self.model_combo.addItems(supermodels) scales_label = QtWidgets.QLabel("Scale list") self.scales_line = QtWidgets.QLineEdit("", parent=self) diff --git a/deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml b/deeplabcut/modelzoo/model_configs/dlcrnet.yaml similarity index 53% rename from deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml rename to deeplabcut/modelzoo/model_configs/dlcrnet.yaml index a0b2da3064..570c1a0aaa 100644 --- a/deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml +++ b/deeplabcut/modelzoo/model_configs/dlcrnet.yaml @@ -1,62 +1,51 @@ -all_joints: -- - 0 -- - 1 -- - 2 -- - 3 -- - 4 -- - 5 -- - 6 -- - 7 -- - 8 -- - 9 -- - 10 -- - 11 -- - 12 -- - 13 -- - 14 -- - 15 -- - 16 -- - 17 -- - 18 -- - 19 -- - 20 -- - 21 -- - 22 -- - 23 -- - 24 -- - 25 -- - 26 -all_joints_names: -- nose -- left_ear -- right_ear -- left_ear_tip -- right_ear_tip -- left_eye -- right_eye -- neck -- mid_back -- mouse_center -- mid_backend -- mid_backend2 -- mid_backend3 -- tail_base -- tail1 -- tail2 -- tail3 -- tail4 -- tail5 -- left_shoulder -- left_midside -- left_hip -- right_shoulder -- right_midside -- right_hip -- tail_end -- head_midpoint + # Project definitions (do not edit) +Task: +scorer: +date: +multianimalproject: +identity: + + # Project path (change when moving around) +project_path: + + # Annotation data set configuration (and individual video cropping parameters) +video_sets: +bodyparts: + + # Fraction of video to start/stop when extracting frames for labeling/refinement +start: +stop: +numframes2pick: + + # Plotting configuration +skeleton: [] +skeleton_color: black +pcutoff: +dotsize: +alphavalue: +colormap: + + # Training,Evaluation and Analysis configuration +TrainingFraction: +iteration: +default_net_type: +default_augmenter: +snapshotindex: +batch_size: 1 + + # Cropping Parameters (for analysis and outlier frame detection) +cropping: + #if cropping is true for analysis, then set the values here: +x1: +x2: +y1: +y2: + + # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) +corner2move2: +move2corner: alpha_r: 0.02 apply_prob: 0.5 -batch_size: 1 clahe: true claheratio: 0.1 crop_sampling: hybrid @@ -64,7 +53,7 @@ crop_size: - 400 - 400 cropratio: 0.4 -dataset: training-datasets/iteration-0/UnaugmentedDataSet_ma_supertopviewMarch30/ma_supertopview_maDLC_scorer95shuffle1.pickle +dataset: dataset_type: multi-animal-imgaug decay_steps: 30000 display_iters: 500 @@ -90,7 +79,11 @@ locref_stdev: 7.2801 lr_init: 0.0005 max_input_size: 1500 max_shift: 0.4 -metadataset: training-datasets/iteration-0/UnaugmentedDataSet_ma_supertopviewMarch30/Documentation_data-ma_supertopview_95shuffle1.pickle +mean_pixel: +- 123.68 +- 116.779 +- 103.939 +metadataset: min_input_size: 64 mirror: false multi_stage: true @@ -114,7 +107,6 @@ partaffinityfield_graph: [] partaffinityfield_predict: false pos_dist_thresh: 17 pre_resize: [] -project_path: rotation: 25 rotratio: 0.4 save_iters: 10000 @@ -122,5 +114,8 @@ scale_jitter_lo: 0.5 scale_jitter_up: 1.25 sharpen: false sharpenratio: 0.3 +stride: 8.0 weigh_only_present_joints: false gradient_masking: true +weight_decay: 0.0001 +weigh_part_predictions: false diff --git a/deeplabcut/modelzoo/model_configs/hrnetw32.yaml b/deeplabcut/modelzoo/model_configs/hrnetw32.yaml new file mode 100644 index 0000000000..153df79357 --- /dev/null +++ b/deeplabcut/modelzoo/model_configs/hrnetw32.yaml @@ -0,0 +1,100 @@ +corner2move2: +move2corner: +cfg_path: +colormode: RGB +data: + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true + rotation: 30 + scale_jitter: + - 0.5 + - 1.25 + auto_padding: + pad_width_divisor: 32 + pad_height_divisor: 32 +data_detector: + hflip: true + normalize_images: true +detector: + model: + type: FasterRCNN + box_score_thresh: 0.6 + optimizer: + type: AdamW + params: + lr: 0.0001 + scheduler: + type: LRListScheduler + params: + milestones: + - 90 + lr_list: + - - 1e-05 + runner: + type: DetectorRunner + max_individuals: + batch_size: 1 + epochs: 500 + save_epochs: 100 + display_iters: 500 +device: cuda +display_iters: 50 +epochs: 200 +method: td +model: + backbone: + type: HRNet + model_name: hrnet_w32 + pretrained: true + only_high_res: true + backbone_output_channels: 32 + heads: + bodypart: + type: HeatmapHead + predictor: + type: HeatmapPredictor + location_refinement: false + locref_std: 7.2801 + target_generator: + type: HeatmapPlateauGenerator + num_heatmaps: # number of bodyparts + pos_dist_thresh: 17 + heatmap_mode: KEYPOINT + generate_locref: false + locref_std: 7.2801 + criterion: + heatmap: + type: WeightedBCECriterion + weight: 1.0 + locref: + type: WeightedHuberCriterion + weight: 0 + heatmap_config: + channels: + - 32 + - 0 # number of bodyparts + kernel_size: + - 1 + strides: + - 1 +optimizer: + params: + lr: 0.0001 + type: AdamW +pose_config_path: +runner: + type: PoseRunner +save_epochs: 50 +scheduler: + params: + lr_list: + - - 1e-05 + - - 1e-06 + milestones: + - 90 + - 120 + type: LRListScheduler +seed: 42 diff --git a/deeplabcut/modelzoo/models.json b/deeplabcut/modelzoo/models.json deleted file mode 100644 index f5e6ab25f9..0000000000 --- a/deeplabcut/modelzoo/models.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "superanimal_quadruped": "superquadruped.yaml", - "superanimal_topviewmouse": "supertopview.yaml" -} diff --git a/deeplabcut/modelzoo/models_to_framework.json b/deeplabcut/modelzoo/models_to_framework.json new file mode 100644 index 0000000000..47d25be63c --- /dev/null +++ b/deeplabcut/modelzoo/models_to_framework.json @@ -0,0 +1,4 @@ +{ + "hrnetw32": "pytorch", + "dlcrnet": "tensorflow" +} diff --git a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml new file mode 100644 index 0000000000..b3d5fae70e --- /dev/null +++ b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml @@ -0,0 +1,87 @@ + # Project definitions (do not edit) +Task: +scorer: +date: +multianimalproject: +identity: + + # Project path (change when moving around) +project_path: + /Users/niels/Documents/upamathis/repos/DLCdev/deeplabcut/modelzoo/project_configs + + # Annotation data set configuration (and individual video cropping parameters) +video_sets: +bodyparts: +- nose +- upper_jaw +- lower_jaw +- mouth_end_right +- mouth_end_left +- right_eye +- right_earbase +- right_earend +- right_antler_base +- right_antler_end +- left_eye +- left_earbase +- left_earend +- left_antler_base +- left_antler_end +- neck_base +- neck_end +- throat_base +- throat_end +- back_base +- back_end +- back_middle +- tail_base +- tail_end +- front_left_thai +- front_left_knee +- front_left_paw +- front_right_thai +- front_right_knee +- front_right_paw +- back_left_paw +- back_left_thai +- back_right_thai +- back_left_knee +- back_right_knee +- back_right_paw +- belly_bottom +- body_middle_right +- body_middle_left +# Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement +start: +stop: +numframes2pick: + + # Plotting configuration +skeleton: [] +skeleton_color: black +pcutoff: +dotsize: +alphavalue: +colormap: + + # Training,Evaluation and Analysis configuration +TrainingFraction: +iteration: +default_net_type: +default_augmenter: +snapshotindex: +batch_size: 1 + + # Cropping Parameters (for analysis and outlier frame detection) +cropping: + #if cropping is true for analysis, then set the values here: +x1: +x2: +y1: +y2: + + # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) +corner2move2: +move2corner: diff --git a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml new file mode 100644 index 0000000000..39a85eb245 --- /dev/null +++ b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml @@ -0,0 +1,73 @@ +# Project definitions (do not edit) +Task: +scorer: +date: +multianimalproject: +identity: + +# Project path (change when moving around) +project_path: + +# Annotation data set configuration (and individual video cropping parameters) +video_sets: +bodyparts: +- nose +- left_ear +- right_ear +- left_ear_tip +- right_ear_tip +- left_eye +- right_eye +- neck +- mid_back +- mouse_center +- mid_backend +- mid_backend2 +- mid_backend3 +- tail_base +- tail1 +- tail2 +- tail3 +- tail4 +- tail5 +- left_shoulder +- left_midside +- left_hip +- right_shoulder +- right_midside +- right_hip +- tail_end +- head_midpoint + +# Fraction of video to start/stop when extracting frames for labeling/refinement +start: +stop: +numframes2pick: + +# Plotting configuration +skeleton: [] +skeleton_color: black +pcutoff: +dotsize: +alphavalue: +colormap: + +# Training,Evaluation and Analysis configuration +TrainingFraction: +iteration: +default_net_type: +default_augmenter: +snapshotindex: +batch_size: 1 + +# Cropping Parameters (for analysis and outlier frame detection) +cropping: +# if cropping is true for analysis, then set the values here: +x1: +x2: +y1: +y2: + + # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) +corner2move2: +move2corner: diff --git a/deeplabcut/modelzoo/utils.py b/deeplabcut/modelzoo/utils.py index adc78170d7..c0e742004e 100644 --- a/deeplabcut/modelzoo/utils.py +++ b/deeplabcut/modelzoo/utils.py @@ -10,12 +10,56 @@ # import json import os +import warnings +from glob import glob +from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path -def parse_available_supermodels(): - import deeplabcut - dlc_path = deeplabcut.utils.auxiliaryfunctions.get_deeplabcut_path() - json_path = os.path.join(dlc_path, "modelzoo", "models.json") - with open(json_path) as file: - return json.load(file) +def parse_project_model_name(superanimal_name: str) -> str: + """ + TODO + + """ + + if superanimal_name == "superanimal_quadruped": + warnings.warn( + f"{superanimal_name} is deprecated and will be removed in a future version. Use {superanimal_name}_model_suffix instead.", + DeprecationWarning, + ) + superanimal_name = "superanimal_quadruped_hrnetw32" + + if superanimal_name == "superanimal_topviewmouse": + warnings.warn( + f"{superanimal_name} is deprecated and will be removed in a future version. Use {superanimal_name}_model_suffix instead.", + DeprecationWarning, + ) + superanimal_name = "superanimal_topviewmouse_dlcrnet" + + model_name = superanimal_name.split("_")[-1] + project_name = superanimal_name.replace(f"_{model_name}", "") + + dlc_root_path = get_deeplabcut_path() + modelzoo_path = os.path.join(dlc_root_path, "modelzoo") + + available_model_configs = glob( + os.path.join(modelzoo_path, "model_configs", "*.yaml") + ) + available_models = [ + os.path.splitext(os.path.basename(path))[0] for path in available_model_configs + ] + + if model_name not in available_models: + raise ValueError( + f"Model {model_name} not found. Available models are: {available_models}" + ) + + available_project_configs = glob( + os.path.join(modelzoo_path, "project_configs", "*.yaml") + ) + available_projects = [ + os.path.splitext(os.path.basename(path))[0] + for path in available_project_configs + ] + + return project_name, model_name diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py new file mode 100644 index 0000000000..b982fb0bfc --- /dev/null +++ b/deeplabcut/modelzoo/video_inference.py @@ -0,0 +1,141 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public Licen + +import json +import os +import warnings +from typing import Optional, Union + +from dlclibrary.dlcmodelzoo.modelzoo_download import download_huggingface_model + +from deeplabcut.modelzoo.utils import parse_project_model_name +from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path + + +def video_inference_superanimal( + videos: Union[str, list], + superanimal_name: str, + scale_list: list = [], + videotype: str = "mp4", + dest_folder: Optional[str] = None, + video_adapt: bool = False, + plot_trajectories: bool = False, + pcutoff: float = 0.1, + adapt_iterations: int = 1000, + pseudo_threshold: float = 0.1, + max_individuals: int = 10, + device: Optional[str] = None, +): + """ + This function performs inference on videos using a superanimal model. The model is downloaded from hugginface to the `modelzoo/checkpoints` folder. + + Args: + + videos (str or list): The path to the video or a list of paths to videos. + superanimal_name (str): The name of the superanimal model. + The name should be in the format: {project_name}_{modelname}. + For example: `superanimal_topviewmouse_dlcrnet` or `superanimal_quadruped_hrnet`. + scale_list (list): A list of different resolutions for the spatial pyramid. Used only for bottom up models. + + videotype (str): Checks for the extension of the video in case the input to the video is a directory. + Only videos with this extension are analyzed. The default is ``.mp4``. + dest_folder (str): The path to the folder where the results should be saved. + + video_adapt (bool): Whether to perform video adaptation. The default is False. + Current we do not support video adaptation in pytorch models. + plot_trajectories (bool): Whether to plot the trajectories. The default is False. + + pcutoff (float): The p-value cutoff for the confidence of the prediction. The default is 0.1. + + adapt_iterations (int): Number of iterations for adaptation training. Empirically 1000 is sufficient. + + pseudo_threshold (float): The pseudo-label threshold for the confidence of the prediction. The default is 0.1. + + max_individuals (int): The maximum number of individuals in the video. The default is 30. Used only for top down models. + + device (str): The device to use for inference. The default is None (CPU). Used only for pytorch models. + + Raises: + NotImplementedError: If the model is not found in the modelzoo. + Warning: If the superanimal_name will be deprecated in the future. + + """ + project_name, model_name = parse_project_model_name(superanimal_name) + + dlc_root_path = get_deeplabcut_path() + modelzoo_path = os.path.join(dlc_root_path, "modelzoo") + available_architectures = json.load( + open(os.path.join(modelzoo_path, "models_to_framework.json"), "r") + ) + framework = available_architectures[model_name] + print(f"Using {framework} for model {model_name}") + + weight_folder = os.path.join(modelzoo_path, "checkpoints") + + redownload = False + if framework == "pytorch": + pose_model_name = f"{project_name}_{model_name}.pth" + detector_name = f"{project_name}_fasterrcnn.pt" + rename_mapping = { + "pose_model.pth": pose_model_name, + "detector.pt": detector_name, + } + pose_model_path = os.path.join(weight_folder, pose_model_name) + detector_model_path = os.path.join(weight_folder, detector_name) + if not ( + os.path.exists(pose_model_path) and os.path.exists(detector_model_path) + ): + redownload = True + elif framework == "tensorflow": + weight_folder = os.path.join(weight_folder, f"{project_name}_{model_name}") + redownload = not os.path.isdir(weight_folder) + rename_mapping = {} + + if redownload: + download_huggingface_model( + superanimal_name, target_dir=weight_folder, rename_mapping=rename_mapping + ) + + if framework == "tensorflow": + from deeplabcut.pose_estimation_tensorflow.modelzoo.api.superanimal_inference import ( + _video_inference_superanimal, + ) + + if isinstance(videos, str): + videos = [videos] + _video_inference_superanimal( + videos, + project_name, + model_name, + scale_list, + videotype, + video_adapt, + plot_trajectories, + pcutoff, + adapt_iterations, + pseudo_threshold, + ) + elif framework == "pytorch": + from deeplabcut.pose_estimation_pytorch.modelzoo.inference import ( + _video_inference_superanimal, + ) + + if video_adapt: + warnings.warn(f"Video adaptation is not yet implemented for HRNet models.") + + _video_inference_superanimal( + videos, + project_name, + model_name, + max_individuals, + pcutoff, + device, + dest_folder, + ) diff --git a/deeplabcut/modelzoo/webapp/inference.py b/deeplabcut/modelzoo/webapp/inference.py new file mode 100644 index 0000000000..66d0776b94 --- /dev/null +++ b/deeplabcut/modelzoo/webapp/inference.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass +from typing import Dict + +import numpy as np + +from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( + _get_config_model_paths, + _update_config, +) + + +class SingletonTopDownRunners: + """Singleton class for topdown runners + + This class is a singleton class for topdown runners. It is used to + ensure that only one instance of the topdown runners is created. + + Attrs: + config: Configuration dictionary + pose_model_path: Path to the pose model + detector_model_path: Path to the detector model + num_bodyparts: Number of bodyparts + max_individuals: Maximum number of individuals + """ + + _instance = None + + def __new__(cls, *args, **kwargs): + if not cls._instance: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + config, + pose_model_path: str, + detector_model_path: str, + num_bodyparts: int, + max_individuals: int, + ): + + pose_runner, detector_runner = get_runners( + config, + snapshot_path=pose_model_path, + detector_path=detector_model_path, + num_bodyparts=num_bodyparts, + max_individuals=max_individuals, + num_unique_bodyparts=0, + ) + self.pose_runner = pose_runner + self.detector_runner = detector_runner + + +class SuperanimalPyTorchInference: + """Superanimal inference class + + This class is used to perform inference on a superanimal model from the + DeepLabCut model zoo website. + """ + + def __init__( + self, + project_name: str, + pose_model_type: str = "hrnetw32", + max_individuals: int = 30, + device: str = "cpu", + ): + + ( + model_config, + project_config, + _, + _, + ) = _get_config_model_paths(project_name, pose_model_type) + + self.max_individuals = max_individuals + config = {**project_config, **model_config} + config = _update_config(config, max_individuals, device) + + self._config = config + + def initialize_models(self, pose_model_path: str, detector_model_path: str): + + self.models = SingletonTopDownRunners( + self.config, + pose_model_path, + detector_model_path, + len(self.config["bodyparts"]), + self.max_individuals, + ) + + @property + def config(self): + return self._config + + def predict(self, frames: Dict[str, np.array]): + + input_images = np.array(list(frames.values()), dtype=float) + + bbox_predictions = self.models.detector_runner.inference(images=input_images) + input_images = list(zip(input_images, bbox_predictions)) + predictions = self.models.pose_runner.inference(images=input_images) + predictions = [ + {("markers" if k == "bodyparts" else k): v for k, v in d.items()} + for d in predictions + ] + predictions = [ + {**item[1], "image_path": item[0]} + for item in zip(frames.keys(), predictions) + ] + responses = { + "joint_names": self.config["bodyparts"], + "predictions": predictions, + } + + return responses diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 1e5bbb0e89..db41cf5283 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -11,6 +11,7 @@ from __future__ import annotations import copy +import os import pickle import time from pathlib import Path @@ -34,7 +35,7 @@ from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner, Task from deeplabcut.refine_training_dataset.stitch import stitch_tracklets -from deeplabcut.utils import VideoReader, auxfun_multianimal, auxiliaryfunctions +from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, VideoReader class VideoIterator(VideoReader): @@ -89,6 +90,7 @@ def video_inference( pose_runner: InferenceRunner, detector_runner: InferenceRunner | None = None, with_identity: bool = False, + return_video_metadata: bool = False, ) -> list[dict[str, np.ndarray]]: """Runs inference on a video""" video = VideoIterator(str(video_path)) @@ -100,6 +102,11 @@ def video_inference( f" fps: {video.fps}\n" f" resolution: w={vid_w}, h={vid_h}\n" ) + video_metadata = { + "n_frames": n_frames, + "fps": video.fps, + "resolution": (vid_w, vid_h), + } if task == Task.TOP_DOWN: # Get bounding boxes for context @@ -120,7 +127,8 @@ def video_inference( ) for i, p_with_id in enumerate(bodypart_predictions): predictions[i]["bodyparts"] = p_with_id - + if return_video_metadata: + return predictions, video_metadata return predictions @@ -261,6 +269,7 @@ def analyze_videos( output_prefix = video.stem + dlc_scorer output_h5 = output_path / f"{output_prefix}.h5" output_pkl = output_path / f"{output_prefix}_full.pickle" + if not overwrite and output_pkl.exists(): print(f"Video already analyzed at {output_pkl}!") else: @@ -272,22 +281,6 @@ def analyze_videos( detector_runner=detector_runner, ) runtime.append(time.time()) - - # poses must have shape (x, y, score, ...) - bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions]) - unique_bodyparts = None - if len(predictions) > 0 and "unique_bodyparts" in predictions[0]: - unique_bodyparts = np.stack([p["unique_bodyparts"] for p in predictions]) - bodypart_identities = None - if with_identity: - # reshape from (num_assemblies, num_bpts, num_individuals) - # to (num_assemblies, num_bpts) by taking the maximum - # likelihood individual for each bodypart - bodypart_identities = np.stack( - [np.argmax(p["identity_scores"], axis=2) for p in predictions] - ) - - print(f"Inference is done for {video}! Saving results...") metadata = _generate_metadata( cfg=cfg, pytorch_config=pytorch_config, @@ -297,73 +290,36 @@ def analyze_videos( runtime=(runtime[0], runtime[1]), video=VideoReader(str(video)), ) - - cols = [ - [dlc_scorer], - auxiliaryfunctions.get_bodyparts(cfg), - ["x", "y", "likelihood"], - ] - cols_names = ["scorer", "bodyparts", "coords"] - if len(individuals) > 1: - cols.insert(1, individuals) - cols_names.insert(1, "individuals") - - results_df_index = pd.MultiIndex.from_product(cols, names=cols_names) - df = pd.DataFrame( - bodyparts.reshape((len(bodyparts), -1)), - columns=results_df_index, - index=range(len(bodyparts)), - ) - if unique_bodyparts is not None: - coordinate_labels_unique = ["x", "y", "likelihood"] - results_unique_df_index = pd.MultiIndex.from_product( - [ - [dlc_scorer], - auxiliaryfunctions.get_unique_bodyparts(cfg), - coordinate_labels_unique, - ], - names=["scorer", "bodyparts", "coords"], - ) - df_u = pd.DataFrame( - unique_bodyparts.reshape((len(unique_bodyparts), -1)), - columns=results_unique_df_index, - index=range(len(unique_bodyparts)), - ) - df = df.join(df_u, how="outer") - - df.to_hdf(str(output_h5), "df_with_missing", format="table", mode="w") - results.append((str(video), df)) output_data = _generate_output_data(pose_cfg, predictions) _ = auxfun_multianimal.SaveFullMultiAnimalData( output_data, metadata, str(output_h5) ) + df = create_df_from_prediction( + predictions=predictions, + cfg=cfg, + dlc_scorer=dlc_scorer, + output_path=output_path, + output_prefix=output_prefix, + ) + results.append((str(video), df)) + if cfg["multianimalproject"] and len(individuals) > 1: - output_ass = output_path / f"{output_prefix}_assemblies.pickle" - assemblies = {} - for i, bpt in enumerate(bodyparts): - if with_identity: - extra_column = np.expand_dims(bodypart_identities[i], axis=-1) - else: - extra_column = np.full( - (bpt.shape[0], bpt.shape[1], 1), - -1.0, - dtype=np.float32, - ) - ass = np.concatenate((bpt, extra_column), axis=-1) - assemblies[i] = ass - - if unique_bodyparts is not None: - assemblies["single"] = {} - for i, unique_bpt in enumerate(unique_bodyparts): - extra_column = np.full( - (unique_bpt.shape[1], 1), -1.0, dtype=np.float32 - ) - ass = np.concatenate((unique_bpt[0], extra_column), axis=-1) - assemblies["single"][i] = ass - - with open(output_ass, "wb") as handle: - pickle.dump(assemblies, handle, protocol=pickle.HIGHEST_PROTOCOL) + bodypart_identities = None + if with_identity: + # reshape from (num_assemblies, num_bpts, num_individuals) + # to (num_assemblies, num_bpts) by taking the maximum + # likelihood individual for each bodypart + bodypart_identities = [np.argmax(p["identity_scores"], axis=2) for p in predictions] + + _save_assemblies( + output_path, + output_prefix, + bodyparts, + bodypart_identities, + unique_bodyparts, + with_identity, + ) if auto_track: convert_detections2tracklets( config=config, @@ -384,25 +340,99 @@ def analyze_videos( destfolder=destfolder, ) - else: - results_df_index = pd.MultiIndex.from_product( - [ - [dlc_scorer], - pose_cfg["all_joints_names"], - ["x", "y", "likelihood"], - ], - names=["scorer", "bodyparts", "coords"], - ) - df = pd.DataFrame( - np.array(predictions).reshape((len(predictions), -1)), - columns=results_df_index, - index=range(len(predictions)), - ) - df.to_hdf(str(output_h5), "df_with_missing", format="table", mode="w") - results.append((str(video), df)) return results +def create_df_from_prediction( + predictions: list[dict[str, np.ndarray]], + dlc_scorer: str, + cfg: dict, + output_path: str | Path, + output_prefix: str | Path, +) -> pd.DataFrame: + output_h5 = Path(output_path) / f"{output_prefix}.h5" + output_pkl = Path(output_path) / f"{output_prefix}_full.pickle" + + print(f"Saving results in {output_h5} and {output_pkl}") + bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions]) + unique_bodyparts = None + + if len(predictions) > 0 and "unique_bodyparts" in predictions[0]: + unique_bodyparts = np.stack([p["unique_bodyparts"] for p in predictions]) + + cols = [ + [dlc_scorer], + list(auxiliaryfunctions.get_bodyparts(cfg)), + ["x", "y", "likelihood"], + ] + cols_names = ["scorer", "bodyparts", "coords"] + individuals = cfg.get("individuals", ["animal"]) + n_individuals = len(individuals) + if n_individuals > 1: + cols.insert(1, individuals) + cols_names.insert(1, "individuals") + + results_df_index = pd.MultiIndex.from_product(cols, names=cols_names) + bodyparts = bodyparts[:, :n_individuals] + df = pd.DataFrame( + bodyparts.reshape((len(bodyparts), -1)), + columns=results_df_index, + index=range(len(bodyparts)), + ) + if unique_bodyparts is not None: + coordinate_labels_unique = ["x", "y", "likelihood"] + results_unique_df_index = pd.MultiIndex.from_product( + [ + [dlc_scorer], + auxiliaryfunctions.get_unique_bodyparts(cfg), + coordinate_labels_unique, + ], + names=["scorer", "bodyparts", "coords"], + ) + df_u = pd.DataFrame( + unique_bodyparts.reshape((len(unique_bodyparts), -1)), + columns=results_unique_df_index, + index=range(len(unique_bodyparts)), + ) + df = df.join(df_u, how="outer") + + df.to_hdf(output_h5, "df_with_missing", format="table", mode="w") + return df + + +def _save_assemblies( + output_path: Path, + output_prefix: str, + bodyparts: list, + bodypart_identities: list, + unique_bodyparts: list, + with_identity: bool, +) -> None: + output_ass = output_path / f"{output_prefix}_assemblies.pickle" + assemblies = {} + for i, bpt in enumerate(bodyparts): + if with_identity: + extra_column = np.expand_dims(bodypart_identities[i], axis=-1) + else: + extra_column = np.full( + (bpt.shape[0], bpt.shape[1], 1), + -1.0, + dtype=np.float32, + ) + ass = np.concatenate((bpt, extra_column), axis=-1) + assemblies[i] = ass + + if unique_bodyparts is not None: + assemblies["single"] = {} + for i, unique_bpt in enumerate(unique_bodyparts): + extra_column = np.full((unique_bpt.shape[1], 1), -1.0, dtype=np.float32) + ass = np.concatenate((unique_bpt[0], extra_column), axis=-1) + assemblies["single"][i] = ass + + with open(output_ass, "wb") as handle: + pickle.dump(assemblies, handle, protocol=pickle.HIGHEST_PROTOCOL) + + def _validate_destfolder(destfolder: str | None) -> None: """Checks that the destfolder for video analysis is valid""" if destfolder is not None and destfolder != "": @@ -530,8 +560,12 @@ def _generate_output_data( # loaded using them key = "frame" + str(frame_num).zfill(str_width) - bodyparts = frame_predictions["bodyparts"] # shape (num_assemblies, num_bpts, 3) - bodyparts = bodyparts.transpose((1, 0, 2)) # shape (num_bpts, num_assemblies, 3) + bodyparts = frame_predictions[ + "bodyparts" + ] # shape (num_assemblies, num_bpts, 3) + bodyparts = bodyparts.transpose( + (1, 0, 2) + ) # shape (num_bpts, num_assemblies, 3) coordinates = [bpt[:, :2] for bpt in bodyparts] scores = [bpt[:, 2:] for bpt in bodyparts] diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 82e2fccade..42b3433bfc 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -40,9 +40,9 @@ ) from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel from deeplabcut.pose_estimation_pytorch.runners import ( + build_inference_runner, InferenceRunner, Task, - build_inference_runner, ) from deeplabcut.utils import auxfun_videos @@ -121,9 +121,7 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose # ) # ) scale_jitter_lo, scale_jitter_up = aug_cfg.get("scale_jitter", (1, 1)) - transforms.append( - A.Affine(scale=(scale_jitter_lo, scale_jitter_up), p=1) - ) + transforms.append(A.Affine(scale=(scale_jitter_lo, scale_jitter_up), p=1)) if rotation := aug_cfg.get("rotation", 0) != 0: transforms.append( A.Affine( @@ -135,7 +133,7 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose transforms.append(A.Equalize(p=0.5)) if aug_cfg.get("motion_blur", False): transforms.append(A.MotionBlur(p=0.5)) - if aug_cfg.get('covering', False): + if aug_cfg.get("covering", False): transforms.append( CoarseDropout( max_holes=10, @@ -143,12 +141,12 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose min_height=0.01, max_width=0.05, min_width=0.01, - p=0.5 + p=0.5, ) ) - if aug_cfg.get('elastic_transform', False): + if aug_cfg.get("elastic_transform", False): transforms.append(ElasticTransform(sigma=5, p=0.5)) - if aug_cfg.get('grayscale', False): + if aug_cfg.get("grayscale", False): transforms.append(Grayscale(alpha=(0.5, 1.0))) if aug_cfg.get("gaussian_noise", False): opt = aug_cfg.get("gaussian_noise", False) # std @@ -183,7 +181,9 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose return A.Compose( transforms, - keypoint_params=A.KeypointParams("xy", remove_invisible=False, label_fields=["class_labels"]), + keypoint_params=A.KeypointParams( + "xy", remove_invisible=False, label_fields=["class_labels"] + ), bbox_params=bbox_params, ) @@ -505,7 +505,7 @@ def get_runners( task=Task.DETECT, model=DETECTORS.build(pytorch_config["detector"]["model"]), device=device, - snapshot_path=snapshot_path, + snapshot_path=detector_path, preprocessor=build_bottom_up_preprocessor( color_mode="RGB", # TODO: read from Loader transform=detector_transform, diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py b/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py new file mode 100644 index 0000000000..2dd1b06028 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py @@ -0,0 +1,10 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py b/deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py new file mode 100644 index 0000000000..874e13beb7 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py @@ -0,0 +1,132 @@ +import argparse +import json +import os +from pathlib import Path + +import torch + +from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model +from deeplabcut.pose_estimation_pytorch.models.model import PoseModel +from deeplabcut.utils import auxiliaryfunctions + + +def _map_modelzoo_to_dlc(state_dict: dict) -> dict: + """Map the model zoo weights to the DLC format + Args: + state_dict: the model zoo state dict + Returns: + the mapped state dict + """ + + updated_dict = {} + for k, v in state_dict.items(): + parts = k.split(".") + if parts[0] == "backbone": + parts = [parts[0], "model", *parts[1:]] + elif parts[0] == "keypoint_head": + parts = ["heads", "bodypart", "heatmap_head", "model", parts[-1]] + if parts[-1] == "weight": + v = v.permute(1, 0, 2, 3) + + updated_dict[".".join(parts)] = v + return updated_dict + + +def map_modelzoo_to_dlc( + model_zoo_weights_path: str, device: str, pytorch_config: dict +) -> PoseModel: + """Map the model zoo weights to the DLC format + Args: + model_zoo_weights_path: the path to the model zoo weights + device: the device to load the weights on + pytorch_config: the pytorch config to use for model building + Returns: + the mapped state dict + """ + model_weights = torch.load( + str(model_zoo_weights_path), map_location=torch.device(device) + ) + model = build_pose_model(pytorch_config) + mising_keys = model.load_state_dict( + _map_modelzoo_to_dlc(model_weights["state_dict"]), strict=False + ) + + assert len(mising_keys[1]) == 0 + + return model + + +def _shift_category_ids(ann_files): + """Shift category ids to 1""" + + for mode in ["train", "test"]: + with open(ann_files[mode], "r") as f: + data = json.load(f) + + cleaned_data = data.copy() + for cat in cleaned_data["categories"]: + cat["id"] = 1 + + for ann in cleaned_data["annotations"]: + ann["category_id"] = 1 + + with open(ann_files[mode], "w") as f: + json.dump(cleaned_data, f) + + +def _clean_result_json(results_files): + """Clean the json files""" + + for mode in ["train", "test"]: + with open(results_files[mode], "r") as f: + data = json.load(f) + + out_anns = [] + for ann in data: + ann["score"] = ann["bbox_scores"][0] + out_anns.append(ann) + + with open(results_files[mode], "w") as f: + json.dump(out_anns, f) + + +def _change_path_annotations(ann_files, project_root): + for mode in ["train", "test"]: + with open(ann_files[mode], "r") as f: + data = json.load(f) + for i in range(len(data["images"])): + basename = os.path.basename(data["images"][i]["file_name"]) + data["images"][i]["file_name"] = f"{project_root}/images/{basename}" + + +def modify_annotations(project_root, ann_files): + _shift_category_ids(ann_files) + _change_path_annotations(ann_files, project_root) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("project_root") + parser.add_argument("pytorch_config_path") + parser.add_argument("model_zoo_weights_path") + parser.add_argument("--train_file", default="train.json") + parser.add_argument("--test_file", default="test.json") + parser.add_argument("--device", default="cpu") + + args = parser.parse_args() + pytorch_config = auxiliaryfunctions.read_config(args.pytorch_config_path) + backbone_type = pytorch_config["model"]["backbone"]["model_name"] + save_path = ( + f"{os.path.dirname(args.pytorch_config_path)}/checkpoints/{backbone_type}.pth" + ) + annotatin_files = { + "train": Path(args.project_root) / "annotations" / args.test_file, + "test": Path(args.project_root) / "annotations" / args.train_file, + } + + modify_annotations(args.project_root, annotatin_files) + model = map_modelzoo_to_dlc( + args.model_zoo_weights_path, args.device, pytorch_config + ) + + torch.save(model.state_dict(), save_path) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py new file mode 100644 index 0000000000..cd080f9a51 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -0,0 +1,161 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import os +import pickle +import time +from pathlib import Path +from typing import Optional, Union + +import torch + +from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import ( + create_df_from_prediction, + video_inference, +) +from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( + _get_config_model_paths, + _update_config, + raise_warning_if_called_directly, + select_device, +) +from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path, read_config +from deeplabcut.utils.make_labeled_video import _create_labeled_video + + +def construct_bodypart_names(max_individuals, bodyparts): + multianimalbodyparts = [] + for i in range(max_individuals): + for bodypart in bodyparts: + multianimalbodyparts.append(f"{bodypart}_{i}") + return multianimalbodyparts + + +def _video_inference_superanimal( + video_paths: Union[str, list], + project_name: str, + model_name: str, + max_individuals: int, + pcutoff: float, + device: Optional[str] = None, + dest_folder: Optional[str] = None, +) -> dict: + """ + + Perform inference on a video using a superanimal model from the model zoo specified by `superanimal_name`. + During inference, the video is analyzed using the specified model and the results are saved in the specified + destination folder. The predictions are saved in the form of a .h5 file. The video with the predictions is saved + in the form of a .mp4 file. + + WARNING: This function is an internal utility function and should not be + called directly. It is designed to be used by deeplabcut.modelzoo.api.video_inference.py + + Args: + video_paths: Path to the video to be analyzed or list of paths to videos to be analyzed + + project_name: Name of the superanimal project (e.g. superanimal_quadruped) + + model_name: Name of the model (e.g. hrnetw32) + + max_individuals: Maximum number of individuals in the video + + pcutoff: Cutoff for cutting off the predicted keypoints with probability lower than pcutoff + + device: The device on which to perform the operation. + If not specified, the device is automatically determined by the + `select_device` function. Defaults to None, which triggers + automatic device selection. + dest_folder: Destination folder for the results. If not specified, the + results are saved in the same folder as the video. Defaults to None. + + Returns: + results: Dictionary with the result pd.DataFrame for each video + + Raises: + Warning: If the function is called directly. + """ + + raise_warning_if_called_directly() + ( + model_config, + project_config, + pose_model_path, + detector_model_path, + ) = _get_config_model_paths(project_name, model_name) + if device is None: + device = select_device() + + config = {**project_config, **model_config} + config = _update_config(config, max_individuals, device) + + pose_runner, detector_runner = get_runners( + config, + snapshot_path=pose_model_path, + detector_path=detector_model_path, + num_bodyparts=len(config["bodyparts"]), + max_individuals=max_individuals, + num_unique_bodyparts=0, + ) + pose_task = Task(config.get("method", "BU")) + results = {} + + if isinstance(video_paths, str): + video_paths = [video_paths] + + if dest_folder is None: + dest_folder = Path(video_paths[0]).parent + + if not os.path.exists(dest_folder): + os.makedirs(dest_folder) + + for video_path in video_paths: + print(f"Processing video {video_path}") + + prediction, video_metadata = video_inference( + video_path, + task=pose_task, + pose_runner=pose_runner, + detector_runner=detector_runner, + return_video_metadata=True, + ) + bbox = (0, video_metadata["resolution"][0], 0, video_metadata["resolution"][1]) + print(f"Saving results to {dest_folder}") + config["uniquebodyparts"] = [] + config["multianimalbodyparts"] = config["bodyparts"] + + dlc_scorer = f"{project_name}_{model_name}" + output_prefix = f"{Path(video_path).stem}_{dlc_scorer}" + output_path = Path(dest_folder) + df = create_df_from_prediction( + prediction, + dlc_scorer, + config, + output_path, + output_prefix, + ) + + results[video_path] = df + + output_h5 = Path(output_path) / f"{output_prefix}.h5" + output_video = output_path / f"{output_prefix}_labeled.mp4" + _create_labeled_video( + video_path, + output_h5, + pcutoff=pcutoff, + fps=video_metadata["fps"], + bbox=bbox, + output_path=str(output_video), + ) + + print(f"Video with predictions was saved as {output_path}") + + return results diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py new file mode 100644 index 0000000000..f3e59abbcd --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py @@ -0,0 +1,105 @@ +import inspect +import json +import os +import subprocess +import warnings + +import torch + +from deeplabcut.utils import auxiliaryfunctions + + +def _get_config_model_paths( + project_name: str, + pose_model_type: str, + detector_type: str = "fasterrcnn", + weight_folder: str = None, +): + """Get the paths to the model and project configs + + Args: + project_name: the name of the project + pose_model_name: the name of the pose model + detector_type: the type of the detector + weight_folder: the folder containing the weights + Returns: + the paths to the models and project configs + """ + dlc_root_path = auxiliaryfunctions.get_deeplabcut_path() + modelzoo_path = os.path.join(dlc_root_path, "modelzoo") + + model_config = auxiliaryfunctions.read_config( + os.path.join(modelzoo_path, "model_configs", f"{pose_model_type}.yaml") + ) + project_config = auxiliaryfunctions.read_config( + os.path.join(modelzoo_path, "project_configs", f"{project_name}.yaml") + ) + if weight_folder is None: + weight_folder = os.path.join(modelzoo_path, "checkpoints") + + pose_model_path = os.path.join( + weight_folder, f"{project_name}_{pose_model_type}.pth" + ) + detector_model_path = os.path.join( + weight_folder, f"{project_name}_{detector_type}.pt" + ) + + return ( + model_config, + project_config, + pose_model_path, + detector_model_path, + ) + + +def get_gpu_memory_map(): + """Get the current gpu usage.""" + result = subprocess.check_output( + ["nvidia-smi", "--query-gpu=memory.free", "--format=csv,nounits,noheader"], + encoding="utf-8", + ) + gpu_memory = [int(x) for x in result.strip().split("\n")] + gpu_memory_map = dict(zip(range(len(gpu_memory)), gpu_memory)) + return gpu_memory_map + + +def select_device(): + if torch.cuda.is_available(): + gpu_memory_map = get_gpu_memory_map() + selected_device = max(gpu_memory_map, key=gpu_memory_map.get) + print(f"Device was set to cuda:{selected_device}") + + return torch.device(f"cuda:{selected_device}") + else: + return torch.device("cpu") + + +def raise_warning_if_called_directly(): + current_frame = inspect.currentframe() + caller_frame = inspect.getouterframes(current_frame, 2) + caller_name = caller_frame[1].filename + + if not "pose_estimation_" in caller_name: + warnings.warn( + f"{caller_name} is intended for internal use only and should not be called directly.", + UserWarning, + ) + + +def _update_config(config, max_individuals, device): + num_bodyparts = len(config["bodyparts"]) + config["detector"]["runner"]["max_individuals"] = max_individuals + config["multianimalproject"] = max_individuals > 1 + config["individuals"] = ["animal"] + config["multianimalbodyparts"] = config["bodyparts"] + config["uniquebodyparts"] = [] + config["device"] = device + config["model"]["heads"]["bodypart"]["target_generator"][ + "num_heatmaps" + ] = num_bodyparts + config["model"]["heads"]["bodypart"]["heatmap_config"]["channels"][ + -1 + ] = num_bodyparts + config["individuals"] = ["single"] * max_individuals + + return config diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_modelzoo.py b/deeplabcut/pose_estimation_pytorch/tests/test_modelzoo.py new file mode 100644 index 0000000000..b6c53f6658 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/tests/test_modelzoo.py @@ -0,0 +1,38 @@ +import os + +import pytest + +from deeplabcut.modelzoo.video_inference import video_inference_superanimal +from deeplabcut.utils import auxiliaryfunctions + +examples_folder = os.path.join( + auxiliaryfunctions.get_deeplabcut_path(), + "modelzoo", + "examples", +) + +# requires videos to be in the examples folder +@pytest.mark.parametrize( + "video_paths, superanimal_name", + [ + (f"{examples_folder}/black_dog.mp4", "superanimal_quadruped"), + (f"{examples_folder}/black_dog.mp4", "superanimal_quadruped_hrnetw32"), + (f"{examples_folder}/swear_mouse_tiny.mp4", "superanimal_topviewmouse"), + ( + f"{examples_folder}/swear_mouse_tiny.mp4", + "superanimal_topviewmouse_hrnetw32", + ), + ], +) +def test_video_inference_saves_file(video_paths, superanimal_name): + video_inference_superanimal( + video_paths, + superanimal_name=superanimal_name, + ) + if isinstance(video_paths, str): + video_paths = [video_paths] + for video_path in video_paths: + output_path = video_path.replace(".mp4", f"_labeled.mp4") + assert os.path.exists(output_path), "Output video file does not exist" + + assert os.stat(output_path).st_size > 0, "Output video file is empty" diff --git a/deeplabcut/pose_estimation_tensorflow/__init__.py b/deeplabcut/pose_estimation_tensorflow/__init__.py index 38586cb91f..819a48b2c7 100644 --- a/deeplabcut/pose_estimation_tensorflow/__init__.py +++ b/deeplabcut/pose_estimation_tensorflow/__init__.py @@ -12,7 +12,6 @@ # Licensed under GNU Lesser General Public License v3.0 # - from deeplabcut.pose_estimation_tensorflow.config import * from deeplabcut.pose_estimation_tensorflow.datasets import * from deeplabcut.pose_estimation_tensorflow.default_config import * @@ -26,6 +25,3 @@ from deeplabcut.pose_estimation_tensorflow.training import * from deeplabcut.pose_estimation_tensorflow.util import * from deeplabcut.pose_estimation_tensorflow.visualizemaps import * -from deeplabcut.pose_estimation_tensorflow.predict_supermodel import ( - video_inference_superanimal, -) diff --git a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py index 79f96c539a..4d98c226a0 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py +++ b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py @@ -61,7 +61,10 @@ def train( setup_logging() - cfg = load_config(config_yaml) + if isinstance(config_yaml, dict): + cfg = config_yaml + else: + cfg = load_config(config_yaml) cfg["pseudo_threshold"] = pseudo_threshold cfg["video_path"] = video_path @@ -192,8 +195,10 @@ def train( cumloss, partloss, locrefloss, pwloss = 0.0, 0.0, 0.0, 0.0 lr_gen = LearningRate(cfg) - stats_path = Path(config_yaml).with_name("learning_stats.csv") - lrf = open(str(stats_path), "w") + lrf = None + if not isinstance(config_yaml, dict): + stats_path = Path(config_yaml).with_name("learning_stats.csv") + lrf = open(str(stats_path), "w") print("Training parameters:") print(cfg) @@ -232,26 +237,28 @@ def train( ) ) - lrf.write( - "iteration: {}, loss: {}, scmap loss: {}, locref loss: {}, limb loss: {}, lr: {}\n".format( - it, - "{0:.4f}".format(cumloss / display_iters), - "{0:.4f}".format(partloss / display_iters), - "{0:.4f}".format(locrefloss / display_iters), - "{0:.4f}".format(pwloss / display_iters), - current_lr, + if lrf: + lrf.write( + "iteration: {}, loss: {}, scmap loss: {}, locref loss: {}, limb loss: {}, lr: {}\n".format( + it, + "{0:.4f}".format(cumloss / display_iters), + "{0:.4f}".format(partloss / display_iters), + "{0:.4f}".format(locrefloss / display_iters), + "{0:.4f}".format(pwloss / display_iters), + current_lr, + ) ) - ) cumloss, partloss, locrefloss, pwloss = 0.0, 0.0, 0.0, 0.0 - lrf.flush() + if lrf: + lrf.flush() # Save snapshot if (it % save_iters == 0 and it != start_iter) or it == max_iter: model_name = cfg["snapshot_prefix"] saver.save(sess, model_name, global_step=it) - - lrf.close() + if lrf: + lrf.close() sess.close() coord.request_stop() diff --git a/deeplabcut/modelzoo/api/__init__.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/__init__.py similarity index 100% rename from deeplabcut/modelzoo/api/__init__.py rename to deeplabcut/pose_estimation_tensorflow/modelzoo/api/__init__.py diff --git a/deeplabcut/modelzoo/api/spatiotemporal_adapt.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py similarity index 77% rename from deeplabcut/modelzoo/api/spatiotemporal_adapt.py rename to deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py index bef6146ba2..8fb03bfdaa 100644 --- a/deeplabcut/modelzoo/api/spatiotemporal_adapt.py +++ b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py @@ -8,13 +8,21 @@ # # Licensed under GNU Lesser General Public License v3.0 # -import deeplabcut import glob import os -from deeplabcut.modelzoo.utils import parse_available_supermodels -from deeplabcut.modelzoo.api import superanimal_inference -from deeplabcut.utils.plotting import _plot_trajectories +import io from pathlib import Path +import yaml +from deeplabcut.pose_estimation_tensorflow.modelzoo.api.superanimal_inference import ( + video_inference, +) +from deeplabcut.utils.auxiliaryfunctions import ( + get_deeplabcut_path, + load_analyzed_data, + read_config, +) +from deeplabcut.utils.make_labeled_video import create_labeled_video +from deeplabcut.utils.plotting import _plot_trajectories class SpatiotemporalAdaptation: @@ -72,12 +80,6 @@ def __init__( if scale_list is None: scale_list = [] - supermodels = parse_available_supermodels() - if supermodel_name not in supermodels: - raise ValueError( - f"`supermodel_name` should be one of: {', '.join(supermodels)}." - ) - self.video_path = video_path self.supermodel_name = supermodel_name self.scale_list = scale_list @@ -88,32 +90,53 @@ def __init__( self.modelfolder = modelfolder self.init_weights = init_weights + project_name = "_".join(supermodel_name.split("_")[:-1]) + model_name = supermodel_name.split("_")[-1] + self.project_name = project_name + self.model_name = model_name + if not customized_pose_config: - dlc_root_path = os.sep.join(deeplabcut.__file__.split(os.sep)[:-1]) - self.customized_pose_config = os.path.join( - dlc_root_path, - "pose_estimation_tensorflow", - "superanimal_configs", - supermodels[self.supermodel_name], + dlc_root_path = get_deeplabcut_path() + + project_config = read_config( + os.path.join( + dlc_root_path, "modelzoo", "project_configs", f"{project_name}.yaml" + ) + ) + + model_config = read_config( + os.path.join( + dlc_root_path, "modelzoo", "model_configs", f"{model_name}.yaml" + ) ) + + joints = [i for i in range(len(project_config["bodyparts"]))] + num_joints = len(joints) + model_config["all_joints"] = joints + model_config["all_joints_names"] = project_config["bodyparts"] + model_config["num_joints"] = num_joints + model_config["num_limbs"] = int((num_joints * (num_joints - 1)) // 2) + self.customized_pose_config = {**project_config, **model_config} else: self.customized_pose_config = customized_pose_config def before_adapt_inference(self, make_video=False, **kwargs): if self.init_weights != "": print("using customized weights", self.init_weights) - _, datafiles = superanimal_inference.video_inference( + _, datafiles = video_inference( [self.video_path], - self.supermodel_name, + self.project_name, + self.model_name, videotype=self.videotype, scale_list=self.scale_list, init_weights=self.init_weights, customized_test_config=self.customized_pose_config, ) else: - self.init_weights, datafiles = superanimal_inference.video_inference( + self.init_weights, datafiles = video_inference( [self.video_path], - self.supermodel_name, + self.project_name, + self.model_name, videotype=self.videotype, scale_list=self.scale_list, customized_test_config=self.customized_pose_config, @@ -125,7 +148,7 @@ def before_adapt_inference(self, make_video=False, **kwargs): _plot_trajectories(datafiles[0]) if make_video: - deeplabcut.create_labeled_video( + create_labeled_video( "", [self.video_path], videotype=self.videotype, @@ -167,7 +190,7 @@ def adaptation_training(self, displayiters=500, saveiters=1000, **kwargs): vname = str(Path(self.video_path).stem) video_root = Path(self.video_path).parent - _, pseudo_label_path, _, _ = deeplabcut.auxiliaryfunctions.load_analyzed_data( + _, pseudo_label_path, _, _ = load_analyzed_data( video_root, vname, DLCscorer, False, "" ) if self.modelfolder != "": @@ -175,19 +198,13 @@ def adaptation_training(self, displayiters=500, saveiters=1000, **kwargs): self.adapt_iterations = kwargs.get("adapt_iterations", self.adapt_iterations) - if os.path.exists( - os.path.join(self.modelfolder, f"snapshot-{self.adapt_iterations}.index") - ): - print( - f"model checkpoint snapshot-{self.adapt_iterations}.index exists, skipping the video adaptation" - ) - else: - self.train_without_project( - pseudo_label_path, - displayiters=displayiters, - saveiters=saveiters, - **kwargs, - ) + + self.train_without_project( + pseudo_label_path, + displayiters=displayiters, + saveiters=saveiters, + **kwargs, + ) def after_adapt_inference(self, **kwargs): pattern = os.path.join( @@ -208,9 +225,10 @@ def after_adapt_inference(self, **kwargs): # spatial pyramid can still be useful for reducing jittering and quantization error - _, datafiles = superanimal_inference.video_inference( + _, datafiles = video_inference( [self.video_path], - self.supermodel_name, + self.project_name, + self.model_name, videotype=self.videotype, init_weights=adapt_weights, scale_list=scale_list, @@ -220,7 +238,7 @@ def after_adapt_inference(self, **kwargs): if kwargs.pop("plot_trajectories", True): _plot_trajectories(datafiles[0]) - deeplabcut.create_labeled_video( + create_labeled_video( ref_proj_config_path, [self.video_path], videotype=self.videotype, diff --git a/deeplabcut/modelzoo/api/superanimal_inference.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py similarity index 73% rename from deeplabcut/modelzoo/api/superanimal_inference.py rename to deeplabcut/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py index 0d6ebe97fa..b7cee8aa4f 100644 --- a/deeplabcut/modelzoo/api/superanimal_inference.py +++ b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py @@ -8,10 +8,12 @@ # # Licensed under GNU Lesser General Public License v3.0 # +import glob import os import os.path import pickle import time +import warnings from pathlib import Path import imgaug.augmenters as iaa @@ -20,18 +22,11 @@ from skimage.util import img_as_ubyte from tqdm import tqdm -from deeplabcut.modelzoo.utils import parse_available_supermodels from deeplabcut.pose_estimation_tensorflow.config import load_config from deeplabcut.pose_estimation_tensorflow.core import predict as single_predict from deeplabcut.pose_estimation_tensorflow.core import predict_multianimal as predict from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.auxfun_videos import VideoWriter -from dlclibrary.dlcmodelzoo.modelzoo_download import ( - download_huggingface_model, - MODELOPTIONS, -) -import glob -import warnings warnings.simplefilter("ignore", category=RuntimeWarning) @@ -260,7 +255,8 @@ def _video_inference( def video_inference( videos, - superanimal_name, + project_name, + model_name, scale_list=[], videotype="avi", destfolder=None, @@ -273,36 +269,41 @@ def video_inference( dlc_root_path = auxiliaryfunctions.get_deeplabcut_path() if customized_test_config == "": - supermodels = parse_available_supermodels() - test_cfg = load_config( + project_cfg = load_config( os.path.join( dlc_root_path, - "pose_estimation_tensorflow", - "superanimal_configs", - supermodels[superanimal_name], + "modelzoo", + "project_configs", + f"{project_name}.yaml", ) ) + model_cfg = load_config( + os.path.join( + dlc_root_path, + "modelzoo", + "model_configs", + f"{model_name}.yaml", + ) + ) + test_cfg = {**project_cfg, **model_cfg} + test_cfg["all_joints"] = [i for i in range(len(test_cfg["bobyparts"]))] + test_cfg["all_joints_names"] = test_cfg["bobyparts"] + num_joints = len(test_cfg["all_joints"]) + test_cfg["num_joints"] = num_joints + test_cfg["num_limbs"] = int((num_joints * (num_joints - 1)) // 2) + else: - test_cfg = load_config(customized_test_config) + test_cfg = customized_test_config # add a temp folder for checkpoint weight_folder = str( Path(dlc_root_path) - / "pose_estimation_tensorflow" - / "models" - / "pretrained" - / (superanimal_name + "_weights") + / "modelzoo" + / "checkpoints" + / f"{project_name}_{model_name}" ) - if superanimal_name in MODELOPTIONS: - if not os.path.exists(weight_folder): - download_huggingface_model(superanimal_name, weight_folder) - else: - print(f"{weight_folder} exists, using the downloaded weights") - else: - print(f"{superanimal_name} not available. Available ones are: ", MODELOPTIONS) - snapshots = glob.glob(os.path.join(weight_folder, "snapshot-*.index")) test_cfg["partaffinityfield_graph"] = [] @@ -311,6 +312,11 @@ def video_inference( if init_weights != "": test_cfg["init_weights"] = init_weights else: + if len(snapshots) == 0: + raise FileNotFoundError( + f"Did not find any super animal snapshots in {weight_folder}" + ) + init_weights = os.path.abspath(snapshots[0]).replace(".index", "") test_cfg["init_weights"] = init_weights @@ -436,3 +442,102 @@ def video_inference( df.to_hdf(dataname, key="df_with_missing") return init_weights, datafiles + + +def _video_inference_superanimal( + videos, + project_name, + model_name, + scale_list=[], + videotype=".mp4", + video_adapt=False, + plot_trajectories=True, + pcutoff=0.1, + adapt_iterations=1000, + pseudo_threshold=0.1, +): + """ + WARNING: This function is an internal utility function and should not be + called directly. It is designed to be used by deeplabcut.modelzoo.api.video_inference.py + + Makes prediction based on a super animal model. Note right now we only support single animal video inference + + The index of the trained network is specified by parameters in the config file (in particular the variable 'snapshotindex') + + Output: The labels are stored as MultiIndex Pandas Array, which contains the name of the network, body part name, (x, y) label position \n + in pixels, and the likelihood for each frame per body part. These arrays are stored in an efficient Hierarchical Data Format (HDF) \n + in the same directory, where the video is stored. + + Parameters + ---------- + videos: list + A list of strings containing the full paths to videos for analysis or a path to the directory, where all the videos with same extension are stored. + + superanimal_name: str + The name of the superanimal model. We currently only support "superanimal_quadruped" and "superanimal_topviewmouse" + scale_list: list + A list of int containing the target height of the multi scale test time augmentation. By default it uses the original size. Users are advised to try a wide range of scale list when the super model does not give reasonable results + + videotype: string, optional + Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed. The default is ``.avi`` + + video_adapt: bool, optional + Set True if you want to apply video adaptation to make the resulted video less jittering and better. However, adaptation training takes more time than usual video inference + + plot_trajectories: bool, optional (default=True) + By default, plot the trajectories of various body parts across the video. + + pcutoff: float, optional + Keypoints confidence that are under pcutoff will not be shown in the resulted video + + adapt_iterations: int, optional: + Number of iterations for adaptation training + + pseudo_threshold: float, default 0.1 + Video adaptation only uses predictions that are above pseudo_threshold + + Given a list of scales for spatial pyramid, i.e. [600, 700] + + scale_list = range(600,800,100) + + superanimal_name = 'superanimal_topviewmouse' + videotype = 'mp4' + scale_list = [200, 300, 400] + deeplabcut.video_inference_superanimal( + video, + superanimal_name, + videotype = '.avi', + scale_list = scale_list, + ) + >>> + """ + from deeplabcut.pose_estimation_tensorflow.modelzoo.api import ( + SpatiotemporalAdaptation, + ) + + superanimal_name = project_name + "_" + model_name + for video in videos: + modelfolder = Path(video).parent / f"{Path(video).stem}_video_adaptation" + modelfolder.mkdir(exist_ok=True, parents=True) + + adapter = SpatiotemporalAdaptation( + video, + superanimal_name, + modelfolder=str(modelfolder), + videotype=video.split(".")[-1], + scale_list=scale_list, + ) + if not video_adapt: + adapter.before_adapt_inference( + make_video=True, pcutoff=pcutoff, plot_trajectories=plot_trajectories + ) + else: + adapter.before_adapt_inference(make_video=False) + adapter.adaptation_training( + adapt_iterations=adapt_iterations, + pseudo_threshold=pseudo_threshold, + ) + adapter.after_adapt_inference( + pcutoff=pcutoff, + plot_trajectories=plot_trajectories, + ) diff --git a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py b/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py index 1933e013b3..e69de29bb2 100644 --- a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py +++ b/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py @@ -1,110 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -from pathlib import Path -from deeplabcut.modelzoo.api import SpatiotemporalAdaptation - - -def video_inference_superanimal( - videos, - superanimal_name, - scale_list=[], - videotype=".mp4", - video_adapt=False, - plot_trajectories=True, - pcutoff=0.1, - adapt_iterations=1000, - pseudo_threshold=0.1, -): - """ - Makes prediction based on a super animal model. Note right now we only support single animal video inference - - The index of the trained network is specified by parameters in the config file (in particular the variable 'snapshotindex') - - Output: The labels are stored as MultiIndex Pandas Array, which contains the name of the network, body part name, (x, y) label position \n - in pixels, and the likelihood for each frame per body part. These arrays are stored in an efficient Hierarchical Data Format (HDF) \n - in the same directory, where the video is stored. - - Parameters - ---------- - videos: list - A list of strings containing the full paths to videos for analysis or a path to the directory, where all the videos with same extension are stored. - - superanimal_name: str - The name of the superanimal model. We currently only support "superanimal_quadruped" and "superanimal_topviewmouse" - scale_list: list - A list of int containing the target height of the multi scale test time augmentation. By default it uses the original size. Users are advised to try a wide range of scale list when the super model does not give reasonable results - - videotype: string, optional - Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed. The default is ``.avi`` - - video_adapt: bool, optional - Set True if you want to apply video adaptation to make the resulted video less jittering and better. However, adaptation training takes more time than usual video inference - - plot_trajectories: bool, optional (default=True) - By default, plot the trajectories of various body parts across the video. - - pcutoff: float, optional - Keypoints confidence that are under pcutoff will not be shown in the resulted video - - adapt_iterations: int, optional: - Number of iterations for adaptation training - - pseudo_threshold: float, default 0.1 - Video adaptation only uses predictions that are above pseudo_threshold - - Given a list of scales for spatial pyramid, i.e. [600, 700] - - scale_list = range(600,800,100) - - superanimal_name = 'superanimal_topviewmouse' - videotype = 'mp4' - scale_list = [200, 300, 400] - deeplabcut.video_inference_superanimal( - video, - superanimal_name, - videotype = '.avi', - scale_list = scale_list, - ) - >>> - """ - from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path - - for video in videos: - vname = Path(video).stem - dlcparent_path = get_deeplabcut_path() - modelfolder = ( - Path(dlcparent_path) - / "pose_estimation_tensorflow" - / "models" - / "pretrained" - / (superanimal_name + "_" + vname + "_weights") - ) - adapter = SpatiotemporalAdaptation( - video, - superanimal_name, - modelfolder=modelfolder, - videotype=videotype, - scale_list=scale_list, - ) - if not video_adapt: - adapter.before_adapt_inference( - make_video=True, pcutoff=pcutoff, plot_trajectories=plot_trajectories - ) - else: - adapter.before_adapt_inference(make_video=False) - adapter.adaptation_training( - adapt_iterations=adapt_iterations, - pseudo_threshold=pseudo_threshold, - ) - adapter.after_adapt_inference( - pcutoff=pcutoff, - plot_trajectories=plot_trajectories, - ) diff --git a/deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml b/deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml deleted file mode 100644 index b6088f9c9b..0000000000 --- a/deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml +++ /dev/null @@ -1,150 +0,0 @@ -all_joints: -- - 0 -- - 1 -- - 2 -- - 3 -- - 4 -- - 5 -- - 6 -- - 7 -- - 8 -- - 9 -- - 10 -- - 11 -- - 12 -- - 13 -- - 14 -- - 15 -- - 16 -- - 17 -- - 18 -- - 19 -- - 20 -- - 21 -- - 22 -- - 23 -- - 24 -- - 25 -- - 26 -- - 27 -- - 28 -- - 29 -- - 30 -- - 31 -- - 32 -- - 33 -- - 34 -- - 35 -- - 36 -- - 37 -- - 38 -all_joints_names: -- nose -- upper_jaw -- lower_jaw -- mouth_end_right -- mouth_end_left -- right_eye -- right_earbase -- right_earend -- right_antler_base -- right_antler_end -- left_eye -- left_earbase -- left_earend -- left_antler_base -- left_antler_end -- neck_base -- neck_end -- throat_base -- throat_end -- back_base -- back_end -- back_middle -- tail_base -- tail_end -- front_left_thai -- front_left_knee -- front_left_paw -- front_right_thai -- front_right_knee -- front_right_paw -- back_left_paw -- back_left_thai -- back_right_thai -- back_left_knee -- back_right_knee -- back_right_paw -- belly_bottom -- body_middle_right -- body_middle_left -alpha_r: 0.02 -apply_prob: 0.5 -batch_size: 1 -clahe: true -claheratio: 0.1 -crop_sampling: hybrid -crop_size: -- 400 -- 400 -cropratio: 0.4 -dataset: training-datasets/iteration-0/UnaugmentedDataSet_ma_superquadrupedMarch30/ma_superquadruped_maDLC_scorer85shuffle1.pickle -dataset_type: multi-animal-imgaug -decay_steps: 30000 -display_iters: 500 -edge: false -emboss: - alpha: - - 0.0 - - 1.0 - embossratio: 0.1 - strength: - - 0.5 - - 1.5 -global_scale: 0.8 -histeq: true -histeqratio: 0.1 -init_weights: -intermediate_supervision: false -intermediate_supervision_layer: 12 -location_refinement: true -locref_huber_loss: true -locref_loss_weight: 0.05 -locref_stdev: 7.2801 -lr_init: 0.0005 -max_input_size: 1500 -max_shift: 0.4 -metadataset: training-datasets/iteration-0/UnaugmentedDataSet_ma_superquadrupedMarch30/Documentation_data-ma_superquadruped_85shuffle1.pickle -min_input_size: 64 -mirror: false -multi_stage: true -multi_step: -- - 0.0001 - - 7500 -- - 5.0e-05 - - 12000 -- - 1.0e-05 - - 1000000 -net_type: resnet_50 -num_idchannel: 0 -num_joints: 39 -num_limbs: 741 -optimizer: adam -pafwidth: 20 -pairwise_huber_loss: false -pairwise_loss_weight: 0.1 -pairwise_predict: false -partaffinityfield_graph: [] -partaffinityfield_predict: false -pos_dist_thresh: 17 -pre_resize: [] -project_path: -rotation: 25 -rotratio: 0.4 -save_iters: 10000 -scale_jitter_lo: 0.5 -scale_jitter_up: 1.25 -sharpen: false -sharpenratio: 0.3 -weigh_only_present_joints: false -gradient_masking: true diff --git a/deeplabcut/utils/make_labeled_video.py b/deeplabcut/utils/make_labeled_video.py index 238fd4e3ef..2f0b669feb 100644 --- a/deeplabcut/utils/make_labeled_video.py +++ b/deeplabcut/utils/make_labeled_video.py @@ -28,10 +28,10 @@ # Dependencies #################################################### import os.path -from pathlib import Path from functools import partial -from multiprocessing import Pool, get_start_method -from typing import Iterable, Callable, Optional, Union +from multiprocessing import get_start_method, Pool +from pathlib import Path +from typing import Callable, Iterable, Optional, Union import matplotlib.colors as mcolors import matplotlib.pyplot as plt @@ -42,13 +42,13 @@ from skimage.draw import disk, line_aa, set_color from skimage.util import img_as_ubyte from tqdm import trange -from deeplabcut.modelzoo.utils import parse_available_supermodels + from deeplabcut.pose_estimation_tensorflow.config import load_config -from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, visualization +from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, visualization +from deeplabcut.utils.auxfun_videos import VideoWriter from deeplabcut.utils.video_processor import ( VideoProcessorCV as vp, ) # used to CreateVideo -from deeplabcut.utils.auxfun_videos import VideoWriter def get_segment_indices(bodyparts2connect, all_bpts): @@ -587,17 +587,18 @@ def create_labeled_video( if superanimal_name != "": dlc_root_path = auxiliaryfunctions.get_deeplabcut_path() - supermodels = parse_available_supermodels() + dataset_name = "_".join(superanimal_name.split("_")[:-1]) + test_cfg = load_config( os.path.join( dlc_root_path, - "pose_estimation_tensorflow", - "superanimal_configs", - supermodels[superanimal_name], + "modelzoo", + "project_configs", + f"{dataset_name}.yaml", ) ) - bodyparts = test_cfg["all_joints_names"] + bodyparts = test_cfg["bodyparts"] cfg = { "skeleton": skeleton, "skeleton_color": skeleton_color, @@ -1049,9 +1050,10 @@ def create_video_with_all_detections( keypoint will be set as a function of its score: alpha = f(score). The default function used when True is f(x) = x. """ - from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembler import re + from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembler + cfg = auxiliaryfunctions.read_config(config) trainFraction = cfg["TrainingFraction"][trainingsetindex] DLCscorername, _ = auxiliaryfunctions.get_scorer_name( @@ -1148,6 +1150,7 @@ def create_video_with_all_detections( def _create_video_from_tracks(video, tracks, destfolder, output_name, pcutoff, scale=1): import subprocess + from tqdm import tqdm if not os.path.isdir(destfolder): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..e15d2a759c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool.yapf] + based_on_style = "google" + indent_width = 4 + + [tool.isort] + multi_line_output = 3 + include_trailing_comma = true + force_sort_within_sections = false + lexicographical = true + single_line_exclusions = ['typing'] + order_by_type = false + group_by_package = true + line_length = 88 + skip = [ + "__init__.py", + ] +[tool.pytest.ini_options] +markers = [ + "require_models: mark test as requiring models to run" +] \ No newline at end of file diff --git a/setup.py b/setup.py index 776c68ea41..e30eab4842 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ def pytorch_config_paths() -> list[str]: url="https://github.com/DeepLabCut/DeepLabCut", install_requires=[ "albumentations", - "dlclibrary", + "dlclibrary>=0.0.5", "einops", "filterpy>=1.4.4", "ruamel.yaml>=0.15.0", diff --git a/tests/tests_modelzoo.py b/tests/pose_estimation_pytorch/modelzoo/test_download.py similarity index 99% rename from tests/tests_modelzoo.py rename to tests/pose_estimation_pytorch/modelzoo/test_download.py index 555c590307..5ae386e12c 100644 --- a/tests/tests_modelzoo.py +++ b/tests/pose_estimation_pytorch/modelzoo/test_download.py @@ -8,8 +8,9 @@ # # Licensed under GNU Lesser General Public License v3.0 # -import dlclibrary import os + +import dlclibrary import pytest from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS diff --git a/tests/pose_estimation_pytorch/modelzoo/test_utils.py b/tests/pose_estimation_pytorch/modelzoo/test_utils.py new file mode 100644 index 0000000000..8f7ead7380 --- /dev/null +++ b/tests/pose_estimation_pytorch/modelzoo/test_utils.py @@ -0,0 +1,29 @@ +import os + +import pytest + +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import _get_config_model_paths + + +@pytest.mark.parametrize( + "project_name", ["superanimal_quadruped", "superanimal_topviewmouse"] +) +def test_get_config_model_paths(project_name): + ( + model_config, + project_config, + pose_model_path, + detector_model_path, + ) = _get_config_model_paths( + project_name, + "hrnetw32", + detector_type="fasterrcnn", + weight_folder=None, + ) + + assert isinstance(model_config, dict) + assert isinstance(project_config, dict) + assert isinstance(pose_model_path, str) + assert isinstance(detector_model_path, str) + assert os.path.exists(pose_model_path) + assert os.path.exists(detector_model_path) diff --git a/tests/pose_estimation_pytorch/modelzoo/test_webapp.py b/tests/pose_estimation_pytorch/modelzoo/test_webapp.py new file mode 100644 index 0000000000..12bf9a3e2f --- /dev/null +++ b/tests/pose_estimation_pytorch/modelzoo/test_webapp.py @@ -0,0 +1,71 @@ +import os + +import cv2 +import numpy as np +import pytest + +from deeplabcut.modelzoo.webapp.inference import SuperanimalPyTorchInference +from deeplabcut.utils import auxiliaryfunctions + + +@pytest.mark.parametrize("max_individuals", [1, 3]) +@pytest.mark.parametrize( + "project_name", ["superanimal_quadruped", "superanimal_topviewmouse"] +) +@pytest.mark.parametrize("pose_model_type", ["hrnetw32"]) +def test_class_init(project_name, pose_model_type, max_individuals): + inference_pipeline = SuperanimalPyTorchInference( + project_name, pose_model_type, max_individuals=max_individuals + ) + + assert isinstance(inference_pipeline.config, dict) + assert inference_pipeline.config["bodyparts"] + assert len(inference_pipeline.config["bodyparts"]) > 0 + + +@pytest.mark.require_models +@pytest.mark.parametrize( + "project_name", ["superanimal_quadruped", "superanimal_topviewmouse"] +) +@pytest.mark.parametrize("pose_model_type", ["hrnetw32"]) +def test_runner_init(project_name, pose_model_type): + inference_pipeline = SuperanimalPyTorchInference( + project_name, pose_model_type, max_individuals=1 + ) + weight_folder = f"{auxiliaryfunctions.get_deeplabcut_path()}/modelzoo/checkpoints" + snapshot_path = f"{weight_folder}/{project_name}_{pose_model_type}.pth" + detector_path = f"{weight_folder}/{project_name}_fasterrcnn.pt" + + inference_pipeline.initialize_models(snapshot_path, detector_path) + + assert inference_pipeline.models.pose_runner + assert inference_pipeline.models.detector_runner + + +@pytest.mark.require_models +@pytest.mark.parametrize("max_individuals", [10, 4, 1]) +@pytest.mark.parametrize( + "project_name", ["superanimal_quadruped", "superanimal_topviewmouse"] +) +@pytest.mark.parametrize("pose_model_type", ["hrnetw32"]) +def test_predict(project_name, pose_model_type, max_individuals): + inference_pipeline = SuperanimalPyTorchInference( + project_name, pose_model_type, max_individuals=max_individuals + ) + image_path = "img0001.png" + weight_folder = f"{auxiliaryfunctions.get_deeplabcut_path()}/modelzoo/checkpoints" + snapshot_path = f"{weight_folder}/{project_name}_{pose_model_type}.pth" + detector_path = f"{weight_folder}/{project_name}_fasterrcnn.pt" + + inference_pipeline.initialize_models(snapshot_path, detector_path) + frame = {image_path: np.random.rand(100, 100, 3)} + response = inference_pipeline.predict(frame) + + assert isinstance(response, dict) + assert response["joint_names"] == inference_pipeline.config["bodyparts"] + assert response["predictions"][0]["markers"].shape == ( + max_individuals, + len(inference_pipeline.config["bodyparts"]), + 3, + ) + assert response["predictions"][0]["image_path"] == image_path diff --git a/tests/pose_estimation_pytorch/modelzoo_test.py b/tests/pose_estimation_pytorch/modelzoo_test.py new file mode 100644 index 0000000000..a47344ba3e --- /dev/null +++ b/tests/pose_estimation_pytorch/modelzoo_test.py @@ -0,0 +1,65 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import os + +import dlclibrary +import pytest +from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS + +from deeplabcut.utils import auxiliaryfunctions + + +def test_download_huggingface_model(tmp_path_factory, model="full_cat"): + folder = tmp_path_factory.mktemp("temp") + dlclibrary.download_huggingface_model(model, str(folder)) + + assert os.path.exists(folder / "pose_cfg.yaml") + assert any(f.startswith("snapshot-") for f in os.listdir(folder)) + # Verify that the Hugging Face folder was removed + assert not any(f.startswith("models--") for f in os.listdir(folder)) + + +def test_download_huggingface_wrong_model(): + with pytest.raises(ValueError): + dlclibrary.download_huggingface_model("wrong_model_name") + + +@pytest.mark.skip +@pytest.mark.parametrize("model", MODELOPTIONS) +def test_download_all_models(tmp_path_factory, model): + test_download_huggingface_model(tmp_path_factory, model) + + +examples_folder = os.path.join( + auxiliaryfunctions.get_deeplabcut_path(), + "examples", + "openfield-Pranav-2018-10-30", + "labeled-data", + "m4s1", +) + + +@pytest.mark.parametrize( + "image_path", + [ + f"{examples_folder}/img0001.png", + f"{examples_folder}/img0004.png", + f"{examples_folder}/img0009.png", + ], +) +@pytest.mark.parametrize("max_individuals", [1, 3]) +@pytest.mark.parametrize( + "project_name", ["superanimal_quadruped", "superanimal_topview"] +) +def test_webapp_init(project_name, max_individuals): + inference_pipeline = SuperanimalPyTorchInference( + project_name, pose_model_type, max_individuals=max_individuals + ) diff --git a/tests/test_pose_estimation_pytorch_solvers_inference.py b/tests/test_pose_estimation_pytorch_solvers_inference.py deleted file mode 100644 index c18c4ae836..0000000000 --- a/tests/test_pose_estimation_pytorch_solvers_inference.py +++ /dev/null @@ -1,79 +0,0 @@ -import pytest -import numpy as np -import pandas as pd -import torch -from torch import nn -import deeplabcut.pose_estimation_pytorch.solvers.inference as dlc_pose_estimation_pytorch_solvers_inference - - -# Sample test data -cfg = { - 'location_refinement': True, - 'locref_stdev': 0.1, - 'pcutoff': 0.5 -} -output = (torch.randn(2, 10, 64, 64), torch.randn(2, 10, 64, 64)) -stride = (8, 8) -prediction = pd.DataFrame( - { - ("scorer1", "likelihood", "bodypart1"): [0.8, 0.9], - ("scorer1", "x", "bodypart1"): [1.0, 2.0], - ("scorer1", "y", "bodypart1"): [3.0, 4.0], - } -) -target = pd.DataFrame( - { - ("scorer2", "likelihood", "bodypart1"): [0.8, 0.9], - ("scorer2", "x", "bodypart1"): [1.5, 2.5], - ("scorer2", "y", "bodypart1"): [3.5, 4.5], - } -) -bodyparts = [("bodypart1",)] - -def test_multi_pose_predict(): - scmap = np.random.rand(64, 64, 10) - locref = np.random.rand(64, 64, 10, 2) - stride = (8, 8) - num_outputs = 5 - pose = dlc_pose_estimation_pytorch_solvers_inference.multi_pose_predict(scmap, locref, stride, num_outputs) - assert isinstance(pose, np.ndarray) - assert pose.shape == (10, 15) - -def test_get_prediction_invalid_output(): - # Test get_prediction function with invalid output - with pytest.raises(Exception): - invalid_output = (torch.randn(2, 10, 64, - - 64),) # Missing locref - dlc_pose_estimation_pytorch_solvers_inferenceget_prediction(cfg, invalid_output, stride) - -def test_get_prediction(): - predictions = dlc_pose_estimation_pytorch_solvers_inference.get_prediction(cfg, output, stride) - assert isinstance(predictions, np.ndarray) - assert predictions.shape == (output[0].shape[0], output[0].shape[1], 3) - - -@pytest.mark.parametrize( - "test_n_top", - [ - (10), - (4), - (15), - (20), - (1) - ]) - - -def test_get_top_values(test_n_top): - """ - Tests if n_tops are actually selected - """ - test_scmap = np.random.rand(5, 64, 64, 6) - batchsize, ny, nx, num_joints = test_scmap.shape - top_vals = dlc_pose_estimation_pytorch_solvers_inference.get_top_values(test_scmap,test_n_top) - assert len(top_vals[0]) == test_n_top - - - - - diff --git a/tests/test_pose_estimation_pytorch_solvers_utils.py b/tests/test_pose_estimation_pytorch_solvers_utils.py deleted file mode 100644 index e90086b8e4..0000000000 --- a/tests/test_pose_estimation_pytorch_solvers_utils.py +++ /dev/null @@ -1,279 +0,0 @@ -import pytest -import deeplabcut.pose_estimation_pytorch.solvers.utils as deeplabcut_pytorch_pose_utils -import deeplabcut.utils.auxiliaryfunctions as deeplabcut_utils_auxiliary_functions - -test_data = [ - ([ - "/path/to/snapshot-100.pt", - "/path/to/snapshot-10.pt", - "/path/to/snapshot-5.pt", - "/path/to/snapshot-50.pt", - "/path/to/snapshot5-50.pt", - "/path/to/snapshot1-00.pt", - ], [ - "/path/to/snapshot-100.pt", "/path/to/snapshot-10.pt", - "/path/to/snapshot-5.pt", "/path/to/snapshot-50.pt" - ]), - ([ - "\\path\\to\\snapshot-100.pt", - "\\path\\to\\snapshot-10.pt", - "\\path\\to\\snapshot-5.pt", - "\\path\\to\\snapshot-50.pt", - "\\path\\to\\snapshot5-50.pt", - "\\path\\to\\snapshot1-00.pt", - ], [ - "\\path\\to\\snapshot-100.pt", "\\path\\to\\snapshot-10.pt", - "\\path\\to\\snapshot-5.pt", "\\path\\to\\snapshot-50.pt" - ]), - ([ - "\path\to\snapshot-100.pt", - "\path\to\snapshot-10.pt", - "\path\to\snapshot-5.pt", - "\path\to\snapshot-50.pt", - "\path\to\snapshot5-50.pt", - "\path\to\snapshot1-00.pt", - ], [ - "\path\to\snapshot-100.pt", "\path\to\snapshot-10.pt", - "\path\to\snapshot-5.pt", "\path\to\snapshot-50.pt" - ]), - ([ - "C:\\path\\to\\snapshot-100.pt", - "C:\\path\\to\\snapshot-10.pt", - "C:\\path\\to\\snapshot-5.pt", - "C:\\path\\to\\snapshot-50.pt", - "C:\\path\\to\\snapshot5-50.pt", - "C:\\path\\to\\snapshot1-00.pt", - ], [ - "C:\\path\\to\\snapshot-100.pt", "C:\\path\\to\\snapshot-10.pt", - "C:\\path\\to\\snapshot-5.pt", "C:\\path\\to\\snapshot-50.pt" - ]), - ([ - "C:\path\to\snapshot-100.pt", - "C:\path\to\snapshot-10.pt", - "C:\path\to\snapshot-5.pt", - "C:\path\to\snapshot-50.pt", - "C:\path\to\snapshot5-50.pt", - "C:\path\to\snapshot1-00.pt", - ], [ - "C:\path\to\snapshot-100.pt", "C:\path\to\snapshot-10.pt", - "C:\path\to\snapshot-5.pt", "C:\path\to\snapshot-50.pt" - ]), -] - - -@pytest.mark.parametrize("paths,expected_verified_paths", test_data) -def test_verify_paths_model(paths, expected_verified_paths): - with pytest.warns(): - verified_paths = deeplabcut_pytorch_pose_utils.verify_paths(paths) - assert verified_paths == expected_verified_paths - -test_data = [ - ([ - "/path/to/snapshot-100.pt", - "/path/to/snapshot-10.pt", - "/path/to/snapshot-5.pt", - "/path/to/snapshot-50.pt", - "/path/to/snapshot5-50.pt", - "/path/to/snapshot1-00.pt", - ], [ - "/path/to/snapshot-5.pt", "/path/to/snapshot-10.pt", - "/path/to/snapshot-50.pt", "/path/to/snapshot-100.pt" - ]), - ([ - "\\path\\to\\snapshot-100.pt", - "\\path\\to\\snapshot-10.pt", - "\\path\\to\\snapshot-5.pt", - "\\path\\to\\snapshot-50.pt", - "\\path\\to\\snapshot5-50.pt", - "\\path\\to\\snapshot1-00.pt", - ], [ - "\\path\\to\\snapshot-5.pt", "\\path\\to\\snapshot-10.pt", - "\\path\\to\\snapshot-50.pt", "\\path\\to\\snapshot-100.pt" - ]), - ([ - "\path\to\snapshot-100.pt", - "\path\to\snapshot-10.pt", - "\path\to\snapshot-5.pt", - "\path\to\snapshot-50.pt", - "\path\to\snapshot5-50.pt", - "\path\to\snapshot1-00.pt", - ], [ - "\path\to\snapshot-5.pt", "\path\to\snapshot-10.pt", - "\path\to\snapshot-50.pt", "\path\to\snapshot-100.pt" - ]), - ([ - "C:\\path\\to\\snapshot-100.pt", - "C:\\path\\to\\snapshot-10.pt", - "C:\\path\\to\\snapshot-5.pt", - "C:\\path\\to\\snapshot-50.pt", - "C:\\path\\to\\snapshot5-50.pt", - "C:\\path\\to\\snapshot1-00.pt", - ], [ - "C:\\path\\to\\snapshot-5.pt", "C:\\path\\to\\snapshot-10.pt", - "C:\\path\\to\\snapshot-50.pt", "C:\\path\\to\\snapshot-100.pt" - ]), - ([ - "C:\path\to\snapshot-100.pt", - "C:\path\to\snapshot-10.pt", - "C:\path\to\snapshot-5.pt", - "C:\path\to\snapshot-50.pt", - "C:\path\to\snapshot5-50.pt", - "C:\path\to\snapshot1-00.pt", - ], [ - "C:\path\to\snapshot-5.pt", "C:\path\to\snapshot-10.pt", - "C:\path\to\snapshot-50.pt", "C:\path\to\snapshot-100.pt" - ]), -] - - -@pytest.mark.parametrize("paths,expected_sorted_paths", test_data) -def test_sort_model_paths(paths, expected_sorted_paths): - with pytest.warns(): - sorted_paths = deeplabcut_pytorch_pose_utils.sort_paths(paths) - assert sorted_paths == expected_sorted_paths - - -test_data = [([ - "/path/to/detector-snapshot-100.pt", - "/path/to/detector-snapshot-10.pt", - "/path/to/detector-snapshot-5.pt", - "/path/to/detector-snapshot-50.pt", - "/path/to/detector-snapshot5-50.pt", - "/path/to/snapshot1-00.pt", -], [ - "/path/to/detector-snapshot-100.pt", "/path/to/detector-snapshot-10.pt", - "/path/to/detector-snapshot-5.pt", "/path/to/detector-snapshot-50.pt" -]), - ([ - "\\path\\to\\detector-snapshot-100.pt", - "\\path\\to\\detector-snapshot-10.pt", - "\\path\\to\\detector-snapshot-5.pt", - "\\path\\to\\detector-snapshot-50.pt", - "\\path\\to\\detector-snapshot5-50.pt", - "\\path\\to\\detector-snapshot1-00.pt", - ], [ - "\\path\\to\\detector-snapshot-100.pt", - "\\path\\to\\detector-snapshot-10.pt", - "\\path\\to\\detector-snapshot-5.pt", - "\\path\\to\\detector-snapshot-50.pt" - ]), - ([ - "\path\to\detector-snapshot-100.pt", - "\path\to\detector-snapshot-10.pt", - "\path\to\detector-snapshot-5.pt", - "\path\to\detector-snapshot-50.pt", - "\path\to\detector-snapshot5-50.pt", - "\path\to\snapshot1-00.pt", - ], [ - "\path\to\detector-snapshot-100.pt", - "\path\to\detector-snapshot-10.pt", - "\path\to\detector-snapshot-5.pt", - "\path\to\detector-snapshot-50.pt" - ]), - ([ - "C:\\path\\to\\detector-snapshot-100.pt", - "C:\\path\\to\\detector-snapshot-10.pt", - "C:\\path\\to\\detector-snapshot-5.pt", - "C:\\path\\to\\detector-snapshot-50.pt", - "C:\\path\\to\\detector-snapshot5-50.pt", - "C:\\path\\to\\detector-snapshot1-00.pt", - ], [ - "C:\\path\\to\\detector-snapshot-100.pt", - "C:\\path\\to\\detector-snapshot-10.pt", - "C:\\path\\to\\detector-snapshot-5.pt", - "C:\\path\\to\\detector-snapshot-50.pt" - ]), - ([ - "C:\path\to\detector-snapshot-100.pt", - "C:\path\to\detector-snapshot-10.pt", - "C:\path\to\detector-snapshot-5.pt", - "C:\path\to\detector-snapshot-50.pt", - "C:\path\to\detector-snapshot5-50.pt", - "C:\path\to\snapshot1-00.pt", - ], [ - "C:\path\to\detector-snapshot-100.pt", - "C:\path\to\detector-snapshot-10.pt", - "C:\path\to\detector-snapshot-5.pt", - "C:\path\to\detector-snapshot-50.pt" - ])] - - -@pytest.mark.parametrize("paths,expected_verified_paths", test_data) -def test_verify_paths_detector(paths, expected_verified_paths): - with pytest.warns(): - verified_paths = deeplabcut_pytorch_pose_utils.verify_paths( - paths, r"^(.*)?detector-snapshot-(\d+)\.pt$") - assert verified_paths == expected_verified_paths - - -test_data = [ - ([ - "/path/to/detector-snapshot-100.pt", - "/path/to/detector-snapshot-10.pt", - "/path/to/detector-snapshot-5.pt", - "/path/to/detector-snapshot-50.pt", - "/path/to/detector-snapshot5-50.pt", - "/path/to/snapshot1-00.pt", - ], [ - "/path/to/detector-snapshot-5.pt", "/path/to/detector-snapshot-10.pt", - "/path/to/detector-snapshot-50.pt", "/path/to/detector-snapshot-100.pt" - ]), - ([ - "\\path\\to\\detector-snapshot-100.pt", - "\\path\\to\\detector-snapshot-10.pt", - "\\path\\to\\detector-snapshot-5.pt", - "\\path\\to\\detector-snapshot-50.pt", - "\\path\\to\\detector-snapshot5-50.pt", - "\\path\\to\\detector-snapshot1-00.pt", - ], [ - "\\path\\to\\detector-snapshot-5.pt", - "\\path\\to\\detector-snapshot-10.pt", - "\\path\\to\\detector-snapshot-50.pt", - "\\path\\to\\detector-snapshot-100.pt" - ]), - ([ - "\path\to\detector-snapshot-100.pt", - "\path\to\detector-snapshot-10.pt", - "\path\to\detector-snapshot-5.pt", - "\path\to\detector-snapshot-50.pt", - "\path\to\detector-snapshot5-50.pt", - "\path\to\snapshot1-00.pt", - ], [ - "\path\to\detector-snapshot-5.pt", "\path\to\detector-snapshot-10.pt", - "\path\to\detector-snapshot-50.pt", "\path\to\detector-snapshot-100.pt" - ]), - ([ - "C:\\path\\to\\detector-snapshot-100.pt", - "C:\\path\\to\\detector-snapshot-10.pt", - "C:\\path\\to\\detector-snapshot-5.pt", - "C:\\path\\to\\detector-snapshot-50.pt", - "C:\\path\\to\\detector-snapshot5-50.pt", - "C:\\path\\to\\detector-snapshot1-00.pt", - ], [ - "C:\\path\\to\\detector-snapshot-5.pt", - "C:\\path\\to\\detector-snapshot-10.pt", - "C:\\path\\to\\detector-snapshot-50.pt", - "C:\\path\\to\\detector-snapshot-100.pt" - ]), - ([ - "C:\path\to\detector-snapshot-100.pt", - "C:\path\to\detector-snapshot-10.pt", - "C:\path\to\detector-snapshot-5.pt", - "C:\path\to\detector-snapshot-50.pt", - "C:\path\to\detector-snapshot5-50.pt", - "C:\path\to\snapshot1-00.pt", - ], [ - "C:\path\to\detector-snapshot-5.pt", - "C:\path\to\detector-snapshot-10.pt", - "C:\path\to\detector-snapshot-50.pt", - "C:\path\to\detector-snapshot-100.pt" - ]) -] - - -@pytest.mark.parametrize("paths,expected_sorted_paths", test_data) -def test_sort_detector_paths(paths, expected_sorted_paths): - with pytest.warns(): - sorted_paths = deeplabcut_pytorch_pose_utils.sort_paths( - paths, r"^(.*)?detector-snapshot-(\d+)\.pt$") - assert sorted_paths == expected_sorted_paths From 2f67d015b6d45bb7a64525c5de2379adcc56f097 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Fri, 12 Jan 2024 13:36:08 +0100 Subject: [PATCH 065/293] various bug fixes --- NOTICE.yml | 2 +- .../make_pytorch_config.py | 0 .../trainingsetmanipulation.py | 12 +++-- deeplabcut/gui/tabs/modelzoo.py | 47 ++++++++++++------- deeplabcut/modelzoo/utils.py | 1 + .../pose_estimation_pytorch/apis/evaluate.py | 7 +-- .../pose_estimation_pytorch/apis/utils.py | 6 +-- .../pose_estimation_pytorch/data/dataset.py | 8 ++-- .../data/dlcproject.py | 0 .../pose_estimation_pytorch/data/utils.py | 19 ++++++-- .../models/detectors/fasterRCNN.py | 29 ++++++++---- .../models/heads/dlcrnet.py | 4 +- .../predict_supermodel.py | 0 requirements.txt | 6 +-- setup.py | 10 ++-- tools/README.md | 28 +++++++++++ 16 files changed, 124 insertions(+), 55 deletions(-) delete mode 100644 deeplabcut/generate_training_dataset/make_pytorch_config.py delete mode 100644 deeplabcut/pose_estimation_pytorch/data/dlcproject.py delete mode 100644 deeplabcut/pose_estimation_tensorflow/predict_supermodel.py diff --git a/NOTICE.yml b/NOTICE.yml index 68ff6362df..fbde253f06 100644 --- a/NOTICE.yml +++ b/NOTICE.yml @@ -5,7 +5,7 @@ https://github.com/DeepLabCut/DeepLabCut Please see AUTHORS for contributors. - https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS + https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS Licensed under GNU Lesser General Public License v3.0 include: diff --git a/deeplabcut/generate_training_dataset/make_pytorch_config.py b/deeplabcut/generate_training_dataset/make_pytorch_config.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 2ef8797cbe..6dfcbac4fc 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -31,8 +31,6 @@ auxfun_multianimal, ) from deeplabcut.utils.auxfun_videos import VideoReader -from deeplabcut.pose_estimation_tensorflow.config import load_config -from deeplabcut.modelzoo.utils import parse_available_supermodels def comparevideolistsanddatafolders(config): @@ -904,7 +902,15 @@ def create_training_dataset( elif cfg.get("engine", "pytorch").lower() == "pytorch": # TODO: Change default to tensorflow pass else: - raise ValueError("Invalid network type:", net_type) + if ( + "resnet" in net_type + or "mobilenet" in net_type + or "efficientnet" in net_type + or "dlcrnet" in net_type + ): + pass + else: + raise ValueError("Invalid network type:", net_type) if augmenter_type is None: augmenter_type = cfg.get("default_augmenter", "imgaug") diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index 6b24a357de..03b0a6eb69 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -167,20 +167,33 @@ def run_video_adaptation(self): supermodel_name = self.model_combo.currentText() videotype = self.video_selection_widget.videotype_widget.currentText() - func = partial( - deeplabcut.video_inference_superanimal, - videos, - supermodel_name, - videotype=videotype, - video_adapt=self.adapt_checkbox.isChecked(), - scale_list=scales, - pseudo_threshold=self.pseudo_threshold_spinbox.value(), - adapt_iterations=self.adapt_iter_spinbox.value(), - ) - - self.worker, self.thread = move_to_separate_thread(func) - self.worker.finished.connect(lambda: self.run_button.setEnabled(True)) - self.worker.finished.connect(lambda: self.root._progress_bar.hide()) - self.thread.start() - self.run_button.setEnabled(False) - self.root._progress_bar.show() + can_run_in_background = False + if can_run_in_background: + func = partial( + deeplabcut.video_inference_superanimal, + videos, + supermodel_name, + videotype=videotype, + video_adapt=self.adapt_checkbox.isChecked(), + scale_list=scales, + pseudo_threshold=self.pseudo_threshold_spinbox.value(), + adapt_iterations=self.adapt_iter_spinbox.value(), + ) + + self.worker, self.thread = move_to_separate_thread(func) + self.worker.finished.connect(lambda: self.run_button.setEnabled(True)) + self.worker.finished.connect(lambda: self.root._progress_bar.hide()) + self.thread.start() + self.run_button.setEnabled(False) + self.root._progress_bar.show() + + else: + deeplabcut.video_inference_superanimal( + videos, + supermodel_name, + videotype=videotype, + video_adapt=self.adapt_checkbox.isChecked(), + scale_list=scales, + pseudo_threshold=self.pseudo_threshold_spinbox.value(), + adapt_iterations=self.adapt_iter_spinbox.value(), + ) diff --git a/deeplabcut/modelzoo/utils.py b/deeplabcut/modelzoo/utils.py index c0e742004e..121f903854 100644 --- a/deeplabcut/modelzoo/utils.py +++ b/deeplabcut/modelzoo/utils.py @@ -10,6 +10,7 @@ # import json import os + import warnings from glob import glob diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 060c838f49..5f02f71f19 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -17,9 +17,10 @@ import albumentations as A import numpy as np import pandas as pd +from tqdm import tqdm import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils -from deeplabcut.pose_estimation_pytorch.data import DLCLoader, Loader +from deeplabcut.pose_estimation_pytorch.data import Loader, DLCLoader from deeplabcut.pose_estimation_pytorch.apis.scoring import ( compute_identity_scores, get_scores, @@ -63,7 +64,7 @@ def predict( if pose_task == Task.TOP_DOWN: # Get bounding boxes for context if detector_runner is not None: - bbox_predictions = detector_runner.inference(images=image_paths) + bbox_predictions = detector_runner.inference(images=tqdm(image_paths)) context = bbox_predictions else: ground_truth_bboxes = loader.ground_truth_bboxes(mode=mode) @@ -77,7 +78,7 @@ def predict( ) images_with_context = list(zip(image_paths, context)) - predictions = pose_runner.inference(images=images_with_context) + predictions = pose_runner.inference(images=tqdm(images_with_context)) return { image_path: image_predictions for image_path, image_predictions in zip(image_paths, predictions) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 42b3433bfc..bb5c39c10a 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -156,7 +156,7 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose A.GaussNoise( var_limit=(0, opt**2), mean=0, - per_channel=True, # Albumentations doesn't support per_cahnnel = 0.5 + per_channel=True, # Albumentations doesn't support per_channel = 0.5 p=0.5, ) ) @@ -414,10 +414,10 @@ def build_predictions_dataframe( prediction_data = [] index_data = [] for image, image_predictions in predictions.items(): - image_data = image_predictions["bodyparts"].reshape(-1) + image_data = image_predictions["bodyparts"][..., :3].reshape(-1) if "unique_bodyparts" in image_predictions: image_data = np.concatenate( - [image_data, image_predictions["unique_bodyparts"].reshape(-1)] + [image_data, image_predictions["unique_bodyparts"][..., :3].reshape(-1)] ) prediction_data.append(image_data) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index d3efdf5348..baa6127b0f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -191,7 +191,7 @@ def __getitem__(self, index: int) -> dict: image, keypoints, keypoints_unique, bboxes ) keypoints = transformed["keypoints"] - bboxes = np.array(transformed["bboxes"]) + bboxes = transformed["bboxes"] if self.parameters.with_center_keypoints: keypoints = self.add_center_keypoints(keypoints) @@ -250,8 +250,8 @@ def _prepare_final_annotation_dict( "keypoints_unique": keypoints_unique[..., :2], "area": pad_to_length(annotations_merged["area"], num_animals, 0), "boxes": pad_to_length(bboxes, num_animals, 0), - "is_crowd": pad_to_length(is_crowd, num_animals, 0), - "labels": pad_to_length(cat_ids, num_animals, -1), + "is_crowd": pad_to_length(is_crowd, num_animals, 0).astype(int), + "labels": pad_to_length(cat_ids, num_animals, -1).astype(int), } def _load_image(self, image_path): @@ -313,7 +313,7 @@ def apply_transform_all_keypoints( """ class_labels = [ f"individual{i}_{bpt}" - for i in range(self.parameters.max_num_animals) + for i in range(len(keypoints)) for bpt in self.parameters.bodyparts ] + [f"unique_{bpt}" for bpt in self.parameters.unique_bpts] diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcproject.py b/deeplabcut/pose_estimation_pytorch/data/dlcproject.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index ca94c5b15f..316f0dc4d4 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -420,10 +420,19 @@ def apply_transform( transformed["keypoints"] = np.array(transformed["keypoints"]) transformed["keypoints"][~defined_keypoint_mask] = -1 shape_transformed = transformed["image"].shape - mask_valid = _check_keypoints_within_bounds( - transformed["keypoints"], shape_transformed - ) - transformed["keypoints"][~mask_valid] = -1 + + if len(transformed["keypoints"]) > 0: + mask_valid = _check_keypoints_within_bounds( + transformed["keypoints"], shape_transformed + ) + transformed["keypoints"][~mask_valid] = -1 + + # TODO: Check that the transformed bboxes are still within the image + if len(transformed["bboxes"]) > 0: + transformed["bboxes"] = np.array(transformed["bboxes"]) + else: + transformed["bboxes"] = np.zeros(shape=(0, 4)) + else: transformed = {"keypoints": keypoints, "image": image} np.nan_to_num(transformed["keypoints"], copy=False, nan=-1) @@ -487,7 +496,7 @@ def _set_invalid_keypoints_to_neg_one( ] for label in undef_class_labels: new_index = transformed["class_labels"].index(label) - transformed["keypoints"][new_index] = (-1, -1) + transformed["keypoints"][new_index] = (-1, -1, -1) return transformed diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index 3b93209ae6..c89824f090 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -98,7 +98,6 @@ def get_target(self, labels: dict) -> list[dict[str, torch.Tensor]]: Each dictionary contains the following keys: 'area' 'labels' - 'image_id' 'is_crowd' 'boxes' @@ -107,13 +106,24 @@ def get_target(self, labels: dict) -> list[dict[str, torch.Tensor]]: annotations = {"area": torch.Tensor([100, 200]), "labels": torch.Tensor([1, 2]), "is_crowd": torch.Tensor([0, 1]), - "image_id": torch.Tensor([1, 1]), "boxes": torch.Tensor([[10, 20, 30, 40], [50, 60, 70, 80]])} output: - res = [{'area': tensor([100.]), 'labels': tensor([1]), 'image_id': tensor([1]), 'is_crowd': tensor([0]), - 'boxes': tensor([[10., 20., 40., 60.]])}, - {'area': tensor([200.]), 'labels': tensor([2]), 'image_id': tensor([1]), 'is_crowd': tensor([1]), 'boxes': - tensor([[50., 60., 70., 80.]])}] + res = [ + { + 'area': tensor([100.]), + 'labels': tensor([1]), + 'image_id': tensor([1]), + 'is_crowd': tensor([0]), + 'boxes': tensor([[10., 20., 40., 60.]]) + }, + { + 'area': tensor([200.]), + 'labels': tensor([2]), + 'image_id': tensor([1]), + 'is_crowd': tensor([1]), + 'boxes': tensor([[50., 60., 70., 80.]]) + } + ] """ res = [] for i, box_ann in enumerate(labels["boxes"]): @@ -124,10 +134,9 @@ def get_target(self, labels: dict) -> list[dict[str, torch.Tensor]]: box_ann[:, 3] += box_ann[:, 1] res.append( { - "area": labels["area"][i], - "labels": labels["labels"][i], - # "image_id": labels["image_id"][i], - "is_crowd": labels["is_crowd"][i], + "area": labels["area"][i][mask], + "labels": labels["labels"][i][mask], + "is_crowd": labels["is_crowd"][i][mask], "boxes": box_ann, } ) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py index 91ab44a180..8b77d98085 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py @@ -34,8 +34,8 @@ def __init__( self, predictor: BasePredictor, target_generator: BaseGenerator, - criterion: dict[str, BaseCriterion] | BaseCriterion, - aggregator: BaseLossAggregator | None, + criterion: dict[str, BaseCriterion], + aggregator: BaseLossAggregator, heatmap_config: dict, locref_config: dict, paf_config: dict, diff --git a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py b/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/requirements.txt b/requirements.txt index f48647dcf2..ad1cab24ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ dlclibrary ipython filterpy ruamel.yaml>=0.15.0 -# intel-openmp +intel-openmp imageio-ffmpeg imgaug>=0.4.0 numba>=0.54.0 @@ -21,9 +21,9 @@ Pillow>=7.1 pyyaml scikit-image>=0.17 scikit-learn>=1.0 -scipy>=1.9 +scipy>=1.4,<1.11.0 statsmodels>=0.11 -tensorflow>=2.0,<=2.10 +tensorflow>=2.0,<2.13.0 tables>=3.7.0 tensorpack>=0.11 tf_slim>=1.1.0 diff --git a/setup.py b/setup.py index e30eab4842..632dce48b4 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ © A. & M. Mathis Labs https://github.com/DeepLabCut/DeepLabCut Please see AUTHORS for contributors. -https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS Licensed under GNU Lesser General Public License v3.0 """ from __future__ import annotations @@ -90,9 +90,12 @@ def pytorch_config_paths() -> list[str]: "deeplabcut/pose_cfg.yaml", "deeplabcut/inference_cfg.yaml", "deeplabcut/reid_cfg.yaml", + "deeplabcut/modelzoo/model_configs/dlcrnet.yaml", + "deeplabcut/modelzoo/model_configs/hrnetw32.yaml", + "deeplabcut/modelzoo/models_to_framework.json", + "deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml", + "deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml", "deeplabcut/pose_estimation_tensorflow/models/pretrained/pretrained_model_urls.yaml", - "deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml", - "deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml", "deeplabcut/gui/style.qss", "deeplabcut/gui/media/logo.png", "deeplabcut/gui/media/dlc_1-01.png", @@ -105,7 +108,6 @@ def pytorch_config_paths() -> list[str]: "deeplabcut/gui/assets/icons/new_project2.png", "deeplabcut/gui/assets/icons/open.png", "deeplabcut/gui/assets/icons/open2.png", - "deeplabcut/modelzoo/models.json", ] + pytorch_config_paths(), ) ], diff --git a/tools/README.md b/tools/README.md index 32389da346..07632283a3 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,5 +1,11 @@ # Developer tools useful for maintaining the repository +As developer you'll need: + +```bash +pip install coverage pytest fnmatch black +``` + ## Code headers The code headers can be standardized by running @@ -11,3 +17,25 @@ python tools/update_license_headers.py from the repository root. You can edit the `NOTICE.yml` to update the header. + + +## Workflow for contributing/checking your code + +```bash +black . +``` + +## Running the tests (locally) + +We use the pytest framework. You can just run: + +```bash +pytest +``` + +For coverage run: + +``` +coverage run -m pytest +coverage report +``` From e91534c7d5d3920f001fef4c7f6d01129e886a59 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 17 Jan 2024 11:05:57 +0100 Subject: [PATCH 066/293] niels/main_clean_testing (#165) * fixed bugs in tests, move all to correct folder * fixed test imports, removed obsolete tests * fixed all tests. fixed a few bugs. * updated file headers * fixed codespell issues * updated macos version to 13 * skip tests that require models * remove code install for functional tests --- .github/workflows/codespell.yml | 2 + .github/workflows/python-package.yml | 7 +- deeplabcut/modelzoo/__init__.py | 2 +- .../superanimal_quadruped.yaml | 3 +- .../superanimal_topviewmouse.yaml | 16 +- deeplabcut/modelzoo/utils.py | 13 +- deeplabcut/modelzoo/video_inference.py | 5 +- deeplabcut/modelzoo/webapp/__init__.py | 10 + deeplabcut/modelzoo/webapp/inference.py | 11 +- .../pose_estimation_pytorch/__init__.py | 10 + .../pose_estimation_pytorch/apis/__init__.py | 1 + .../pose_estimation_pytorch/apis/utils.py | 2 +- .../config/__init__.py | 10 + .../config/tokenpose/tokenpose_base.yaml | 12 +- .../pose_estimation_pytorch/config/utils.py | 10 + .../pose_estimation_pytorch/data/__init__.py | 10 + .../pose_estimation_pytorch/data/base.py | 2 +- .../data/cocoloader.py | 3 +- .../pose_estimation_pytorch/data/dataset.py | 2 +- .../pose_estimation_pytorch/data/dlcloader.py | 10 + .../pose_estimation_pytorch/data/helper.py | 10 + .../data/postprocessor.py | 10 + .../data/preprocessor.py | 10 + .../data/transforms.py | 12 +- .../pose_estimation_pytorch/data/utils.py | 2 +- .../models/__init__.py | 10 + .../models/backbones/__init__.py | 2 +- .../models/backbones/base.py | 2 +- .../models/backbones/hrnet.py | 2 +- .../models/backbones/resnet.py | 2 +- .../models/criterions/__init__.py | 10 + .../models/criterions/aggregators.py | 2 +- .../models/criterions/base.py | 2 +- .../models/criterions/utils.py | 10 + .../models/criterions/weighted.py | 2 +- .../models/detectors/__init__.py | 10 + .../models/detectors/base.py | 2 +- .../models/detectors/fasterRCNN.py | 2 +- .../models/heads/__init__.py | 10 + .../models/heads/base.py | 2 +- .../models/heads/dekr.py | 2 +- .../models/heads/dlcrnet.py | 2 +- .../models/heads/simple_head.py | 2 +- .../models/heads/transformer.py | 2 +- .../pose_estimation_pytorch/models/model.py | 2 +- .../models/modules/__init__.py | 10 + .../models/modules/conv_block.py | 4 +- .../models/modules/conv_module.py | 4 +- .../models/necks/__init__.py | 2 +- .../models/necks/base.py | 2 +- .../models/necks/layers.py | 20 +- .../models/necks/transformer.py | 20 +- .../models/necks/utils.py | 20 +- .../models/predictors/__init__.py | 2 +- .../models/predictors/base.py | 2 +- .../models/predictors/dekr_predictor.py | 2 +- .../models/predictors/identity_predictor.py | 15 +- .../models/predictors/paf_predictor.py | 2 +- .../models/predictors/single_predictor.py | 2 +- .../models/predictors/top_down_prediction.py | 2 +- .../models/predictors/utils.py | 2 +- .../models/target_generators/__init__.py | 2 +- .../models/target_generators/base.py | 2 +- .../models/target_generators/dekr_targets.py | 2 +- .../target_generators/heatmap_targets.py | 4 +- .../models/target_generators/pafs_targets.py | 2 +- .../modelzoo/__init__.py | 2 +- .../modelzoo/_mmpose_to_dlc3.py | 10 + .../modelzoo/inference.py | 2 +- .../pose_estimation_pytorch/modelzoo/utils.py | 10 + .../post_processing/__init__.py | 10 + .../post_processing/identity.py | 10 + .../match_predictions_to_gt.py | 2 +- .../pose_estimation_pytorch/registry.py | 2 +- .../runners/__init__.py | 2 +- .../pose_estimation_pytorch/runners/base.py | 2 +- .../runners/inference.py | 2 +- .../pose_estimation_pytorch/runners/logger.py | 2 +- .../runners/schedulers.py | 4 +- .../pose_estimation_pytorch/runners/train.py | 2 +- .../pose_estimation_pytorch/runners/utils.py | 2 +- .../pose_estimation_pytorch/tests/__init__.py | 0 .../tests/test_get_predictions.py | 126 -------- .../tests/test_pose_model.py | 182 ------------ .../tests/test_single_animal.py | 195 ------------- .../tests/test_utils.py | 79 ----- deeplabcut/pose_estimation_pytorch/utils.py | 2 +- docs/pytorch_dlc.md | 2 +- .../target_generators/test_heatmap_targets.py | 4 +- .../target_generators/test_plateau_targets.py | 2 +- .../modelzoo/test_download.py | 2 +- .../modelzoo/test_utils.py | 1 + .../modelzoo/test_webapp.py | 4 +- .../pose_estimation_pytorch/modelzoo_test.py | 65 ----- .../other}/test_api_utils.py | 37 +-- .../other}/test_configs/config.yaml | 0 .../other}/test_configs/pose_cfg.yaml | 0 .../other}/test_configs/pytorch_config.yaml | 0 .../other/test_custom_transforms.py | 0 .../other}/test_data_helper.py | 34 ++- .../other}/test_dataset.py | 84 +++--- .../other}/test_gaussian_targets.py | 16 +- .../other/test_heatmap_plateau_targets.py | 29 +- .../other}/test_helper.py | 0 .../other}/test_match_predictions_to_gt.py | 0 .../other}/test_modelzoo.py | 1 + .../other}/test_paf_targets.py | 10 +- .../other/test_pose_model.py | 274 ++++++++++++++++++ .../other}/test_schedulers.py | 6 +- .../other}/test_seq_targets.py | 0 tests/test_dekr_predictor.py | 23 -- tests/test_predict_supermodel.py | 2 +- 112 files changed, 744 insertions(+), 888 deletions(-) create mode 100644 deeplabcut/modelzoo/webapp/__init__.py delete mode 100644 deeplabcut/pose_estimation_pytorch/tests/__init__.py delete mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py delete mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py delete mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py delete mode 100644 deeplabcut/pose_estimation_pytorch/tests/test_utils.py delete mode 100644 tests/pose_estimation_pytorch/modelzoo_test.py rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_api_utils.py (73%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_configs/config.yaml (100%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_configs/pose_cfg.yaml (100%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_configs/pytorch_config.yaml (100%) rename deeplabcut/pose_estimation_pytorch/tests/test_transforms.py => tests/pose_estimation_pytorch/other/test_custom_transforms.py (100%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_data_helper.py (61%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_dataset.py (75%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_gaussian_targets.py (73%) rename deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py => tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py (88%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_helper.py (100%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_match_predictions_to_gt.py (100%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_modelzoo.py (98%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_paf_targets.py (70%) create mode 100644 tests/pose_estimation_pytorch/other/test_pose_model.py rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_schedulers.py (94%) rename {deeplabcut/pose_estimation_pytorch/tests => tests/pose_estimation_pytorch/other}/test_seq_targets.py (100%) delete mode 100644 tests/test_dekr_predictor.py diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 243ba8ce5f..cd0a1e8133 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -17,3 +17,5 @@ jobs: uses: actions/checkout@v3 - name: Codespell uses: codespell-project/actions-codespell@v1 + with: + ignore_words_list: bu,BU,td,TD diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bcf980d6b1..44be842036 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -13,17 +13,17 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-11, windows-latest] + os: [ubuntu-latest, macos-13, windows-latest] python-version: [3.9, "3.10"] include: - os: ubuntu-latest path: ~/.cache/pip - - os: macos-11 + - os: macos-13 path: ~/Library/Caches/pip - os: windows-latest path: ~\AppData\Local\pip\Cache exclude: - - os: macos-11 + - os: macos-13 python-version: 3.7 - os: windows-latest python-version: 3.7 @@ -61,6 +61,5 @@ jobs: - name: Run functional tests run: | - pip install git+https://github.com/${{ github.repository }}.git@${{ github.sha }} python examples/testscript.py python examples/testscript_multianimal.py diff --git a/deeplabcut/modelzoo/__init__.py b/deeplabcut/modelzoo/__init__.py index 2dd1b06028..117d127147 100644 --- a/deeplabcut/modelzoo/__init__.py +++ b/deeplabcut/modelzoo/__init__.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml index b3d5fae70e..39fdbf2001 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml @@ -6,8 +6,7 @@ multianimalproject: identity: # Project path (change when moving around) -project_path: - /Users/niels/Documents/upamathis/repos/DLCdev/deeplabcut/modelzoo/project_configs +project_path: # Annotation data set configuration (and individual video cropping parameters) video_sets: diff --git a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml index 39a85eb245..bb5ac87ac6 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml @@ -1,14 +1,14 @@ -# Project definitions (do not edit) + # Project definitions (do not edit) Task: scorer: date: multianimalproject: identity: -# Project path (change when moving around) + # Project path (change when moving around) project_path: -# Annotation data set configuration (and individual video cropping parameters) + # Annotation data set configuration (and individual video cropping parameters) video_sets: bodyparts: - nose @@ -40,11 +40,13 @@ bodyparts: - head_midpoint # Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement start: stop: numframes2pick: -# Plotting configuration + # Plotting configuration skeleton: [] skeleton_color: black pcutoff: @@ -52,7 +54,7 @@ dotsize: alphavalue: colormap: -# Training,Evaluation and Analysis configuration + # Training,Evaluation and Analysis configuration TrainingFraction: iteration: default_net_type: @@ -60,9 +62,9 @@ default_augmenter: snapshotindex: batch_size: 1 -# Cropping Parameters (for analysis and outlier frame detection) + # Cropping Parameters (for analysis and outlier frame detection) cropping: -# if cropping is true for analysis, then set the values here: + #if cropping is true for analysis, then set the values here: x1: x2: y1: diff --git a/deeplabcut/modelzoo/utils.py b/deeplabcut/modelzoo/utils.py index 121f903854..a6d64fdd90 100644 --- a/deeplabcut/modelzoo/utils.py +++ b/deeplabcut/modelzoo/utils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # @@ -18,10 +18,15 @@ def parse_project_model_name(superanimal_name: str) -> str: - """ - TODO + """Parses model zoo model names for SuperAnimal models - """ + Args: + superanimal_name: the name of the SuperAnimal model name to parse + + Returns: + project_name: the parsed SuperAnimal model name + model_name: the model architecture (e.g., dlcrnet, hrnetw32) + """ if superanimal_name == "superanimal_quadruped": warnings.warn( diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index b982fb0bfc..2979b4cd50 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -4,9 +4,10 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 # -# Licensed under GNU Lesser General Public Licen import json import os diff --git a/deeplabcut/modelzoo/webapp/__init__.py b/deeplabcut/modelzoo/webapp/__init__.py new file mode 100644 index 0000000000..117d127147 --- /dev/null +++ b/deeplabcut/modelzoo/webapp/__init__.py @@ -0,0 +1,10 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# diff --git a/deeplabcut/modelzoo/webapp/inference.py b/deeplabcut/modelzoo/webapp/inference.py index 66d0776b94..476192f080 100644 --- a/deeplabcut/modelzoo/webapp/inference.py +++ b/deeplabcut/modelzoo/webapp/inference.py @@ -1,4 +1,13 @@ -from dataclasses import dataclass +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from typing import Dict import numpy as np diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index d23b349601..7680732202 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.apis import ( analyze_videos, convert_detections2tracklets, diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py index 884223f960..eb4b17e952 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py @@ -1,3 +1,4 @@ +# # DeepLabCut Toolbox (deeplabcut.org) # © A. & M.W. Mathis Labs # https://github.com/DeepLabCut/DeepLabCut diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index bb5c39c10a..461722ff67 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/config/__init__.py b/deeplabcut/pose_estimation_pytorch/config/__init__.py index 8450c8a8c4..26ac98bc84 100644 --- a/deeplabcut/pose_estimation_pytorch/config/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/config/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config from deeplabcut.pose_estimation_pytorch.config.utils import ( pretty_print_config, diff --git a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml b/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml index 55ddd4c553..ee053a1060 100644 --- a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml @@ -1,5 +1,6 @@ -# TODO: MATCH https://github.com/leeyegy/TokenPose/blob/main/experiments/coco/tokenpose/tokenpose_b_256_192_patch43_dim192_depth12_heads8.yaml -# See https://arxiv.org/pdf/2104.03516.pdf +# TODO: This default configuration file needs to be reviewed so it matches the original + # base TokenPose configuration, as defined in + # https://github.com/leeyegy/TokenPose/blob/main/experiments/coco/tokenpose/tokenpose_b_256_192_patch43_dim192_depth12_heads8.yaml data: auto_padding: # Required for HRNet backbones pad_width_divisor: 32 @@ -29,10 +30,11 @@ model: bodypart: type: TransformerHead target_generator: - type: PlateauGenerator - generate_locref: false - num_joints: "num_bodyparts" + type: HeatmapPlateauGenerator + num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 + heatmap_mode: KEYPOINT + generate_locref: false criterion: type: WeightedBCECriterion predictor: diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py index c8007740d9..4b122ae02f 100644 --- a/deeplabcut/pose_estimation_pytorch/config/utils.py +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Util functions to create pytorch pose configuration files""" from __future__ import annotations diff --git a/deeplabcut/pose_estimation_pytorch/data/__init__.py b/deeplabcut/pose_estimation_pytorch/data/__init__.py index 24a88d5eed..e65c83f98e 100644 --- a/deeplabcut/pose_estimation_pytorch/data/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/data/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.data.base import Loader from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 56aed73fe6..25e4e116a0 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py index b4b8e27631..9f9d6203b7 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py @@ -1,9 +1,10 @@ +# # DeepLabCut Toolbox (deeplabcut.org) # © A. & M.W. Mathis Labs # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index baa6127b0f..38e7b6cc13 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index 3f80efb77a..5d8af7234e 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from __future__ import annotations import os diff --git a/deeplabcut/pose_estimation_pytorch/data/helper.py b/deeplabcut/pose_estimation_pytorch/data/helper.py index 287bf22db8..de1d632b5e 100644 --- a/deeplabcut/pose_estimation_pytorch/data/helper.py +++ b/deeplabcut/pose_estimation_pytorch/data/helper.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from __future__ import annotations from abc import ABCMeta diff --git a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py index e298467189..0a4ee0ac5c 100644 --- a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Post-process predictions made by models""" from __future__ import annotations diff --git a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py index 499abb3c92..9bf4224852 100644 --- a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Helpers to run preprocess data before running inference""" from __future__ import annotations diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 504cb7ecfe..73539d20cf 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from __future__ import annotations from typing import Any, Iterable, Sequence @@ -170,7 +180,7 @@ def __init__( """ Args: alpha: int, float or tuple of floats, optional - The alpha value of the new colorspace when overlayed over the + The alpha value of the new colorspace when overlaid over the old one. A value close to 1.0 means that mostly the new colorspace is visible. A value close to 0.0 means that mostly the old image is visible. diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 316f0dc4d4..f78e07df43 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/__init__.py b/deeplabcut/pose_estimation_pytorch/models/__init__.py index 38d38cc303..4b1bad5dea 100644 --- a/deeplabcut/pose_estimation_pytorch/models/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES from deeplabcut.pose_estimation_pytorch.models.criterions import CRITERIONS from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py index 6bbc95baf4..d476de084d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index d8ab679883..b7fccf0001 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index 9d22246233..e469535b5f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 0c579a9f3a..84e91ee038 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py index fa92fcd507..b2d2af3408 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.models.criterions.aggregators import ( WeightedLossAggregator, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py b/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py index 71c28ce98b..973cabfc63 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/base.py b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py index f4456b40bf..8520366b7f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py b/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py index b33aeb8d1a..693ec74e09 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from __future__ import annotations import torch diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py index 8a1ea4a20a..57186c87ce 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py index 8bdb8a5830..f8633f009e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( DETECTORS, BaseDetector, diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index d98f8560d9..30abbf5713 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index c89824f090..eb7f86bce5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py index beda47ff44..2878c8b548 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS, BaseHead from deeplabcut.pose_estimation_pytorch.models.heads.dekr import DEKRHead from deeplabcut.pose_estimation_pytorch.models.heads.dlcrnet import DLCRNetHead diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index 68da8a92e4..8e3c1b8ecc 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py index 7e6e4635e2..481a415357 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py index 8b77d98085..33c4d22298 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 58a41a5d31..c3506b2d44 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py index 33b8e954c4..75a79a6adf 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index ac1b14bb1b..d1056552f9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py index 2f9a4788d1..9e571798fa 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import ( AdaptBlock, BasicBlock, diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py index d827382a61..72816bcbcf 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py @@ -4,11 +4,11 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # -# The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main +"""The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main""" from __future__ import annotations from abc import ABC, abstractmethod diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py index 9e32116def..630eed830f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py +++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py @@ -4,11 +4,11 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # -# The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main +"""The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main""" import logging from typing import List diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py index 139b541af6..5eecc5dbbc 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/base.py b/deeplabcut/pose_estimation_pytorch/models/necks/base.py index 4273523bfd..b57df5bab5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py index b46c28c4c2..6c3ce7d45d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py @@ -1,13 +1,13 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M.W. Mathis Labs -https://github.com/DeepLabCut/DeepLabCut - -Please see AUTHORS for contributors. -https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS - -Licensed under GNU Lesser General Public License v3.0 -""" +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import torch import torch.nn.functional as F diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py index 948ed13d4e..0cb8e60958 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py @@ -1,13 +1,13 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M.W. Mathis Labs -https://github.com/DeepLabCut/DeepLabCut - -Please see AUTHORS for contributors. -https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS - -Licensed under GNU Lesser General Public License v3.0 -""" +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from typing import Tuple import torch diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/utils.py b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py index 1a3db92c40..028078b8ab 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py @@ -1,13 +1,13 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M.W. Mathis Labs -https://github.com/DeepLabCut/DeepLabCut - -Please see AUTHORS for contributors. -https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS - -Licensed under GNU Lesser General Public License v3.0 -""" +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import math diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index 4318d086f5..9c917d49fc 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index a5fe617368..4a09092938 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 76e3c7dc04..7d94f3cc11 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py index 8b0196f03f..f76c72364b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py @@ -1,7 +1,14 @@ -"""Predictor to generate identity maps from head outputs - - -""" +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Predictor to generate identity maps from head outputs""" from __future__ import annotations import torch diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index 389621962e..8cf46595b7 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index 295dfd7b74..77a20ae2c6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py index 3c2e8d57bb..fd1f523bc3 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py index fb0f11dbb3..5723dcc639 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py index 96a74f39e2..86cbaa935c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py index bc018fe7ae..d54809402e 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index 9d88fc8e85..2d7166a3ef 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py index 4133ef86ac..2234e74857 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # @@ -247,7 +247,7 @@ def update( locref_map[:, :, 0] = dx * self.locref_scale locref_map[:, :, 1] = dy * self.locref_scale - if locref_mask: + if locref_mask is not None: locref_mask[dist <= self.dist_thresh_sq] = 1 diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py index 11eab03a8d..1ea045c5ed 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py b/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py index 2dd1b06028..117d127147 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py b/deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py index 874e13beb7..85a18508ec 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import argparse import json import os diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index cd080f9a51..8ea5962e6a 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py index f3e59abbcd..914a82af9a 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import inspect import json import os diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py index cb2fe2fc2a..25f07ebbf1 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from deeplabcut.pose_estimation_pytorch.post_processing.match_predictions_to_gt import ( oks_match_prediction_to_gt, rmse_match_prediction_to_gt, diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/identity.py b/deeplabcut/pose_estimation_pytorch/post_processing/identity.py index d8852011f3..c5df326165 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/identity.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/identity.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Functions to assign identity to predictions from an identity head""" from __future__ import annotations diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 4307fdadc2..2f7f99b167 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/registry.py b/deeplabcut/pose_estimation_pytorch/registry.py index a2322ca09a..0cc91ac0be 100644 --- a/deeplabcut/pose_estimation_pytorch/registry.py +++ b/deeplabcut/pose_estimation_pytorch/registry.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/runners/__init__.py b/deeplabcut/pose_estimation_pytorch/runners/__init__.py index 5a5b1b86a9..4d107c502a 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/runners/__init__.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index 6d2f03ec93..3c683f1447 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py index 49fe909e0e..89fdb5ba8e 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index 52efefe0e0..2d38f98693 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py index 4727deda08..6e37e901a9 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py +++ b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # @@ -31,7 +31,7 @@ def __init__( Args: optimizer: optimizer used for learning. - last_epoch: where to start the scheduler. Defaults to -1, starts from beggining. + last_epoch: where to start the scheduler. Defaults to -1, starts from beginning. verbose: prints model summary. Defaults to False. milestones: number of epochs. Defaults to [10]. lr_list: learning rate list. Defaults to [0.001]. diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index f0746b7159..bd4e0566bd 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/runners/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py index 8f01eec460..aeb91a047b 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/utils.py +++ b/deeplabcut/pose_estimation_pytorch/runners/utils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/tests/__init__.py b/deeplabcut/pose_estimation_pytorch/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py b/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py deleted file mode 100644 index af31c98db0..0000000000 --- a/deeplabcut/pose_estimation_pytorch/tests/test_get_predictions.py +++ /dev/null @@ -1,126 +0,0 @@ -from itertools import product - -import pytest -import torch -from torchvision.transforms import Resize as TorchResize - -from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config -from deeplabcut.pose_estimation_pytorch.apis import inference, utils -from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector, DETECTORS -from deeplabcut.pose_estimation_pytorch.models.predictors import ( - BasePredictor, - PREDICTORS, -) -from deeplabcut.pose_estimation_pytorch.tests.test_utils import write_config - -# Check implemented net types -single_nets = ["resnet_50"] -multi_nets = ["dekr_w18"] -multi_nets_td = ["token_pose_w32"] - -single = [ele for ele in product(single_nets, [False])] -multi = [ele for ele in product(multi_nets, [True])] -multi_td = [ele for ele in product(multi_nets_td, [True])] - -params_bu = single + multi - - -@pytest.mark.parametrize("net_type, multianimal", params_bu) -def test_get_predictions_bottom_up( - net_type: str, - multianimal: bool, - batch_size: int = 8, - image_shape: tuple = (128, 256), -): - # Create a batch image tensors - images = torch.rand((batch_size, 3, image_shape[1], image_shape[0])) * 100 - # Create config and pytorch_config dicts - cfg = write_config(multianimal) - # read N animals and N keypoints from config - num_animals = len(cfg["individuals"]) if "individuals" in cfg.keys() else 1 - num_keypoints = ( - len(cfg["multianimalbodyparts"]) - if cfg["multianimalproject"] - else len(cfg["bodyparts"]) - ) - pytorch_config = make_pytorch_pose_config( - project_config=cfg, pose_config_path="my/pytorch_config.yaml", net_type=net_type - ) - # Pretrained set to False to initialize model without using a snapshot - pytorch_config["model"]["backbone"]["pretrained"] = False - # build model - model = utils.build_pose_model(pytorch_config) - - # build predictor - predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config["predictor"])) - - # get predictions - with torch.no_grad(): - output, _ = inference.get_predictions_bottom_up(model, predictor, images) - - # Generate test tensor with expected output shape - test = torch.randint(1, 12, (batch_size, num_animals, num_keypoints, 3)) - - assert test.shape == output.shape - - -@pytest.mark.parametrize("net_type, multianimal", multi_td) -def test_get_predicitons_top_down( - net_type: str, - multianimal: bool, - batch_size: int = 8, - num_animals: int = 10, - num_keypoints: int = 10, - image_shape: tuple = (128, 256), -): - # Create a batch image tensors - images = torch.rand((batch_size, 3, image_shape[1], image_shape[0])) * 100 - # Create config and pytorch_config dicts - cfg = write_config(multianimal) - pytorch_config = make_pytorch_pose_config( - project_config=cfg, pose_config_path="my/pytorch_config.yaml", net_type=net_type - ) - # Pretrained set to False to initialize model without using a snapshot - pytorch_config["model"]["backbone"]["pretrained"] = False - # read N animals and N keypoints from config - num_animals = len(cfg["individuals"]) if "individuals" in cfg.keys() else 1 - num_keypoints = ( - len(cfg["multianimalbodyparts"]) - if cfg["multianimalproject"] - else len(cfg["bodyparts"]) - ) - - # build detector - detector: BaseDetector = DETECTORS.build( - dict(pytorch_config["detector"]["detector_model"]) - ) - # build model - model = utils.build_pose_model(pytorch_config) - - # build predictors - top_down_predictor: BasePredictor = PREDICTORS.build( - {"type": "TopDownPredictor", "format_bbox": "xyxy"} - ) - pose_predictor: BasePredictor = PREDICTORS.build(dict(pytorch_config["predictor"])) - detector.eval() - model.eval() - pose_predictor.eval() - top_down_predictor.eval() - - # get predictions - with torch.no_grad(): - output, _ = inference.get_predictions_top_down( - detector, - model, - pose_predictor, - top_down_predictor, - images, - num_animals, - num_keypoints, - TorchResize((256, 256)), - ) - - # Generate test tensor with expected output shape - test = torch.randint(1, 12, (batch_size, num_animals, num_keypoints, 3)) - - assert test.shape == output.shape diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py b/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py deleted file mode 100644 index b6440c7885..0000000000 --- a/deeplabcut/pose_estimation_pytorch/tests/test_pose_model.py +++ /dev/null @@ -1,182 +0,0 @@ -import random - -import pytest -import torch - -import deeplabcut.pose_estimation_pytorch.models as dlc_models -from deeplabcut.pose_estimation_pytorch.models.modules import AdaptBlock, BasicBlock - -backbones_dicts = [ - {"type": "HRNet", "model_name": "hrnet_w32", "output_channels": 480, "stride": 4}, - {"type": "HRNet", "model_name": "hrnet_w18", "output_channels": 270, "stride": 4}, - {"type": "HRNet", "model_name": "hrnet_w48", "output_channels": 720, "stride": 4}, - { - "type": "HRNetTopDown", - "model_name": "hrnet_w32", - "output_channels": 32, - "stride": 4, - }, - { - "type": "HRNetTopDown", - "model_name": "hrnet_w18", - "output_channels": 18, - "stride": 4, - }, - { - "type": "HRNetTopDown", - "model_name": "hrnet_w48", - "output_channels": 48, - "stride": 4, - }, - {"type": "ResNet", "model_name": "resnet50", "output_channels": 2048, "stride": 32}, -] - -heads_dicts = [ - { - "type": "SimpleHead", - "channels": [2048, 1024, -1], - "kernel_size": [2, 2], - "strides": [2, 2], - "output_channels": -1, - "input_channels": 2048, - "total_stride": 4, - }, - { - "type": "TransformerHead", - "dim": 192, - "hidden_heatmap_dim": 384, - "heatmap_dim": -1, - "apply_multi": True, - "heatmap_size": [-1, -1], - "apply_init": True, - "total_stride": 1, - "input_channels": -1, - "output_channels": -1, - }, - { - "type": "HeatmapDEKRHead", - "channels": [480, 64, -1], - "num_blocks": 1, - "dilation_rate": 1, - "final_conv_kernel": 1, - "block": BasicBlock, - "total_stride": 1, - "input_channels": 480, - "output_channels": -1, - }, - { - "type": "OffsetDEKRHead", - "channels": [480, -1, -1], - "num_offset_per_kpt": 15, - "num_blocks": 1, - "dilation_rate": 1, - "final_conv_kernel": 1, - "total_stride": 1, - "input_channels": 480, - "output_channels": -1, - }, -] - - -def _generate_random_backbone_inputs(i): - # Returns sizes that are divisible by 64to be able to predict consistently output szie - # (and be able to do the forward pass of HRNet) - x_size_tmp, y_size_tmp = random.randint(100, 1000), random.randint(100, 1000) - return ( - backbones_dicts[i], - (x_size_tmp - x_size_tmp % 64, y_size_tmp - y_size_tmp % 64), - ) - - -@pytest.mark.parametrize( - "backbone_dict, input_size", - [_generate_random_backbone_inputs(i) for i in range(len(backbones_dicts))], -) -def test_backbone(backbone_dict, input_size): - input_tensor = torch.Tensor(1, 3, input_size[1], input_size[0]) - - stride = backbone_dict.pop("stride") - output_channels = backbone_dict.pop("output_channels") - backbone = dlc_models.BACKBONES.build(backbone_dict) - - features = backbone(input_tensor) - _, c, h, w = features.shape - assert c == output_channels - assert h == input_size[1] // stride - assert w == input_size[0] // stride - - -def _generate_random_head_inputs(i): - # Returns sizes that are divisible by 64to be able to predict consistently output szie - # (and be able to do the forward pass of HRNet) - x_size_tmp, y_size_tmp = random.randint(8, 500), random.randint(8, 500) - num_kpts = random.randint(2, 50) - return ( - heads_dicts[i], - (x_size_tmp - x_size_tmp % 4, y_size_tmp - y_size_tmp % 4), - num_kpts, - ) - - -@pytest.mark.parametrize( - "head_dict, input_shape, num_keypoints", - [_generate_random_head_inputs(i) for i in range(len(heads_dicts))], -) -def test_head(head_dict, input_shape, num_keypoints): - w, h = input_shape - - head_type = head_dict["type"] - input_channels = head_dict.pop("input_channels") - output_channels = head_dict.pop("output_channels") - total_stride = head_dict.pop("total_stride") - if head_type == "SimpleHead": - output_channels = num_keypoints - head_dict["channels"][2] = output_channels - input_tensor = torch.zeros((1, input_channels, h, w)) - - elif head_type == "TransformerHead": - output_channels = num_keypoints - input_channels = num_keypoints - head_dict["heatmap_dim"] = h * w - head_dict["heatmap_size"] = [h, w] - input_tensor = torch.zeros((1, input_channels, head_dict["dim"] * 3)) - - elif head_type == "HeatmapDEKRHead": - output_channels = num_keypoints + 1 - head_dict["channels"][2] = output_channels - input_tensor = torch.zeros((1, input_channels, h, w)) - - elif head_type == "OffsetDEKRHead": - output_channels = num_keypoints * 2 - head_dict["channels"][1] = num_keypoints * head_dict["num_offset_per_kpt"] - head_dict["channels"][2] = num_keypoints - input_tensor = torch.zeros((1, input_channels, h, w)) - - head = dlc_models.HEADS.build(head_dict) - - output = head(input_tensor) - _, c_out, h_out, w_out = output.shape - assert (h_out == h * total_stride) and (w_out == w * total_stride) - assert c_out == output_channels - - -def test_msa_hrnet(): - # TODO: build microsoft asia hrnet and check dimension of output - # TODO: check if hyperparameters are loaded correctly (from the config file) - pass - - -def test_msa_tokenpose(): - # TODO: build microsoft asia hrnet and check dimension of output - # TODO: check if hyperparameters are loaded correctly (from the config file) - # cf https://github.com/amathislab/BUCTDdev/blob/main/lib/models/transpose_h.py#L1 - pass - - -def test_msa_hrnetCOAM(): - # TODO: build BUCTD COAM hrnet and check dimension of output - # TODO: check if hyperparameters are loaded correctly (from the config file) - pass - - -# TODO: add other model variants our pipeline can build ;) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py b/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py deleted file mode 100644 index 368df9ab6f..0000000000 --- a/deeplabcut/pose_estimation_pytorch/tests/test_single_animal.py +++ /dev/null @@ -1,195 +0,0 @@ -import os -import pickle -import time - -import albumentations as A -import numpy as np -import torch -import yaml - -import deeplabcut -from deeplabcut.pose_estimation_pytorch.apis.utils import build_pose_model -from deeplabcut.pose_estimation_pytorch.models.criterion import PoseLoss -from deeplabcut.pose_estimation_pytorch.solvers.inference import get_prediction -from deeplabcut.pose_estimation_pytorch.solvers.utils import ( - get_paths, - get_results_filename, -) - - -def read_yaml(path): - try: - with open(path, "r") as stream: - try: - return yaml.safe_load(stream) - except yaml.YAMLError as exc: - print(exc) - except: - raise FileNotFoundError("An eero occured whilereading the file") - - -def get_training_set_length(cfg, train_fraction, shuffle): - training_folder = os.path.join( - cfg["project_path"], deeplabcut.auxiliaryfunctions.get_training_set_folder(cfg) - ) - train_idx_path = os.path.join( - training_folder, - f'Documentation_data-{cfg["Task"]}_{int(train_fraction*100)}shuffle{shuffle}.pickle', - ) - - with open(train_idx_path, "rb") as file: - meta = pickle.load(file) - - print( - f"length of the training set {len(meta[1])}, length of the test set {len(meta[2])}" - ) - return len(meta[1]) - - -def load_model(cfg, pytorch_config, shuffle, model_prefix="", train_iteration=-1): - names = get_paths( - train_fraction=cfg["TrainingFraction"][0], - model_prefix=model_prefix, - shuffle=shuffle, - cfg=cfg, - train_iterations=train_iteration, - ) - print(names["model_path"]) - - results_filename = get_results_filename( - names["evaluation_folder"], - names["dlc_scorer"], - names["dlc_scorer_legacy"], - names["model_path"][:-3], - ) - - pose_cfg = deeplabcut.auxiliaryfunctions.read_config( - pytorch_config["pose_cfg_path"] - ) - model = build_pose_model(pytorch_config["model"], pose_cfg) - model.load_state_dict(torch.load(names["model_path"])) - - return model - - -def evaluate_network_custom( - config_path, shuffle, model_prefix="", transform=None, train_iteration=-1 -): - cfg = read_yaml(config_path) - train_fraction = cfg["TrainingFraction"][0] - model_folder = os.path.join( - cfg["project_path"], - deeplabcut.auxiliaryfunctions.get_model_folder( - train_fraction, shuffle, cfg, modelprefix=model_prefix - ), - ) - pytorch_config_path = os.path.join(model_folder, "train", "pytorch_config.yaml") - pytorch_config = read_yaml(pytorch_config_path) - pose_cfg = deeplabcut.auxiliaryfunctions.read_config( - pytorch_config["pose_cfg_path"] - ) - - batch_size = pytorch_config["batch_size"] - project = deeplabcut.pose_estimation_pytorch.DLCProject( - shuffle=shuffle, proj_root=pytorch_config["project_root"] - ) - - valid_dataset = deeplabcut.pose_estimation_pytorch.PoseDataset( - project, transform=transform, mode="train" - ) - valid_dataloader = torch.utils.data.DataLoader( - valid_dataset, batch_size=batch_size, shuffle=True - ) - - model = load_model(cfg, pytorch_config, shuffle, model_prefix, train_iteration) - model.to("cuda") - model.eval() - criterion = PoseLoss(locref_huber_loss=True) - - with torch.no_grad(): - losses = [] - rmses = [] - for i, item in enumerate(valid_dataloader): - _, keypoints = item - if isinstance(item, tuple) or (isinstance, list): - item = item[0].to("cuda") - output = model(item) - - scale_factor = ( - item.shape[2] / output[0].shape[2], - item.shape[3] / output[0].shape[3], - ) - - gt = model.get_target(keypoints, output[0].shape[2:], scale_factor) - for key in gt: - if gt[key] is not None: - gt[key] = gt[key].to("cuda") - - predictions = get_prediction(pose_cfg, output, scale_factor) - - rmse = keypoints.numpy() - predictions[:, :, :2] - rmse *= rmse - rmse = np.sqrt(rmse.sum(axis=2)) - rmses.append(np.nanmean(rmse)) - - losses.append(criterion(output, gt)[0].cpu().numpy()) - - print(np.mean(losses), np.nanmean(rmses)) - return np.mean(losses), np.nanmean(rmses) - - -def runBenchmark(path_dataset, train_fraction, shuffle, transform=None): - """Trains the model and evaluates it on a given dataset""" - config_path = os.path.join(path_dataset, "config.yaml") - - # Training the network - print("Training started") - start_time = time.time() - deeplabcut.pose_estimation_pytorch.apis.train.train_network( - config_path, shuffle=shuffle, transform=transform - ) - delta_time = time.time() - start_time - print("Training ended") - - # #evaluate the nework - print("Starting evaluation of the last saved model") - evaluate_network_custom(config_path, shuffle, transform=transform) - - -class CustomHorizontalFlip(A.HorizontalFlip): - def __init__(self, flipped_keypoints, always_apply=False, p=0.5): - """ - flipped_keypoints : list of the new order of keypoints - """ - super().__init__(always_apply=always_apply, p=p) - self.flipped_keypoints = flipped_keypoints - - def apply_to_keypoints(self, keypoints, **params): - keypoints = list(super().apply_to_keypoints(keypoints, **params)) - - return [keypoints[i] for i in self.flipped_keypoints] - - -if __name__ == "__main__": - path_dataset = "/home/quentin/datasets/Openfield_pytorch" - config_path = os.path.join(path_dataset, "config.yaml") - - cfg = read_yaml(config_path) - if cfg.get("flipped_keypoints"): - flip_transform = CustomHorizontalFlip(cfg["flipped_keypoints"]) - else: - flip_transform = A.HorizontalFlip() - - transform = A.Compose( - [ - flip_transform, - A.RandomScale(scale_limit=[-0.25, 0.25]), - A.RandomBrightnessContrast(p=0.5), - A.Rotate(limit=10), - A.MotionBlur(), - A.PixelDropout(), - A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), - ], - keypoint_params=A.KeypointParams(format="xy", remove_invisible=False), - ) - runBenchmark(path_dataset, 0.95, 1, transform=transform) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py b/deeplabcut/pose_estimation_pytorch/tests/test_utils.py deleted file mode 100644 index f5af28c586..0000000000 --- a/deeplabcut/pose_estimation_pytorch/tests/test_utils.py +++ /dev/null @@ -1,79 +0,0 @@ -"""TODO""" -import torch - -import deeplabcut.pose_estimation_pytorch.models as dlc_models - - -def _get_keypoints(number_of_joints: int = 4, axis: int = 2): - keypoints_torch = torch.Tensor(number_of_joints, axis) - - return keypoints_torch, number_of_joints - - -def test_generate_heatmaps(): - keypoints_torch, number_of_joints = _get_keypoints() - image_size = (256, 256) - sigma = 5 - heatmap_size = (64, 64) - heatmaps = dlc_models._generate_heatmaps( - keypoints_torch, heatmap_size, image_size, sigma=sigma - ) - assert heatmaps.shape == (number_of_joints, heatmap_size[0], heatmap_size[1]) - - -# Create fake config dict for testing purposes -def write_config(multianimal: bool) -> dict: - cfg_file = {} - if multianimal: # parameters specific to multianimal project - cfg_file["multianimalproject"] = multianimal - cfg_file["identity"] = False - cfg_file["individuals"] = ["individual1", "individual2", "individual3"] - cfg_file["multianimalbodyparts"] = ["bodypart1", "bodypart2", "bodypart3"] - cfg_file["uniquebodyparts"] = [] - cfg_file["bodyparts"] = "MULTI!" - cfg_file["skeleton"] = [ - ["bodypart1", "bodypart2"], - ["bodypart2", "bodypart3"], - ["bodypart1", "bodypart3"], - ] - cfg_file["default_augmenter"] = "multi-animal-imgaug" - cfg_file["default_net_type"] = "dlcrnet_ms5" - cfg_file["default_track_method"] = "ellipse" - else: - cfg_file["multianimalproject"] = False - cfg_file["bodyparts"] = ["bodypart1", "bodypart2", "bodypart3", "objectA"] - cfg_file["skeleton"] = [["bodypart1", "bodypart2"], ["objectA", "bodypart3"]] - cfg_file["default_augmenter"] = "default" - cfg_file["default_net_type"] = "resnet_50" - - # common parameters: - cfg_file["Task"] = "test" - cfg_file["scorer"] = "experimenter" - cfg_file["video_sets"] = "placeholder" - cfg_file["project_path"] = "fake\\path" - cfg_file["date"] = "Oct30" - cfg_file["cropping"] = False - cfg_file["start"] = 0 - cfg_file["stop"] = 1 - cfg_file["numframes2pick"] = 20 - cfg_file["TrainingFraction"] = [0.95] - cfg_file["iteration"] = 0 - cfg_file["snapshotindex"] = -1 - cfg_file["x1"] = 0 - cfg_file["x2"] = 640 - cfg_file["y1"] = 277 - cfg_file["y2"] = 624 - cfg_file[ - "batch_size" - ] = ( - 8 - ) # batch size during inference (video - analysis); see https://www.biorxiv.org/content/early/2018/10/30/457242 - cfg_file["corner2move2"] = (50, 50) - cfg_file["move2corner"] = True - cfg_file["skeleton_color"] = "black" - cfg_file["pcutoff"] = 0.6 - cfg_file["dotsize"] = 12 # for plots size of dots - cfg_file["alphavalue"] = 0.7 # for plots transparency of markers - cfg_file["colormap"] = "rainbow" # for plots type of colormap - - return cfg_file diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index bc1395bd2b..33674f4d1b 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/docs/pytorch_dlc.md b/docs/pytorch_dlc.md index 175db891ce..157a1c19af 100644 --- a/docs/pytorch_dlc.md +++ b/docs/pytorch_dlc.md @@ -6,7 +6,7 @@ The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. - `Project` class provides train and test splitting and converts dataset to required - format. For intance, to [COCO]() format. + format. For instance, to [COCO]() format. - `PoseTrainDataset` class is a [torch.utils.Dataset](https://pytorch.org/docs/stable/data.html) class, which converts raw images and keypoints to a tensor dataset for training and evaluation. - [models](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models): diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py index 53c76af7f0..5657ada8c8 100644 --- a/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py +++ b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py @@ -56,7 +56,7 @@ def test_gaussian_heatmap_generation_single_keypoint(data): generator = HeatmapGaussianGenerator( num_heatmaps=data["num_heatmaps"], pos_dist_thresh=dist_thresh, - heatmap_mode=HeatmapGaussianGenerator.Mode.INDIVIDUAL, + heatmap_mode=HeatmapGaussianGenerator.Mode.KEYPOINT, generate_locref=False, ) inputs = torch.zeros((1, 3, *data["in_shape"])) @@ -103,7 +103,7 @@ def test_random_gaussian_target_generation( generator = HeatmapGaussianGenerator( num_heatmaps=num_keypoints, pos_dist_thresh=17, - heatmap_mode=HeatmapGaussianGenerator.Mode.INDIVIDUAL, + heatmap_mode=HeatmapGaussianGenerator.Mode.KEYPOINT, generate_locref=False, ) targets = generator(inputs, predicted_heatmaps, annotations) diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py index 42ff827484..01230b7747 100644 --- a/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py +++ b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py @@ -58,7 +58,7 @@ def test_plateau_heatmap_generation_single_keypoint(data): generator = HeatmapPlateauGenerator( num_heatmaps=data["num_heatmaps"], pos_dist_thresh=dist_thresh, - heatmap_mode=HeatmapGenerator.Mode.INDIVIDUAL, + heatmap_mode=HeatmapGenerator.Mode.KEYPOINT, generate_locref=False, ) inputs = torch.zeros((1, 3, *data["in_shape"])) diff --git a/tests/pose_estimation_pytorch/modelzoo/test_download.py b/tests/pose_estimation_pytorch/modelzoo/test_download.py index 5ae386e12c..0d6fcf557f 100644 --- a/tests/pose_estimation_pytorch/modelzoo/test_download.py +++ b/tests/pose_estimation_pytorch/modelzoo/test_download.py @@ -30,7 +30,7 @@ def test_download_huggingface_wrong_model(): dlclibrary.download_huggingface_model("wrong_model_name") -@pytest.mark.skip +@pytest.mark.skip(reason="slow") @pytest.mark.parametrize("model", MODELOPTIONS) def test_download_all_models(tmp_path_factory, model): test_download_huggingface_model(tmp_path_factory, model) diff --git a/tests/pose_estimation_pytorch/modelzoo/test_utils.py b/tests/pose_estimation_pytorch/modelzoo/test_utils.py index 8f7ead7380..08cecebc37 100644 --- a/tests/pose_estimation_pytorch/modelzoo/test_utils.py +++ b/tests/pose_estimation_pytorch/modelzoo/test_utils.py @@ -5,6 +5,7 @@ from deeplabcut.pose_estimation_pytorch.modelzoo.utils import _get_config_model_paths +@pytest.mark.skip(reason="require-models") @pytest.mark.parametrize( "project_name", ["superanimal_quadruped", "superanimal_topviewmouse"] ) diff --git a/tests/pose_estimation_pytorch/modelzoo/test_webapp.py b/tests/pose_estimation_pytorch/modelzoo/test_webapp.py index 12bf9a3e2f..e7a0dd847e 100644 --- a/tests/pose_estimation_pytorch/modelzoo/test_webapp.py +++ b/tests/pose_estimation_pytorch/modelzoo/test_webapp.py @@ -23,7 +23,7 @@ def test_class_init(project_name, pose_model_type, max_individuals): assert len(inference_pipeline.config["bodyparts"]) > 0 -@pytest.mark.require_models +@pytest.mark.skip(reason="require-models") @pytest.mark.parametrize( "project_name", ["superanimal_quadruped", "superanimal_topviewmouse"] ) @@ -42,7 +42,7 @@ def test_runner_init(project_name, pose_model_type): assert inference_pipeline.models.detector_runner -@pytest.mark.require_models +@pytest.mark.skip(reason="require-models") @pytest.mark.parametrize("max_individuals", [10, 4, 1]) @pytest.mark.parametrize( "project_name", ["superanimal_quadruped", "superanimal_topviewmouse"] diff --git a/tests/pose_estimation_pytorch/modelzoo_test.py b/tests/pose_estimation_pytorch/modelzoo_test.py deleted file mode 100644 index a47344ba3e..0000000000 --- a/tests/pose_estimation_pytorch/modelzoo_test.py +++ /dev/null @@ -1,65 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -import os - -import dlclibrary -import pytest -from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS - -from deeplabcut.utils import auxiliaryfunctions - - -def test_download_huggingface_model(tmp_path_factory, model="full_cat"): - folder = tmp_path_factory.mktemp("temp") - dlclibrary.download_huggingface_model(model, str(folder)) - - assert os.path.exists(folder / "pose_cfg.yaml") - assert any(f.startswith("snapshot-") for f in os.listdir(folder)) - # Verify that the Hugging Face folder was removed - assert not any(f.startswith("models--") for f in os.listdir(folder)) - - -def test_download_huggingface_wrong_model(): - with pytest.raises(ValueError): - dlclibrary.download_huggingface_model("wrong_model_name") - - -@pytest.mark.skip -@pytest.mark.parametrize("model", MODELOPTIONS) -def test_download_all_models(tmp_path_factory, model): - test_download_huggingface_model(tmp_path_factory, model) - - -examples_folder = os.path.join( - auxiliaryfunctions.get_deeplabcut_path(), - "examples", - "openfield-Pranav-2018-10-30", - "labeled-data", - "m4s1", -) - - -@pytest.mark.parametrize( - "image_path", - [ - f"{examples_folder}/img0001.png", - f"{examples_folder}/img0004.png", - f"{examples_folder}/img0009.png", - ], -) -@pytest.mark.parametrize("max_individuals", [1, 3]) -@pytest.mark.parametrize( - "project_name", ["superanimal_quadruped", "superanimal_topview"] -) -def test_webapp_init(project_name, max_individuals): - inference_pipeline = SuperanimalPyTorchInference( - project_name, pose_model_type, max_individuals=max_individuals - ) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py b/tests/pose_estimation_pytorch/other/test_api_utils.py similarity index 73% rename from deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py rename to tests/pose_estimation_pytorch/other/test_api_utils.py index 8966d7cc06..b48a220273 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_api_utils.py +++ b/tests/pose_estimation_pytorch/other/test_api_utils.py @@ -7,7 +7,7 @@ transform_dicts = [ {"auto_padding": {"pad_height_divisor": 64, "pad_width_divisor": 27}}, - {"resize": [512, 256]}, + {"resize": {"height": 512, "width": 256, "keep_ration": True}}, { "covering": True, "gaussian_noise": 12.75, @@ -48,11 +48,8 @@ def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals transform_bbox_aug = dlc_api_utils.build_transforms( transform_dict, augment_bbox=True ) - transform_no_bbox_aug = dlc_api_utils.build_transforms( - transform_dict, augment_bbox=False - ) transform_inference = dlc_api_utils.build_inference_transform( - transform_dict, augment_bbox=False + transform_dict, augment_bbox=True ) w, h = size_image @@ -63,11 +60,6 @@ def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals bboxes[:, 3] = h - bboxes[:, 1] keypoints = np.random.randint(0, min(w, h), (num_keypoints, 2)) - with pytest.raises(Exception): - transformed = transform_no_bbox_aug( - image=test_image, keypoints=keypoints.copy(), bboxes=bboxes.copy() - ) - with pytest.raises(Exception): transformed = transform_inference(image=test_image) transformed = transform_inference(image=test_image, bboxes=bboxes.copy()) @@ -80,24 +72,24 @@ def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals keypoints=keypoints.copy(), bboxes=bboxes.copy(), bbox_labels=np.arange(num_animals), + class_labels=[0 for _ in range(len(keypoints))] ) - transformed_without_bbox = transform_no_bbox_aug( - image=test_image, keypoints=keypoints.copy() + transformed_inference = transform_inference( + image=test_image, + keypoints=[], + bboxes=bboxes.copy(), + bbox_labels=np.arange(num_animals), + class_labels=[0 for _ in range(len(keypoints))] ) - transformed_inference = transform_inference(image=test_image, keypoints=[]) if "resize" in transform_dict.keys(): assert transformed_inference["image"].shape[:2] == ( - transform_dict["resize"][0], - transform_dict["resize"][1], + transform_dict["resize"]["height"], + transform_dict["resize"]["width"], ) assert transformed_with_bbox["image"].shape[:2] == ( - transform_dict["resize"][0], - transform_dict["resize"][1], - ) - assert transformed_without_bbox["image"].shape[:2] == ( - transform_dict["resize"][0], - transform_dict["resize"][1], + transform_dict["resize"]["height"], + transform_dict["resize"]["width"], ) if "auto_padding" in transform_dict.keys(): @@ -107,10 +99,7 @@ def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals ) assert transformed_inference["image"].shape[0] % modh == 0 assert transformed_with_bbox["image"].shape[0] % modh == 0 - assert transformed_without_bbox["image"].shape[0] % modh == 0 assert transformed_inference["image"].shape[1] % modw == 0 assert transformed_with_bbox["image"].shape[1] % modw == 0 - assert transformed_without_bbox["image"].shape[1] % modw == 0 assert len(transformed_with_bbox["keypoints"]) == len(keypoints) - assert len(transformed_without_bbox["keypoints"]) == len(keypoints) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_configs/config.yaml b/tests/pose_estimation_pytorch/other/test_configs/config.yaml similarity index 100% rename from deeplabcut/pose_estimation_pytorch/tests/test_configs/config.yaml rename to tests/pose_estimation_pytorch/other/test_configs/config.yaml diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_configs/pose_cfg.yaml b/tests/pose_estimation_pytorch/other/test_configs/pose_cfg.yaml similarity index 100% rename from deeplabcut/pose_estimation_pytorch/tests/test_configs/pose_cfg.yaml rename to tests/pose_estimation_pytorch/other/test_configs/pose_cfg.yaml diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_configs/pytorch_config.yaml b/tests/pose_estimation_pytorch/other/test_configs/pytorch_config.yaml similarity index 100% rename from deeplabcut/pose_estimation_pytorch/tests/test_configs/pytorch_config.yaml rename to tests/pose_estimation_pytorch/other/test_configs/pytorch_config.yaml diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_transforms.py b/tests/pose_estimation_pytorch/other/test_custom_transforms.py similarity index 100% rename from deeplabcut/pose_estimation_pytorch/tests/test_transforms.py rename to tests/pose_estimation_pytorch/other/test_custom_transforms.py diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py b/tests/pose_estimation_pytorch/other/test_data_helper.py similarity index 61% rename from deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py rename to tests/pose_estimation_pytorch/other/test_data_helper.py index 6daf0d6e3a..7b5460858d 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_data_helper.py +++ b/tests/pose_estimation_pytorch/other/test_data_helper.py @@ -1,24 +1,43 @@ from __future__ import annotations import os +from unittest.mock import patch, Mock +from zipfile import Path import numpy as np import pytest from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset -from deeplabcut.pose_estimation_pytorch.data.dlcproject import DLCProject -from deeplabcut.pose_estimation_pytorch.data.helper import merge_list_of_dicts +from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader +from deeplabcut.pose_estimation_pytorch.data.utils import merge_list_of_dicts +from deeplabcut.generate_training_dataset import create_training_dataset +def mock_aux() -> Mock: + aux_functions = Mock() + aux_functions.read_plainconfig = Mock() + aux_functions.read_plainconfig.return_value = {} + return aux_functions + + +@patch("deeplabcut.pose_estimation_pytorch.data.base.auxiliaryfunctions", mock_aux()) +def _get_loader(project_root): + if not (Path(project_root) / "training-datasets").exists(): + create_training_dataset(config=str(Path(project_root) / "config.yaml")) + return DLCLoader(project_root, model_config_path="", shuffle=1) + + +@pytest.mark.skip @pytest.mark.parametrize("repo_path", ["/home/anastasiia/DLCdev"]) def test_propertymeta_project(repo_path): project_root = os.path.join(repo_path, "examples", "openfield-Pranav-2018-10-30") - dlc_project = DLCProject(project_root, shuffle=1) + dlc_loader = _get_loader(project_root) - for prop in dlc_project.properties: - print(prop, getattr(dlc_project, prop)) + for prop in dlc_loader.properties: + print(prop, getattr(dlc_loader, prop)) +@pytest.mark.skip @pytest.mark.parametrize( "repo_path, mode", [("/home/anastasiia/DLCdev", "train"), ("/home/anastasiia/DLCdev", "test")], @@ -26,10 +45,9 @@ def test_propertymeta_project(repo_path): def test_propertymeta_dataset(repo_path, mode): repo_path = "/home/anastasiia/DLCdev" mode = "train" - mode = "train" project_root = os.path.join(repo_path, "examples", "openfield-Pranav-2018-10-30") - dlc_project = DLCProject(project_root, shuffle=1) - dataset = PoseDataset(dlc_project, mode) + dlc_loader = _get_loader(project_root) + dataset = dlc_loader.create_dataset(transform=None, mode=mode) for prop in dataset.properties: print(prop, getattr(dataset, prop)) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py b/tests/pose_estimation_pytorch/other/test_dataset.py similarity index 75% rename from deeplabcut/pose_estimation_pytorch/tests/test_dataset.py rename to tests/pose_estimation_pytorch/other/test_dataset.py index 64513dcb73..e6d77b2468 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_dataset.py +++ b/tests/pose_estimation_pytorch/other/test_dataset.py @@ -1,6 +1,7 @@ import os import random from pathlib import Path +from unittest.mock import Mock, patch import albumentations as A import pytest @@ -11,13 +12,21 @@ from deeplabcut.generate_training_dataset import create_training_dataset +def mock_aux() -> Mock: + aux_functions = Mock() + aux_functions.read_plainconfig = Mock() + aux_functions.read_plainconfig.return_value = {} + return aux_functions + + +@patch("deeplabcut.pose_estimation_pytorch.data.base.auxiliaryfunctions", mock_aux()) def _get_dataset(path, transform, mode="train"): project_root = Path(path) if not (project_root / "training-datasets").exists(): create_training_dataset(config=str(project_root / "config.yaml")) - dlc_project = dlc.DLCProject(path, shuffle=1) - dataset = dlc.PoseDataset(dlc_project, transform=transform, mode=mode) + loader = dlc.DLCLoader(path, model_config_path="", shuffle=1) + dataset = loader.create_dataset(transform=transform, mode=mode) return dataset @@ -29,6 +38,25 @@ def _get_openfield_dataset(transform=None): return _get_dataset(openfield_path, transform=transform) +key_set = { + "offsets", + "path", + "scales", + "image", + "original_size", + "annotations", + "image_id", +} +anno_key_set = { + "keypoints", + "keypoints_unique", + "area", + "boxes", + "is_crowd", + "labels", +} + + @pytest.mark.parametrize("batch_size", [1, 2, random.randint(2, 20)]) def test_iter_all_dataset_no_transform(batch_size): if batch_size > 1: # if batched, all images need to be the same size @@ -38,23 +66,15 @@ def test_iter_all_dataset_no_transform(batch_size): bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), ) else: - transform = None + transform = A.Compose( + [A.Normalize()], + keypoint_params=A.KeypointParams(format="xy"), + bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), + ) dataset = _get_openfield_dataset(transform=transform) dataloader = DataLoader(dataset, batch_size=batch_size) - key_set = {"image", "original_size", "annotations"} - anno_key_set = { - "keypoints", - "area", - "ids", - "boxes", - "image_id", - "is_crowd", - "labels", - "unique_kpts", - } - - max_num_animals = dataset.max_num_animals - num_keypoints = dataset.num_joints + max_num_animals = dataset.parameters.max_num_animals + num_keypoints = dataset.parameters.num_joints for i, item in enumerate(dataloader): is_last_batch = i == (len(dataloader) - 1) assert ( @@ -88,26 +108,26 @@ def _generate_random_test_values_aug(min_exa): batch_size = random.randint(1, 20) x_size = random.randint(50, 600) y_size = random.randint(50, 600) - exageration = random.randint(min_exa, 99) + exaggeration = random.randint(min_exa, 99) - return (batch_size, x_size, y_size, exageration) + return batch_size, x_size, y_size, exaggeration @pytest.mark.parametrize( - "batch_size, x_size, y_size, exageration", + "batch_size, x_size, y_size, exaggeration", [ (1, 512, 512, 1), _generate_random_test_values_aug(1), _generate_random_test_values_aug(50), ], ) -def test_iter_all_augmented_dataset(batch_size, x_size, y_size, exageration): +def test_iter_all_augmented_dataset(batch_size, x_size, y_size, exaggeration): transform = A.Compose( [ A.Affine( - scale=(1 - exageration * 0.01, 1 + exageration), - rotate=(-exageration * 2, exageration * 2), - translate_px=(-exageration * 10, exageration * 10), + scale=(1 - exaggeration * 0.01, 1 + exaggeration), + rotate=(-exaggeration * 2, exaggeration * 2), + translate_px=(-exaggeration * 10, exaggeration * 10), ), A.Resize(y_size, x_size), ], @@ -116,20 +136,8 @@ def test_iter_all_augmented_dataset(batch_size, x_size, y_size, exageration): ) dataset = _get_openfield_dataset(transform=transform) dataloader = DataLoader(dataset, batch_size=batch_size) - key_set = {"image", "original_size", "annotations"} - anno_key_set = { - "keypoints", - "area", - "ids", - "boxes", - "image_id", - "is_crowd", - "labels", - "unique_kpts", - } - - max_num_animals = dataset.max_num_animals - num_keypoints = dataset.num_joints + max_num_animals = dataset.parameters.max_num_animals + num_keypoints = dataset.parameters.num_joints for i, item in enumerate(dataloader): is_last_batch = i == (len(dataloader) - 1) assert ( diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py b/tests/pose_estimation_pytorch/other/test_gaussian_targets.py similarity index 73% rename from deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py rename to tests/pose_estimation_pytorch/other/test_gaussian_targets.py index e61ddaf7f7..fe780170ab 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_gaussian_targets.py +++ b/tests/pose_estimation_pytorch/other/test_gaussian_targets.py @@ -12,15 +12,17 @@ def test_gaussian_target_generation( batch_size: int, num_keypoints: int, image_size: tuple, num_animals=1 ): # generate annotations - annotations = { + labels = { "keypoints": torch.randint( 1, min(image_size), (batch_size, num_animals, num_keypoints, 2) ) } # batch size, num animals, num keypoints, 2 for x,y # generate predictions - prediction = [ - torch.rand((batch_size, num_keypoints, image_size[0], image_size[1])) - ] # batch size, num keypoints , imageh, imagew + inputs = torch.rand((batch_size, 3, *image_size[:2])) + prediction = { + "heatmap": torch.rand((batch_size, num_keypoints, *image_size[:2])), + "locref": torch.rand((batch_size, 2 * num_keypoints, *image_size[:2])), + } # generate heatmap output = HeatmapGaussianGenerator( @@ -29,7 +31,7 @@ def test_gaussian_target_generation( locref_std=5.0, ) output = torch.tensor( - output(annotations, prediction, image_size)["heatmaps"].reshape( + output(inputs, prediction, labels)["heatmap"]["target"].reshape( batch_size, num_keypoints, image_size[0] * image_size[1] ) ) @@ -44,7 +46,7 @@ def test_gaussian_target_generation( # get heatmap center tensor predict_kp = torch.stack((x, y), dim=-1) # Remove num_animals dimension - only one animal is supported - annotations["keypoints"] = torch.squeeze(annotations["keypoints"], dim=1) + labels["keypoints"] = torch.squeeze(labels["keypoints"], dim=1) # compare heatmap center to annotation - assert torch.eq(annotations["keypoints"], predict_kp).all().item() + assert torch.eq(labels["keypoints"], predict_kp).all().item() diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py b/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py similarity index 88% rename from deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py rename to tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py index 971a84bf6f..a7f4ab579f 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_plateau_targets.py +++ b/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py @@ -9,9 +9,8 @@ # Licensed under GNU Lesser General Public License v3.0 # -from typing import List, Tuple +from typing import Tuple -import numpy as np import pytest import torch @@ -52,12 +51,16 @@ def get_target( output: """ - annotations = { + labels = { "keypoints": torch.randint( 1, min(image_size), (batch_size, num_animals, num_joints, 2) ) } # 2 for x,y coords - prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + inputs = torch.rand((batch_size, 3, image_size[0], image_size[1])) + prediction = { + "heatmap": torch.rand((batch_size, num_joints, image_size[0], image_size[1])), + "locref": torch.rand((batch_size, 2 * num_joints, image_size[0], image_size[1])), + } generator = HeatmapPlateauGenerator( num_heatmaps=num_joints, pos_dist_thresh=pos_dist_thresh, @@ -65,8 +68,8 @@ def get_target( generate_locref=True, ) - targets_output = generator(annotations, prediction, image_size) - return targets_output, annotations + targets_output = generator(inputs, prediction, labels) + return targets_output, labels data = [(1, 1, 10, (256, 256), 7.2801, 17)] @@ -113,23 +116,21 @@ def test_expected_output( batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh ) - assert "heatmaps" in targets_output - assert "locref_maps" in targets_output - assert "locref_masks" in targets_output - - assert targets_output["heatmaps"].shape == ( + assert "heatmap" in targets_output + assert "locref" in targets_output + assert targets_output["heatmap"]["target"].shape == ( batch_size, num_joints, image_size[0], image_size[1], ) # heatmaps score output - assert targets_output["locref_masks"].shape == ( + assert targets_output["locref"]["weights"].shape == ( batch_size, num_joints * 2, image_size[0], image_size[1], ) - assert targets_output["locref_maps"].shape == ( + assert targets_output["locref"]["target"].shape == ( batch_size, num_joints * 2, image_size[0], @@ -188,7 +189,7 @@ def test_single_animal( ) targets_output = torch.tensor( - targets_output["heatmaps"].reshape(1, 10, image_size[0] * image_size[1]) + targets_output["heatmap"]["target"].reshape(1, 10, image_size[0] * image_size[1]) ) # converting from dict to tensor. 'argmax' works on tensors. plt_max = torch.argmax(targets_output, dim=2) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_helper.py b/tests/pose_estimation_pytorch/other/test_helper.py similarity index 100% rename from deeplabcut/pose_estimation_pytorch/tests/test_helper.py rename to tests/pose_estimation_pytorch/other/test_helper.py diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py b/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py similarity index 100% rename from deeplabcut/pose_estimation_pytorch/tests/test_match_predictions_to_gt.py rename to tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_modelzoo.py b/tests/pose_estimation_pytorch/other/test_modelzoo.py similarity index 98% rename from deeplabcut/pose_estimation_pytorch/tests/test_modelzoo.py rename to tests/pose_estimation_pytorch/other/test_modelzoo.py index b6c53f6658..73ffa0a3dd 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_modelzoo.py +++ b/tests/pose_estimation_pytorch/other/test_modelzoo.py @@ -12,6 +12,7 @@ ) # requires videos to be in the examples folder +@pytest.mark.skip @pytest.mark.parametrize( "video_paths, superanimal_name", [ diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py b/tests/pose_estimation_pytorch/other/test_paf_targets.py similarity index 70% rename from deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py rename to tests/pose_estimation_pytorch/other/test_paf_targets.py index 362dd10dfe..3f08b05e9f 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_paf_targets.py +++ b/tests/pose_estimation_pytorch/other/test_paf_targets.py @@ -11,15 +11,19 @@ def test_paf_target_generation( batch_size: int, num_keypoints: int, image_size: tuple, num_animals=2 ): - annotations = { + labels = { "keypoints": torch.randint( 1, min(image_size), (batch_size, num_animals, num_keypoints, 2) ) } # 2 for x,y coords - prediction = [torch.rand((batch_size, num_keypoints, image_size[0], image_size[1]))] graph = [(i, j) for i in range(num_keypoints) for j in range(i + 1, num_keypoints)] + inputs = torch.rand((batch_size, 3, image_size[0], image_size[1])) + prediction = { + "heatmap": torch.rand((batch_size, num_keypoints, image_size[0], image_size[1])), + "paf": torch.rand((batch_size, len(graph) * 2, image_size[0], image_size[1])), + } generator = pafs_targets.PartAffinityFieldGenerator(graph=graph, width=20) - targets_output = generator(annotations, prediction, image_size) + targets_output = generator(inputs, prediction, labels) assert targets_output["paf"]["target"].shape == ( batch_size, len(graph) * 2, diff --git a/tests/pose_estimation_pytorch/other/test_pose_model.py b/tests/pose_estimation_pytorch/other/test_pose_model.py new file mode 100644 index 0000000000..5dad5613fd --- /dev/null +++ b/tests/pose_estimation_pytorch/other/test_pose_model.py @@ -0,0 +1,274 @@ +import copy +import random + +import pytest +import torch + +import deeplabcut.pose_estimation_pytorch.models as dlc_models +from deeplabcut.pose_estimation_pytorch.models import CRITERIONS, TARGET_GENERATORS, PREDICTORS +from deeplabcut.pose_estimation_pytorch.models.criterions import LOSS_AGGREGATORS +from deeplabcut.pose_estimation_pytorch.models.modules import AdaptBlock, BasicBlock + +backbones_dicts = [ + {"type": "HRNet", "model_name": "hrnet_w32", "output_channels": 480, "stride": 4}, + {"type": "HRNet", "model_name": "hrnet_w18", "output_channels": 270, "stride": 4}, + {"type": "HRNet", "model_name": "hrnet_w48", "output_channels": 720, "stride": 4}, + { + "type": "HRNet", + "model_name": "hrnet_w32", + "output_channels": 32, + "only_high_res": True, + "stride": 4, + }, + { + "type": "HRNet", + "model_name": "hrnet_w18", + "output_channels": 18, + "only_high_res": True, + "stride": 4, + }, + { + "type": "HRNet", + "model_name": "hrnet_w48", + "output_channels": 48, + "only_high_res": True, + "stride": 4, + }, + {"type": "ResNet", "model_name": "resnet50", "output_channels": 2048, "stride": 32}, +] + +heads_dicts = [ + { + "type": "HeatmapHead", + "predictor": { + "type": "HeatmapPredictor", + "location_refinement": True, + "locref_std": 7.2801, + }, + "target_generator": { + "type": "HeatmapPlateauGenerator", + "num_heatmaps": "num_bodyparts", + "pos_dist_thresh": 17, + "heatmap_mode": "KEYPOINT", + "generate_locref": True, + "locref_std": 7.2801, + }, + "criterion": { + "heatmap": { + "type": "WeightedBCECriterion", + "weight": 1.0, + }, + "locref": { + "type": "WeightedHuberCriterion", + "weight": 0.05, + }, + }, + "heatmap_config": { + "channels": [2048, 1024, -1], + "kernel_size": [2, 2], + "strides": [2, 2], + }, + "locref_config": { + "channels": [2048, 1024, -1], + "kernel_size": [2, 2], + "strides": [2, 2], + }, + "output_channels": -1, + "input_channels": 2048, + "total_stride": 4, + }, + { + "type": "TransformerHead", + "predictor": { + "type": "HeatmapPredictor", + "location_refinement": False, + }, + "target_generator": { + "type": "HeatmapPlateauGenerator", + "num_heatmaps": "num_bodyparts", + "pos_dist_thresh": 17, + "heatmap_mode": "KEYPOINT", + "generate_locref": False, + }, + "criterion": {"type": "WeightedBCECriterion"}, + "dim": 192, + "hidden_heatmap_dim": 384, + "heatmap_dim": -1, + "apply_multi": True, + "heatmap_size": [-1, -1], + "apply_init": True, + "total_stride": 1, + "input_channels": -1, + "output_channels": -1, + }, + { + "type": "DEKRHead", + "predictor": { + "type": "DEKRPredictor", + "num_animals": 1, + "keypoint_score_type": "heatmap", + "max_absorb_distance": 75, + }, + "target_generator": { + "type": "DEKRGenerator", + "num_joints": "num_bodyparts", + "pos_dist_thresh": 17, + "bg_weight": 0.1, + }, + "criterion": { + "heatmap": { + "type": "WeightedBCECriterion", + "weight": 1.0, + }, + "offset": { + "type": "WeightedHuberCriterion", + "weight": 0.03, + }, + }, + "heatmap_config": { + "channels": [480, 64, -1], + "num_blocks": 1, + "dilation_rate": 1, + "final_conv_kernel": 1, + "block": BasicBlock, + }, + "offset_config": { + "channels": [480, -1, -1], + "num_offset_per_kpt": 15, + "num_blocks": 1, + "dilation_rate": 1, + "final_conv_kernel": 1, + "block": AdaptBlock, + }, + "total_stride": 1, + "input_channels": 480, + "output_channels": -1, + }, +] + + +def _generate_random_backbone_inputs(i): + # Returns sizes that are divisible by 64to be able to predict consistently output size + # (and be able to do the forward pass of HRNet) + x_size_tmp, y_size_tmp = random.randint(100, 1000), random.randint(100, 1000) + return ( + backbones_dicts[i], + (x_size_tmp - x_size_tmp % 64, y_size_tmp - y_size_tmp % 64), + ) + + +@pytest.mark.parametrize( + "backbone_dict, input_size", + [_generate_random_backbone_inputs(i) for i in range(len(backbones_dicts))], +) +def test_backbone(backbone_dict, input_size): + input_tensor = torch.Tensor(1, 3, input_size[1], input_size[0]) + + stride = backbone_dict.pop("stride") + output_channels = backbone_dict.pop("output_channels") + backbone = dlc_models.BACKBONES.build(backbone_dict) + + features = backbone(input_tensor) + _, c, h, w = features.shape + assert c == output_channels + assert h == input_size[1] // stride + assert w == input_size[0] // stride + + +def _generate_random_head_inputs(i): + # Returns sizes that are divisible by 64to be able to predict consistently output size + # (and be able to do the forward pass of HRNet) + x_size_tmp, y_size_tmp = random.randint(8, 500), random.randint(8, 500) + num_kpts = random.randint(2, 50) + return ( + heads_dicts[i], + (x_size_tmp - x_size_tmp % 4, y_size_tmp - y_size_tmp % 4), + num_kpts, + ) + + +@pytest.mark.parametrize( + "head_dict, input_shape, num_keypoints", + [_generate_random_head_inputs(i) for i in range(len(heads_dicts))], +) +def test_head(head_dict, input_shape, num_keypoints): + w, h = input_shape + head_dict = copy.deepcopy(head_dict) + + head_type = head_dict["type"] + input_channels = head_dict.pop("input_channels") + output_channels = head_dict.pop("output_channels") + total_stride = head_dict.pop("total_stride") + if head_type == "HeatmapHead": + output_channels = num_keypoints + head_dict["heatmap_config"]["channels"][2] = output_channels + head_dict["locref_config"]["channels"][2] = 2 * output_channels + head_dict["target_generator"]["num_heatmaps"] = output_channels + input_tensor = torch.zeros((1, input_channels, h, w)) + + elif head_type == "TransformerHead": + output_channels = num_keypoints + input_channels = num_keypoints + head_dict["heatmap_dim"] = h * w + head_dict["heatmap_size"] = [h, w] + head_dict["target_generator"]["num_heatmaps"] = output_channels + input_tensor = torch.zeros((1, input_channels, head_dict["dim"] * 3)) + + elif head_type == "DEKRHead": + output_channels = num_keypoints + 1 + head_dict["target_generator"]["num_joints"] = num_keypoints + head_dict["heatmap_config"]["channels"][2] = num_keypoints + 1 + head_dict["offset_config"]["channels"][1] = ( + num_keypoints * head_dict["offset_config"]["num_offset_per_kpt"] + ) + head_dict["offset_config"]["channels"][2] = num_keypoints + input_tensor = torch.zeros((1, input_channels, h, w)) + + if "type" in head_dict["criterion"]: + head_dict["criterion"] = CRITERIONS.build(head_dict["criterion"]) + else: + weights = {} + criterions = {} + for loss_name, criterion_cfg in head_dict["criterion"].items(): + weights[loss_name] = criterion_cfg.get("weight", 1.0) + criterion_cfg = { + k: v for k, v in criterion_cfg.items() if k != "weight" + } + criterions[loss_name] = CRITERIONS.build(criterion_cfg) + + aggregator_cfg = {"type": "WeightedLossAggregator", "weights": weights} + head_dict["aggregator"] = LOSS_AGGREGATORS.build(aggregator_cfg) + head_dict["criterion"] = criterions + + head_dict["target_generator"] = TARGET_GENERATORS.build( + head_dict["target_generator"] + ) + head_dict["predictor"] = PREDICTORS.build(head_dict["predictor"]) + head = dlc_models.HEADS.build(head_dict) + + output = head(input_tensor)["heatmap"] + _, c_out, h_out, w_out = output.shape + assert (h_out == h * total_stride) and (w_out == w * total_stride) + assert c_out == output_channels + + +def test_msa_hrnet(): + # TODO: build microsoft asia hrnet and check dimension of output + # TODO: check if hyperparameters are loaded correctly (from the config file) + pass + + +def test_msa_tokenpose(): + # TODO: build microsoft asia hrnet and check dimension of output + # TODO: check if hyperparameters are loaded correctly (from the config file) + # cf https://github.com/amathislab/BUCTDdev/blob/main/lib/models/transpose_h.py#L1 + pass + + +def test_msa_hrnetCOAM(): + # TODO: build BUCTD COAM hrnet and check dimension of output + # TODO: check if hyperparameters are loaded correctly (from the config file) + pass + + +# TODO: add other model variants our pipeline can build ;) diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py b/tests/pose_estimation_pytorch/other/test_schedulers.py similarity index 94% rename from deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py rename to tests/pose_estimation_pytorch/other/test_schedulers.py index aa582f58ae..8bba7ed99f 100644 --- a/deeplabcut/pose_estimation_pytorch/tests/test_schedulers.py +++ b/tests/pose_estimation_pytorch/other/test_schedulers.py @@ -15,7 +15,7 @@ import torch from torch.optim import SGD -import deeplabcut.pose_estimation_pytorch.solvers.schedulers as deeplabcut_torch_schedulers +import deeplabcut.pose_estimation_pytorch.runners.schedulers as deeplabcut_torch_schedulers def generate_random_lr_list(num_floats: int): @@ -42,13 +42,13 @@ def generate_random_lr_list(num_floats: int): milestones = random.sample(range(0, 999), 2) milestones.sort() data = [([10, 430], [[0.05], [0.005]]), (milestones, generate_random_lr_list(2))] -# tetsing for default values in pytorch_config and also for random values with pytest parametrize +# testing for default values in pytorch_config and also for random values with pytest parametrize @pytest.mark.parametrize("milestones, lr_list", data) def test_scheduler(milestones, lr_list): """Summary: - Testing shedulers.py. + Testing schedulers.py. Given a list of milestones and a list of learning rates, this function tests if the length of each list is the same. Furthermore, it will assess if the current learning rate (output from the function we are testing) is a float diff --git a/deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py b/tests/pose_estimation_pytorch/other/test_seq_targets.py similarity index 100% rename from deeplabcut/pose_estimation_pytorch/tests/test_seq_targets.py rename to tests/pose_estimation_pytorch/other/test_seq_targets.py diff --git a/tests/test_dekr_predictor.py b/tests/test_dekr_predictor.py deleted file mode 100644 index 5328e2bf3c..0000000000 --- a/tests/test_dekr_predictor.py +++ /dev/null @@ -1,23 +0,0 @@ -import torch -import pytest -import deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor as dlc_pep_models_predictors_dekr_predictor - - -def test_DEKRPredictor(): - predictor = dlc_pep_models_predictors_dekr_predictor.DEKRPredictor(num_animals=2) - outputs = ( - torch.randn(1, 18, 64, 64), # example heatmap - torch.randn(1, 34, 64, 64), # example offsets - ) - scale_factors = (1.0, 0.5) - - try: - pose_dict = predictor.forward(outputs, scale_factors) - poses_with_scores = pose_dict["poses"] - except Exception as e: - pytest.fail(f"DEKRPredictor forward pass raised an exception: {e}") - - assert poses_with_scores.shape == (1, 2, 17, 3) - - assert torch.all(poses_with_scores[:, :, :, 2] >= 0) - assert torch.all(poses_with_scores[:, :, :, 2] <= 1) diff --git a/tests/test_predict_supermodel.py b/tests/test_predict_supermodel.py index 767e2739a8..e10d211400 100644 --- a/tests/test_predict_supermodel.py +++ b/tests/test_predict_supermodel.py @@ -10,7 +10,7 @@ # import numpy as np import pytest -from deeplabcut.modelzoo.api import superanimal_inference +from deeplabcut.pose_estimation_tensorflow.modelzoo.api import superanimal_inference def test_get_multi_scale_frames(): From 5ae8ae2daceb9d2dd0fb873249a1c48bac955661 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:38:32 +0100 Subject: [PATCH 067/293] niels/model_benchmark_improvements (#166) * IMPORTANT BUG FIX - set python seed for Albumentations reproducibility!!!!! * unified affine transform * improve code to freeze batch norm * added call to model.train in TrainingRunner fit * improved docs * model.train() gets called for each epoch * optional save optimizer_state_dict, save last epoch, remove resnet FC layer * remove hrnet FC layer * added the possibility for dropout in resnets * fix seed setting * added code to compare image aug * added backbone config to augmentation study * test multiple backbones * improved logging of train/val transforms * added optimizer config * overwrite pytorch config when custom parameters are given to train * improved experimental setup * added script to benchmark lightning pose * improved scheduler for resnets * changed default optimizer LR for resnets * added OOD evaluation script * added lighting pose tf eval script * updated LP ood image evaluation script * improved coco benchmarking: create multi-animal configs * fix doc * fix madlc_test_inference * improved logging * added division to config creation and fixed HRNet channels * experimental: correct hrnet config * fixed HRNet backbone * updated lightning pose benchmark script * added mirror-mouse project * removed second deconv layer from default bodypart head --- benchmark/benchmark_lightning_pose.py | 95 ++++++ benchmark/benchmark_run_experiments.py | 307 +++++++++++++++++ benchmark/benchmark_train.py | 12 +- benchmark/coco/README.md | 4 + benchmark/coco/make_config.py | 16 +- benchmark/lightning_pose_ood_evaluation.py | 138 ++++++++ benchmark/lightning_pose_tf_eval.py | 318 ++++++++++++++++++ benchmark/madlc_test_inference.py | 2 +- benchmark/utils.py | 16 +- .../pose_estimation_pytorch/apis/train.py | 11 +- .../pose_estimation_pytorch/apis/utils.py | 36 +- .../config/backbones/hrnet_w18.yaml | 4 +- .../config/backbones/hrnet_w32.yaml | 4 +- .../config/backbones/hrnet_w48.yaml | 4 +- .../config/backbones/resnet_101.yaml | 13 + .../config/backbones/resnet_50.yaml | 13 + .../config/base/base.yaml | 4 +- .../config/base/detector.yaml | 8 +- .../config/base/head_bodyparts.yaml | 6 - .../config/base/head_bodyparts_with_paf.yaml | 9 - .../config/base/head_hrnet.yaml | 22 ++ .../config/base/head_identity.yaml | 2 +- .../config/make_pose_config.py | 5 +- .../pose_estimation_pytorch/config/utils.py | 24 +- .../models/backbones/base.py | 56 ++- .../models/backbones/hrnet.py | 17 +- .../models/backbones/resnet.py | 20 +- .../models/heads/simple_head.py | 92 +++-- .../pose_estimation_pytorch/models/model.py | 4 - .../pose_estimation_pytorch/runners/train.py | 26 +- deeplabcut/pose_estimation_pytorch/utils.py | 11 +- .../config/test_utils.py | 66 ++++ 32 files changed, 1206 insertions(+), 159 deletions(-) create mode 100644 benchmark/benchmark_lightning_pose.py create mode 100644 benchmark/benchmark_run_experiments.py create mode 100644 benchmark/lightning_pose_ood_evaluation.py create mode 100644 benchmark/lightning_pose_tf_eval.py create mode 100644 deeplabcut/pose_estimation_pytorch/config/base/head_hrnet.yaml create mode 100644 tests/pose_estimation_pytorch/config/test_utils.py diff --git a/benchmark/benchmark_lightning_pose.py b/benchmark/benchmark_lightning_pose.py new file mode 100644 index 0000000000..808a932aff --- /dev/null +++ b/benchmark/benchmark_lightning_pose.py @@ -0,0 +1,95 @@ +"""Code to make an ablation study with different image augmentation parameters""" +from __future__ import annotations + +from pathlib import Path + +from deeplabcut.utils import get_bodyparts + +from benchmark_run_experiments import ( + main, + BackboneConfig, + HeadConfig, + ImageAugmentations, + ModelConfig, + WandBConfig, + DEFAULT_OPTIMIZER, + DEFAULT_SCHEDULER, + RESNET_OPTIMIZER, + RESNET_SCHEDULER, +) +from utils import Project + + +LP_DLC_DATA_ROOT = Path("/home/niels/datasets/lightning-pose") +LP_DLC_BENCHMARKS = { + "mirrorFish": Project( + root=LP_DLC_DATA_ROOT, + name="mirror-fish-rick-2023-10-26", + iteration=1, # ITERATION 0 IS THE PAPER DATA + ), + "mirrorMouse": Project( + root=LP_DLC_DATA_ROOT, + name="mirror-mouse-rick-2022-12-02", + iteration=2, # ITERATION 0 IS THE PAPER DATA + ), +} + + +if __name__ == "__main__": + project_benchmarked = LP_DLC_BENCHMARKS["mirrorFish"] + splits_file = (LP_DLC_DATA_ROOT / "lightning_pose_splits.json") + cfg = project_benchmarked.cfg + num_bodyparts = len(get_bodyparts(cfg)) + + FULL_AUG = ImageAugmentations( + covering=True, + gaussian_noise=12.75, + hist_eq=True, + motion_blur=True, + rotation=30, + scale_jitter=(0.5, 1.25), + translation=40, + ) + + model_configs = [ + ModelConfig( + net_type="resnet_50", + batch_size=8, + epochs=125, + save_epochs=25, + augmentations=FULL_AUG, + backbone_config=BackboneConfig( + model_name="resnet50_gn", + output_stride=16, + freeze_bn_stats=True, + freeze_bn_weights=False, + ), + head_config=HeadConfig( + plateau_targets=True, + heatmap_config=dict( + channels=[2048, num_bodyparts], + kernel_size=[3], + strides=[2], + final_conv=None, + ), + locref_config=dict( + channels=[2048, 2 * num_bodyparts], + kernel_size=[3], + strides=[2], + final_conv=None, + ), + ), + optimizer_config=RESNET_OPTIMIZER, + scheduler_config=RESNET_SCHEDULER, + wandb_config=WandBConfig(project="dlc3-mirror-mouse-dev", run_name="resnet_single_deconv"), + ) + ] + + main( + project=project_benchmarked, + splits_file=splits_file, + trainset_index=0, + train_fraction=0.81, + models_to_train=model_configs, + splits_to_train=(0, 1, 2, 3, 4), + ) diff --git a/benchmark/benchmark_run_experiments.py b/benchmark/benchmark_run_experiments.py new file mode 100644 index 0000000000..350f12d73e --- /dev/null +++ b/benchmark/benchmark_run_experiments.py @@ -0,0 +1,307 @@ +"""Code to make an ablation study with different image augmentation parameters""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path + +import torch +import wandb + +from deeplabcut.utils import get_bodyparts + +from benchmark_train import EvalParameters, RunParameters, TrainParameters, run_dlc +from projects import SA_DLC_BENCHMARKS, SA_DLC_DATA_ROOT +from utils import Project, Shuffle, create_shuffles + + +@dataclass +class WandBConfig: + project: str + run_name: str + + def data(self) -> dict: + return { + "type": "WandbLogger", + "project_name": self.project, + "run_name": self.run_name, + } + + +@dataclass +class BackboneConfig: + """ + Attributes: + model_name: one of "resnet50", "resnet50_gn" + output_stride: 8, 16 or 32 + freeze_bn_weights: + freeze_bn_stats: + """ + model_name: str = "resnet50" + output_stride: int | None = None + freeze_bn_weights: bool | None = None + freeze_bn_stats: bool | None = None + drop_path_rate: float | None = None + drop_block_rate: float | None = None + + def to_dict(self) -> dict: + config = asdict(self) + for k in list(config.keys()): + if config[k] is None: + config.pop(k) + return config + + +@dataclass +class HeadConfig: + plateau_targets: bool + heatmap_config: dict + locref_config: dict | None + + def to_dict(self) -> dict: + output_channels = self.heatmap_config["channels"][-1] + if self.heatmap_config.get("final_conv") is not None: + output_channels = self.heatmap_config["final_conv"]["out_channels"] + predictor = dict( + type="HeatmapPredictor", + location_refinement=self.locref_config is not None, + locref_std=7.2801, + ) + target_generator = dict( + type="HeatmapPlateauGenerator" if self.plateau_targets else "HeatmapGaussianGenerator", + num_heatmaps=output_channels, + pos_dist_thresh=17, + heatmap_mode="KEYPOINT", + generate_locref=self.locref_config is not None, + locref_std=7.2801, + ) + criterion = dict(heatmap=dict(type="WeightedBCECriterion", weight=1.0)) + if self.locref_config is not None: + criterion["locref"] = dict( + type="WeightedHuberCriterion", weight=0.05 + ) + + return dict( + type="HeatmapHead", + predictor=predictor, + target_generator=target_generator, + criterion=criterion, + heatmap_config=self.heatmap_config, + locref_config=self.locref_config, + ) + + +@dataclass +class ImageAugmentations: + """ + The default augmentation only normalizes images. + + Examples: + gaussian_noise: 12.75 + resize: {height: 800, width: 800, keep_ratio: true} + rotation: 30 + scale_jitter: (0.5, 1.25) + translation: 40 + """ + normalize: bool = True + covering: bool = False + gaussian_noise: float = 0.0 + hist_eq: bool = False + motion_blur: bool = False + resize: dict | None = None + rotation: int = 0 + scale_jitter: tuple[float, float] | None = None + translation: int = 0 + + def data(self) -> dict: + augmentations = { + "normalize_images": self.normalize, + "covering": self.covering, + "gaussian_noise": self.gaussian_noise, + "hist_eq": self.hist_eq, + "motion_blur": self.motion_blur, + "rotation": self.rotation, + "scale_jitter": False, + "translation": self.translation, + } + if self.resize: + augmentations["resize"] = self.resize + if self.scale_jitter: + augmentations["scale_jitter"] = self.scale_jitter + return augmentations + + +@dataclass +class ModelConfig(TrainParameters): + net_type: str = "resnet_50" + augmentations: ImageAugmentations | None = None + backbone_config: BackboneConfig | None = None + head_config: HeadConfig | None = None + optimizer_config: dict | None = None + scheduler_config: dict | None = None + wandb_config: WandBConfig | None = None + + def train_kwargs(self) -> dict: + kwargs = super().train_kwargs() + if self.augmentations is not None: + kwargs["data"] = self.augmentations.data() + if self.backbone_config is not None: + kwargs["model"] = dict(backbone=self.backbone_config.to_dict()) + if self.head_config is not None: + model_config = kwargs.get("model", {}) + model_config["heads"] = dict(bodypart=self.head_config.to_dict()) + kwargs["model"] = model_config + if self.wandb_config is not None: + kwargs["logger"] = self.wandb_config.data() + if self.optimizer_config is not None: + kwargs["optimizer"] = self.optimizer_config + if self.scheduler_config is not None: + kwargs["scheduler"] = self.scheduler_config + return kwargs + + +def main( + project: Project, + splits_file: Path, + trainset_index: int, + train_fraction: float, + models_to_train: list[ModelConfig], + splits_to_train: tuple[int, ...] = (0, 1, 2), +): + project.update_iteration_in_config() + for config in models_to_train: + if wandb.run is not None: # TODO: Finish wandb run in DLC + wandb.finish() + + print(100 * "-") + print(f"Backbone config: {config.backbone_config}") + print(f"Head config: {config.head_config}") + print(f"Augmentation: {config.augmentations}") + + shuffle_indices = create_shuffles( + project, splits_file, trainset_index, config.net_type + ) + shuffles_to_train = [shuffle_indices[i] for i in splits_to_train] + print(f"training shuffles {shuffles_to_train}") + for shuffle_idx in shuffles_to_train: + if wandb.run is not None: # TODO: Finish wandb run in DLC + wandb.finish() + + print(" ModelParameters") + for k, v in asdict(config).items(): + print(f" {k}: {v}") + print(" Train kwargs") + for k, v in config.train_kwargs().items(): + print(f" {k}: {v}") + + if config.wandb_config is not None: + config.wandb_config.run_name += f"-it{project.iteration}-shuf{shuffle_idx}" + + run_dlc( + parameters=RunParameters( + shuffle=Shuffle( + project=project, + train_fraction=train_fraction, + index=shuffle_idx, + model_prefix="", + ), + train=True, + evaluate=True, + device="cuda:0", + train_params=config, + eval_params=EvalParameters(snapshotindex="all", plotting=False) + ) + ) + + +RESNET_OPTIMIZER = {"type": "AdamW", "params": {"lr": 1e-3}} +RESNET_SCHEDULER = { + "type": "LRListScheduler", + "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [90, 120]}, +} +DEFAULT_OPTIMIZER = {"type": "AdamW", "params": {"lr": 5e-4}} +DEFAULT_SCHEDULER = { + "type": "LRListScheduler", + "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [90, 120]}, +} + + +if __name__ == "__main__": + project_benchmarked = SA_DLC_BENCHMARKS["fly"] + splits_file = (SA_DLC_DATA_ROOT / "saDLC_benchmarking_splits.json") + cfg = project_benchmarked.cfg + num_bodyparts = len(get_bodyparts(cfg)) + + FULL_AUG = ImageAugmentations( + covering=True, + gaussian_noise=12.75, + hist_eq=True, + motion_blur=True, + rotation=30, + scale_jitter=(0.5, 1.25), + translation=40, + ) + model_configs = [ + ModelConfig( + net_type="resnet_50", + batch_size=8, + epochs=125, + save_epochs=25, + augmentations=FULL_AUG, + backbone_config=BackboneConfig( + model_name="resnet50_gn", + freeze_bn_stats=True, + freeze_bn_weights=False, + ), + head_config=HeadConfig( + plateau_targets=True, + heatmap_config=dict( + channels=[2048, num_bodyparts], + kernel_size=[3], + strides=[2], + final_conv=None, + ), + locref_config=dict( + channels=[2048, 2 * num_bodyparts], + kernel_size=[3], + strides=[2], + final_conv=None, + ), + ), + optimizer_config=RESNET_OPTIMIZER, + scheduler_config=RESNET_SCHEDULER, + wandb_config=WandBConfig(project="dlc3_hrnet", run_name="resnet_single_deconv"), + ), + ModelConfig( + net_type="hrnet_w32", + batch_size=8, + epochs=125, + save_epochs=25, + augmentations=FULL_AUG, + backbone_config=BackboneConfig( + model_name="hrnet_w32", + freeze_bn_stats=True, + freeze_bn_weights=False, + ), + head_config=HeadConfig( + plateau_targets=False, + heatmap_config=dict( + channels=[32], + kernel_size=[], + strides=[], + final_conv=dict(out_channels=num_bodyparts, kernel_size=1), + ), + locref_config=None + ), + optimizer_config=DEFAULT_OPTIMIZER, + scheduler_config=DEFAULT_SCHEDULER, + wandb_config=WandBConfig(project="dlc3_hrnet", run_name="hrnet_gauss"), + ), + ] + main( + project=project_benchmarked, + splits_file=splits_file, + trainset_index=0, + train_fraction=0.8, + models_to_train=model_configs, + splits_to_train=(0, 1, 2), + ) diff --git a/benchmark/benchmark_train.py b/benchmark/benchmark_train.py index a2caa53ced..880c2493b7 100644 --- a/benchmark/benchmark_train.py +++ b/benchmark/benchmark_train.py @@ -43,6 +43,7 @@ class TrainParameters: epochs: int | None = 100 save_epochs: int | None = 25 snapshot_path: Path | None = None + detector_batch_size: int | None = None detector_max_epochs: int | None = None detector_save_epochs: int | None = None @@ -57,10 +58,17 @@ def train_kwargs(self) -> dict: kwargs["save_epochs"] = self.save_epochs if self.snapshot_path is not None: kwargs["snapshot_path"] = str(self.snapshot_path) + + detector_kwargs = {} + if self.detector_batch_size is not None: + detector_kwargs["batch_size"] = self.detector_batch_size if self.detector_max_epochs is not None: - kwargs["detector_max_epochs"] = self.detector_max_epochs + detector_kwargs["epochs"] = self.detector_max_epochs if self.detector_save_epochs is not None: - kwargs["detector_save_epochs"] = self.detector_save_epochs + detector_kwargs["save_epochs"] = self.detector_save_epochs + if len(detector_kwargs) > 0: + kwargs["detector"] = detector_kwargs + return kwargs diff --git a/benchmark/coco/README.md b/benchmark/coco/README.md index c16004c937..b636a6de1e 100644 --- a/benchmark/coco/README.md +++ b/benchmark/coco/README.md @@ -19,6 +19,10 @@ You can either copy an existing model configuration file and modify it to fit yo updated project (or run a new experiment), or you can create a default one for a given model architecture using `make_config.py`. +This creates a configuration file for single-animal pose estimation as a default, but +you can create multi-animal projects by passing the command-line argument +`--multi_animal`. + This will create a `train` and a `test` folder in the `output` folder that you've specified. The configuration file will be saved in the `train` folder, and an `inference_cfg.yaml` file will be created in the `test` folder, which contains diff --git a/benchmark/coco/make_config.py b/benchmark/coco/make_config.py index 6314d18753..6403ed9fde 100644 --- a/benchmark/coco/make_config.py +++ b/benchmark/coco/make_config.py @@ -13,6 +13,7 @@ from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config from deeplabcut.pose_estimation_pytorch.data import COCOLoader + def get_base_config( project_path: str, pose_config_path: str, @@ -20,10 +21,11 @@ def get_base_config( bodyparts: list[str], unique_bodyparts: list[str], individuals: list[str], + multi_animal: bool, ) -> dict: cfg = { "project_path": project_path, - "multianimalproject": True, + "multianimalproject": multi_animal, "bodyparts": bodyparts, "multianimalbodyparts": bodyparts, "uniquebodyparts": unique_bodyparts, @@ -58,7 +60,13 @@ def make_inference_config( MakeInference_yaml(items2change, output_path, default_config_path) -def main(project_root: str, train_file: str, output: str, model_arch: str): +def main( + project_root: str, + train_file: str, + output: str, + model_arch: str, + multi_animal: bool, +): output_path = Path(output) if output_path.exists(): raise RuntimeError( @@ -83,6 +91,7 @@ def main(project_root: str, train_file: str, output: str, model_arch: str): bodyparts=bodyparts, unique_bodyparts=[], individuals=[f"individual{i}" for i in range(num_individuals)], + multi_animal=multi_animal, ) af.write_plainconfig(str(train_dir / "pytorch_config.yaml"), pytorch_cfg) make_inference_config( @@ -100,5 +109,6 @@ def main(project_root: str, train_file: str, output: str, model_arch: str): parser.add_argument("output") parser.add_argument("model_arch") parser.add_argument("--train_file", default="train.json") + parser.add_argument("--multi_animal", action="store_true") args = parser.parse_args() - main(args.project_root, args.train_file, args.output, args.model_arch) + main(args.project_root, args.train_file, args.output, args.model_arch, args.multi_animal) diff --git a/benchmark/lightning_pose_ood_evaluation.py b/benchmark/lightning_pose_ood_evaluation.py new file mode 100644 index 0000000000..7843ebc0cf --- /dev/null +++ b/benchmark/lightning_pose_ood_evaluation.py @@ -0,0 +1,138 @@ +"""Evaluate LightningPose OOD data""" +from __future__ import annotations +from pathlib import Path + +import numpy as np +import pandas as pd +from tqdm import tqdm + +from deeplabcut.pose_estimation_pytorch import DLCLoader, PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.apis.scoring import pair_predicted_individuals_with_gt, get_scores +from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.pose_estimation_pytorch.data.utils import map_id_to_annotations +from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.utils import df_to_generic + +from benchmark_lightning_pose import LP_DLC_BENCHMARKS +from utils import Project, Shuffle + + +def load_ground_truth(gt_data, parameters: PoseDatasetParameters): + annotations = DLCLoader.filter_annotations(gt_data["annotations"], task=Task.BOTTOM_UP) + img_to_ann_map = map_id_to_annotations(annotations) + + ground_truth_dict = {} + for image in gt_data["images"]: + image_path = image["file_name"] + individual_keypoints = { + annotations[i]["individual"]: annotations[i]["keypoints"] + for i in img_to_ann_map[image["id"]] + } + gt_array = np.empty((parameters.max_num_animals, parameters.num_joints, 3)) + gt_array.fill(np.nan) + + # Keep the shape of the ground truth + for idv_idx, idv in enumerate(parameters.individuals): + if idv in individual_keypoints: + keypoints = individual_keypoints[idv].reshape(parameters.num_joints, -1) + gt_array[idv_idx, :, :] = keypoints[:, :3] + + ground_truth_dict[image_path] = gt_array + + return ground_truth_dict + + +def evaluate_ood( + shuffle: Shuffle, + snapshot_indices: list[int] | None = None, +): + df_ood_path = shuffle.project.path / "CollectedData_new.csv" + df_ood = pd.read_csv( + df_ood_path, + index_col=0, + header=[0, 1, 2], + ) + df_ood = df_ood[~df_ood.index.duplicated(keep="first")] + images = [shuffle.project.path / Path(img) for img in df_ood.index] + + snapshots = shuffle.snapshots(detector=False) + if snapshot_indices is not None: + snapshots = [snapshots[i] for i in snapshot_indices] + + loader = DLCLoader( + project_root=str(shuffle.project.path), + model_config_path=str(shuffle.pytorch_cfg_path), + shuffle=shuffle.index, + ) + parameters = loader.get_dataset_parameters() + + best_results = {"rmse": 1_000_000} + for snapshot in snapshots: + runner, detector_runner = get_runners( + pytorch_config=shuffle.pytorch_cfg, + snapshot_path=str(snapshot), + max_individuals=parameters.max_num_animals, + num_bodyparts=parameters.num_joints, + num_unique_bodyparts=parameters.num_unique_bpts, + with_identity=False, + transform=None, + detector_path=None, + detector_transform=None, + ) + image_paths = [str(i) for i in images] + print("Running pose prediction") + predictions = runner.inference(tqdm(image_paths)) + poses = { + image_path: image_predictions["bodyparts"][..., :3] + for image_path, image_predictions in zip(image_paths, predictions) + } + + gt_data = df_to_generic(str(shuffle.project.path), df_ood, image_id_offset=1) + annotations_with_bbox = DLCLoader._get_all_bboxes(gt_data["images"], gt_data["annotations"]) + gt_data["annotations"] = annotations_with_bbox + gt_keypoints = load_ground_truth(gt_data, loader.get_dataset_parameters()) + + if parameters.max_num_animals > 1: + poses = pair_predicted_individuals_with_gt(poses, gt_keypoints) + + results = get_scores( + poses, + gt_keypoints, + pcutoff=0.6, + unique_bodypart_poses=None, + unique_bodypart_gt=None, + ) + print(snapshot, results["rmse"]) + if results["rmse"] < best_results["rmse"]: + best_results = results + + return best_results + + +def main(project: Project, train_fraction: float, shuffle_indices: list[int]): + full_results = {"shuffles": []} + for idx in shuffle_indices: + shuffle_results = evaluate_ood( + shuffle=Shuffle(project=project, train_fraction=train_fraction, index=idx), + snapshot_indices=None, + ) + full_results["shuffles"].append(idx) + for k, v in shuffle_results.items(): + metric_list = full_results.get(k, []) + metric_list.append(v) + full_results[k] = metric_list + + print("Results:") + for k, v in full_results.items(): + print(f" {k}: {v}") + + print("mean", np.mean(full_results["rmse"])) + print("std", np.std(full_results["rmse_pcutoff"])) + + +if __name__ == "__main__": + main( + LP_DLC_BENCHMARKS["mirrorFish"], + train_fraction=0.81, + shuffle_indices=[36, 37, 38, 39, 40], + ) diff --git a/benchmark/lightning_pose_tf_eval.py b/benchmark/lightning_pose_tf_eval.py new file mode 100644 index 0000000000..d1df541a07 --- /dev/null +++ b/benchmark/lightning_pose_tf_eval.py @@ -0,0 +1,318 @@ +"""LightningPose Evaluation as used by Matthew R. Whiteway + +Transmitted on January 3rd, 2024 +Forwarded on January 5th, 2024 +""" +import argparse +import os +from pathlib import Path + +import pandas as pd +import numpy as np +import tensorflow as tf +import yaml +from tqdm import tqdm + +import deeplabcut +from deeplabcut.pose_estimation_tensorflow import pairwisedistances +from deeplabcut.utils.auxfun_videos import imread, imresize +from deeplabcut.pose_estimation_tensorflow.core import predict +from deeplabcut.pose_estimation_tensorflow.config import load_config +from deeplabcut.pose_estimation_tensorflow.datasets.utils import data_to_input +from deeplabcut.utils import auxiliaryfunctions, conversioncode + + +DATA_DIR = "/home/niels/datasets/lightning-pose" +DISPLAY_ITERS = 500 +SAVE_ITERS = 5000 +MAX_ITERS = 50000 + + +def pixel_error(keypoints_true: np.ndarray, keypoints_pred: np.ndarray) -> np.ndarray: + """Root mean square error between true and predicted keypoints. + + Taken from https://github.com/danbider/lightning-pose/blob/main/lightning_pose/metrics.py + + Args: + keypoints_true: shape (samples, n_keypoints, 2) + keypoints_pred: shape (samples, n_keypoints, 2) + + Returns: + shape (samples, n_keypoints) + + """ + error = np.linalg.norm(keypoints_true - keypoints_pred, axis=2) + return error + + +def evaluate_network( + config, + csv_file, + resultsfilename, + shuffle=0, + trainingsetindex=0, + gputouse=None, + modelprefix="", + scale=1.0, +): + tf.compat.v1.reset_default_graph() + os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" # + # tf.logging.set_verbosity(tf.logging.WARN) + + # Read file path for pose_config file. >> pass it on + cfg = auxiliaryfunctions.read_config(config) + if gputouse is not None: # gpu selectinon + os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse) + + if trainingsetindex < len(cfg["TrainingFraction"]) and trainingsetindex >= 0: + trainFraction = cfg["TrainingFraction"][int(trainingsetindex)] + else: + raise Exception( + "Please check the trainingsetindex! ", + trainingsetindex, + " should be an integer from 0 .. ", + int(len(cfg["TrainingFraction"]) - 1), + ) + + # Loading human annotatated data + data = pd.read_csv(csv_file, index_col=0, header=[0, 1, 2]) + df_index = data.index.copy() + + ################################################## + # Load and setup CNN part detector + ################################################## + modelfolder = os.path.join( + cfg["project_path"], + str( + auxiliaryfunctions.get_model_folder( + trainFraction, shuffle, cfg, modelprefix=modelprefix + ) + ), + ) + + path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml" + + try: + dlc_cfg = load_config(str(path_test_config)) + except FileNotFoundError: + raise FileNotFoundError( + "It seems the model for shuffle %s and trainFraction %s does not exist." + % (shuffle, trainFraction) + ) + + # change batch size, if it was edited during analysis! + dlc_cfg["batch_size"] = 1 # in case this was edited for analysis. + + # Check which snapshots are available and sort them by # iterations + Snapshots = np.array( + [ + fn.split(".")[0] + for fn in os.listdir(os.path.join(str(modelfolder), "train")) + if "index" in fn + ] + ) + try: # check if any where found? + Snapshots[0] + except IndexError: + raise FileNotFoundError( + "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so." + % (shuffle, trainFraction) + ) + + increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) + Snapshots = Snapshots[increasing_indices] + snapindex = -1 + + conversioncode.guarantee_multiindex_rows(data) + ################################################## + # Compute predictions over images + ################################################## + # setting weights to corresponding snapshot. + dlc_cfg["init_weights"] = os.path.join(str(modelfolder), "train", Snapshots[snapindex]) + # read how many training siterations that corresponds to. + trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1] + + # Name for deeplabcut net (based on its parameters) + DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name( + cfg, + shuffle, + trainFraction, + trainingsiterations, + modelprefix=modelprefix, + ) + print("Running ", DLCscorer, " with # of training iterations:", trainingsiterations) + + # Specifying state of model (snapshot / training state) + sess, inputs, outputs = predict.setup_pose_prediction(dlc_cfg) + Numimages = len(df_index) + PredicteData = np.zeros((Numimages, 3 * len(dlc_cfg["all_joints_names"]))) + print("Running evaluation ...") + for imageindex, imagename in tqdm(enumerate(df_index)): + image = imread( + os.path.join(cfg["project_path"], imagename), + mode="skimage", + ) + if scale != 1: + image = imresize(image, scale) + + image_batch = data_to_input(image) + # Compute prediction with the CNN + outputs_np = sess.run(outputs, feed_dict={inputs: image_batch}) + scmap, locref = predict.extract_cnn_output(outputs_np, dlc_cfg) + + # Extract maximum scoring location from the heatmap, assume 1 person + pose = predict.argmax_pose_predict(scmap, locref, dlc_cfg["stride"]) + PredicteData[imageindex, :] = pose.flatten() + # NOTE: thereby cfg_test['all_joints_names'] should be same order as bodyparts! + + sess.close() # closes the current tf session + + index = pd.MultiIndex.from_product( + [ + [DLCscorer], + dlc_cfg["all_joints_names"], + ["x", "y", "likelihood"], + ], + names=["scorer", "bodyparts", "coords"], + ) + + # rescale + PredicteData[:, 0::3] /= scale + PredicteData[:, 1::3] /= scale + + # Saving results + DataMachine = pd.DataFrame(PredicteData, columns=index, index=df_index) + # DataMachine.loc[:, ("set", "", "")] = "test" + DataMachine.to_csv(resultsfilename) + + tf.compat.v1.reset_default_graph() + + # compute metrics + conversioncode.guarantee_multiindex_rows(DataMachine) + DataCombined = pd.concat( + [data.T, DataMachine.T], axis=0, sort=False + ).T + + rmse, rmse_pcutoff = pairwisedistances( + DataCombined, + cfg["scorer"], + DLCscorer, + cfg["pcutoff"], + bodyparts=None, + ) + test_error = np.nanmean(rmse.values.flatten()) + test_error_pcutoff = np.nanmean(rmse_pcutoff.values.flatten()) + print(f"Test error {test_error:.2f}") + print(f"Test error pcutoff {test_error_pcutoff:.2f}") + + pred_data = DataMachine.drop(labels="likelihood", level=2, axis=1) + num_images = len(pred_data) + lp_pixel_error = pixel_error( + data.to_numpy().reshape((num_images, -1, 2)), + pred_data.to_numpy().reshape((num_images, -1, 2)) + ) + print(f"Test error LP {np.nanmean(lp_pixel_error)}") + return np.nanmean(lp_pixel_error) + + +def run_main(args): + batch_size = 8 + + if args.dataset == 'mirror-mouse': + scorer = 'rick' + date = '2022-12-02' + date_str = 'Dec2' + global_scale = 0.64 + if args.train_frames == 75: + shuffle_list = [750, 751, 752, 753, 754] + trainingsetindex = 0 + trainingset = 49 + else: + shuffle_list = [10, 11, 12, 13, 14] + trainingsetindex = 1 + trainingset = 89 + elif args.dataset == 'mirror-fish': + scorer = 'rick' + date = '2023-10-26' + date_str = 'Oct26' + global_scale = 0.7 + if args.train_frames == 75: + shuffle_list = [750, 751, 752, 753, 754] + trainingsetindex = 0 + trainingset = 81 + else: + shuffle_list = [10, 11, 12, 13, 14] + trainingsetindex = 1 + trainingset = 95 + elif args.dataset == 'ibl-pupil': + scorer = 'mic' + date = '2022-12-06' + date_str = 'Dec6' + global_scale = 1.28 + if args.train_frames == 75: + shuffle_list = [750, 751, 752, 753, 754] + trainingsetindex = 0 + trainingset = 22 + else: + shuffle_list = [10, 11, 12, 13, 14] + trainingsetindex = 1 + trainingset = 89 + elif args.dataset == 'ibl-paw': + scorer = 'mic' + date = '2023-01-09' + date_str = 'Jan9' + global_scale = 1.28 + if args.train_frames == 75: + shuffle_list = [750, 751, 752, 753, 754] + trainingsetindex = 0 + trainingset = 11 + else: + shuffle_list = [10, 11, 12, 13, 14] + trainingsetindex = 1 + trainingset = 89 + else: + raise NotImplementedError + + project_dir = os.path.join(DATA_DIR, '%s-%s-%s' % (args.dataset, scorer, date)) + config_path = os.path.join(project_dir, 'config.yaml') + + shuffle_results = [] + for shuffle in shuffle_list: + model_folder = os.path.join( + project_dir, 'dlc-models', 'iteration-0', '%s%s-trainset%ishuffle%i' % ( + args.dataset, date_str, trainingset, shuffle, + ) + ) + + # evaluate model on OOD data + print(f"Shuffle {shuffle}") + shuffle_results.append( + evaluate_network( + config_path, + csv_file=os.path.join(project_dir, 'CollectedData_new.csv'), + resultsfilename=os.path.join(model_folder, 'predictions_new.csv'), + shuffle=shuffle, + trainingsetindex=trainingsetindex, + gputouse=args.gpu_id, + scale=global_scale, + ) + ) + + print(f"Results on all shuffles") + print(f" Mean: {np.mean(shuffle_results):.2f}") + print(f" STD: {np.std(shuffle_results):.2f}") + + +if __name__ == '__main__': + """(dlc) python eval_lp_ood.py --dataset=mirror-fish --gpu_id=0 --train_frames=75""" + """(dlc) python eval_lp_ood.py --dataset=mirror-mouse --gpu_id=0 --train_frames=75""" + + parser = argparse.ArgumentParser() + + # base params + parser.add_argument('--dataset', type=str) + parser.add_argument('--gpu_id', default=0, type=int) + parser.add_argument('--train_frames', type=int) + + namespace, _ = parser.parse_known_args() + run_main(namespace) diff --git a/benchmark/madlc_test_inference.py b/benchmark/madlc_test_inference.py index 914f0cb52b..55450c20d0 100644 --- a/benchmark/madlc_test_inference.py +++ b/benchmark/madlc_test_inference.py @@ -57,7 +57,7 @@ def run_inference_on_all_images( num_unique_bodyparts=parameters.num_unique_bpts, with_identity=False, # TODO: implement transform=None, - detector_path=detector_snapshot, + detector_path=str(detector_snapshot), detector_transform=None, ) diff --git a/benchmark/utils.py b/benchmark/utils.py index 130a0c6457..c8de0a16cc 100644 --- a/benchmark/utils.py +++ b/benchmark/utils.py @@ -97,11 +97,14 @@ def __post_init__(self): self._metadata = None self._pytorch_cfg = None + @property + def pytorch_cfg_path(self) -> Path: + return self.model_folder / "train" / "pytorch_config.yaml" + @property def pytorch_cfg(self) -> dict: if self._pytorch_cfg is None: - cfg_path = self.model_folder / "train" / "pytorch_config.yaml" - self._pytorch_cfg = af.read_plainconfig(str(cfg_path)) + self._pytorch_cfg = af.read_plainconfig(str(self.pytorch_cfg_path)) return self._pytorch_cfg @@ -206,7 +209,7 @@ def create_shuffles( splits_file: Path, trainset_index: int, net_type: str, -) -> None: +) -> list[int]: """Creates shuffles for a project using predefined train/test splits Creates train/test splits according to what is defined in a file (can be created @@ -248,6 +251,9 @@ def create_shuffles( splits_file: the splits containing the train and test indices trainset_index: the index of the training fractions to create the shuffles with net_type: the type of neural net to create the shuffles with + + Returns: + the shuffle indices created """ shuffle_folder = project.get_shuffle_folder(model_prefix=None) shuffle_indices = [] @@ -275,8 +281,7 @@ def create_shuffles( print(f"Creating training datasets with indices {shuffles_to_create} and splits:") for s in splits: - print(f" Train: {s['train']}") - print(f" Test: {s['test']}") + print(f" train=[{s['train'][:10]}...], test=[{s['test'][:10]}...]") dlc.create_training_dataset( project.config_path(), @@ -286,6 +291,7 @@ def create_shuffles( net_type=net_type, augmenter_type="imgaug", ) + return shuffles_to_create def _get_model_folder( diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index fbed4da79f..1911e7e379 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -102,12 +102,15 @@ def train( logging.info(f"No transform passed to augment images for {task}, using default") transform = build_transforms(transform_config, augment_bbox=True) valid_transform = build_inference_transform(transform_config, augment_bbox=True) + logging.info("Data Transforms:") + logging.info(f" Training: {transform}") + logging.info(f" Validation: {valid_transform}") train_dataset = loader.create_dataset(transform=transform, mode="train", task=task) valid_dataset = loader.create_dataset( transform=valid_transform, mode="test", task=task ) - print( + logging.info( f"Using {len(train_dataset)} images to train {task} and {len(valid_dataset)}" f" for testing" ) @@ -179,8 +182,10 @@ def train_network( pytorch_config = read_config_as_dict(model_config_path) pytorch_config = update_config(pytorch_config, kwargs) - print("Training with configuration:") - pretty_print_config(pytorch_config) + logging.info("Training with configuration:") + pretty_print_config(pytorch_config, print_fn=logging.info) + # write updated configuration + auxiliaryfunctions.write_plainconfig(model_config_path, pytorch_config) if transform is None: logging.info("No transform specified... using default") diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 461722ff67..2ea9a70b0c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -106,29 +106,29 @@ def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose hflip_proba = aug_cfg["hflip"] transforms.append(A.HorizontalFlip(p=hflip_proba)) - # TODO code again this augmentation to match the symmetric_pair syntax in original dlc - # if aug_cfg.get('flipr', False) and aug_cfg.get('symmetric_pair', False): - # opt = aug_cfg.get("fliplr", False) - # if type(opt) == int: - # p = opt - # else: - # p = 0.5 - # transforms.append( - # CustomHorizontalFlip( - - # symmetric_pairs = aug_cfg['symmetric_pairs'], - # p=p - # ) - # ) - scale_jitter_lo, scale_jitter_up = aug_cfg.get("scale_jitter", (1, 1)) - transforms.append(A.Affine(scale=(scale_jitter_lo, scale_jitter_up), p=1)) - if rotation := aug_cfg.get("rotation", 0) != 0: + scale_jitter = aug_cfg.get("scale_jitter") + rotation = aug_cfg.get("rotation") + translation = aug_cfg.get("translation") + if scale_jitter or rotation or translation: + scale = None + if scale_jitter: + scale = scale_jitter[0], scale_jitter[1] + rotate = None + if rotation: + rotate = (-rotation, rotation) + translation_px = None + if translation: + translation_px = (0, translation) transforms.append( A.Affine( - rotate=(-rotation, rotation), + scale=scale, + rotate=rotate, + translate_px=translation_px, p=0.5, + keep_ratio=True, ) ) + if aug_cfg.get("hist_eq", False): transforms.append(A.Equalize(p=0.5)) if aug_cfg.get("motion_blur", False): diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml index 3265f068e5..fff5c9ad16 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml @@ -7,5 +7,5 @@ model: type: HRNet model_name: hrnet_w18 pretrained: true - only_high_res: false - backbone_output_channels: 270 + only_high_res: true + backbone_output_channels: 18 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml index d563abc805..d3392c76ab 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml @@ -7,5 +7,5 @@ model: type: HRNet model_name: hrnet_w32 pretrained: true - only_high_res: false - backbone_output_channels: 480 + only_high_res: true + backbone_output_channels: 32 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml index e6c2d5d6b9..ed52e9f927 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml @@ -7,5 +7,5 @@ model: type: HRNet model_name: hrnet_w48 pretrained: true - only_high_res: false - backbone_output_channels: 720 + only_high_res: true + backbone_output_channels: 48 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml index 9581dcfe09..0e38912311 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml @@ -4,3 +4,16 @@ model: model_name: resnet101 pretrained: true backbone_output_channels: 2048 +optimizer: + type: AdamW + params: + lr: 0.001 +scheduler: + type: LRListScheduler + params: + lr_list: + - - 0.0001 + - - 0.00001 + milestones: + - 90 + - 120 \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml index 6582b7aab0..d1d25006ca 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml @@ -4,3 +4,16 @@ model: model_name: resnet50 pretrained: true backbone_output_channels: 2048 +optimizer: + type: AdamW + params: + lr: 0.001 +scheduler: + type: LRListScheduler + params: + lr_list: + - - 0.0001 + - - 0.00001 + milestones: + - 90 + - 120 \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml index fe6205540b..3a1aed5502 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -15,13 +15,14 @@ device: cuda display_iters: 50 epochs: 200 optimizer: + type: AdamW params: lr: 0.0001 - type: AdamW runner: type: PoseRunner save_epochs: 50 scheduler: + type: LRListScheduler params: lr_list: - - 0.00001 @@ -29,6 +30,5 @@ scheduler: milestones: - 90 - 120 - type: LRListScheduler seed: 42 method: bu diff --git a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml index 7c7c1e798d..b8f49c41ba 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml @@ -3,18 +3,18 @@ data_detector: normalize_images: true detector: model: - type: "FasterRCNN" + type: FasterRCNN optimizer: - type: "AdamW" + type: AdamW params: lr: 1e-4 scheduler: - type: "LRListScheduler" + type: LRListScheduler params: milestones: [90] lr_list: [[1e-5]] runner: - type: "DetectorRunner" + type: DetectorRunner max_individuals: "num_individuals" batch_size: 1 epochs: 500 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml index 28c9c84b4d..186237cf79 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml @@ -20,22 +20,16 @@ criterion: heatmap_config: channels: - "backbone_output_channels" - - 1024 - "num_bodyparts" kernel_size: - 3 - - 3 strides: - 2 - - 2 locref_config: channels: - "backbone_output_channels" - - 1024 - "num_bodyparts x 2" kernel_size: - 3 - - 3 strides: - 2 - - 2 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml index d3c1007d6e..e61788e373 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml @@ -35,34 +35,25 @@ criterion: heatmap_config: channels: - "backbone_output_channels" - - 1024 - "num_bodyparts" kernel_size: - 3 - - 3 strides: - 2 - - 2 locref_config: channels: - "backbone_output_channels" - - 1024 - "num_bodyparts x 2" kernel_size: - 3 - - 3 strides: - 2 - - 2 paf_config: channels: - "backbone_output_channels" - - 1024 - "num_limbs x 2" # num_limbs = len(graph) kernel_size: - 3 - - 3 strides: - 2 - - 2 num_stages: 5 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_hrnet.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_hrnet.yaml new file mode 100644 index 0000000000..6233b37a29 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_hrnet.yaml @@ -0,0 +1,22 @@ +type: HeatmapHead +predictor: + type: HeatmapPredictor + location_refinement: false +target_generator: + type: HeatmapGaussianGenerator + num_heatmaps: "num_bodyparts" + pos_dist_thresh: 17 + heatmap_mode: KEYPOINT + generate_locref: false +criterion: + heatmap: + type: WeightedBCECriterion + weight: 1.0 +heatmap_config: + channels: + - "backbone_output_channels" + kernel_size: [] + strides: [] + final_conv: + out_channels: "num_bodyparts" + kernel_size: 1 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml index 9ac9910342..73419f67c8 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml @@ -15,7 +15,7 @@ criterion: heatmap_config: channels: - "backbone_output_channels" - - 1024 + - "backbone_output_channels // 2" - "num_individuals" kernel_size: - 3 diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index 506f2c65b1..d72d882df5 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -212,7 +212,10 @@ def create_backbone_with_heatmap_model( model_config["method"] = "td" # add a bodypart head - bodypart_head_config = read_config_as_dict(configs_dir / "base" / f"head_bodyparts.yaml") + bodypart_head_name = "head_bodyparts.yaml" + if "hrnet" in net_type.lower(): + bodypart_head_name = "head_hrnet.yaml" + bodypart_head_config = read_config_as_dict(configs_dir / "base" / bodypart_head_name) model_config["model"]["heads"] = { "bodypart": replace_default_values( bodypart_head_config, diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py index 4b122ae02f..f734f4e1ea 100644 --- a/deeplabcut/pose_estimation_pytorch/config/utils.py +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -13,6 +13,8 @@ import copy from pathlib import Path +from typing import Callable + from ruamel.yaml import YAML from deeplabcut.utils import auxiliaryfunctions @@ -33,8 +35,10 @@ def replace_default_values( This code can also do some basic arithmetic. You can write "num_bodyparts x 2" (or any factor other than 2) for location refinement channels, and the number of - channels will be twice the number of bodyparts. You can write "num_bodyparts + 1" - (such as for DEKR heatmaps, where a "center" bodypart is added). + channels will be twice the number of bodyparts. You can write + "backbone_output_channels // 2" for the number of channels in a layer, and it will + be half the number of channels output by the backbone. You can write + "num_bodyparts + 1" (such as for DEKR heatmaps, where a "center" bodypart is added). The three base placeholder values that can be computed are "num_bodyparts", "num_individuals" and "backbone_output_channels". You can add more through the @@ -76,6 +80,8 @@ def get_updated_value(variable: str) -> int | list[int]: return updated_values[var_name] + factor elif operator == "x": return updated_values[var_name] * factor + elif operator == "//": + return updated_values[var_name] // factor else: raise ValueError(f"Unknown operator for variable: {variable}") @@ -183,16 +189,24 @@ def read_config_as_dict(config_path: str | Path) -> dict: return cfg -def pretty_print_config(config: dict, indent: int = 0) -> None: +def pretty_print_config( + config: dict, + indent: int = 0, + print_fn: Callable[[str], None] | None = None, +) -> None: """Prints a model configuration in a pretty and readable way Args: config: the config to print indent: the base indent on all keys + print_fn: custom function to call (simply calls ``print`` if None) """ + if print_fn is None: + print_fn = print + for k, v in config.items(): if isinstance(v, dict): - print(f"{indent * ' '}{k}:") + print_fn(f"{indent * ' '}{k}:") pretty_print_config(v, indent + 2) else: - print(f"{indent * ' '}{k}: {v}") + print_fn(f"{indent * ' '}{k}: {v}") diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index b7fccf0001..c4dc93a14b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -11,24 +11,24 @@ from abc import ABC, abstractmethod import torch +import torch.nn as nn from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry BACKBONES = Registry("backbones", build_func=build_from_cfg) -class BaseBackbone(ABC, torch.nn.Module): +class BaseBackbone(ABC, nn.Module): """Base Backbone class for pose estimation. Attributes: - batch_norm_on: Indicates whether batch normalization is activated during training. - Batch Norm should not be on for small batch sizes. """ - def __init__(self): + def __init__(self, freeze_bn_weights: bool = True, freeze_bn_stats: bool = True): """Initialize the BaseBackbone.""" super().__init__() - self.batch_norm_on = False + self.freeze_bn_weights = freeze_bn_weights + self.freeze_bn_stats = freeze_bn_stats @abstractmethod def forward(self, x: torch.Tensor) -> torch.Tensor: @@ -42,39 +42,33 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: """ pass - def _init_weights(self, pretrained: str = None) -> None: - """Initialize the backbone with pretrained weights. + def freeze_batch_norm_layers(self, weights: bool, stats: bool) -> None: + """Freezes batch norm layers - Args: - pretrained: Path to the pretrained weights. - """ - if not pretrained: - pass - elif pretrained.startswith("http") or pretrained.startswith("ftp"): - state_dict = torch.hub.load_state_dict_from_url(pretrained) - self.model.load_state_dict(state_dict, strict=False) - else: - self.model.load_state_dict(torch.load(pretrained), strict=False) - - def activate_batch_norm(self, activation: bool = False) -> None: - """Activate or deactivate batch normalization layers during training. + Running mean + var are always given to F.batch_norm, except when the layer is + in `train` mode and track_running_stats is False, see + https://pytorch.org/docs/stable/_modules/torch/nn/modules/batchnorm.html + So to 'freeze' the running stats, the only way is to set the layer to "eval" + mode. Args: - activation: Activate or deactivate batch normalization. + weights: whether to freeze the batch norm weights + stats: whether to freeze the batch norm stats """ - self.batch_norm_on = activation + for module in self.modules(): + if isinstance(module, nn.BatchNorm2d): + if weights: + module.weight.requires_grad = False + module.bias.requires_grad = False + if stats: + module.eval() def train(self, mode: bool = True) -> None: - """Set the training mode with optional batch normalization activation. + """Sets the module in training or evaluation mode. Args: - mode: Training mode. Defaults to True. + mode: whether to set training mode (True) or evaluation mode (False) """ super().train(mode) - - if not self.batch_norm_on: - for module in self.modules(): - if isinstance(module, torch.nn.BatchNorm2d): - module.eval() - module.weight.requires_grad = False - module.bias.requires_grad = False + if self.freeze_bn_weights or self.freeze_bn_stats: + self.freeze_batch_norm_layers(self.freeze_bn_weights, self.freeze_bn_stats) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index e469535b5f..f648f1270f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -36,6 +36,7 @@ def __init__( model_name: str = "hrnet_w32", pretrained: bool = True, only_high_res: bool = False, + **kwargs, ) -> None: """Constructs an ImageNet pretrained HRNet from timm. @@ -43,8 +44,9 @@ def __init__( model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). pretrained: If True, loads the model with ImageNet pretrained weights. only_high_res: Whether to only return the high resolution features + kwargs: BaseBackbone kwargs """ - super().__init__() + super().__init__(**kwargs) self.model = _load_hrnet(model_name, pretrained) self.only_high_res = only_high_res @@ -64,7 +66,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: >>> x = torch.randn(1, 3, 256, 256) >>> y = backbone(x) """ - y_list = self.model.forward_features(x) + y_list = self.model(x) if self.only_high_res: return y_list[0] @@ -94,6 +96,11 @@ def _load_hrnet(model_name: str, pretrained: bool) -> nn.Module: Returns: the HRNet model """ - model = timm.create_model(model_name, pretrained=pretrained) - model.incre_modules = None - return model + # First stem conv is used for stride 2 features, so only return branches 1-4 + return timm.create_model( + model_name, + pretrained=pretrained, + features_only=True, + feature_location="", # TODO: benchmark with "incre" + out_indices=(1, 2, 3, 4), + ) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 84e91ee038..9bd61ba56a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -34,17 +34,30 @@ def __init__( model_name: str = "resnet50", output_stride: int = 32, pretrained: bool = True, + drop_path_rate: float = 0.0, + drop_block_rate: float = 0.0, + **kwargs, ) -> None: """Initialize the ResNet backbone. Args: model_name: Name of the ResNet model to use, e.g., 'resnet50', 'resnet101' + output_stride: Output stride of the network, 32, 16, or 8. pretrained: If True, initializes with ImageNet pretrained weights. + drop_path_rate: Stochastic depth drop-path rate + drop_block_rate: Drop block rate + kwargs: BaseBackbone kwargs """ - super().__init__() + super().__init__(**kwargs) self.model = timm.create_model( - model_name, output_stride=output_stride, pretrained=pretrained + model_name, + output_stride=output_stride, + pretrained=pretrained, + num_classes=1, # smaller classification layer + drop_path_rate=drop_path_rate, + drop_block_rate=drop_block_rate, ) + self.model.fc = nn.Identity() # remove the FC layer def forward(self, x: torch.Tensor) -> torch.Tensor: """Forward pass through the ResNet backbone. @@ -75,8 +88,9 @@ def __init__( model_name: str = "resnet50", output_stride: int = 32, pretrained: bool = True, + **kwargs, ) -> None: - super().__init__(model_name, output_stride, pretrained) + super().__init__(model_name, output_stride, pretrained, **kwargs) self.interm_features = {} self.model.layer1[2].register_forward_hook(self._get_features("bank1")) self.model.layer2[2].register_forward_hook(self._get_features("bank2")) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index c3506b2d44..b8d768a921 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -57,56 +57,76 @@ class DeconvModule(nn.Module): """ def __init__( - self, channels: list[int], kernel_size: list[int], strides: list[int] + self, + channels: list[int], + kernel_size: list[int], + strides: list[int], + final_conv: dict | None = None, ) -> None: """ Args: - channels: list containing the number of input and output channels for each deconvolutional layer. - kernel_size: list containing the kernel size for each deconvolutional layer. - strides: list containing the stride for each deconvolutional layer. + channels: List containing the number of input and output channels for each + deconvolutional layer. + kernel_size: List containing the kernel size for each deconvolutional layer. + strides: List containing the stride for each deconvolutional layer. + final_conv: Configuration for a conv layer after the deconvolutional layers, + if one should be added. Must have keys "out_channels" and "kernel_size". """ super().__init__() - self.kernel_size = kernel_size - self.strides = strides + if not (len(channels) == len(kernel_size) + 1 == len(strides) + 1): + raise ValueError( + "Incorrect DeconvModule configuration: there should be one more number" + f" of channels than kernel_sizes and strides, found {len(channels)} " + f"channels, {len(kernel_size)} kernels and {len(strides)} strides." + ) + + in_channels = channels[0] + self.deconv_layers = nn.Identity() + if len(kernel_size) > 0: + self.deconv_layers = nn.Sequential( + *self._make_layers(in_channels, channels[1:], kernel_size, strides) + ) - if len(kernel_size) == 1: - self.model = self._make_layer( - channels[0], channels[1], kernel_size[0], strides[0] + self.final_conv = nn.Identity() + if final_conv: + self.final_conv = nn.Conv2d( + in_channels=channels[-1], + out_channels=final_conv["out_channels"], + kernel_size=final_conv["kernel_size"], + stride=1, ) - else: - layers = [] - for i in range(len(channels) - 1): - up_layer = self._make_layer( - channels[i], channels[i + 1], kernel_size[i], strides[i] - ) - layers.append(up_layer) - if i < len(channels) - 2: - layers.append(nn.ReLU()) - self.model = nn.Sequential(*layers) - - def _make_layer( - self, in_channels: int, out_channels: int, kernel_size: int, stride: int - ) -> torch.nn.ConvTranspose2d: + + @staticmethod + def _make_layers( + in_channels: int, + out_channels: list[int], + kernel_sizes: list[int], + strides: list[int], + ) -> list[nn.Module]: """ - Helper function to create a deconvolutional layer. + Helper function to create the deconvolutional layers. Args: - in_channels: number of input channels - out_channels: number of output channels - kernel_size: size of the deconvolutional kernel - stride: stride for the convolution operation + in_channels: number of input channels to the module + out_channels: number of output channels of each layer + kernel_sizes: size of the deconvolutional kernel + strides: stride for the convolution operation Returns: - upsample_layer: the deconvolutional layer. + the deconvolutional layers """ - upsample_layer = nn.ConvTranspose2d( - in_channels, out_channels, kernel_size, stride=stride - ) - return upsample_layer + layers = [] + for out_channels, k, s in zip(out_channels, kernel_sizes, strides): + layers.append( + nn.ConvTranspose2d(in_channels, out_channels, kernel_size=k, stride=s) + ) + layers.append(nn.ReLU()) + in_channels = out_channels + return layers[:-1] def forward(self, x: torch.Tensor) -> torch.Tensor: """ - Forward pass of the SimpleHead object. + Forward pass of the HeatmapHead Args: x: input tensor @@ -114,4 +134,6 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: Returns: out: output tensor """ - return self.model(x) + x = self.deconv_layers(x) + x = self.final_conv(x) + return x diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index d1056552f9..b7c0010481 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -53,15 +53,11 @@ def __init__( """ super().__init__() self.cfg = cfg - self.backbone = backbone self.heads = nn.ModuleDict(heads) self.neck = neck self.stride = stride - # TODO: Explore results, check batch size impact - self.backbone.activate_batch_norm(False) - def forward(self, x: torch.Tensor) -> dict[str, dict[str, torch.Tensor]]: """ Forward pass of the PoseModel. diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index bd4e0566bd..448b9216f1 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -42,6 +42,7 @@ def __init__( snapshot_path: str | None = None, scheduler: torch.optim.lr_scheduler.LRScheduler | None = None, logger: BaseLogger | None = None, + save_optimizer_state: bool = False, ): """ Args: @@ -52,6 +53,8 @@ def __init__( snapshot_path: if defined, the path of a snapshot from which to load pretrained weights scheduler: Scheduler for adjusting the lr of the optimizer. logger: logger to monitor training (e.g WandB logger) + save_optimizer_state: whether to save the optimizer state, which allows to + restart training (warning - this makes the snapshots much heavier) """ super().__init__(model=model, device=device, snapshot_path=snapshot_path) self.optimizer = optimizer @@ -60,6 +63,7 @@ def __init__( self.snapshot_prefix = snapshot_prefix self.logger = logger self.starting_epoch = 0 + self.save_optimizer_state = save_optimizer_state if self.snapshot_path is not None and len(self.snapshot_path) > 0: self.starting_epoch = self.load_snapshot( @@ -127,18 +131,18 @@ def fit( valid_loader, mode="eval", step=i + 1, display_iters=display_iters ) - if (i + 1) % save_epochs == 0: + if (i + 1) % save_epochs == 0 or (i + 1) == epochs: logging.info(f"Finished epoch {i + 1}; saving model") - torch.save( - { - "model_state_dict": self.model.state_dict(), - "epoch": i + 1, - "optimizer_state_dict": self.optimizer.state_dict(), - "train_loss": train_loss, - "validation_loss": valid_loss, - }, - f"{model_folder}/train/{self.snapshot_prefix}-{i + 1}.pt", - ) + save_path = f"{model_folder}/train/{self.snapshot_prefix}-{i + 1}.pt" + state = { + "model_state_dict": self.model.state_dict(), + "epoch": i + 1, + "train_loss": train_loss, + "validation_loss": valid_loss, + } + if self.save_optimizer_state: + state["optimizer_state_dict"] = self.optimizer.state_dict() + torch.save(state, save_path) logging.info( f"Epoch {i + 1}/{epochs}, " diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index 33674f4d1b..67376f52f1 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -12,6 +12,7 @@ import abc import os +import random import numpy as np import pandas as pd @@ -184,12 +185,14 @@ def create_folder(path_to_folder): os.makedirs(path_to_folder) -def fix_seeds(seed: int): +def fix_seeds(seed: int) -> None: """ - Fixes seed for all random functions - @param seed: int - Seed to be fixed + Fixes the random seed for python, numpy and pytorch + + Args: + seed: the seed to set """ + random.seed(seed) torch.manual_seed(seed) np.random.seed(seed) torch.backends.cudnn.deterministic = True diff --git a/tests/pose_estimation_pytorch/config/test_utils.py b/tests/pose_estimation_pytorch/config/test_utils.py new file mode 100644 index 0000000000..1084e5b940 --- /dev/null +++ b/tests/pose_estimation_pytorch/config/test_utils.py @@ -0,0 +1,66 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Test util functions for config creation""" +import pytest + +import deeplabcut.pose_estimation_pytorch.config.utils as utils + + +@pytest.mark.parametrize( + "data", + [ + dict( + config={}, + num_bodyparts=None, + num_individuals=None, + backbone_output_channels=None, + output_config={}, + ), + dict( + config={ + "a": "num_bodyparts", + "b": ["num_bodyparts // 2", "num_bodyparts // 3"], + "c": "num_bodyparts x 2", + "d": "num_bodyparts + 2", + }, + num_bodyparts=10, + num_individuals=None, + backbone_output_channels=None, + output_config={ + "a": 10, + "b": [5, 3], + "c": 20, + "d": 12, + }, + ), + dict( + config={ + "a": [{"b": "num_individuals x 3"}], + "b": [[{"b": "num_bodyparts x 3"}]], + }, + num_bodyparts=10, + num_individuals=1, + backbone_output_channels=None, + output_config={ + "a": [{"b": 3}], + "b": [[{"b": 30}]], + }, + ) + ], +) +def test_replace_default_values_no_extras(data: dict): + output_config = utils.replace_default_values( + config=data["config"], + num_bodyparts=data["num_bodyparts"], + num_individuals=data["num_individuals"], + backbone_output_channels=data["backbone_output_channels"], + ) + assert output_config == data["output_config"] From c67b03fabd27b8b1217748f24c36af36980193c2 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 20 Feb 2024 14:43:21 +0100 Subject: [PATCH 068/293] niels/main_dlc3_api (#164) integrates PyTorch into the DeepLabCut API * CLI integration through compat.py * GUI integration (train, evaluate, analyze videos) * added some documentation (more will be needed) * bug fixes - video analysis * compat methods for shuffles check whether it's pytorch or tf using model config * added engine to project config and set default engine to pytorch * updated trainingset manipulation code to not overwrite shuffles * refactored code so TF is an optional dependency. all tests pass with torch==2.1.2 * added torch testscript; moved torch models to dlc-models-torch; bug fixes; * set default net type to value in project config * updated userfeedback variable * added confirmation prompt and overwrite checkbox for GUI create_training_dataset * bug fixes - general --- benchmark/utils.py | 7 +- deeplabcut/__init__.py | 6 +- deeplabcut/benchmark/metrics.py | 2 +- deeplabcut/benchmark/mot.py | 6 +- deeplabcut/compat.py | 709 ++++++++++++++++++ deeplabcut/core/__init__.py | 10 + .../lib => core}/crossvalutils.py | 19 +- .../lib => core}/inferenceutils.py | 0 .../lib => core}/trackingutils.py | 0 ...ple_individuals_trainingsetmanipulation.py | 49 +- .../trainingsetmanipulation.py | 142 +++- .../gui/tabs/create_training_dataset.py | 116 ++- deeplabcut/gui/tabs/refine_tracklets.py | 2 +- deeplabcut/gui/tabs/train_network.py | 183 +++-- deeplabcut/gui/widgets.py | 5 +- deeplabcut/gui/window.py | 28 +- .../superanimal_quadruped.yaml | 30 +- .../superanimal_topviewmouse.yaml | 29 +- .../pose_estimation_3d/triangulation.py | 2 +- .../pose_estimation_pytorch/__init__.py | 1 + .../apis/analyze_videos.py | 61 +- .../apis/convert_detections_to_tracklets.py | 11 +- .../pose_estimation_pytorch/apis/scoring.py | 16 +- .../pose_estimation_pytorch/apis/utils.py | 64 +- .../config/__init__.py | 3 +- .../config/make_pose_config.py | 7 +- .../pose_estimation_pytorch/config/utils.py | 40 +- .../pose_estimation_pytorch/data/dlcloader.py | 8 +- .../data/transforms.py | 6 + .../models/predictors/paf_predictor.py | 3 +- .../match_predictions_to_gt.py | 2 +- .../pose_estimation_pytorch/runners/utils.py | 36 +- deeplabcut/pose_estimation_pytorch/utils.py | 4 +- .../pose_estimation_tensorflow/__init__.py | 4 + .../core/evaluate_multianimal.py | 6 +- .../lib/__init__.py | 15 +- .../predict_videos.py | 2 +- .../pose_tracking_pytorch/create_dataset.py | 2 +- .../refine_training_dataset/outlier_frames.py | 2 +- deeplabcut/refine_training_dataset/stitch.py | 2 +- deeplabcut/utils/auxfun_models.py | 3 +- deeplabcut/utils/auxfun_multianimal.py | 2 +- deeplabcut/utils/auxiliaryfunctions.py | 328 ++++---- deeplabcut/utils/make_labeled_video.py | 6 +- deeplabcut/utils/plotting.py | 2 +- docs/pytorch/pytorch_config.md | 7 +- docs/pytorch/user_guide.md | 76 ++ examples/testscript_pytorch_single_animal.py | 128 ++++ tests/conftest.py | 2 +- .../{test_utils.py => test_config_utils.py} | 0 .../config/test_make_pose_config.py | 17 +- .../{test_utils.py => test_modelzoo_utils.py} | 0 .../other/test_dataset.py | 8 +- tests/test_crossvalutils.py | 3 +- tests/test_inferenceutils.py | 2 +- tests/test_trackingutils.py | 2 +- 56 files changed, 1801 insertions(+), 425 deletions(-) create mode 100644 deeplabcut/compat.py create mode 100644 deeplabcut/core/__init__.py rename deeplabcut/{pose_estimation_tensorflow/lib => core}/crossvalutils.py (96%) rename deeplabcut/{pose_estimation_tensorflow/lib => core}/inferenceutils.py (100%) rename deeplabcut/{pose_estimation_tensorflow/lib => core}/trackingutils.py (100%) create mode 100644 docs/pytorch/user_guide.md create mode 100644 examples/testscript_pytorch_single_animal.py rename tests/pose_estimation_pytorch/config/{test_utils.py => test_config_utils.py} (100%) rename tests/pose_estimation_pytorch/modelzoo/{test_utils.py => test_modelzoo_utils.py} (100%) diff --git a/benchmark/utils.py b/benchmark/utils.py index c8de0a16cc..a3f9b85b07 100644 --- a/benchmark/utils.py +++ b/benchmark/utils.py @@ -11,6 +11,7 @@ import deeplabcut.pose_estimation_pytorch.apis.utils as api_utils import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.compat import Engine @dataclass @@ -91,7 +92,11 @@ class Shuffle: def __post_init__(self): self.model_prefix_ = self.model_prefix if self.model_prefix is not None else "" self.model_folder = self.project.path / af.get_model_folder( - self.train_fraction, self.index, self.project.cfg, modelprefix=self.model_prefix_ + self.train_fraction, + self.index, + self.project.cfg, + engine=Engine.PYTORCH, + modelprefix=self.model_prefix_, ) self.trainset_folder = af.get_training_set_folder(self.project.cfg) self._metadata = None diff --git a/deeplabcut/__init__.py b/deeplabcut/__init__.py index c1e7b02700..7f11baa379 100644 --- a/deeplabcut/__init__.py +++ b/deeplabcut/__init__.py @@ -12,10 +12,6 @@ import os -# Suppress tensorflow warning messages -import tensorflow as tf - -tf.compat.v1.logging.set_verbosity(tf.compat.v1.logging.ERROR) DEBUG = True and "DEBUG" in os.environ and os.environ["DEBUG"] from deeplabcut.version import __version__, VERSION @@ -95,7 +91,7 @@ ) # Train, evaluate & predict functions / all require TF -from deeplabcut.pose_estimation_tensorflow import ( +from deeplabcut.compat import ( train_network, return_train_network_path, evaluate_network, diff --git a/deeplabcut/benchmark/metrics.py b/deeplabcut/benchmark/metrics.py index a3b000ed3f..fe525a6eaa 100644 --- a/deeplabcut/benchmark/metrics.py +++ b/deeplabcut/benchmark/metrics.py @@ -29,7 +29,7 @@ import deeplabcut.benchmark.utils from deeplabcut.pose_estimation_tensorflow.core import evaluate_multianimal -from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils +from deeplabcut.core import inferenceutils from deeplabcut.utils.conversioncode import guarantee_multiindex_rows diff --git a/deeplabcut/benchmark/mot.py b/deeplabcut/benchmark/mot.py index 095469bae3..32c99b6987 100644 --- a/deeplabcut/benchmark/mot.py +++ b/deeplabcut/benchmark/mot.py @@ -11,13 +11,15 @@ from __future__ import annotations +import warnings + import motmetrics as mm import numpy as np import pandas as pd -import warnings -from deeplabcut.pose_estimation_tensorflow.lib import trackingutils from numpy.typing import NDArray +from deeplabcut.core import trackingutils + def _convert_bboxes_to_xywh(bboxes: NDArray, inplace: bool = False) -> NDArray: w = bboxes[:, 2] - bboxes[:, 0] diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py new file mode 100644 index 0000000000..a1d04ea147 --- /dev/null +++ b/deeplabcut/compat.py @@ -0,0 +1,709 @@ +"""Compatibility file for methods available with either PyTorch or Tensorflow""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Iterable + +import numpy as np +from ruamel.yaml import YAML + + +@dataclass(frozen=True) +class EngineDataMixin: + aliases: tuple[str] + model_folder_name: str + pose_cfg_name: str + results_folder_name: str + + +class Engine(EngineDataMixin, Enum): + PYTORCH = ( + ("pytorch", "torch"), + "dlc-models-pytorch", + "pytorch_config.yaml", + "evaluation-results-pytorch", + ) + TF = ( + ("tensorflow", "tf"), + "dlc-models", + "pose_cfg.yaml", + "evaluation-results", + ) + + @classmethod + def _missing_(cls, value): + if isinstance(value, str): + for member in cls: + if value.lower() in member.aliases: + return member + return None + + +DEFAULT_ENGINE = Engine.PYTORCH + + +def get_project_engine(cfg: dict) -> Engine: + """ + Args: + cfg: the project configuration file + + Returns: + the engine specified for the project, or the default engine if none is specified + """ + if cfg.get("engine") is not None: + return Engine(cfg["engine"]) + + return DEFAULT_ENGINE + + +def get_shuffle_engine( + cfg: dict, + trainingsetindex: int, + shuffle: int, + modelprefix: str = "", +) -> Engine: + """ + Args: + cfg: the project configuration file + trainingsetindex: the training set index used + shuffle: the shuffle for which to get the engine + modelprefix: the added prefix + + Returns: + the engine that the shuffle was created with + + Raises: + ValueError if the engine for the shuffle cannot be determined or the shuffle + doesn't exist + """ + project_path = Path(cfg["project_path"]) + train_frac = int(100 * cfg["TrainingFraction"][trainingsetindex]) + shuffle_name = f"{cfg['Task']}{cfg['date']}-trainset{train_frac}shuffle{shuffle}" + + found_engines = set() + for engine in Engine: + models_root = project_path / modelprefix / engine.model_folder_name + train_folder = models_root / f"iteration-{cfg['iteration']}" / shuffle_name + if train_folder.exists(): + found_engines.add(engine) + + if len(found_engines) == 1: + return found_engines.pop() + elif len(found_engines) > 1: + logging.warning( + "There are multiple engines with model configurations defined for " + f"train_frac={train_frac} and shuffle={shuffle}: {found_engines}" + ) + if DEFAULT_ENGINE in found_engines: + logging.warning(f" -> using the default engine: {DEFAULT_ENGINE}") + return DEFAULT_ENGINE + else: + selected_engine = found_engines.pop() + logging.warning(f" -> using a random engine: {selected_engine}") + return selected_engine + + raise ValueError( + f"Could not get the engine for the shuffle {shuffle_name}. Could not find a " + f"folder for any engine." + ) + + +def train_network( + config: str, + shuffle: int = 1, + trainingsetindex: int = 0, + max_snapshots_to_keep: int = 5, + displayiters: int | None = None, + saveiters: int | None = None, + maxiters: int | None = None, + allow_growth: bool = True, + gputouse: str | None = None, + autotune: bool = False, + keepdeconvweights: bool = True, + modelprefix: str = "", + **torch_kwargs, +): + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import train_network + return train_network( + config, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + max_snapshots_to_keep=max_snapshots_to_keep, + displayiters=displayiters, + saveiters=saveiters, + maxiters=maxiters, + allow_growth=allow_growth, + gputouse=gputouse, + autotune=autotune, + keepdeconvweights=keepdeconvweights, + modelprefix=modelprefix, + ) + elif engine == Engine.PYTORCH: + from deeplabcut.pose_estimation_pytorch.apis import train_network + _update_device(gputouse, torch_kwargs) + return train_network( + config, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + modelprefix=modelprefix, + **torch_kwargs, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def return_train_network_path( + config, + shuffle: int = 1, + trainingsetindex: int = 0, + modelprefix: str = "", + engine: Engine | None = None, +) -> tuple[Path, Path, Path]: + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import return_train_network_path + return return_train_network_path( + config, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + modelprefix=modelprefix, + ) + elif engine == Engine.PYTORCH: + from deeplabcut.pose_estimation_pytorch.apis.utils import return_train_network_path + return return_train_network_path( + config, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + modelprefix=modelprefix, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def evaluate_network( + config, + Shuffles: Iterable[int] = (1,), + trainingsetindex: int | str = 0, + plotting: bool | str = False, + show_errors: bool = True, + comparisonbodyparts: str | list[str] = "all", + gputouse: str | None = None, + rescale: bool = False, + modelprefix: str = "", + per_keypoint_evaluation: bool = False, + **torch_kwargs, +): + cfg = _load_config(config) + engines = set() + for shuffle in Shuffles: + engines.add( + get_shuffle_engine( + cfg, + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + ) + if len(engines) == 0: + raise ValueError( + f"You must pass at least one shuffle to evaluate (had {list(Shuffles)})" + ) + elif len(engines) > 1: + raise ValueError( + f"All shuffles must have the same engine (found {list(engines)})" + ) + + engine = engines.pop() + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import evaluate_network + return evaluate_network( + config, + Shuffles=Shuffles, + trainingsetindex=trainingsetindex, + plotting=plotting, + show_errors=show_errors, + comparisonbodyparts=comparisonbodyparts, + gputouse=gputouse, + rescale=rescale, + modelprefix=modelprefix, + per_keypoint_evaluation=per_keypoint_evaluation, + ) + elif engine == Engine.PYTORCH: + from deeplabcut.pose_estimation_pytorch.apis import evaluate_network + _update_device(gputouse, torch_kwargs) + return evaluate_network( + config, + shuffles=Shuffles, + trainingsetindex=trainingsetindex, + plotting=plotting, + show_errors=show_errors, + modelprefix=modelprefix, + **torch_kwargs, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def return_evaluate_network_data( + config: str, + shuffle: int = 0, + trainingsetindex: int = 0, + comparisonbodyparts: str | list[str] = "all", + Snapindex: str | int | None = None, + rescale: bool = False, + fulldata: bool = False, + show_errors: bool = True, + modelprefix: str = "", + returnjustfns: bool = True, +): + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import return_evaluate_network_data + return return_evaluate_network_data( + config, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + comparisonbodyparts=comparisonbodyparts, + Snapindex=Snapindex, + rescale=rescale, + fulldata=fulldata, + show_errors=show_errors, + modelprefix=modelprefix, + returnjustfns=returnjustfns, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def analyze_videos( + config: str, + videos: list[str], + videotype: str = "", + shuffle: int = 1, + trainingsetindex: int = 0, + gputouse: str | None = None, + save_as_csv: bool = False, + in_random_order: bool = True, + destfolder: str | None = None, + batchsize: int = None, + cropping: list[int] | None = None, + TFGPUinference: bool = True, + dynamic: tuple[bool, float, int] = (False, 0.5, 10), + modelprefix: str = "", + robust_nframes: bool = False, + allow_growth: bool = False, + use_shelve: bool = False, + auto_track: bool = True, + n_tracks: int | None = None, + calibrate: bool = False, + identity_only: bool = False, + use_openvino: str | None = None, + **torch_kwargs, +): + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import analyze_videos + kwargs = {} + if use_openvino is not None: # otherwise default comes from tensorflow API + kwargs["use_openvino"] = use_openvino + + return analyze_videos( + config, + videos, + videotype=videotype, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + gputouse=gputouse, + save_as_csv=save_as_csv, + in_random_order=in_random_order, + destfolder=destfolder, + batchsize=batchsize, + cropping=cropping, + TFGPUinference=TFGPUinference, + dynamic=dynamic, + modelprefix=modelprefix, + robust_nframes=robust_nframes, + allow_growth=allow_growth, + use_shelve=use_shelve, + auto_track=auto_track, + n_tracks=n_tracks, + calibrate=calibrate, + identity_only=identity_only, + **kwargs, + ) + elif engine == Engine.PYTORCH: + from deeplabcut.pose_estimation_pytorch.apis import analyze_videos + _update_device(gputouse, torch_kwargs) + + if use_shelve: + raise NotImplementedError( + f"The 'use_shelve' option is not yet implemented with {engine}" + ) + + return analyze_videos( + config, + videos=videos, + videotype=videotype, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + destfolder=destfolder, + batchsize=batchsize, + modelprefix=modelprefix, + auto_track=auto_track, + identity_only=identity_only, + overwrite=False, + **torch_kwargs, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def create_tracking_dataset( + config: str, + videos: list[str], + track_method: str, + videotype: str = "", + shuffle: int = 1, + trainingsetindex: int = 0, + gputouse: int | None = None, + destfolder: str | None = None, + batchsize: int | None = None, + cropping: list[int] | None = None, + TFGPUinference: bool = True, + dynamic: tuple[bool, float, int] = (False, 0.5, 10), + modelprefix: str = "", + robust_nframes: bool = False, + n_triplets: int = 1000, +): + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import create_tracking_dataset + return create_tracking_dataset( + config, + videos, + track_method, + videotype=videotype, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + gputouse=gputouse, + save_as_csv=False, # not used in method + destfolder=destfolder, + batchsize=batchsize, + cropping=cropping, + TFGPUinference=TFGPUinference, + dynamic=dynamic, + modelprefix=modelprefix, + robust_nframes=robust_nframes, + n_triplets=n_triplets, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def analyze_time_lapse_frames( + config: str, + directory: str, + frametype: str = ".png", + shuffle: int = 1, + trainingsetindex: int = 0, + gputouse: int | None = None, + save_as_csv: bool = False, + modelprefix: str = "", +): + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import analyze_time_lapse_frames + return analyze_time_lapse_frames( + config, + directory, + frametype=frametype, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + gputouse=gputouse, + save_as_csv=save_as_csv, + modelprefix=modelprefix, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def convert_detections2tracklets( + config: str, + videos: list[str], + videotype: str = "", + shuffle: int = 1, + trainingsetindex: int = 0, + overwrite: bool = False, + destfolder: str | None = None, + ignore_bodyparts: list[str] | None = None, + inferencecfg: dict | None = None, + modelprefix: str = "", + greedy: bool = False, + calibrate: bool = False, + window_size: int = 0, + identity_only: int = False, + track_method: str = "", +): + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import convert_detections2tracklets + return convert_detections2tracklets( + config, + videos, + videotype=videotype, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + overwrite=overwrite, + destfolder=destfolder, + ignore_bodyparts=ignore_bodyparts, + inferencecfg=inferencecfg, + modelprefix=modelprefix, + greedy=greedy, + calibrate=calibrate, + window_size=window_size, + identity_only=identity_only, + track_method=track_method, + ) + + elif engine == Engine.PYTORCH: + from deeplabcut.pose_estimation_pytorch.apis import convert_detections2tracklets + + if greedy or calibrate or window_size: + raise NotImplementedError( + f"The 'greedy', 'calibrate' and 'window_size' option are not yet " + f"implemented with {engine}" + ) + + return convert_detections2tracklets( + config, + videos, + videotype=videotype, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + overwrite=overwrite, + destfolder=destfolder, + ignore_bodyparts=ignore_bodyparts, + inferencecfg=inferencecfg, + modelprefix=modelprefix, + identity_only=identity_only, + track_method=track_method, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def extract_maps( + config, + shuffle: int = 0, + trainingsetindex: int = 0, + gputouse: int | None = None, + rescale: bool = False, + Indices: list[int] | None = None, + modelprefix: str = "", +): + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import extract_maps + return extract_maps( + config, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + gputouse=gputouse, + rescale=rescale, + Indices=Indices, + modelprefix=modelprefix, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def visualize_scoremaps( + image: np.ndarray, scmap: np.ndarray, engine: Engine = DEFAULT_ENGINE, +): + if engine == Engine.TF: + # TODO: also works for Pytorch, but should not import as then requires TF + from deeplabcut.pose_estimation_tensorflow import visualize_scoremaps + return visualize_scoremaps(image, scmap) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def visualize_locrefs( + image: np.ndarray, + scmap: np.ndarray, + locref_x: np.ndarray, + locref_y: np.ndarray, + step: int = 5, + zoom_width: int = 0, + engine: Engine = DEFAULT_ENGINE, +): + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import visualize_locrefs + return visualize_locrefs(image, scmap, locref_x, locref_y, step=step, zoom_width=zoom_width) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def visualize_paf( + image: np.ndarray, + paf: np.ndarray, + step: int = 5, + colors: list | None = None, + engine: Engine = DEFAULT_ENGINE, +): + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import visualize_paf + return visualize_paf(image, paf, step=step, colors=colors) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def extract_save_all_maps( + config, + shuffle: int = 1, + trainingsetindex: int = 0, + comparisonbodyparts: str | list[str] = "all", + extract_paf: bool = True, + all_paf_in_one: bool = True, + gputouse: int = None, + rescale: bool = False, + Indices: list[int] | None = None, + modelprefix: str = "", + dest_folder: str = None, +): + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import extract_save_all_maps + return extract_save_all_maps( + config, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + comparisonbodyparts=comparisonbodyparts, + extract_paf=extract_paf, + all_paf_in_one=all_paf_in_one, + gputouse=gputouse, + rescale=rescale, + Indices=Indices, + modelprefix=modelprefix, + dest_folder=dest_folder, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def export_model( + cfg_path: str, + shuffle: int = 1, + trainingsetindex: int = 0, + snapshotindex: int | None = None, + iteration: int = None, + TFGPUinference: bool = True, + overwrite: bool = False, + make_tar: bool = True, + wipepaths: bool = False, + modelprefix: str = "", +): + engine = get_shuffle_engine( + _load_config(cfg_path), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) + + if engine == Engine.TF: + from deeplabcut.pose_estimation_tensorflow import export_model + return export_model( + cfg_path=cfg_path, + shuffle=shuffle, + trainingsetindex=trainingsetindex, + snapshotindex=snapshotindex, + iteration=iteration, + TFGPUinference=TFGPUinference, + overwrite=overwrite, + make_tar=make_tar, + wipepaths=wipepaths, + modelprefix=modelprefix, + ) + + raise NotImplementedError(f"This function is not implemented for {engine}") + + +def _update_device(gpu_to_use: int | None, torch_kwargs: dict) -> None: + if "device" not in torch_kwargs and gpu_to_use is not None: + if isinstance(gpu_to_use, int): + torch_kwargs["device"] = f"cuda:{gpu_to_use}" + else: + torch_kwargs["device"] = gpu_to_use + + +def _load_config(config: str) -> dict: + config_path = Path(config) + if not config_path.exists(): + raise FileNotFoundError( + f"Config {config} is not found. Please make sure that the file exists." + ) + + with open(config, "r") as f: + project_config = YAML(typ="safe", pure=True).load(f) + + return project_config diff --git a/deeplabcut/core/__init__.py b/deeplabcut/core/__init__.py new file mode 100644 index 0000000000..117d127147 --- /dev/null +++ b/deeplabcut/core/__init__.py @@ -0,0 +1,10 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# diff --git a/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py b/deeplabcut/core/crossvalutils.py similarity index 96% rename from deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py rename to deeplabcut/core/crossvalutils.py index df52558e6a..e143d9f02f 100644 --- a/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py +++ b/deeplabcut/core/crossvalutils.py @@ -23,7 +23,7 @@ from scipy.spatial import cKDTree from sklearn.metrics.cluster import contingency_matrix -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( +from deeplabcut.core.inferenceutils import ( Assembler, evaluate_assembly, _parse_ground_truth_data, @@ -56,7 +56,18 @@ def _unsorted_unique(array): return np.asarray(array)[np.sort(inds)] -def _find_closest_neighbors(query, ref, k=3): +def find_closest_neighbors(query: np.ndarray, ref: np.ndarray, k: int = 3) -> np.ndarray: + """Greedy matching of predicted keypoints to ground truth keypoints + + Args: + query: the query keypoints + ref: the reference keypoints + k: The list of k-th nearest neighbors to return. + + Returns: + an array of shape (len(query), ) containing the index of the closest + reference keypoint for each query keypoint + """ n_preds = ref.shape[0] tree = cKDTree(ref) dist, inds = tree.query(query, k=k) @@ -456,3 +467,7 @@ def cross_validate_paf_graphs( with open(output_name, "wb") as file: pickle.dump([results], file) return results[:3], paf_scores, results[3][size_opt] + + +# Backwards compatibility +_find_closest_neighbors = find_closest_neighbors diff --git a/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py b/deeplabcut/core/inferenceutils.py similarity index 100% rename from deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py rename to deeplabcut/core/inferenceutils.py diff --git a/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py b/deeplabcut/core/trackingutils.py similarity index 100% rename from deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py rename to deeplabcut/core/trackingutils.py diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index 9045086b3e..f166d82d8a 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -8,6 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations import os import os.path @@ -19,6 +20,7 @@ import numpy as np from tqdm import tqdm +from deeplabcut.compat import Engine, get_project_engine from deeplabcut.generate_training_dataset import ( merge_annotateddatasets, read_image_shape_fast, @@ -27,6 +29,7 @@ MakeTest_pose_yaml, MakeInference_yaml, pad_train_test_indices, + validate_shuffles, ) from deeplabcut.utils import ( auxiliaryfunctions, @@ -109,6 +112,8 @@ def create_multianimaltraining_dataset( testIndices=None, n_edges_threshold=105, paf_graph_degree=6, + userfeedback: bool = True, + engine: Engine | None = None, ): """ Creates a training dataset for multi-animal datasets. Labels from all the extracted frames are merged into a single .h5 file.\n @@ -168,6 +173,16 @@ def create_multianimaltraining_dataset( paf_graph_degree: int, optional (default=6) Degree of paf_graph when automatically pruning it (before training). + userfeedback: bool, optional, default=True + If ``False``, all requested train/test splits are created (no matter if they + already exist). If you want to assure that previous splits etc. are not + overwritten, set this to ``True`` and you will be asked for each split. + + engine: Engine, optional + Whether to create a pose config for a Tensorflow or PyTorch model. Defaults to + the value specified in the project configuration file. If no engine is specified + for the project, defaults to ``deeplabcut.compat.DEFAULT_ENGINE``. + Example -------- >>> deeplabcut.create_multianimaltraining_dataset('/analysis/project/reaching-task/config.yaml',num_shuffles=1) @@ -210,20 +225,23 @@ def create_multianimaltraining_dataset( if net_type is None: # loading & linking pretrained models net_type = cfg.get("default_net_type", "dlcrnet_ms5") - if any(net in net_type for net in ("resnet", "eff", "dlc", "mob")): - pass - elif cfg.get("engine", "pytorch").lower() == "pytorch": - pass # TODO: Change default to tensorflow - else: - raise ValueError(f"Unsupported network {net_type}.") + # load the engine to use to create the shuffle + if engine is None: + engine = get_project_engine(cfg) + + if not ( + any(net in net_type for net in ("resnet", "eff", "dlc", "mob")) + or engine == Engine.PYTORCH + ): + raise ValueError(f"Unsupported network {net_type} for engine {engine}.") multi_stage = False ### dlcnet_ms5: backbone resnet50 + multi-fusion & multi-stage module ### dlcr101_ms5/dlcr152_ms5: backbone resnet101/152 + multi-fusion & multi-stage module if ( all(net in net_type for net in ("dlcr", "_ms5")) - and cfg.get("engine", "pytorch").lower() != "pytorch" - ): # TODO: Change default to tensorflow + and engine != Engine.PYTORCH + ): num_layers = re.findall("dlcr([0-9]*)", net_type)[0] if num_layers == "": num_layers = 50 @@ -281,18 +299,14 @@ def create_multianimaltraining_dataset( dlcparent_path = auxiliaryfunctions.get_deeplabcut_path() defaultconfigfile = os.path.join(dlcparent_path, "pose_cfg.yaml") - # TODO: Clean this - if cfg.get("engine", "pytorch") == "pytorch": + if engine == Engine.PYTORCH: model_path = dlcparent_path else: model_path = auxfun_models.check_for_weights( net_type, Path(dlcparent_path) ) - if Shuffles is None: - Shuffles = range(1, num_shuffles + 1, 1) - else: - Shuffles = [i for i in Shuffles if isinstance(i, int)] + Shuffles = validate_shuffles(cfg, Shuffles, num_shuffles, userfeedback) # print(trainIndices,testIndices, Shuffles, augmenter_type,net_type) if trainIndices is None and testIndices is None: @@ -374,7 +388,7 @@ def create_multianimaltraining_dataset( ################################################################################# modelfoldername = auxiliaryfunctions.get_model_folder( - trainFraction, shuffle, cfg + trainFraction, shuffle, cfg, engine=engine, ) auxiliaryfunctions.attempt_to_make_folder( Path(config).parents[0] / modelfoldername, recursive=True @@ -415,7 +429,7 @@ def create_multianimaltraining_dataset( jointnames.extend([str(bpt) for bpt in uniquebodyparts]) items2change = { "dataset": datafilename, - "engine": cfg.get("engine", "pytorch"), # TODO: Default to tensorflow + "engine": engine.aliases[0], "metadataset": metadatafilename, "num_joints": len(multianimalbodyparts) + len(uniquebodyparts), # cfg["uniquebodyparts"]), @@ -493,8 +507,7 @@ def create_multianimaltraining_dataset( ) # Populate the pytorch config yaml file - # TODO: Add switch for PyTorch projects - if cfg.get("engine", "pytorch").lower() == "pytorch": + if engine == Engine.PYTORCH: from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config top_down = False diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 6dfcbac4fc..2d7032d0ae 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -8,6 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations import math import logging @@ -18,12 +19,17 @@ from functools import lru_cache from pathlib import Path from PIL import Image +from typing import List import numpy as np import pandas as pd import yaml -from deeplabcut.pose_estimation_tensorflow import training +from deeplabcut.compat import ( + Engine, + get_project_engine, + return_train_network_path, +) from deeplabcut.utils import ( auxiliaryfunctions, conversioncode, @@ -724,13 +730,14 @@ def create_training_dataset( num_shuffles=1, Shuffles=None, windows2linux=False, - userfeedback=False, + userfeedback=True, trainIndices=None, testIndices=None, net_type=None, augmenter_type=None, posecfg_template=None, superanimal_name="", + engine: Engine | None = None, ): """Creates a training dataset. @@ -749,7 +756,7 @@ def create_training_dataset( Shuffles: list[int], optional Alternatively the user can also give a list of shuffles. - userfeedback: bool, optional, default=False + userfeedback: bool, optional, default=True If ``False``, all requested train/test splits are created (no matter if they already exist). If you want to assure that previous splits etc. are not overwritten, set this to ``True`` and you will be asked for each split. @@ -797,6 +804,10 @@ def create_training_dataset( superanimal_name: string, optional, default="" Specify the superanimal name is transfer learning with superanimal is desired. This makes sure the pose config template uses superanimal configs as template + engine: Engine, optional + Whether to create a pose config for a Tensorflow or PyTorch model. Defaults to + the value specified in the project configuration file. If no engine is specified + for the project, defaults to ``deeplabcut.compat.DEFAULT_ENGINE``. Returns ------- @@ -876,10 +887,14 @@ def create_training_dataset( net_type=net_type, trainIndices=trainIndices, testIndices=testIndices, + engine=engine, ) else: scorer = cfg["scorer"] project_path = cfg["project_path"] + if engine is None: + engine = get_project_engine(cfg) + # Create path for training sets & store data there trainingsetfolder = auxiliaryfunctions.get_training_set_folder( cfg @@ -899,7 +914,7 @@ def create_training_dataset( # loading & linking pretrained models if net_type is None: # loading & linking pretrained models net_type = cfg.get("default_net_type", "resnet_50") - elif cfg.get("engine", "pytorch").lower() == "pytorch": # TODO: Change default to tensorflow + elif engine == Engine.PYTORCH: pass else: if ( @@ -944,18 +959,14 @@ def create_training_dataset( elif posecfg_template: defaultconfigfile = posecfg_template - # TODO: Clean this - if cfg.get("engine", "pytorch") == "pytorch": + if engine == Engine.PYTORCH: model_path = dlcparent_path else: model_path = auxfun_models.check_for_weights( net_type, Path(dlcparent_path) ) - if Shuffles is None: - Shuffles = range(1, num_shuffles + 1) - else: - Shuffles = [i for i in Shuffles if isinstance(i, int)] + Shuffles = validate_shuffles(cfg, Shuffles, num_shuffles, userfeedback) # print(trainIndices,testIndices, Shuffles, augmenter_type,net_type) if trainIndices is None and testIndices is None: @@ -998,10 +1009,11 @@ def create_training_dataset( for trainFraction, shuffle, (trainIndices, testIndices) in splits: if len(trainIndices) > 0: if userfeedback: - trainposeconfigfile, _, _ = training.return_train_network_path( + trainposeconfigfile, _, _ = return_train_network_path( config, shuffle=shuffle, trainingsetindex=cfg["TrainingFraction"].index(trainFraction), + engine=engine, ) if trainposeconfigfile.is_file(): askuser = input( @@ -1054,7 +1066,7 @@ def create_training_dataset( # Test files as well as pose_yaml files (containing training and testing information) ################################################################################# modelfoldername = auxiliaryfunctions.get_model_folder( - trainFraction, shuffle, cfg + trainFraction, shuffle, cfg, engine=engine, ) auxiliaryfunctions.attempt_to_make_folder( Path(config).parents[0] / modelfoldername, recursive=True @@ -1085,7 +1097,7 @@ def create_training_dataset( # str(cfg['proj_path']+'/'+Path(modelfoldername) / 'test' / 'pose_cfg.yaml') items2change = { "dataset": datafilename, - "engine": cfg.get("engine", "pytorch"), # TODO: Default to tensorflow + "engine": engine.aliases[0], "metadataset": metadatafilename, "num_joints": len(bodyparts), "all_joints": [[i] for i in range(len(bodyparts))], @@ -1126,8 +1138,7 @@ def create_training_dataset( ) # Populate the pytorch config yaml file - # TODO: Add switch for PyTorch projects - if cfg.get("engine", "pytorch").lower() == "pytorch": + if engine == Engine.PYTORCH: from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config top_down = False @@ -1166,6 +1177,107 @@ def get_largestshuffle_index(config): return max_shuffle_index +def get_existing_shuffle_indices( + cfg: dict | str | Path, + train_fraction: float | None = None, + engine: Engine | None = None, +) -> List[int]: + """ + Args: + cfg: The content of a project configuration file, or the path to the project + configuration file. + train_fraction: If defined, only get the indices of shuffles with this train + fraction. + engine: If specified, returns only the shuffle indices that were created with + the given engine. Can only be used when train_fraction is also defined. + + Returns: + the indices of existing shuffles for this iteration of the project, sorted by + ascending index + """ + def is_valid_data_stem(stem: str) -> bool: + if len(stem) == 0: + return False + suffix = stem.split("_")[-1] + if len(suffix) == 0: + return False + info = suffix.split("shuffle") + if len(info) != 2: + return False + train_frac, idx = info + return ( + train_frac.isdigit() and idx.isdigit() + and (train_fraction is None or int(train_frac) == int(100 * train_fraction)) + ) + + if isinstance(cfg, (str, Path)): + cfg = auxiliaryfunctions.read_config(cfg) + + project = Path(cfg["project_path"]) + trainset_folder = project / auxiliaryfunctions.get_training_set_folder(cfg) + if not trainset_folder.exists(): + return [] + + shuffle_indices = [ + int(p.stem.split("shuffle")[-1]) + for p in trainset_folder.iterdir() + if ( + p.stem.startswith("Documentation_data") + and p.suffix == ".pickle" + and is_valid_data_stem(p.stem) + ) + ] + if engine is not None: + if train_fraction is None: + raise ValueError( + f"Must select {train_fraction} to filter shuffles by engine" + ) + + shuffle_indices = [ + idx for idx in shuffle_indices + if ( + project / auxiliaryfunctions.get_model_folder( + trainFraction=train_fraction, + shuffle=idx, + cfg=cfg, + engine=engine, + ) + ).exists() + ] + + return sorted(shuffle_indices) + + +def validate_shuffles( + cfg: dict, + shuffles: list[int] | None, + num_shuffles: int | None, + userfeedback: bool, +) -> list[int]: + existing_shuffles = get_existing_shuffle_indices(cfg) + if shuffles is None: + first_index = 1 + if len(existing_shuffles) > 0: + first_index = existing_shuffles[-1] + 1 + + shuffles = range(first_index, num_shuffles + first_index) + else: + shuffles = [i for i in shuffles if isinstance(i, int)] + for shuffle_idx in shuffles: + if userfeedback and shuffle_idx in existing_shuffles: + raise ValueError( + f"Cannot create shuffle {shuffle_idx} as it already exists - " + f"you must either create the dataset with `userfeedback=False` " + f"or delete the shuffle with index {shuffle_idx} manually (in " + f"`dlc-models`/`dlc-models-pytorch` and in the " + f"`training-datasets` folder) if you want to create a new " + f"shuffle with that index. You can otherwise create a shuffle " + f"with a new index. Existing indices are {existing_shuffles}." + ) + + return shuffles + + def create_training_model_comparison( config, trainindex=0, diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index e640a5c44f..07094fcf90 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -14,6 +14,9 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QIcon +import deeplabcut +from deeplabcut import compat +from deeplabcut.generate_training_dataset import get_existing_shuffle_indices from deeplabcut.gui.dlc_params import DLCParams from deeplabcut.gui.components import ( DefaultTab, @@ -21,8 +24,6 @@ _create_grid_layout, _create_label_widget, ) - -import deeplabcut from deeplabcut.utils.auxiliaryfunctions import ( get_data_and_metadata_filenames, get_training_set_folder, @@ -63,19 +64,40 @@ def _generate_layout_attributes(self, layout): # Neural Network nnet_label = QtWidgets.QLabel("Network architecture") self.net_choice = QtWidgets.QComboBox() - nets = DLCParams.NNETS.copy() - if not self.root.is_multianimal: - nets.remove("dlcrnet_ms5") + + if self.root.project_engine == compat.Engine.TF: + nets = DLCParams.NNETS.copy() + if not self.root.is_multianimal: + nets.remove("dlcrnet_ms5") + else: + # FIXME: Circular imports make it impossible to import this at the top + from deeplabcut.pose_estimation_pytorch import available_models + nets = available_models() + self.net_choice.addItems(nets) - self.net_choice.setCurrentText("resnet_50") + default_net_type = self.root.cfg.get("default_net_type", "resnet_50") + if default_net_type in nets: + self.net_choice.setCurrentIndex(nets.index(default_net_type)) self.net_choice.currentTextChanged.connect(self.log_net_choice) + self.overwrite = QtWidgets.QCheckBox("Overwrite") + self.overwrite.setChecked(False) + self.overwrite.setToolTip( + "When checked, creating a new shuffle with an index that already exists " + "will overwrite the existing index. Be careful with this option as you " + "might lose data." + ) + self.overwrite.stateChanged.connect( + lambda s: self.root.logger.info(f"Overwrite: {s}") + ) + layout.addWidget(shuffle_label, 0, 0) layout.addWidget(self.shuffle, 0, 1) layout.addWidget(nnet_label, 0, 2) layout.addWidget(self.net_choice, 0, 3) layout.addWidget(augmentation_label, 0, 4) layout.addWidget(self.aug_choice, 0, 5) + layout.addWidget(self.overwrite, 1, 0) def log_net_choice(self, net): self.root.logger.info(f"Network architecture set to {net.upper()}") @@ -85,6 +107,30 @@ def log_augmentation_choice(self, augmentation): def create_training_dataset(self): shuffle = self.shuffle.value() + cfg = self.root.cfg + existing_indices = get_existing_shuffle_indices( + cfg=cfg, + train_fraction=cfg["TrainingFraction"][self.root.trainingset_index] + ) + + overwrite = self.overwrite.isChecked() + if shuffle in existing_indices: + if overwrite: + if not self._confirm_overwrite(shuffle, existing_indices): + return + else: + msg = _create_message_box( + f"The training dataset could not be created.", + ( + f"Shuffle {shuffle} already exists - you can create a new " + "training dataset with an unused shuffle index (existing " + f"shuffles are {existing_indices}) or you can overwrite the " + f"shuffle by ticking the 'Overwrite' checkbox" + ), + ) + msg.exec_() + self.root.writer.write("Training dataset creation failed.") + return if self.model_comparison: raise NotImplementedError @@ -102,6 +148,7 @@ def create_training_dataset(self): shuffle, Shuffles=[self.shuffle.value()], net_type=self.net_choice.currentText(), + userfeedback=not overwrite, ) else: deeplabcut.create_training_dataset( @@ -110,6 +157,7 @@ def create_training_dataset(self): Shuffles=[self.shuffle.value()], net_type=self.net_choice.currentText(), augmenter_type=self.aug_choice.currentText(), + userfeedback=not overwrite, ) # Check that training data files were indeed created. trainingsetfolder = get_training_set_folder(self.root.cfg) @@ -141,6 +189,47 @@ def create_training_dataset(self): msg.exec_() self.root.writer.write("Training dataset creation failed.") + def _confirm_overwrite(self, shuffle: int, existing_indices: list[int]) -> bool: + """ + Asks the user to confirm that they want to overwrite a shuffle. + + Args: + shuffle: the shuffle the user wants to overwrite + existing_indices: the indices of existing shuffles + + Returns: + whether the user confirmed overwriting the shuffle + """ + try: + engine = compat.get_shuffle_engine( + self.root.cfg, self.root.trainingset_index, shuffle + ) + engine_str = f" (with engine '{engine.aliases[0]}')" + except ValueError: + engine_str = "" + + conf = _create_confirmation_box( + title=f"Are you sure you want to overwrite shuffle {shuffle}?", + description=( + f"As shuffle {shuffle} already exists{engine_str}, " + f"the training-dataset files would be overwritten." + ) + ) + result = conf.exec() + if result != QtWidgets.QMessageBox.Yes: + msg = _create_message_box( + text="The training dataset was not be created.", + info_text=( + "You can create a shuffle with another index. Existing indices " + f"are {existing_indices}" + ), + ) + msg.exec_() + self.root.writer.write("Training dataset creation interrupted.") + return False + + return True + def _create_message_box(text, info_text): msg = QtWidgets.QMessageBox() @@ -155,3 +244,18 @@ def _create_message_box(text, info_text): msg.setWindowIcon(QIcon(logo)) msg.setStandardButtons(QtWidgets.QMessageBox.Ok) return msg + + +def _create_confirmation_box(title, description): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Information) + msg.setText(title) + msg.setInformativeText(description) + + msg.setWindowTitle("Confirmation") + msg.setMinimumWidth(900) + logo_dir = os.path.dirname(os.path.realpath("logo.png")) + os.path.sep + logo = logo_dir + "/assets/logo.png" + msg.setWindowIcon(QIcon(logo)) + msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + return msg diff --git a/deeplabcut/gui/tabs/refine_tracklets.py b/deeplabcut/gui/tabs/refine_tracklets.py index 25b4b51ca8..b97b13b7a0 100644 --- a/deeplabcut/gui/tabs/refine_tracklets.py +++ b/deeplabcut/gui/tabs/refine_tracklets.py @@ -24,7 +24,7 @@ ) import deeplabcut -from deeplabcut.pose_estimation_tensorflow.lib import trackingutils +from deeplabcut.core import trackingutils from deeplabcut.utils.auxiliaryfunctions import GetScorerName diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py index f50f215d3e..b49fa6f925 100644 --- a/deeplabcut/gui/tabs/train_network.py +++ b/deeplabcut/gui/tabs/train_network.py @@ -9,12 +9,13 @@ # Licensed under GNU Lesser General Public License v3.0 # import os -from pathlib import Path +from dataclasses import dataclass from PySide6 import QtWidgets from PySide6.QtCore import Qt from PySide6.QtGui import QIcon +import deeplabcut.compat as compat from deeplabcut.gui.components import ( DefaultTab, ShuffleSpinBox, @@ -23,23 +24,20 @@ ) from deeplabcut.gui.widgets import ConfigEditor -import deeplabcut -from deeplabcut.utils import auxiliaryfunctions + +@dataclass +class IntTrainAttribute: + label: str + fn_key: str + default: int + min: int + max: int class TrainNetwork(DefaultTab): - def __init__(self, root, parent, h1_description): + def __init__(self, root, parent, h1_description, engine: compat.Engine = compat.Engine.TF): super(TrainNetwork, self).__init__(root, parent, h1_description) - - # use the default pose_cfg file for default values - default_pose_cfg_path = os.path.join( - Path(deeplabcut.__file__).parent, "pose_cfg.yaml" - ) - pose_cfg = auxiliaryfunctions.read_plainconfig(default_pose_cfg_path) - self.display_iters = str(pose_cfg["display_iters"]) - self.save_iters = str(pose_cfg["save_iters"]) - self.max_iters = str(pose_cfg["multi_step"][-1][-1]) - + self.train_attributes = get_train_attributes(engine=engine) self._set_page() def _set_page(self): @@ -65,62 +63,26 @@ def _generate_layout_attributes(self, layout): # Shuffle shuffle_label = QtWidgets.QLabel("Shuffle") self.shuffle = ShuffleSpinBox(root=self.root, parent=self) - - # Display iterations - dispiters_label = QtWidgets.QLabel("Display iterations") - self.display_iters_spin = QtWidgets.QSpinBox() - self.display_iters_spin.setMinimum(1) - self.display_iters_spin.setMaximum(int(self.max_iters)) - self.display_iters_spin.setValue(1000) - self.display_iters_spin.valueChanged.connect(self.log_display_iters) - - # Save iterations - saveiters_label = QtWidgets.QLabel("Save iterations") - self.save_iters_spin = QtWidgets.QSpinBox() - self.save_iters_spin.setMinimum(1) - self.save_iters_spin.setMaximum(int(self.max_iters)) - self.save_iters_spin.setValue(50000) - self.save_iters_spin.valueChanged.connect(self.log_save_iters) - - # Max iterations - maxiters_label = QtWidgets.QLabel("Maximum iterations") - self.max_iters_spin = QtWidgets.QSpinBox() - self.max_iters_spin.setMinimum(1) - self.max_iters_spin.setMaximum(int(self.max_iters)) - self.max_iters_spin.setValue(100000) - self.max_iters_spin.valueChanged.connect(self.log_max_iters) - - # Max number snapshots to keep - snapkeep_label = QtWidgets.QLabel("Number of snapshots to keep") - self.snapshots = QtWidgets.QSpinBox() - self.snapshots.setMinimum(1) - self.snapshots.setMaximum(100) - self.snapshots.setValue(5) - self.snapshots.valueChanged.connect(self.log_snapshots) - layout.addWidget(shuffle_label, 0, 0) layout.addWidget(self.shuffle, 0, 1) - layout.addWidget(dispiters_label, 0, 2) - layout.addWidget(self.display_iters_spin, 0, 3) - layout.addWidget(saveiters_label, 0, 4) - layout.addWidget(self.save_iters_spin, 0, 5) - layout.addWidget(maxiters_label, 0, 6) - layout.addWidget(self.max_iters_spin, 0, 7) - layout.addWidget(snapkeep_label, 0, 8) - layout.addWidget(self.snapshots, 0, 9) - # layout.addWidget() - - def log_display_iters(self, value): - self.root.logger.info(f"Display iters set to {value}") - def log_save_iters(self, value): - self.root.logger.info(f"Save iters set to {value}") - - def log_max_iters(self, value): - self.root.logger.info(f"Max iters set to {value}") - - def log_snapshots(self, value): - self.root.logger.info(f"Max snapshots to keep set to {value}") + # Other parameters + self.attribute_spin_boxes = {} + for i, attribute in enumerate(self.train_attributes): + label = QtWidgets.QLabel(attribute.label) + spin_box = QtWidgets.QSpinBox() + spin_box.setMinimum(attribute.min) + spin_box.setMaximum(attribute.max) + spin_box.setValue(attribute.default) + spin_box.valueChanged.connect( + lambda new_val: self.log_attribute_change(attribute, new_val) + ) + self.attribute_spin_boxes[attribute.fn_key] = spin_box + layout.addWidget(label, 0, 2 * (i + 1)) + layout.addWidget(spin_box, 0, 2 * (i + 1) + 1) + + def log_attribute_change(self, attribute: IntTrainAttribute, value: int) -> None: + self.root.logger.info(f"{attribute.label} set to {value}") def open_posecfg_editor(self): editor = ConfigEditor(self.root.pose_cfg_path) @@ -129,21 +91,11 @@ def open_posecfg_editor(self): def train_network(self): config = self.root.config shuffle = int(self.shuffle.value()) - max_snapshots_to_keep = int(self.snapshots.value()) - displayiters = int(self.display_iters_spin.value()) - saveiters = int(self.save_iters_spin.value()) - maxiters = int(self.max_iters_spin.value()) - - deeplabcut.train_network( - config, - shuffle, - gputouse=None, - max_snapshots_to_keep=max_snapshots_to_keep, - autotune=None, - displayiters=displayiters, - saveiters=saveiters, - maxiters=maxiters, - ) + kwargs = dict(gputouse=None, autotune=False) + for k, spin_box in self.attribute_spin_boxes.items(): + kwargs[k] = int(spin_box.value()) + + compat.train_network(config, shuffle, **kwargs) msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Information) msg.setText("The network is now trained and ready to evaluate.") @@ -158,3 +110,70 @@ def train_network(self): msg.setWindowIcon(QIcon(self.logo)) msg.setStandardButtons(QtWidgets.QMessageBox.Ok) msg.exec_() + + +def get_train_attributes(engine: compat.Engine) -> list[IntTrainAttribute]: + if engine == compat.Engine.TF: + return [ + IntTrainAttribute( + label="Display iterations", + fn_key="displayiters", + default=1000, + min=1, + max=1000, + ), + IntTrainAttribute( + label="Save iterations", + fn_key="saveiters", + default=50_000, + min=1, + max=50_000, + ), + IntTrainAttribute( + label="Maximum iterations", + fn_key="maxiters", + default=100_000, + min=1, + max=1_030_000, + ), + IntTrainAttribute( + label="Number of snapshots to keep", + fn_key="max_snapshots_to_keep", + default=5, + min=1, + max=100, + ), + ] + elif engine == compat.Engine.PYTORCH: + return[ + IntTrainAttribute( + label="Display iterations", + fn_key="display_iters", + default=1_000, + min=1, + max=100_000, + ), + IntTrainAttribute( + label="Save epochs", + fn_key="save_epochs", + default=50, + min=1, + max=250, + ), + IntTrainAttribute( + label="Maximum epochs", + fn_key="epochs", + default=200, + min=1, + max=1000, + ), + # IntTrainAttribute( # FIXME: Implement + # label="Number of snapshots to keep", + # fn_key="max_snapshots_to_keep", + # default=5, + # min=1, + # max=100, + # ), + ] + + raise NotImplementedError(f"Unknown engine: {engine}") diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index 4e16ab83f7..948b1fdc7b 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -451,7 +451,10 @@ class ConfigEditor(QtWidgets.QDialog): def __init__(self, config, parent=None): super(ConfigEditor, self).__init__(parent) self.config = config - if config.endswith("config.yaml"): + if ( + config.endswith("config.yaml") + and not config.endswith("pytorch_config.yaml") + ): self.read_func = auxiliaryfunctions.read_config self.write_func = auxiliaryfunctions.write_config else: diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index fa458d79d4..d2ef3c640c 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -18,7 +18,7 @@ import qdarkstyle import deeplabcut -from deeplabcut import auxiliaryfunctions, VERSION +from deeplabcut import auxiliaryfunctions, VERSION, compat from deeplabcut.gui import BASE_DIR, components, utils from deeplabcut.gui.tabs import * from deeplabcut.gui.widgets import StreamReceiver, StreamWriter @@ -151,6 +151,10 @@ def cfg(self): cfg = {} return cfg + @property + def project_engine(self) -> compat.Engine: + return compat.get_project_engine(self.cfg) + @property def project_folder(self) -> str: return self.cfg.get("project_path", os.path.expanduser("~/Desktop")) @@ -176,15 +180,14 @@ def all_individuals(self) -> List: @property def pose_cfg_path(self) -> str: try: - return os.path.join( - self.cfg["project_path"], - auxiliaryfunctions.get_model_folder( - self.cfg["TrainingFraction"][int(self.trainingset_index)], - int(self.shuffle_value), - self.cfg, - ), - "train", - "pose_cfg.yaml", + return str( + compat.return_train_network_path( + self.config, + shuffle=int(self.shuffle_value), + trainingsetindex=int(self.trainingset_index), + modelprefix="", + engine=self.project_engine, + )[0] ) except FileNotFoundError: return str(Path(deeplabcut.__file__).parent / "pose_cfg.yaml") @@ -490,7 +493,10 @@ def add_tabs(self): h1_description="DeepLabCut - Step 4. Create training dataset", ) self.train_network = TrainNetwork( - root=self, parent=None, h1_description="DeepLabCut - Train network" + root=self, + parent=None, + h1_description="DeepLabCut - Train network", + engine=self.project_engine, ) self.evaluate_network = EvaluateNetwork( root=self, diff --git a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml index 39fdbf2001..e24c0a8827 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml @@ -1,14 +1,20 @@ - # Project definitions (do not edit) +# Project definitions (do not edit) Task: scorer: date: multianimalproject: identity: - # Project path (change when moving around) + +# Project path (change when moving around) project_path: - # Annotation data set configuration (and individual video cropping parameters) + +# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow) +engine: pytorch + + +# Annotation data set configuration (and individual video cropping parameters) video_sets: bodyparts: - nose @@ -50,14 +56,15 @@ bodyparts: - belly_bottom - body_middle_right - body_middle_left -# Fraction of video to start/stop when extracting frames for labeling/refinement - # Fraction of video to start/stop when extracting frames for labeling/refinement + +# Fraction of video to start/stop when extracting frames for labeling/refinement start: stop: numframes2pick: - # Plotting configuration + +# Plotting configuration skeleton: [] skeleton_color: black pcutoff: @@ -65,7 +72,8 @@ dotsize: alphavalue: colormap: - # Training,Evaluation and Analysis configuration + +# Training,Evaluation and Analysis configuration TrainingFraction: iteration: default_net_type: @@ -73,14 +81,16 @@ default_augmenter: snapshotindex: batch_size: 1 - # Cropping Parameters (for analysis and outlier frame detection) + +# Cropping Parameters (for analysis and outlier frame detection) cropping: - #if cropping is true for analysis, then set the values here: +#if cropping is true for analysis, then set the values here: x1: x2: y1: y2: - # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) + +# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) corner2move2: move2corner: diff --git a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml index bb5ac87ac6..d90b890bb6 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml @@ -1,14 +1,20 @@ - # Project definitions (do not edit) +# Project definitions (do not edit) Task: scorer: date: multianimalproject: identity: - # Project path (change when moving around) + +# Project path (change when moving around) project_path: - # Annotation data set configuration (and individual video cropping parameters) + +# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow) +engine: pytorch + + +# Annotation data set configuration (and individual video cropping parameters) video_sets: bodyparts: - nose @@ -39,14 +45,14 @@ bodyparts: - tail_end - head_midpoint -# Fraction of video to start/stop when extracting frames for labeling/refinement - # Fraction of video to start/stop when extracting frames for labeling/refinement +# Fraction of video to start/stop when extracting frames for labeling/refinement start: stop: numframes2pick: - # Plotting configuration + +# Plotting configuration skeleton: [] skeleton_color: black pcutoff: @@ -54,7 +60,8 @@ dotsize: alphavalue: colormap: - # Training,Evaluation and Analysis configuration + +# Training,Evaluation and Analysis configuration TrainingFraction: iteration: default_net_type: @@ -62,14 +69,16 @@ default_augmenter: snapshotindex: batch_size: 1 - # Cropping Parameters (for analysis and outlier frame detection) + +# Cropping Parameters (for analysis and outlier frame detection) cropping: - #if cropping is true for analysis, then set the values here: +#if cropping is true for analysis, then set the values here: x1: x2: y1: y2: - # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) + +# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) corner2move2: move2corner: diff --git a/deeplabcut/pose_estimation_3d/triangulation.py b/deeplabcut/pose_estimation_3d/triangulation.py index 36d04c5b9c..1e02b9860f 100644 --- a/deeplabcut/pose_estimation_3d/triangulation.py +++ b/deeplabcut/pose_estimation_3d/triangulation.py @@ -19,7 +19,7 @@ from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions from deeplabcut.utils import auxiliaryfunctions_3d -from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import TRACK_METHODS +from deeplabcut.core.trackingutils import TRACK_METHODS matplotlib_axes_logger.setLevel("ERROR") diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index 7680732202..966cb7b390 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -14,6 +14,7 @@ evaluate_network, train_network, ) +from deeplabcut.pose_estimation_pytorch.config import available_models from deeplabcut.pose_estimation_pytorch.data.base import Loader from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader from deeplabcut.pose_estimation_pytorch.data.dataset import ( diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index db41cf5283..a2c2de54a2 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -22,6 +22,7 @@ import pandas as pd from tqdm import tqdm +from deeplabcut.compat import Engine from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( convert_detections2tracklets, ) @@ -208,7 +209,7 @@ def analyze_videos( project_path = Path(cfg["project_path"]) train_fraction = cfg["TrainingFraction"][trainingsetindex] model_folder = project_path / auxiliaryfunctions.get_model_folder( - train_fraction, shuffle, cfg, modelprefix=modelprefix + train_fraction, shuffle, cfg, modelprefix=modelprefix, engine=Engine.PYTORCH, ) model_path = _get_model_path(model_folder, snapshotindex, cfg) model_epochs = int(model_path.stem.split("-")[-1]) @@ -217,6 +218,7 @@ def analyze_videos( shuffle, train_fraction, trainingsiterations=model_epochs, + engine=Engine.PYTORCH, modelprefix=modelprefix, ) # Get general project parameters @@ -295,8 +297,16 @@ def analyze_videos( output_data, metadata, str(output_h5) ) + pred_bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions]) + pred_unique_bodyparts = None + if len(predictions) > 0 and "unique_bodyparts" in predictions[0]: + pred_unique_bodyparts = np.stack( + [p["unique_bodyparts"] for p in predictions] + ) + df = create_df_from_prediction( - predictions=predictions, + pred_bodyparts=pred_bodyparts, + pred_unique_bodyparts=pred_unique_bodyparts, cfg=cfg, dlc_scorer=dlc_scorer, output_path=output_path, @@ -305,19 +315,21 @@ def analyze_videos( results.append((str(video), df)) if cfg["multianimalproject"] and len(individuals) > 1: - bodypart_identities = None + pred_bodypart_ids = None if with_identity: # reshape from (num_assemblies, num_bpts, num_individuals) # to (num_assemblies, num_bpts) by taking the maximum # likelihood individual for each bodypart - bodypart_identities = [np.argmax(p["identity_scores"], axis=2) for p in predictions] + pred_bodypart_ids = np.stack( + [np.argmax(p["identity_scores"], axis=2) for p in predictions] + ) _save_assemblies( output_path, output_prefix, - bodyparts, - bodypart_identities, - unique_bodyparts, + pred_bodyparts, + pred_bodypart_ids, + pred_unique_bodyparts, with_identity, ) if auto_track: @@ -344,7 +356,8 @@ def analyze_videos( def create_df_from_prediction( - predictions: list[dict[str, np.ndarray]], + pred_bodyparts: np.ndarray, + pred_unique_bodyparts: np.ndarray, dlc_scorer: str, cfg: dict, output_path: str | Path, @@ -354,12 +367,6 @@ def create_df_from_prediction( output_pkl = Path(output_path) / f"{output_prefix}_full.pickle" print(f"Saving results in {output_h5} and {output_pkl}") - bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions]) - unique_bodyparts = None - - if len(predictions) > 0 and "unique_bodyparts" in predictions[0]: - unique_bodyparts = np.stack([p["unique_bodyparts"] for p in predictions]) - cols = [ [dlc_scorer], list(auxiliaryfunctions.get_bodyparts(cfg)), @@ -373,13 +380,13 @@ def create_df_from_prediction( cols_names.insert(1, "individuals") results_df_index = pd.MultiIndex.from_product(cols, names=cols_names) - bodyparts = bodyparts[:, :n_individuals] + pred_bodyparts = pred_bodyparts[:, :n_individuals] df = pd.DataFrame( - bodyparts.reshape((len(bodyparts), -1)), + pred_bodyparts.reshape((len(pred_bodyparts), -1)), columns=results_df_index, - index=range(len(bodyparts)), + index=range(len(pred_bodyparts)), ) - if unique_bodyparts is not None: + if pred_unique_bodyparts is not None: coordinate_labels_unique = ["x", "y", "likelihood"] results_unique_df_index = pd.MultiIndex.from_product( [ @@ -390,9 +397,9 @@ def create_df_from_prediction( names=["scorer", "bodyparts", "coords"], ) df_u = pd.DataFrame( - unique_bodyparts.reshape((len(unique_bodyparts), -1)), + pred_unique_bodyparts.reshape((len(pred_unique_bodyparts), -1)), columns=results_unique_df_index, - index=range(len(unique_bodyparts)), + index=range(len(pred_unique_bodyparts)), ) df = df.join(df_u, how="outer") @@ -403,16 +410,16 @@ def create_df_from_prediction( def _save_assemblies( output_path: Path, output_prefix: str, - bodyparts: list, - bodypart_identities: list, - unique_bodyparts: list, + pred_bodyparts: np.ndarray, + pred_bodypart_ids: np.ndarray, + pred_unique_bodyparts: np.ndarray, with_identity: bool, ) -> None: output_ass = output_path / f"{output_prefix}_assemblies.pickle" assemblies = {} - for i, bpt in enumerate(bodyparts): + for i, bpt in enumerate(pred_bodyparts): if with_identity: - extra_column = np.expand_dims(bodypart_identities[i], axis=-1) + extra_column = np.expand_dims(pred_bodypart_ids[i], axis=-1) else: extra_column = np.full( (bpt.shape[0], bpt.shape[1], 1), @@ -422,9 +429,9 @@ def _save_assemblies( ass = np.concatenate((bpt, extra_column), axis=-1) assemblies[i] = ass - if unique_bodyparts is not None: + if pred_unique_bodyparts is not None: assemblies["single"] = {} - for i, unique_bpt in enumerate(unique_bodyparts): + for i, unique_bpt in enumerate(pred_unique_bodyparts): extra_column = np.full((unique_bpt.shape[1], 1), -1.0, dtype=np.float32) ass = np.concatenate((unique_bpt[0], extra_column), axis=-1) assemblies["single"][i] = ass diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index a64e720fab..7da659b9a7 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -22,13 +22,13 @@ import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions import deeplabcut.utils.auxfun_multianimal as auxfun_multianimal +from deeplabcut.compat import Engine from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_model_snapshots, list_videos_in_folder, ) -from deeplabcut.pose_estimation_tensorflow import load_config -from deeplabcut.pose_estimation_tensorflow.lib import trackingutils -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembly +from deeplabcut.core import trackingutils +from deeplabcut.core.inferenceutils import Assembly def convert_detections2tracklets( @@ -70,11 +70,11 @@ def convert_detections2tracklets( # print("These are used for all videos, but won't be save to the cfg file.") rel_model_dir = auxiliaryfunctions.get_model_folder( - train_fraction, shuffle, cfg, modelprefix=modelprefix + train_fraction, shuffle, cfg, modelprefix=modelprefix, engine=Engine.PYTORCH, ) model_dir = Path(cfg["project_path"]) / rel_model_dir path_test_config = model_dir / "test" / "pose_cfg.yaml" - dlc_cfg = load_config(str(path_test_config)) + dlc_cfg = auxiliaryfunctions.read_plainconfig(str(path_test_config)) if "multi-animal" not in dlc_cfg["dataset_type"]: raise ValueError("This function is only required for multianimal projects!") @@ -118,6 +118,7 @@ def convert_detections2tracklets( shuffle, train_fraction, trainingsiterations=num_epochs, + engine=Engine.PYTORCH, modelprefix=modelprefix, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/scoring.py b/deeplabcut/pose_estimation_pytorch/apis/scoring.py index 8513e43fdd..23e79428b4 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/apis/scoring.py @@ -14,16 +14,14 @@ import pickle from sklearn.metrics import accuracy_score -from deeplabcut.pose_estimation_pytorch.post_processing import ( - rmse_match_prediction_to_gt, -) -from deeplabcut.pose_estimation_tensorflow.core.evaluate_multianimal import ( - _find_closest_neighbors, -) -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( +from deeplabcut.core.crossvalutils import find_closest_neighbors +from deeplabcut.core.inferenceutils import ( Assembly, evaluate_assembly, ) +from deeplabcut.pose_estimation_pytorch.post_processing import ( + rmse_match_prediction_to_gt, +) from deeplabcut.utils.auxiliaryfunctions import read_config @@ -262,7 +260,7 @@ def compute_identity_scores( # assign ground truth keypoints to the closest prediction, so the ID score # is the closest possible to the ID score computed with "ground truth" indices_gt = np.flatnonzero(np.all(~np.isnan(bpt_gt), axis=1)) - neighbors = _find_closest_neighbors(bpt_gt[indices_gt], bpt_pred, k=3) + neighbors = find_closest_neighbors(bpt_gt[indices_gt], bpt_pred, k=3) found = neighbors != -1 indices = np.flatnonzero(all_bpts == bpt) # Get the predicted identity of each bodypart by taking the argmax @@ -308,7 +306,7 @@ def _match_identity_preds_to_gt( # Pick the predictions closest to ground truth, # rather than the ones the model has most confident in xy_gt_values = xy_gt.iloc[inds_gt].values - neighbors = _find_closest_neighbors(xy_gt_values, xy, k=3) + neighbors = find_closest_neighbors(xy_gt_values, xy, k=3) found = neighbors != -1 inds = np.flatnonzero(all_bpts == bpt) id_ = dict_["prediction"]["identity"][n_joint] diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 2ea9a70b0c..2a3c7fafc2 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -21,6 +21,7 @@ import torch import torch.nn as nn +from deeplabcut.compat import Engine from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( build_bottom_up_postprocessor, @@ -44,7 +45,37 @@ InferenceRunner, Task, ) -from deeplabcut.utils import auxfun_videos +from deeplabcut.utils import auxiliaryfunctions, auxfun_videos + + +def return_train_network_path( + config: str, + shuffle: int = 1, + trainingsetindex: int = 0, + modelprefix: str = "" +) -> tuple[Path, Path, Path]: + """ + Args: + config: Full path of the config.yaml file as a string. + shuffle: The shuffle index to select for training + trainingsetindex: Which TrainingsetFraction to use (note that TrainingFraction + is a list in config.yaml) + Returns: + the path to the training pytorch pose configuration file + the path to the test pytorch pose configuration file + the path to the folder containing the snapshots + """ + cfg = auxiliaryfunctions.read_config(config) + project_path = Path(cfg["project_path"]) + train_frac = cfg["TrainingFraction"][trainingsetindex] + model_folder = auxiliaryfunctions.get_model_folder( + train_frac, shuffle, cfg, engine=Engine.PYTORCH, modelprefix=modelprefix + ) + return ( + project_path / model_folder / "train" / "pytorch_config.yaml", + project_path / model_folder / "test" / "pose_cfg.yaml", + project_path / model_folder / "train", + ) def build_optimizer(optimizer_cfg: dict, model: nn.Module) -> torch.optim.Optimizer: @@ -273,20 +304,27 @@ def list_videos_in_folder( """ TODO """ - video_path = Path(data_path) - if video_path.is_dir(): - if video_type is None: - video_suffixes = ["." + ext for ext in auxfun_videos.SUPPORTED_VIDEOS] + if not isinstance(data_path, list): + data_path = [data_path] + video_paths = [Path(p) for p in data_path] + + videos = [] + for video_path in video_paths: + if video_path.is_dir(): + if video_type is None: + video_suffixes = ["." + ext for ext in auxfun_videos.SUPPORTED_VIDEOS] + else: + video_suffixes = [video_type] + + video_suffixes = [s if s.startswith(".") else "." + s for s in video_suffixes] + videos += [file for file in video_path.iterdir() if file.suffix in video_suffixes] else: - video_suffixes = [video_type] - - video_suffixes = [s if s.startswith(".") else "." + s for s in video_suffixes] - return [file for file in video_path.iterdir() if file.suffix in video_suffixes] + assert ( + video_path.exists() + ), f"Could not find the video: {video_path}. Check access rights." + videos.append(video_path) - assert ( - video_path.exists() - ), f"Could not find the video: {video_path}. Check access rights." - return [video_path] + return videos def build_auto_padding( diff --git a/deeplabcut/pose_estimation_pytorch/config/__init__.py b/deeplabcut/pose_estimation_pytorch/config/__init__.py index 26ac98bc84..b650872648 100644 --- a/deeplabcut/pose_estimation_pytorch/config/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/config/__init__.py @@ -10,7 +10,8 @@ # from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config from deeplabcut.pose_estimation_pytorch.config.utils import ( + available_models, pretty_print_config, read_config_as_dict, update_config, -) \ No newline at end of file +) diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index d72d882df5..e7bb924043 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -15,8 +15,9 @@ from pathlib import Path from deeplabcut.pose_estimation_pytorch.config.utils import ( + get_config_folder_path, load_backbones, - load_config_dir_and_base_config, + load_base_config, read_config_as_dict, replace_default_values, update_config, @@ -69,8 +70,10 @@ def make_pytorch_pose_config( if net_type is None: net_type = project_config.get("default_net_type", "resnet_50") - configs_dir, pose_config = load_config_dir_and_base_config() + configs_dir = get_config_folder_path() + pose_config = load_base_config(configs_dir) pose_config = add_metadata(project_config, pose_config, pose_config_path) + pose_config["net_type"] = net_type backbones = load_backbones(configs_dir) if net_type in backbones: diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py index f734f4e1ea..c3a7262c39 100644 --- a/deeplabcut/pose_estimation_pytorch/config/utils.py +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -148,17 +148,17 @@ def update_config(config: dict, updates: dict, copy_original: bool = True) -> di return config -def load_config_dir_and_base_config() -> tuple[Path, dict]: - """ - Returns: - the Path to the folder containing the "configs" for PyTorch DeepLabCut - the base configuration for all PyTorch DeepLabCut models - """ +def get_config_folder_path() -> Path: + """Returns: the Path to the folder containing the "configs" for PyTorch DeepLabCut""" dlc_parent_path = Path(auxiliaryfunctions.get_deeplabcut_path()) - configs_dir = dlc_parent_path / "pose_estimation_pytorch" / "config" - base_dir = configs_dir / "base" + return dlc_parent_path / "pose_estimation_pytorch" / "config" + + +def load_base_config(config_folder_path: Path) -> dict: + """Returns: the base configuration for all PyTorch DeepLabCut models""" + base_dir = config_folder_path / "base" base_config = read_config_as_dict(base_dir / "base.yaml") - return configs_dir, base_config + return base_config def load_backbones(configs_dir: Path) -> list[str]: @@ -210,3 +210,25 @@ def pretty_print_config( pretty_print_config(v, indent + 2) else: print_fn(f"{indent * ' '}{k}: {v}") + + +def available_models() -> list[str]: + """Returns: the possible variants of models that can be used""" + configs_folder_path = get_config_folder_path() + backbones = load_backbones(configs_folder_path) + models = set() + for backbone in backbones: + models.add(backbone) + models.add("top_down_" + backbone) + + other_architectures = [ + p + for p in configs_folder_path.iterdir() + if p.is_dir() and not p.name in ("backbones", "base") + ] + for folder in other_architectures: + variants = [p.stem for p in folder.iterdir() if p.suffix == ".yaml"] + for variant in variants: + models.add(variant) + + return list(sorted(models)) diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index 5d8af7234e..119fc10e97 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import logging import os import pickle from dataclasses import dataclass @@ -17,6 +18,7 @@ import pandas as pd import deeplabcut +from deeplabcut.compat import Engine from deeplabcut.pose_estimation_pytorch.data.base import Loader from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.helper import CombinedPropertyMeta @@ -44,7 +46,9 @@ class DLCLoader(Loader, metaclass=CombinedPropertyMeta): lambda self: os.path.join(self.project_root, "config.yaml"), ), "model_folder": ( - lambda x: os.path.join(x[0], get_model_folder(x[1], x[2], x[3])), + lambda x: os.path.join( + x[0], get_model_folder(x[1], x[2], x[3], engine=Engine.PYTORCH) + ), lambda self: ( self.project_root, self.cfg["TrainingFraction"][0], @@ -54,7 +58,7 @@ class DLCLoader(Loader, metaclass=CombinedPropertyMeta): ), "_datasets_folder": ( lambda x: os.path.join( - x[0], deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(x[1]) + x[0], deeplabcut.auxiliaryfunctions.get_training_set_folder(x[1]) ), lambda self: (self.project_root, self.cfg), ), diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 73539d20cf..42cdd39f4b 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -338,3 +338,9 @@ def apply_to_keypoints( kp = tuple(kp) new_keypoints.append(kp) return new_keypoints + + def _keypoint_in_hole(self, keypoint, hole: tuple[int, int, int, int]) -> bool: + """Reimplemented from Albumentations as was removed in v1.4.0""" + x1, y1, x2, y2 = hole + x, y = keypoint[:2] + return x1 <= x < x2 and y1 <= y < y2 diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index 8cf46595b7..cb7f2127d1 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -19,8 +19,7 @@ BasePredictor, PREDICTORS, ) -from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils - +from deeplabcut.core import inferenceutils Graph = list[tuple[int, int]] diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 2f7f99b167..930d32653e 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -12,7 +12,7 @@ import numpy as np from scipy.optimize import linear_sum_assignment -from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import ( +from deeplabcut.core.inferenceutils import ( calc_object_keypoint_similarity, ) diff --git a/deeplabcut/pose_estimation_pytorch/runners/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py index aeb91a047b..6b8784f713 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/utils.py +++ b/deeplabcut/pose_estimation_pytorch/runners/utils.py @@ -17,6 +17,7 @@ from typing import List, Tuple import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils +from deeplabcut.compat import Engine from deeplabcut.pose_estimation_pytorch.runners.base import Task from deeplabcut.utils import auxiliaryfunctions @@ -39,14 +40,14 @@ def verify_paths( Example: Inputs: - paths = ['proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-5.pt', - 'proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt', - 'proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-1.pt'] + paths = ['proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-5.pt', + 'proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt', + 'proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-1.pt'] pattern = r"^(.*)?snapshot-(\d+)\.pt$" Output: - valid_paths = ['proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-1.pt', - 'proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-5.pt', - 'proj/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt'] + valid_paths = ['proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-1.pt', + 'proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-5.pt', + 'proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt'] """ valid_paths = [x for x in paths if re.match(pattern, x)] invalid_paths = [x for x in paths if x not in valid_paths] @@ -185,7 +186,12 @@ def get_dlc_scorer( snapshot_epochs = int(snapshot.split("-")[-1]) (dlc_scorer, dlc_scorer_legacy) = auxiliaryfunctions.get_scorer_name( - test_cfg, shuffle, train_fraction, snapshot_epochs, modelprefix=model_prefix + test_cfg, + shuffle, + train_fraction, + trainingsiterations=snapshot_epochs, + engine=Engine.PYTORCH, + modelprefix=model_prefix, ) return dlc_scorer, dlc_scorer_legacy @@ -309,7 +315,7 @@ def get_results_filename( dlc_scorerlegacy = "" model_path = "proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/" Output: - 'proj_name/evaluation-results/iteration-0/behaviordate-trainset95shuffle1/DLC_dekr_w32_behaviordateshuffle1_1-snapshot-10.h5' + 'proj_name/evaluation-results-torch/iteration-0/behaviordate-trainset95shuffle1/DLC_dekr_w32_behaviordateshuffle1_1-snapshot-10.h5' """ (_, results_filename, _) = auxiliaryfunctions.check_if_not_evaluated( evaluation_folder, dlc_scorer, dlc_scorerlegacy, os.path.basename(model_path) @@ -348,7 +354,11 @@ def get_model_folder( project_path, str( auxiliaryfunctions.get_model_folder( - train_fraction, shuffle, cfg, modelprefix=model_prefix + train_fraction, + shuffle, + cfg, + engine=Engine.PYTORCH, + modelprefix=model_prefix, ) ), ) @@ -404,13 +414,17 @@ def get_evaluation_folder( model_prefix = "" test_cfg = dict from auxiliaryfunctions.read_cfg(configpath) Output: - 'proj_name/evaluation-results/iteration-0/behaviordate-trainset95shuffle1' + 'proj_name/evaluation-results-torch/iteration-0/behaviordate-trainset95shuffle1' """ evaluation_folder = os.path.join( test_cfg["project_path"], str( auxiliaryfunctions.get_evaluation_folder( - train_fraction, shuffle, test_cfg, modelprefix=model_prefix + train_fraction, + shuffle, + test_cfg, + engine=Engine.PYTORCH, + modelprefix=model_prefix, ) ), ) diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index 67376f52f1..1761865c35 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -21,9 +21,7 @@ from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( read_image_shape_fast, ) -from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import ( - calc_bboxes_from_keypoints, -) +from deeplabcut.core.trackingutils import calc_bboxes_from_keypoints from deeplabcut.utils.auxiliaryfunctions import read_plainconfig # Shaokai's function diff --git a/deeplabcut/pose_estimation_tensorflow/__init__.py b/deeplabcut/pose_estimation_tensorflow/__init__.py index 819a48b2c7..45560a1114 100644 --- a/deeplabcut/pose_estimation_tensorflow/__init__.py +++ b/deeplabcut/pose_estimation_tensorflow/__init__.py @@ -12,6 +12,10 @@ # Licensed under GNU Lesser General Public License v3.0 # +# Suppress tensorflow warning messages +import tensorflow as tf +tf.compat.v1.logging.set_verbosity(tf.compat.v1.logging.ERROR) + from deeplabcut.pose_estimation_tensorflow.config import * from deeplabcut.pose_estimation_tensorflow.datasets import * from deeplabcut.pose_estimation_tensorflow.default_config import * diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py index 8d6c4de19d..e845ad089d 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py +++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py @@ -18,13 +18,13 @@ from scipy.spatial import cKDTree from tqdm import tqdm +from deeplabcut.core import crossvalutils from deeplabcut.pose_estimation_tensorflow.core.evaluate import ( make_results_file, keypoint_error, ) from deeplabcut.pose_estimation_tensorflow.training import return_train_network_path from deeplabcut.pose_estimation_tensorflow.config import load_config -from deeplabcut.pose_estimation_tensorflow.lib import crossvalutils from deeplabcut.utils import visualization @@ -682,3 +682,7 @@ def evaluate_multianimal_full( make_results_file(final_result, evaluationfolder, DLCscorer) os.chdir(str(start_path)) + + +# backwards compatibility +_find_closest_neighbors = find_closest_neighbors diff --git a/deeplabcut/pose_estimation_tensorflow/lib/__init__.py b/deeplabcut/pose_estimation_tensorflow/lib/__init__.py index 6b45344c4b..52c30a86f5 100644 --- a/deeplabcut/pose_estimation_tensorflow/lib/__init__.py +++ b/deeplabcut/pose_estimation_tensorflow/lib/__init__.py @@ -8,15 +8,8 @@ # # Licensed under GNU Lesser General Public License v3.0 # -""" -DeepLabCut2.0 Toolbox (deeplabcut.org) -© A. & M. Mathis Labs -https://github.com/DeepLabCut/DeepLabCut -Please see AUTHORS for contributors. -https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -Licensed under GNU Lesser General Public License v3.0 - -""" - -from deeplabcut.pose_estimation_tensorflow.lib import * +# imports for backwards compatibility +import deeplabcut.core.crossvalutils +import deeplabcut.core.inferenceutils +import deeplabcut.core.trackingutils diff --git a/deeplabcut/pose_estimation_tensorflow/predict_videos.py b/deeplabcut/pose_estimation_tensorflow/predict_videos.py index 3a994388fa..4dd0046024 100644 --- a/deeplabcut/pose_estimation_tensorflow/predict_videos.py +++ b/deeplabcut/pose_estimation_tensorflow/predict_videos.py @@ -31,9 +31,9 @@ from skimage.util import img_as_ubyte from tqdm import tqdm +from deeplabcut.core import trackingutils, inferenceutils from deeplabcut.pose_estimation_tensorflow.config import load_config from deeplabcut.pose_estimation_tensorflow.core import predict -from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils, trackingutils from deeplabcut.refine_training_dataset.stitch import stitch_tracklets from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, auxfun_models diff --git a/deeplabcut/pose_tracking_pytorch/create_dataset.py b/deeplabcut/pose_tracking_pytorch/create_dataset.py index 9660d85256..7f823d074a 100644 --- a/deeplabcut/pose_tracking_pytorch/create_dataset.py +++ b/deeplabcut/pose_tracking_pytorch/create_dataset.py @@ -13,7 +13,7 @@ import os import pickle import shelve -from deeplabcut.pose_estimation_tensorflow.lib import trackingutils +from deeplabcut.core import trackingutils from deeplabcut.refine_training_dataset.stitch import TrackletStitcher from pathlib import Path from .tracking_utils.preprocessing import query_feature_by_coord_in_img_space diff --git a/deeplabcut/refine_training_dataset/outlier_frames.py b/deeplabcut/refine_training_dataset/outlier_frames.py index 687ce93c36..0072380546 100644 --- a/deeplabcut/refine_training_dataset/outlier_frames.py +++ b/deeplabcut/refine_training_dataset/outlier_frames.py @@ -23,7 +23,7 @@ import statsmodels.api as sm from skimage.util import img_as_ubyte -from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils +from deeplabcut.core import inferenceutils from deeplabcut.utils import ( auxiliaryfunctions, auxfun_multianimal, diff --git a/deeplabcut/refine_training_dataset/stitch.py b/deeplabcut/refine_training_dataset/stitch.py index d760f1efd1..bd88e638f0 100644 --- a/deeplabcut/refine_training_dataset/stitch.py +++ b/deeplabcut/refine_training_dataset/stitch.py @@ -23,7 +23,7 @@ import deeplabcut from deeplabcut.utils.auxfun_videos import VideoWriter from functools import partial -from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import ( +from deeplabcut.core.trackingutils import ( calc_iou, TRACK_METHODS, ) diff --git a/deeplabcut/utils/auxfun_models.py b/deeplabcut/utils/auxfun_models.py index 3d7f83cb9d..2e3b22a98d 100644 --- a/deeplabcut/utils/auxfun_models.py +++ b/deeplabcut/utils/auxfun_models.py @@ -19,7 +19,6 @@ """ import os -import tensorflow as tf from pathlib import Path from deeplabcut.utils import auxiliaryfunctions @@ -158,6 +157,8 @@ def tarfilenamecutting(tarf): def set_visible_devices(gputouse: int): + import tensorflow as tf + physical_devices = tf.config.list_physical_devices("GPU") n_devices = len(physical_devices) if gputouse >= n_devices: diff --git a/deeplabcut/utils/auxfun_multianimal.py b/deeplabcut/utils/auxfun_multianimal.py index fa337444fa..1053e07e93 100644 --- a/deeplabcut/utils/auxfun_multianimal.py +++ b/deeplabcut/utils/auxfun_multianimal.py @@ -33,7 +33,7 @@ from deeplabcut.utils import auxiliaryfunctions, conversioncode from deeplabcut.generate_training_dataset import trainingsetmanipulation -from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import TRACK_METHODS +from deeplabcut.core.trackingutils import TRACK_METHODS def reorder_individuals_in_df(df: pd.DataFrame, order: list) -> pd.DataFrame: diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index a3d5924dbd..9a2e4f9881 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -17,6 +17,7 @@ https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS Licensed under GNU Lesser General Public License v3.0 """ +from __future__ import annotations import os import typing @@ -28,7 +29,9 @@ import ruamel.yaml.representer import yaml from ruamel.yaml import YAML -from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import TRACK_METHODS + +import deeplabcut.compat as compat +from deeplabcut.core.trackingutils import TRACK_METHODS from deeplabcut.utils import auxfun_videos, auxfun_multianimal @@ -38,105 +41,111 @@ def create_config_template(multianimal=False): """ if multianimal: yaml_str = """\ - # Project definitions (do not edit) - Task: - scorer: - date: - multianimalproject: - identity: - \n - # Project path (change when moving around) - project_path: - \n - # Annotation data set configuration (and individual video cropping parameters) - video_sets: - individuals: - uniquebodyparts: - multianimalbodyparts: - bodyparts: - \n - # Fraction of video to start/stop when extracting frames for labeling/refinement - start: - stop: - numframes2pick: - \n - # Plotting configuration - skeleton: - skeleton_color: - pcutoff: - dotsize: - alphavalue: - colormap: - \n - # Training,Evaluation and Analysis configuration - TrainingFraction: - iteration: - default_net_type: - default_augmenter: - default_track_method: - snapshotindex: - batch_size: - \n - # Cropping Parameters (for analysis and outlier frame detection) - cropping: - #if cropping is true for analysis, then set the values here: - x1: - x2: - y1: - y2: - \n - # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) - corner2move2: - move2corner: +# Project definitions (do not edit) +Task: +scorer: +date: +multianimalproject: +identity: +\n +# Project path (change when moving around) +project_path: +\n +# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow) +engine: pytorch +\n +# Annotation data set configuration (and individual video cropping parameters) +video_sets: +individuals: +uniquebodyparts: +multianimalbodyparts: +bodyparts: +\n +# Fraction of video to start/stop when extracting frames for labeling/refinement +start: +stop: +numframes2pick: +\n +# Plotting configuration +skeleton: +skeleton_color: +pcutoff: +dotsize: +alphavalue: +colormap: +\n +# Training,Evaluation and Analysis configuration +TrainingFraction: +iteration: +default_net_type: +default_augmenter: +default_track_method: +snapshotindex: +batch_size: +\n +# Cropping Parameters (for analysis and outlier frame detection) +cropping: +#if cropping is true for analysis, then set the values here: +x1: +x2: +y1: +y2: +\n +# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) +corner2move2: +move2corner: """ else: yaml_str = """\ - # Project definitions (do not edit) - Task: - scorer: - date: - multianimalproject: - identity: - \n - # Project path (change when moving around) - project_path: - \n - # Annotation data set configuration (and individual video cropping parameters) - video_sets: - bodyparts: - \n - # Fraction of video to start/stop when extracting frames for labeling/refinement - start: - stop: - numframes2pick: - \n - # Plotting configuration - skeleton: - skeleton_color: - pcutoff: - dotsize: - alphavalue: - colormap: - \n - # Training,Evaluation and Analysis configuration - TrainingFraction: - iteration: - default_net_type: - default_augmenter: - snapshotindex: - batch_size: - \n - # Cropping Parameters (for analysis and outlier frame detection) - cropping: - #if cropping is true for analysis, then set the values here: - x1: - x2: - y1: - y2: - \n - # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) - corner2move2: - move2corner: +# Project definitions (do not edit) +Task: +scorer: +date: +multianimalproject: +identity: +\n +# Project path (change when moving around) +project_path: +\n +# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow) +engine: pytorch +\n +# Annotation data set configuration (and individual video cropping parameters) +video_sets: +bodyparts: +\n +# Fraction of video to start/stop when extracting frames for labeling/refinement +start: +stop: +numframes2pick: +\n +# Plotting configuration +skeleton: +skeleton_color: +pcutoff: +dotsize: +alphavalue: +colormap: +\n +# Training,Evaluation and Analysis configuration +TrainingFraction: +iteration: +default_net_type: +default_augmenter: +snapshotindex: +batch_size: +\n +# Cropping Parameters (for analysis and outlier frame detection) +cropping: +#if cropping is true for analysis, then set the values here: +x1: +x2: +y1: +y2: +\n +# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) +corner2move2: +move2corner: """ ruamelFile = YAML() @@ -150,27 +159,27 @@ def create_config_template_3d(): """ yaml_str = """\ # Project definitions (do not edit) - Task: - scorer: - date: - \n +Task: +scorer: +date: +\n # Project path (change when moving around) - project_path: - \n +project_path: +\n # Plotting configuration - skeleton: # Note that the pairs must be defined, as you want them linked! - skeleton_color: - pcutoff: - colormap: - dotsize: - alphaValue: - markerType: - markerColor: - \n +skeleton: # Note that the pairs must be defined, as you want them linked! +skeleton_color: +pcutoff: +colormap: +dotsize: +alphaValue: +markerType: +markerColor: +\n # Number of cameras, camera names, path of the config files, shuffle index and trainingsetindex used to analyze videos: - num_cameras: - camera_names: - scorername_3d: # Enter the scorer name for the 3D output +num_cameras: +camera_names: +scorername_3d: # Enter the scorer name for the 3D output """ ruamelFile_3d = YAML() cfg_file_3d = ruamelFile_3d.load(yaml_str) @@ -187,7 +196,7 @@ def read_config(configname): try: with open(path, "r") as f: cfg = ruamelFile.load(f) - curr_dir = os.path.dirname(configname) + curr_dir = str(Path(configname).parent.resolve()) if cfg["project_path"] != curr_dir: cfg["project_path"] = curr_dir write_config(configname, cfg) @@ -287,6 +296,7 @@ def get_bodyparts(cfg: dict) -> typing.List[str]: return cfg["bodyparts"] + def get_unique_bodyparts(cfg : dict) -> typing.List[str]: """ Args: @@ -524,31 +534,64 @@ def get_data_and_metadata_filenames(trainingsetfolder, trainFraction, shuffle, c return datafn, metadatafn -def get_model_folder(trainFraction, shuffle, cfg, modelprefix=""): - Task = cfg["Task"] - date = cfg["date"] - iterate = "iteration-" + str(cfg["iteration"]) +def get_model_folder( + trainFraction: float, + shuffle: int, + cfg: dict, + engine: compat.Engine = compat.Engine.TF, + modelprefix: str = "", +) -> Path: + """ + Args: + trainFraction: the training fraction (as defined in the project configuration) + for which to get the model folder + shuffle: the index of the shuffle for which to get the model folder + cfg: the project configuration + engine: The engine for which we want the model folder. Defaults to `tensorflow` + for backwards compatibility with DeepLabCut 2.X + modelprefix: The name of the folder + + Returns: + the relative path from the project root to the folder containing the model files + for a shuffle (configuration files, snapshots, training logs, ...) + """ + proj_id = f"{cfg['Task']}{cfg['date']}" return Path( modelprefix, - "dlc-models", - iterate, - Task - + date - + "-trainset" - + str(int(trainFraction * 100)) - + "shuffle" - + str(shuffle), + engine.model_folder_name, + f"iteration-{cfg['iteration']}", + f"{proj_id}-trainset{int(trainFraction * 100)}shuffle{shuffle}", ) -def get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=""): +def get_evaluation_folder( + trainFraction, + shuffle, + cfg, + engine: compat.Engine = compat.Engine.TF, + modelprefix="", +): + """ + Args: + trainFraction: the training fraction (as defined in the project configuration) + for which to get the evaluation folder + shuffle: the index of the shuffle for which to get the evaluation folder + cfg: the project configuration + engine: The engine for which we want the model folder. Defaults to `tensorflow` + for backwards compatibility with DeepLabCut 2.X + modelprefix: The name of the folder + + Returns: + the relative path from the project root to the folder containing the model files + for a shuffle (configuration files, snapshots, training logs, ...) + """ Task = cfg["Task"] date = cfg["date"] iterate = "iteration-" + str(cfg["iteration"]) if "eval_prefix" in cfg: eval_prefix = cfg["eval_prefix"] else: - eval_prefix = "evaluation-results" + eval_prefix = engine.results_folder_name return Path( modelprefix, eval_prefix, @@ -600,11 +643,24 @@ def form_data_containers(df, bodyparts): def get_scorer_name( - cfg, shuffle, trainFraction, trainingsiterations="unknown", modelprefix="" + cfg: dict, + shuffle: int, + trainFraction: float, + engine: compat.Engine | None = None, + trainingsiterations: str | int = "unknown", + modelprefix: str = "", ): """Extract the scorer/network name for a particular shuffle, training fraction, etc. + If the engine is not specified, determines which to use from Returns tuple of DLCscorer, DLCscorerlegacy (old naming convention) """ + if engine is None: + engine = compat.get_shuffle_engine( + cfg=cfg, + trainingsetindex=cfg["TrainingFraction"].index(trainFraction), + shuffle=shuffle, + modelprefix=modelprefix, + ) Task = cfg["Task"] date = cfg["date"] @@ -616,12 +672,10 @@ def get_scorer_name( "Changing snapshotindext to the last one -- plotting, videomaking, etc. should not be performed for all indices. For more selectivity enter the ordinal number of the snapshot you want (ie. 4 for the fifth) in the config file." ) snapshotindex = -1 - else: - snapshotindex = cfg["snapshotindex"] modelfolder = os.path.join( cfg["project_path"], - str(get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)), + str(get_model_folder(trainFraction, shuffle, cfg, engine=engine, modelprefix=modelprefix)), "train", ) Snapshots = np.array( @@ -639,13 +693,13 @@ def get_scorer_name( dlc_cfg = read_plainconfig( os.path.join( cfg["project_path"], - str(get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)), + str(get_model_folder(trainFraction, shuffle, cfg, engine=engine, modelprefix=modelprefix)), "train", - "pose_cfg.yaml", + engine.pose_cfg_name, ) ) # ABBREVIATE NETWORK NAMES -- esp. for mobilenet! - if dlc_cfg.get("engine", "pytorch") == "pytorch": # TODO: default should be TF + if engine == compat.Engine.PYTORCH: netname = "".join([p.capitalize() for p in dlc_cfg["net_type"].split("_")]) elif "resnet" in dlc_cfg["net_type"]: if dlc_cfg.get("multi_stage", False): diff --git a/deeplabcut/utils/make_labeled_video.py b/deeplabcut/utils/make_labeled_video.py index 2f0b669feb..c9ef278e7e 100644 --- a/deeplabcut/utils/make_labeled_video.py +++ b/deeplabcut/utils/make_labeled_video.py @@ -43,7 +43,6 @@ from skimage.util import img_as_ubyte from tqdm import trange -from deeplabcut.pose_estimation_tensorflow.config import load_config from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, visualization from deeplabcut.utils.auxfun_videos import VideoWriter from deeplabcut.utils.video_processor import ( @@ -588,8 +587,7 @@ def create_labeled_video( if superanimal_name != "": dlc_root_path = auxiliaryfunctions.get_deeplabcut_path() dataset_name = "_".join(superanimal_name.split("_")[:-1]) - - test_cfg = load_config( + test_cfg = auxiliaryfunctions.read_plainconfig( os.path.join( dlc_root_path, "modelzoo", @@ -1052,7 +1050,7 @@ def create_video_with_all_detections( """ import re - from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembler + from deeplabcut.core.inferenceutils import Assembler cfg = auxiliaryfunctions.read_config(config) trainFraction = cfg["TrainingFraction"][trainingsetindex] diff --git a/deeplabcut/utils/plotting.py b/deeplabcut/utils/plotting.py index 1320791a34..500f42e709 100644 --- a/deeplabcut/utils/plotting.py +++ b/deeplabcut/utils/plotting.py @@ -32,7 +32,7 @@ import matplotlib.pyplot as plt import numpy as np -from deeplabcut.pose_estimation_tensorflow.lib import crossvalutils +from deeplabcut.core import crossvalutils from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, visualization diff --git a/docs/pytorch/pytorch_config.md b/docs/pytorch/pytorch_config.md index f69bb1e096..4ba40e91d6 100644 --- a/docs/pytorch/pytorch_config.md +++ b/docs/pytorch/pytorch_config.md @@ -1,3 +1,4 @@ +(dlc3-pytorch-config)= # The PyTorch Configuration file The `pytorch_config.yaml` file specifies everything about how you'll train a model for a @@ -10,7 +11,7 @@ You can create "base" configurations using `deeplabcut.create_training_set` or ### Bottom-Up -There are a few keys define your model and training run: +There are a few keys which define your model architecture and how it will be trained: - `batch_size`: the batch size to train with (inference always runs with batch size 1) - `data`: the data augmentations you'll apply to images when training (during inference, @@ -36,7 +37,7 @@ configure how you want your detector to be trained: - `data_detector`: the data augmentations to use to train the detector (in the same format as the ones for the pose model) -In this case, the `data` augmentations are applied to the pose model. +In this case, the augmentations described in `data` are applied to the pose model. ## Data Augmentations @@ -60,6 +61,6 @@ Logs results to Weights & Biases. ```yaml logger: type: 'WandbLogger' - project_name: 'dlc3-project' + project_name: 'my-dlc3-project' run_name: 'dekr-w32-shuffle0' ``` diff --git a/docs/pytorch/user_guide.md b/docs/pytorch/user_guide.md new file mode 100644 index 0000000000..da475cbfd1 --- /dev/null +++ b/docs/pytorch/user_guide.md @@ -0,0 +1,76 @@ +(dlc3-user-guide)= +# DeepLabCut 3.0 - Pytorch User Guide + +## Using DeepLabCut 3.0 + +PyTorch models can be trained on any DeepLabCut project. Simply add a new key to your +project `config.yaml` file specifying `engine: pytorch`. Then any new training dataset +that will be created will be a PyTorch model (see +[Creating Shuffles and Model Configuration](#Creating-Shuffles-and-Model-Configuration)) +to learn more about training PyTorch models. To train Tensorflow models again, you can +set `engine: tensorflow`. + +### Using the GUI + +You can use the GUI to train DeepLabCut projects. However, you cannot switch between +PyTorch and Tensorflow models while using the GUI. If you have set your engine to +`pytorch`, then the GUI will only offer the creation of Pytorch shuffles. + +You can create `tensorflow` shuffles and train them again by setting the +`engine: tensorflow` key and either re-loading the project or closing the GUI entirely +and opening the project again. + +## Major changes + +### From iterations to epochs + +Pytorch models in DeepLabCut 3.0 are trained for a set number of epochs, instead of a +maximum number of iterations. An epoch is a single pass through the training dataset, +which means your model has seen each training image exactly once. + +So if you have 64 training images for your network, an epoch is 64 iterations with batch +size 1 (or 32 iterations with batch size 2, 16 with batch size 4, etc.). + +## API + +### Development state + +The table below describes the DeepLabCut API methods that have been implemented, +as well as indications which options are not yet implemented, and which parameters +are not valid for the DLC 3.0 API. + +You can find all DLC 3.0 API methods and the parameters they can be called with in + + +| API Method | Implemented | Parameters not yet implemented | Parameters invalid for pytorch | +|--------------------------------|:-----------:|-------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------| +| `train_network` | 🟢 | `max_snapshots_to_keep`, `keepdeconvweights` | `maxiters`, `saveiters`, `allow_growth`, `autotune` | +| `return_train_network_path` | 🟢 | | | +| `evaluate_network` | 🟢 | `comparisonbodyparts`, `rescale`, `per_keypoint_evaluation` | | +| `return_evaluate_network_data` | 🔴 | | `TFGPUinference`, `allow_growth` | +| `analyze_videos` | 🟢 | `use_shelve`, `save_as_csv`, `in_random_order`, `batchsize`, `cropping`, `dynamic`, `robust_nframes`, `n_tracks`, `calibrate` | | +| `create_tracking_dataset` | 🔴 | | | +| `analyze_time_lapse_frames` | 🔴 | | | +| `convert_detections2tracklets` | 🟢 | `greedy`, `calibrate`, `window_size` | | +| `extract_maps` | 🔴 | | | +| `visualize_scoremaps` | 🔴 | | | +| `visualize_locrefs` | 🔴 | | | +| `visualize_paf` | 🔴 | | | +| `extract_save_all_maps` | 🔴 | | | +| `export_model` | 🔴 | | | + + +### Creating Shuffles and Model Configuration + +You can configure models using the `pytorch_config.yaml` file, as described +[here](dlc3-pytorch-config). You can use the same methods to create new shuffles in +DeepLabCut 3.0 as you did for Tensorflow models (`deeplabcut.create_training_dataset` +and `deeplabcut.create_training_model_comparison`). + +You can see a list of supported +architectures/variants by using: + +```python +from deeplabcut.pose_estimation_pytorch import available_models +print(available_models()) +``` diff --git a/examples/testscript_pytorch_single_animal.py b/examples/testscript_pytorch_single_animal.py new file mode 100644 index 0000000000..231c4303fb --- /dev/null +++ b/examples/testscript_pytorch_single_animal.py @@ -0,0 +1,128 @@ +""" Testscript for single animal PyTorch projects """ +import time +from pathlib import Path +from typing import Any + +import deeplabcut +import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.compat import Engine +from deeplabcut.generate_training_dataset import get_existing_shuffle_indices + + +def run( + config_path: Path, + train_fraction: float, + trainset_index: int, + net_type: str, + videos: list[str], + device: str, + train_kwargs: dict, + engine: Engine = Engine.PYTORCH, + create_labeled_videos: bool = False, +) -> None: + times = [time.time()] + log_step(f"Testing with net type {net_type}") + log_step("Creating the training dataset") + deeplabcut.create_training_dataset(str(config_path), net_type=net_type, engine=engine) + existing_shuffles = get_existing_shuffle_indices( + config_path, train_fraction=train_fraction, engine=engine + ) + shuffle_index = existing_shuffles[-1] + + log_step(f"Starting training for train_frac {train_fraction}, shuffle {shuffle_index}") + deeplabcut.train_network( + config=str(config_path), + shuffle=shuffle_index, + trainingsetindex=trainset_index, + device=device, + **train_kwargs, + ) + times.append(time.time()) + log_step(f"Train time: {times[-1] - times[-2]} seconds") + + log_step(f"Starting evaluation for train_frac {train_fraction}, shuffle {shuffle_index}") + deeplabcut.evaluate_network( + config=str(config_path), + Shuffles=[shuffle_index], + trainingsetindex=trainset_index, + device=device, + ) + times.append(time.time()) + log_step(f"Evaluation time: {times[-1] - times[-2]} seconds") + + log_step(f"Analyzing videos for {train_fraction}, shuffle {shuffle_index}") + deeplabcut.analyze_videos( + config=str(config_path), + videos=videos, + shuffle=shuffle_index, + trainingsetindex=trainset_index, + device=device, + ) + times.append(time.time()) + log_step(f"Video analysis time: {times[-1] - times[-2]} seconds") + log_step(f"Total test time: {times[-1] - times[0]} seconds") + + if create_labeled_videos: + log_step(f"Creating a labeled video for {train_fraction}, shuffle {shuffle_index}") + deeplabcut.create_labeled_video( + config=str(config_path), + videos=videos, + shuffle=shuffle_index, + trainingsetindex=trainset_index, + ) + + +def main( + net_types: list[str], + epochs: int = 1, + save_epochs: int = 1, + batch_size: int = 1, + device: str = "cpu", + create_labeled_videos: bool = False, +) -> None: + engine = Engine.PYTORCH + project_path = Path.cwd() / "openfield-Pranav-2018-10-30" + config_path = project_path / "config.yaml" + cfg = af.read_config(config_path) + trainset_index = 0 + train_frac = cfg["TrainingFraction"][trainset_index] + for net_type in net_types: + try: + run( + config_path=config_path, + train_fraction=train_frac, + trainset_index=trainset_index, + net_type=net_type, + videos=[str(project_path / "videos" / "m3v1mp4.mp4")], + device=device, + train_kwargs=dict( + display_iters=1, + epochs=epochs, + save_epochs=save_epochs, + batch_size=batch_size, + ), + engine=engine, + create_labeled_videos=create_labeled_videos, + ) + except Exception as err: + log_step(f"FAILED TO RUN {net_type}") + log_step(str(err)) + log_step("Continuing to next model") + raise err + + +def log_step(message: Any) -> None: + print(100 * "-") + print(str(message)) + print(100 * "-") + + +if __name__ == "__main__": + main( + net_types=["resnet_50", "hrnet_w18", "hrnet_w32"], + batch_size=8, + epochs=1, + save_epochs=1, + device="cpu", # "cpu", "cuda:0", "mps" + create_labeled_videos=False, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 1e67d0ca9f..1b29154f98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ import shutil import urllib.request import zipfile -from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils +from deeplabcut.core import inferenceutils from io import BytesIO from PIL import Image from tqdm import tqdm diff --git a/tests/pose_estimation_pytorch/config/test_utils.py b/tests/pose_estimation_pytorch/config/test_config_utils.py similarity index 100% rename from tests/pose_estimation_pytorch/config/test_utils.py rename to tests/pose_estimation_pytorch/config/test_config_utils.py diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py index 8387dd4c1b..2eb2deac09 100644 --- a/tests/pose_estimation_pytorch/config/test_make_pose_config.py +++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py @@ -30,12 +30,19 @@ def test_make_single_animal_config(bodyparts: list[str], net_type: str): assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() # check that the bodypart head has locref and heatmaps and the correct output shapes bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"] - for name, output_channels in [ - ("heatmap_config", len(bodyparts)), - ("locref_config", 2 * len(bodyparts)), - ]: + + outputs = [("heatmap_config", len(bodyparts))] + if bodypart_head["predictor"]["location_refinement"]: + outputs += [("locref_config", 2 * len(bodyparts))] + + for name, output_channels in outputs: + head = bodypart_head[name] + if "final_conv" in head: + actual_output_channels = head["final_conv"]["out_channels"] + else: + actual_output_channels = head["channels"][-1] assert name in bodypart_head - assert bodypart_head[name]["channels"][-1] == output_channels + assert actual_output_channels == output_channels @pytest.mark.parametrize("multianimal", [True]) diff --git a/tests/pose_estimation_pytorch/modelzoo/test_utils.py b/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py similarity index 100% rename from tests/pose_estimation_pytorch/modelzoo/test_utils.py rename to tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py diff --git a/tests/pose_estimation_pytorch/other/test_dataset.py b/tests/pose_estimation_pytorch/other/test_dataset.py index e6d77b2468..eb4fa6fc7d 100644 --- a/tests/pose_estimation_pytorch/other/test_dataset.py +++ b/tests/pose_estimation_pytorch/other/test_dataset.py @@ -9,6 +9,7 @@ import deeplabcut.pose_estimation_pytorch as dlc import deeplabcut.utils.auxiliaryfunctions as dlc_auxfun +from deeplabcut.compat import Engine from deeplabcut.generate_training_dataset import create_training_dataset @@ -23,7 +24,12 @@ def mock_aux() -> Mock: def _get_dataset(path, transform, mode="train"): project_root = Path(path) if not (project_root / "training-datasets").exists(): - create_training_dataset(config=str(project_root / "config.yaml")) + print(str(project_root / "config.yaml")) + create_training_dataset( + config=str(project_root / "config.yaml"), + net_type="resnet_50", + engine=Engine.PYTORCH, + ) loader = dlc.DLCLoader(path, model_config_path="", shuffle=1) dataset = loader.create_dataset(transform=transform, mode=mode) diff --git a/tests/test_crossvalutils.py b/tests/test_crossvalutils.py index b501a7f9a6..beef5bc319 100644 --- a/tests/test_crossvalutils.py +++ b/tests/test_crossvalutils.py @@ -10,8 +10,7 @@ # import numpy as np import pickle -from deeplabcut.pose_estimation_tensorflow.lib import crossvalutils - +from deeplabcut.core import crossvalutils BEST_GRAPH = [14, 15, 16, 11, 22, 31, 61, 7, 59, 62, 64] BEST_GRAPH_MONTBLANC = [1, 0, 2, 5, 4, 3] diff --git a/tests/test_inferenceutils.py b/tests/test_inferenceutils.py index f44aad610f..124d1e064c 100644 --- a/tests/test_inferenceutils.py +++ b/tests/test_inferenceutils.py @@ -14,7 +14,7 @@ import pytest from conftest import TEST_DATA_DIR from copy import deepcopy -from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils +from deeplabcut.core import inferenceutils from scipy.spatial.distance import squareform diff --git a/tests/test_trackingutils.py b/tests/test_trackingutils.py index 1795db03ee..984fcc2a76 100644 --- a/tests/test_trackingutils.py +++ b/tests/test_trackingutils.py @@ -10,7 +10,7 @@ # import numpy as np import pytest -from deeplabcut.pose_estimation_tensorflow.lib import trackingutils +from deeplabcut.core import trackingutils @pytest.fixture() From 120c794410f422ad4d03014d225728fc1ebd2782 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 23 Feb 2024 16:04:53 +0100 Subject: [PATCH 069/293] niels/bug_fixes (#169) * updated file headers * updated codespell for CTD and fixed PEP readme * added net type docs + fixed default configs --- .github/workflows/codespell.yml | 2 +- deeplabcut/core/crossvalutils.py | 2 +- deeplabcut/core/inferenceutils.py | 2 +- deeplabcut/core/trackingutils.py | 2 +- deeplabcut/pose_estimation_pytorch/README.md | 22 ++--- .../apis/convert_detections_to_tracklets.py | 2 +- .../pose_estimation_pytorch/apis/scoring.py | 2 +- .../benchmark/__init__.py | 10 +++ .../benchmark/profile_HRNetCoAM.py | 11 +++ .../config/base/detector.yaml | 4 +- .../config/base/head_identity.yaml | 3 - .../{head_hrnet.yaml => head_topdown.yaml} | 0 .../config/make_pose_config.py | 7 +- docs/pytorch/architectures.md | 84 +++++++++++++++++++ docs/pytorch/user_guide.md | 3 +- .../apis/test_scoring.py | 10 +++ .../config/test_make_pose_config.py | 10 +++ .../data/test_postprocessor.py | 10 +++ .../data/test_preprocessor.py | 10 +++ .../data/test_transforms.py | 10 +++ .../target_generators/test_heatmap_targets.py | 10 +++ .../target_generators/test_plateau_targets.py | 10 +++ .../modelzoo/test_download.py | 2 +- .../modelzoo/test_modelzoo_utils.py | 10 +++ .../modelzoo/test_webapp.py | 10 +++ .../other/test_api_utils.py | 10 +++ .../other/test_custom_transforms.py | 10 +++ .../other/test_data_helper.py | 10 +++ .../other/test_dataset.py | 10 +++ .../other/test_gaussian_targets.py | 10 +++ .../other/test_heatmap_plateau_targets.py | 2 +- .../other/test_helper.py | 10 +++ .../other/test_match_predictions_to_gt.py | 2 +- .../other/test_modelzoo.py | 10 +++ .../other/test_paf_targets.py | 10 +++ .../other/test_pose_model.py | 10 +++ .../other/test_schedulers.py | 2 +- .../other/test_seq_targets.py | 10 +++ .../post_processing/test_identity.py | 10 +++ .../runners/bottum_up.py | 10 +++ .../runners/test_task.py | 10 +++ 41 files changed, 354 insertions(+), 30 deletions(-) rename deeplabcut/pose_estimation_pytorch/config/base/{head_hrnet.yaml => head_topdown.yaml} (100%) create mode 100644 docs/pytorch/architectures.md diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index cd0a1e8133..583406cbb0 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,4 +18,4 @@ jobs: - name: Codespell uses: codespell-project/actions-codespell@v1 with: - ignore_words_list: bu,BU,td,TD + ignore_words_list: bu,BU,td,TD,ctd,CTD diff --git a/deeplabcut/core/crossvalutils.py b/deeplabcut/core/crossvalutils.py index e143d9f02f..03a3e3a0c2 100644 --- a/deeplabcut/core/crossvalutils.py +++ b/deeplabcut/core/crossvalutils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/core/inferenceutils.py b/deeplabcut/core/inferenceutils.py index 92bac71ace..65f0f15ddf 100644 --- a/deeplabcut/core/inferenceutils.py +++ b/deeplabcut/core/inferenceutils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/core/trackingutils.py b/deeplabcut/core/trackingutils.py index 7cc88a92bd..b0a03eed5f 100644 --- a/deeplabcut/core/trackingutils.py +++ b/deeplabcut/core/trackingutils.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/README.md b/deeplabcut/pose_estimation_pytorch/README.md index cd84bc0bf4..acffd9454b 100644 --- a/deeplabcut/pose_estimation_pytorch/README.md +++ b/deeplabcut/pose_estimation_pytorch/README.md @@ -1,28 +1,28 @@ # PyTorch DeepLabCut API -##### Structure of the repo: +This is written primarily for maintainers and expert users. It details the logic for the DLC3.0 pytorch code. It is a WIP and will be expanded before the full release in 2024. -#teamDLC Dec 2023 -Like any ML model this repo contains: models (architectures), solvers (losses and optimizers), and data (data loaders). +**Structure of the pytorch DLC code:** + +This repo contains: models (architectures), solvers (losses and optimizers), and data (data loaders). [Models](#models) [Solvers](#solvers) [Data](#data) -[APIs](#apis) +[API](#API) ## Models - [models](models): The `deeplabcut.pose_estimations_pytorch.models` package contains all components related to building a model with `backbone`, `neck` (optional) and `head`. -We provide sota models such as HRNet, BUCTD, TransPose, ... +We provide state-of-the-art models such as DLCRNet, HRNet, BUCTD, TransPose, ... more are coming! -If you want to add a novel model, you have to divide it into backbone, neck and head. Often neck will be just the identity function. +If you want to add a novel model, you need to divide it into a model backbone, neck and head. Often the 'neck' will be just the identity function. For instance, a [standard pose estimation HRNet](https://github.com/HRNet/HRNet-Human-Pose-Estimation) consists of HRNet backbone, an identity neck and a deconvolution head (Simple Head). - - ## Solvers - [solvers](solvers): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. @@ -31,7 +31,7 @@ For instance, a [standard pose estimation HRNet](https://github.com/HRNet/HRNet- - [data](data/project.py#L7): The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. - - `Project` class provides train and test splitting and converts dataset to required format. For instance, to [COCO]() format. + - `Project` class provides train and test splitting and converts dataset to required format. For instance, to [COCO](https://medium.com/@manuktiwary/coco-format-what-and-how-5c7d22cf5301) format. Example: @@ -73,8 +73,10 @@ The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pyt > By now supports only [albumentations](https://albumentations.ai), will be extended in the future. -## Apis +## API -- [apis](apis): The `deeplabcut.pose_estimations_pytorch.apis` contains functionalities for training and testing as well as the corresponding configuration file [config.yaml](apis/config.yaml). +- [API](API): The `deeplabcut.pose_estimations_pytorch.apis` contains functionalities for training and testing as well as the corresponding configuration file [config.yaml](apis/config.yaml). ## Registry + +- WIP \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index 7da659b9a7..a6d322cca0 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/apis/scoring.py b/deeplabcut/pose_estimation_pytorch/apis/scoring.py index 23e79428b4..eac2497686 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/apis/scoring.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py b/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py index e69de29bb2..117d127147 100644 --- a/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py @@ -0,0 +1,10 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# diff --git a/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py b/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py index fbec9f3b8a..a2f8ad408b 100644 --- a/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py +++ b/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py @@ -1,3 +1,14 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + # Script for reproducing results in Zhou* & Stoffl* et al. for BUCTD with CoAM # path=datapath diff --git a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml index b8f49c41ba..8711b9e87c 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml @@ -17,6 +17,6 @@ detector: type: DetectorRunner max_individuals: "num_individuals" batch_size: 1 - epochs: 500 - save_epochs: 100 + epochs: 250 + save_epochs: 50 display_iters: 500 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml index 73419f67c8..6f62e9f927 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml @@ -15,11 +15,8 @@ criterion: heatmap_config: channels: - "backbone_output_channels" - - "backbone_output_channels // 2" - "num_individuals" kernel_size: - 3 - - 3 strides: - 2 - - 2 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_hrnet.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml similarity index 100% rename from deeplabcut/pose_estimation_pytorch/config/base/head_hrnet.yaml rename to deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index e7bb924043..1841c54e23 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -110,7 +110,7 @@ def make_pytorch_pose_config( is_top_down = model_cfg.get("method", "BU").upper() == "TD" if is_top_down: - model_cfg = add_detector(configs_dir, model_cfg, num_individuals=len(individuals)) + model_cfg = add_detector(configs_dir, model_cfg, len(individuals)) # add the model to the config pose_config = update_config(pose_config, model_cfg) @@ -211,13 +211,12 @@ def create_backbone_with_heatmap_model( backbone_output_channels = model_config["model"]["backbone_output_channels"] model_config["method"] = "bu" + bodypart_head_name = "head_bodyparts.yaml" if top_down: model_config["method"] = "td" + bodypart_head_name = "head_topdown.yaml" # add a bodypart head - bodypart_head_name = "head_bodyparts.yaml" - if "hrnet" in net_type.lower(): - bodypart_head_name = "head_hrnet.yaml" bodypart_head_config = read_config_as_dict(configs_dir / "base" / bodypart_head_name) model_config["model"]["heads"] = { "bodypart": replace_default_values( diff --git a/docs/pytorch/architectures.md b/docs/pytorch/architectures.md new file mode 100644 index 0000000000..8ca3224aea --- /dev/null +++ b/docs/pytorch/architectures.md @@ -0,0 +1,84 @@ +(dlc3-architectures)= +# DeepLabCut - PyTorch Model Architectures + +## Introduction + +You can see a list of supported architectures/variants by using: + +```python +from deeplabcut.pose_estimation_pytorch import available_models +print(available_models()) +``` + +## Backbones + +Two families of backbones are currently implemented in DeepLabCut PyTorch (more will +come soon!). + +**ResNets** +- From [He, Kaiming, et al. "Deep residual learning for image recognition." Proceedings of the IEEE conference on computer vision and pattern recognition. 2016.](https://openaccess.thecvf.com/content_cvpr_2016/html/He_Deep_Residual_Learning_CVPR_2016_paper.html) +- Current variants are `resnet_50` and `resnet_101` + +**HRNet** +- From [Wang, Jingdong, et al. "Deep high-resolution representation learning for visual recognition." IEEE transactions on pattern analysis and machine intelligence 43.10 (2020): 3349-3364.](https://arxiv.org/abs/1908.07919) +- Variants are `hrnet_w18`, `hrnet_w32` and `hrnet_w48` +- Slower but more powerful than ResNets + +## Single Animal Models + +Single-animal models are composed of a backbone (encoder) and a head (decoder) +predicting the position of keypoints. The default head contains a single deconvolutional +layer. To create the single animal model composed of a backbone and head, you can call +`deeplabcut.create_training_dataset` with `net_type` set to the backbone name (e.g. +`resnet_50` or `hrnet_w32`). + +If you want to add a second deconvolutional layer (which will make your model slower, +but it might improve performance), you can simply edit your `pytorch_config.yaml` file. + +Of course, any multi-animal model can also be used for single-animal projects! + +## Multi-Animal Models + +### Backbones with Part-Affinity Fields + +As in DeepLabCut 2.X, the base multi-animal model is composed of a backbone (encoder) +and a head predicting keypoints and part-affinity fields (PAFs). These PAFs are used to +assemble keypoints for individuals. + +Passing a backbone as a net type (e.g., `resnet_50`, `hrnet_w32`) for a multi-animal +project will create a model consisting of a backbone and a heatmap + PAF head. + +### Top-Down Models + +Top-down pose estimation models split the task into two distinct parts: individual +localization (through an object detector), followed by pose estimation (for each +individual). As localization of individuals is handled by the detector, this simplifies +the pose task to single-animal pose estimation! + +Hence any single-animal model can be transformed into a top-down, multi-animal model. To +do so, simply prefix `top_down` to your single-animal model name. Currently only a +single FasterRCNN variant is available as a detector. Other variants will be added soon! + +The pose model for top-down nets is simply the backbone followed by a single convolution +for pose estimation. It's also possible to add deconvolutional layers to top-down model +heads. + +Example top-down models would be `top_down_resnet_50` and `top_down_hrnet_w32`. + +### Special Architectures + +**Bottom-Up models** + - DEKR: Bottom-Up Human Pose Estimation via Disentangled Keypoint Regression + - [Geng, Zigang, et al. "Bottom-up human pose estimation via disentangled keypoint regression." Proceedings of the IEEE/CVF conference on computer vision and pattern recognition. 2021.](https://openaccess.thecvf.com/content/CVPR2021/html/Geng_Bottom-Up_Human_Pose_Estimation_via_Disentangled_Keypoint_Regression_CVPR_2021_paper.html) + - This model uses HRNet as a backbone. It learns to predict the center of each animal, and predicts the offset between each animal center and their keypoints. + - Three variants are implemented (from smallest to largest): `dekr_w18`, `dekr_w32`, `dekr_w48` + - DLCRNet: + - [Lauer, Jessy, et al. "Multi-animal pose estimation, identification and tracking with DeepLabCut." Nature Methods 19.4 (2022): 496-504.](https://www.nature.com/articles/s41592-022-01443-0) + - This model uses a multi-scale variant of a ResNet as a backbone, and part-affinity +fields to assemble individuals + - Variants: `dlcrnet_stride16_ms5`, `dlcrnet_stride32_ms5` + +**Top-Down models** +- Tokenpose: Learning Keypoint Tokens for Human Pose Estimation + - [Li, Yanjie, et al. "Tokenpose: Learning keypoint tokens for human pose estimation." Proceedings of the IEEE/CVF International conference on computer vision. 2021.](https://arxiv.org/abs/2104.03516) + - One variant is implemented: `tokenpose_base` diff --git a/docs/pytorch/user_guide.md b/docs/pytorch/user_guide.md index da475cbfd1..8128447945 100644 --- a/docs/pytorch/user_guide.md +++ b/docs/pytorch/user_guide.md @@ -67,7 +67,8 @@ You can configure models using the `pytorch_config.yaml` file, as described DeepLabCut 3.0 as you did for Tensorflow models (`deeplabcut.create_training_dataset` and `deeplabcut.create_training_model_comparison`). -You can see a list of supported +More information about the different PyTorch model architectures available in DeepLabCut +is available [here](dlc3-pytorch-config). You can see a list of supported architectures/variants by using: ```python diff --git a/tests/pose_estimation_pytorch/apis/test_scoring.py b/tests/pose_estimation_pytorch/apis/test_scoring.py index d27f378734..bc67e22547 100644 --- a/tests/pose_estimation_pytorch/apis/test_scoring.py +++ b/tests/pose_estimation_pytorch/apis/test_scoring.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Tests for the scoring methods""" import numpy as np import pytest diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py index 2eb2deac09..63f1207a16 100644 --- a/tests/pose_estimation_pytorch/config/test_make_pose_config.py +++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Tests the pre-processors""" import pytest diff --git a/tests/pose_estimation_pytorch/data/test_postprocessor.py b/tests/pose_estimation_pytorch/data/test_postprocessor.py index 236bf4b8bb..b178e8d175 100644 --- a/tests/pose_estimation_pytorch/data/test_postprocessor.py +++ b/tests/pose_estimation_pytorch/data/test_postprocessor.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Tests the pre-processors""" import numpy as np import pytest diff --git a/tests/pose_estimation_pytorch/data/test_preprocessor.py b/tests/pose_estimation_pytorch/data/test_preprocessor.py index e66e8fde7a..cf96a15a4c 100644 --- a/tests/pose_estimation_pytorch/data/test_preprocessor.py +++ b/tests/pose_estimation_pytorch/data/test_preprocessor.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Tests the pre-processors""" import albumentations as A import numpy as np diff --git a/tests/pose_estimation_pytorch/data/test_transforms.py b/tests/pose_estimation_pytorch/data/test_transforms.py index 470b0a4569..424ad72ddd 100644 --- a/tests/pose_estimation_pytorch/data/test_transforms.py +++ b/tests/pose_estimation_pytorch/data/test_transforms.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Tests the custom transforms""" import albumentations as A import numpy as np diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py index 5657ada8c8..3c5fa85a93 100644 --- a/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py +++ b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Tests the heatmap target generators (plateau and gaussian)""" import numpy as np import torch diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py index 01230b7747..4b68b943ae 100644 --- a/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py +++ b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Tests the heatmap target generators (plateau and gaussian)""" import numpy as np import torch diff --git a/tests/pose_estimation_pytorch/modelzoo/test_download.py b/tests/pose_estimation_pytorch/modelzoo/test_download.py index 0d6fcf557f..06cc9857e1 100644 --- a/tests/pose_estimation_pytorch/modelzoo/test_download.py +++ b/tests/pose_estimation_pytorch/modelzoo/test_download.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py b/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py index 08cecebc37..fa7ecdec96 100644 --- a/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py +++ b/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import os import pytest diff --git a/tests/pose_estimation_pytorch/modelzoo/test_webapp.py b/tests/pose_estimation_pytorch/modelzoo/test_webapp.py index e7a0dd847e..9e45c15217 100644 --- a/tests/pose_estimation_pytorch/modelzoo/test_webapp.py +++ b/tests/pose_estimation_pytorch/modelzoo/test_webapp.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import os import cv2 diff --git a/tests/pose_estimation_pytorch/other/test_api_utils.py b/tests/pose_estimation_pytorch/other/test_api_utils.py index b48a220273..a5b97f5ba6 100644 --- a/tests/pose_estimation_pytorch/other/test_api_utils.py +++ b/tests/pose_estimation_pytorch/other/test_api_utils.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import random import numpy as np diff --git a/tests/pose_estimation_pytorch/other/test_custom_transforms.py b/tests/pose_estimation_pytorch/other/test_custom_transforms.py index 69201a4d92..f312fc9978 100644 --- a/tests/pose_estimation_pytorch/other/test_custom_transforms.py +++ b/tests/pose_estimation_pytorch/other/test_custom_transforms.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import numpy as np import pytest diff --git a/tests/pose_estimation_pytorch/other/test_data_helper.py b/tests/pose_estimation_pytorch/other/test_data_helper.py index 7b5460858d..92453ef8e4 100644 --- a/tests/pose_estimation_pytorch/other/test_data_helper.py +++ b/tests/pose_estimation_pytorch/other/test_data_helper.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from __future__ import annotations import os diff --git a/tests/pose_estimation_pytorch/other/test_dataset.py b/tests/pose_estimation_pytorch/other/test_dataset.py index eb4fa6fc7d..efb30bccf0 100644 --- a/tests/pose_estimation_pytorch/other/test_dataset.py +++ b/tests/pose_estimation_pytorch/other/test_dataset.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import os import random from pathlib import Path diff --git a/tests/pose_estimation_pytorch/other/test_gaussian_targets.py b/tests/pose_estimation_pytorch/other/test_gaussian_targets.py index fe780170ab..443e23ebff 100644 --- a/tests/pose_estimation_pytorch/other/test_gaussian_targets.py +++ b/tests/pose_estimation_pytorch/other/test_gaussian_targets.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import pytest import torch diff --git a/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py b/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py index a7f4ab579f..7d2e1e462e 100644 --- a/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py +++ b/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/tests/pose_estimation_pytorch/other/test_helper.py b/tests/pose_estimation_pytorch/other/test_helper.py index ddd1b3ebef..c7cd0fd6f1 100644 --- a/tests/pose_estimation_pytorch/other/test_helper.py +++ b/tests/pose_estimation_pytorch/other/test_helper.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import torch diff --git a/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py b/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py index b3b8a8825f..9e8ec57df4 100644 --- a/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py +++ b/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/tests/pose_estimation_pytorch/other/test_modelzoo.py b/tests/pose_estimation_pytorch/other/test_modelzoo.py index 73ffa0a3dd..f4ed80f5c8 100644 --- a/tests/pose_estimation_pytorch/other/test_modelzoo.py +++ b/tests/pose_estimation_pytorch/other/test_modelzoo.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import os import pytest diff --git a/tests/pose_estimation_pytorch/other/test_paf_targets.py b/tests/pose_estimation_pytorch/other/test_paf_targets.py index 3f08b05e9f..4db48311ad 100644 --- a/tests/pose_estimation_pytorch/other/test_paf_targets.py +++ b/tests/pose_estimation_pytorch/other/test_paf_targets.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import pytest import torch diff --git a/tests/pose_estimation_pytorch/other/test_pose_model.py b/tests/pose_estimation_pytorch/other/test_pose_model.py index 5dad5613fd..e5565e7d2e 100644 --- a/tests/pose_estimation_pytorch/other/test_pose_model.py +++ b/tests/pose_estimation_pytorch/other/test_pose_model.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import copy import random diff --git a/tests/pose_estimation_pytorch/other/test_schedulers.py b/tests/pose_estimation_pytorch/other/test_schedulers.py index 8bba7ed99f..a043020f70 100644 --- a/tests/pose_estimation_pytorch/other/test_schedulers.py +++ b/tests/pose_estimation_pytorch/other/test_schedulers.py @@ -4,7 +4,7 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # diff --git a/tests/pose_estimation_pytorch/other/test_seq_targets.py b/tests/pose_estimation_pytorch/other/test_seq_targets.py index 97d11e7eea..8273bffd21 100644 --- a/tests/pose_estimation_pytorch/other/test_seq_targets.py +++ b/tests/pose_estimation_pytorch/other/test_seq_targets.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# from itertools import combinations import torch diff --git a/tests/pose_estimation_pytorch/post_processing/test_identity.py b/tests/pose_estimation_pytorch/post_processing/test_identity.py index 30f0467923..e0acb800f3 100644 --- a/tests/pose_estimation_pytorch/post_processing/test_identity.py +++ b/tests/pose_estimation_pytorch/post_processing/test_identity.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """ Tests identity matching """ import numpy as np import pytest diff --git a/tests/pose_estimation_pytorch/runners/bottum_up.py b/tests/pose_estimation_pytorch/runners/bottum_up.py index 1028bfa4ff..821c11422d 100644 --- a/tests/pose_estimation_pytorch/runners/bottum_up.py +++ b/tests/pose_estimation_pytorch/runners/bottum_up.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """ Tests for the bottom-up pytorch runner """ from pathlib import Path from typing import Dict, Any diff --git a/tests/pose_estimation_pytorch/runners/test_task.py b/tests/pose_estimation_pytorch/runners/test_task.py index 5e6340736d..38fdb416af 100644 --- a/tests/pose_estimation_pytorch/runners/test_task.py +++ b/tests/pose_estimation_pytorch/runners/test_task.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """ Tests the Task enum """ import pytest From 949ddc8cee486eb992395ee30b361f086d3bf736 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 29 Feb 2024 16:12:05 +0100 Subject: [PATCH 070/293] niels/trainset_metadata (#168) * added code to create metadata from existing shuffles * added tests --- benchmark/utils.py | 2 +- deeplabcut/compat.py | 263 ++++------ deeplabcut/core/engine.py | 49 ++ .../generate_training_dataset/metadata.py | 458 ++++++++++++++++++ ...ple_individuals_trainingsetmanipulation.py | 20 +- .../trainingsetmanipulation.py | 26 +- .../gui/tabs/create_training_dataset.py | 7 +- deeplabcut/gui/tabs/train_network.py | 9 +- deeplabcut/gui/window.py | 3 +- .../apis/analyze_videos.py | 2 +- .../apis/convert_detections_to_tracklets.py | 2 +- .../pose_estimation_pytorch/apis/utils.py | 2 +- .../pose_estimation_pytorch/data/dlcloader.py | 2 +- .../pose_estimation_pytorch/runners/utils.py | 2 +- deeplabcut/utils/auxiliaryfunctions.py | 15 +- .../openfield-Pranav-2018-10-30/config.yaml | 31 +- examples/testscript_pytorch_single_animal.py | 89 ++-- .../test_trainset_metadata.py | 375 ++++++++++++++ .../other/test_dataset.py | 2 +- 19 files changed, 1127 insertions(+), 232 deletions(-) create mode 100644 deeplabcut/core/engine.py create mode 100644 deeplabcut/generate_training_dataset/metadata.py create mode 100644 tests/generate_training_dataset/test_trainset_metadata.py diff --git a/benchmark/utils.py b/benchmark/utils.py index a3f9b85b07..1e95009ec9 100644 --- a/benchmark/utils.py +++ b/benchmark/utils.py @@ -11,7 +11,7 @@ import deeplabcut.pose_estimation_pytorch.apis.utils as api_utils import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils import deeplabcut.utils.auxiliaryfunctions as af -from deeplabcut.compat import Engine +from deeplabcut.core.engine import Engine @dataclass diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index a1d04ea147..b937fa56d7 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -1,46 +1,24 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Compatibility file for methods available with either PyTorch or Tensorflow""" from __future__ import annotations -import logging -from dataclasses import dataclass -from enum import Enum from pathlib import Path from typing import Iterable import numpy as np from ruamel.yaml import YAML - -@dataclass(frozen=True) -class EngineDataMixin: - aliases: tuple[str] - model_folder_name: str - pose_cfg_name: str - results_folder_name: str - - -class Engine(EngineDataMixin, Enum): - PYTORCH = ( - ("pytorch", "torch"), - "dlc-models-pytorch", - "pytorch_config.yaml", - "evaluation-results-pytorch", - ) - TF = ( - ("tensorflow", "tf"), - "dlc-models", - "pose_cfg.yaml", - "evaluation-results", - ) - - @classmethod - def _missing_(cls, value): - if isinstance(value, str): - for member in cls: - if value.lower() in member.aliases: - return member - return None - +from deeplabcut.core.engine import Engine +from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine DEFAULT_ENGINE = Engine.PYTORCH @@ -59,58 +37,6 @@ def get_project_engine(cfg: dict) -> Engine: return DEFAULT_ENGINE -def get_shuffle_engine( - cfg: dict, - trainingsetindex: int, - shuffle: int, - modelprefix: str = "", -) -> Engine: - """ - Args: - cfg: the project configuration file - trainingsetindex: the training set index used - shuffle: the shuffle for which to get the engine - modelprefix: the added prefix - - Returns: - the engine that the shuffle was created with - - Raises: - ValueError if the engine for the shuffle cannot be determined or the shuffle - doesn't exist - """ - project_path = Path(cfg["project_path"]) - train_frac = int(100 * cfg["TrainingFraction"][trainingsetindex]) - shuffle_name = f"{cfg['Task']}{cfg['date']}-trainset{train_frac}shuffle{shuffle}" - - found_engines = set() - for engine in Engine: - models_root = project_path / modelprefix / engine.model_folder_name - train_folder = models_root / f"iteration-{cfg['iteration']}" / shuffle_name - if train_folder.exists(): - found_engines.add(engine) - - if len(found_engines) == 1: - return found_engines.pop() - elif len(found_engines) > 1: - logging.warning( - "There are multiple engines with model configurations defined for " - f"train_frac={train_frac} and shuffle={shuffle}: {found_engines}" - ) - if DEFAULT_ENGINE in found_engines: - logging.warning(f" -> using the default engine: {DEFAULT_ENGINE}") - return DEFAULT_ENGINE - else: - selected_engine = found_engines.pop() - logging.warning(f" -> using a random engine: {selected_engine}") - return selected_engine - - raise ValueError( - f"Could not get the engine for the shuffle {shuffle_name}. Could not find a " - f"folder for any engine." - ) - - def train_network( config: str, shuffle: int = 1, @@ -124,14 +50,15 @@ def train_network( autotune: bool = False, keepdeconvweights: bool = True, modelprefix: str = "", + engine: Engine | None = None, **torch_kwargs, ): - engine = get_shuffle_engine( - _load_config(config), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import train_network @@ -209,29 +136,31 @@ def evaluate_network( rescale: bool = False, modelprefix: str = "", per_keypoint_evaluation: bool = False, + engine: Engine | None = None, **torch_kwargs, ): - cfg = _load_config(config) - engines = set() - for shuffle in Shuffles: - engines.add( - get_shuffle_engine( - cfg, - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, + if engine is None: + cfg = _load_config(config) + engines = set() + for shuffle in Shuffles: + engines.add( + get_shuffle_engine( + cfg, + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) ) - ) - if len(engines) == 0: - raise ValueError( - f"You must pass at least one shuffle to evaluate (had {list(Shuffles)})" - ) - elif len(engines) > 1: - raise ValueError( - f"All shuffles must have the same engine (found {list(engines)})" - ) + if len(engines) == 0: + raise ValueError( + f"You must pass at least one shuffle to evaluate (had {list(Shuffles)})" + ) + elif len(engines) > 1: + raise ValueError( + f"All shuffles must have the same engine (found {list(engines)})" + ) + engine = engines.pop() - engine = engines.pop() if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import evaluate_network return evaluate_network( @@ -273,13 +202,15 @@ def return_evaluate_network_data( show_errors: bool = True, modelprefix: str = "", returnjustfns: bool = True, + engine: Engine | None = None, ): - engine = get_shuffle_engine( - _load_config(config), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import return_evaluate_network_data @@ -322,14 +253,16 @@ def analyze_videos( calibrate: bool = False, identity_only: bool = False, use_openvino: str | None = None, + engine: Engine | None = None, **torch_kwargs, ): - engine = get_shuffle_engine( - _load_config(config), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import analyze_videos @@ -404,13 +337,15 @@ def create_tracking_dataset( modelprefix: str = "", robust_nframes: bool = False, n_triplets: int = 1000, + engine: Engine | None = None, ): - engine = get_shuffle_engine( - _load_config(config), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import create_tracking_dataset @@ -445,13 +380,15 @@ def analyze_time_lapse_frames( gputouse: int | None = None, save_as_csv: bool = False, modelprefix: str = "", + engine: Engine | None = None, ): - engine = get_shuffle_engine( - _load_config(config), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import analyze_time_lapse_frames @@ -485,13 +422,15 @@ def convert_detections2tracklets( window_size: int = 0, identity_only: int = False, track_method: str = "", + engine: Engine | None = None, ): - engine = get_shuffle_engine( - _load_config(config), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import convert_detections2tracklets @@ -548,13 +487,15 @@ def extract_maps( rescale: bool = False, Indices: list[int] | None = None, modelprefix: str = "", + engine: Engine | None = None, ): - engine = get_shuffle_engine( - _load_config(config), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import extract_maps @@ -624,13 +565,15 @@ def extract_save_all_maps( Indices: list[int] | None = None, modelprefix: str = "", dest_folder: str = None, + engine: Engine | None = None, ): - engine = get_shuffle_engine( - _load_config(config), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(config), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import extract_save_all_maps @@ -662,13 +605,15 @@ def export_model( make_tar: bool = True, wipepaths: bool = False, modelprefix: str = "", + engine: Engine | None = None, ): - engine = get_shuffle_engine( - _load_config(cfg_path), - trainingsetindex=trainingsetindex, - shuffle=shuffle, - modelprefix=modelprefix, - ) + if engine is None: + engine = get_shuffle_engine( + _load_config(cfg_path), + trainingsetindex=trainingsetindex, + shuffle=shuffle, + modelprefix=modelprefix, + ) if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import export_model diff --git a/deeplabcut/core/engine.py b/deeplabcut/core/engine.py new file mode 100644 index 0000000000..c6f07ca69d --- /dev/null +++ b/deeplabcut/core/engine.py @@ -0,0 +1,49 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Defines the deep learning frameworks available""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +@dataclass(frozen=True) +class EngineDataMixin: + aliases: tuple[str] + model_folder_name: str + pose_cfg_name: str + results_folder_name: str + + +class Engine(EngineDataMixin, Enum): + PYTORCH = ( + ("pytorch", "torch"), + "dlc-models-pytorch", + "pytorch_config.yaml", + "evaluation-results-pytorch", + ) + TF = ( + ("tensorflow", "tf"), + "dlc-models", + "pose_cfg.yaml", + "evaluation-results", + ) + + @classmethod + def _missing_(cls, value): + if isinstance(value, str): + for member in cls: + if value.lower() in member.aliases: + return member + return None + + def __repr__(self) -> str: + return f"Engine.{self.name}" diff --git a/deeplabcut/generate_training_dataset/metadata.py b/deeplabcut/generate_training_dataset/metadata.py new file mode 100644 index 0000000000..a24dabce1f --- /dev/null +++ b/deeplabcut/generate_training_dataset/metadata.py @@ -0,0 +1,458 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""File containing methods to load and parse shuffle metadata""" +from __future__ import annotations + +import logging +import pickle +import re +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from ruamel.yaml import YAML + +from deeplabcut.core.engine import Engine +from deeplabcut.utils import auxiliaryfunctions + + +@dataclass(frozen=True) +class DataSplit: + """Class representing the metadata for a shuffle""" + train_indices: tuple[int, ...] + test_indices: tuple[int, ...] + + def __post_init__(self) -> None: + """ + Raises: + ValueError if the indices are not sorted in increasing + """ + for indices in [self.train_indices, self.test_indices]: + idx = np.array(indices) + if not np.all(idx[:-1] < idx[1:]): + raise RuntimeError( + f"The training and test indices in a data split must be sorted in " + f"strictly ascending order." + ) + + +@dataclass(frozen=True) +class ShuffleMetadata: + """Class representing the metadata for a shuffle""" + train_fraction: float + index: int + engine: Engine + split: DataSplit | None + + def load_split(self, cfg: dict, trainset_path: Path) -> "ShuffleMetadata": + """Loads the data split for this shuffle + + Args: + cfg: the config for the DeepLabCut project + trainset_path: the path to the training dataset folder + + Returns: + a new instance with the data split defined + """ + _, doc_path = auxiliaryfunctions.get_data_and_metadata_filenames( + trainset_path, self.train_fraction, self.index, cfg + ) + if not Path(doc_path).exists(): + raise ValueError( + f"Could not load the metadata file for {self} as {doc_path} does not " + f"exist. If you deleted the shuffle, you also need to delete the " + f"shuffle from metadata.yaml or recreate the metadata.yaml file." + ) + + with open(doc_path, "rb") as f: + _, train_idx, test_idx, _ = pickle.load(f) + return ShuffleMetadata( + train_fraction=self.train_fraction, + index=self.index, + engine=self.engine, + split=DataSplit( + train_indices=tuple(sorted([int(idx) for idx in train_idx])), + test_indices=tuple(sorted([int(idx) for idx in test_idx])), + ) + ) + + +@dataclass(frozen=True) +class TrainingDatasetMetadata: + """An immutable class containing the metadata for a dataset + + When creating a new "training-datasets" folder (e.g., when creating the first + training set for a project, or when creating the first training for a given + iteration of a project), TrainingDatasetMetadata.create(cfg) should be called when + the "training-datasets" folder is still empty. + + For existing projects (created with DeepLabCut < 3.0), calling + TrainingDatasetMetadata.create(cfg) will go over documentation data for all existing + shuffles in the training-datasets folder and add them to a new metadata instance. + All shuffles will be given Engine.TF as an engine. + + Examples: + # Creating the metadata file for an existing project + config = "/data/my-dlc-project/config.yaml" + trainset_metadata = TrainingDatasetMetadata.create(config) + trainset_metadata.save() + + # Adding a new shuffle to the metadata file + config = "/data/my-dlc-project/config.yaml" + trainset_metadata = TrainingDatasetMetadata.load(config) + new_shuffle = ShuffleMetadata( + train_fraction=0.6, + index=5, + engine=compat.Engine.PYTORCH, + split=DataSplit(train_indices=(1, 3, 4), test_indices=(0, 2)), + ) + trainset_metadata = trainset_metadata.add(new_shuffle) + trainset_metadata.save() # saves to disk + """ + project_config: dict + shuffles: tuple[ShuffleMetadata, ...] + file_header: tuple[str] = ( + "# This file is automatically generated - DO NOT EDIT", + "# It contains the information about the shuffles created for the dataset", + "---", + ) + + def __post_init__(self) -> None: + """ + Raises: + ValueError if the indices are not sorted in increasing order + """ + shuffle_indices = np.array([s.index for s in self.shuffles]) + if not np.all(shuffle_indices[:-1] < shuffle_indices[1:]): + raise RuntimeError( + f"The shuffles given must be sorted in order of ascending index" + ) + + def add( + self, + shuffle: ShuffleMetadata, + overwrite: bool = False, + ) -> TrainingDatasetMetadata: + """ + Adds a new shuffle to the metadata file + + Args: + shuffle: the shuffle to add + overwrite: if a shuffle with the same index is already stored in the + metadata file, whether to overwrite it + + Returns: + A new instance of TrainingDatasetMetadata with updated shuffles + + Raises: + ValueError: if overwrite=False and there is already a shuffle with the given + index in the metadata file. + """ + existing_indices = [s.index for s in self.shuffles] + if shuffle.index in existing_indices: + if not overwrite: + raise RuntimeError( + f"Cannot add {shuffle} to the meta: a shuffle with index " + f"{shuffle.index} already exists: {self.shuffles}." + ) + + shuffles = [s for s in self.shuffles if s.index != shuffle.index] + [shuffle] + return TrainingDatasetMetadata( + project_config=self.project_config, + shuffles=tuple(sorted(shuffles, key=lambda s: s.index)), + ) + + def get(self, trainset_index: int = 0, index: int = 0) -> ShuffleMetadata: + """ + Args: + trainset_index: the index of the trainset fraction as defined in config.yaml + index: the index of the shuffle + + Returns: + the shuffle with the given trainset index and shuffle index + + Raises: + ValueError if the shuffle is not present in the metadata + """ + train_fraction = self.project_config["TrainingFraction"][trainset_index] + for shuffle in self.shuffles: + if ( + shuffle.train_fraction == train_fraction + and shuffle.index == index + ): + return shuffle + + raise ValueError( + f"Could not find a shuffle with trainingset fraction {train_fraction} and " + f"index {index}" + ) + + def save(self) -> None: + """Saves the training dataset metadata to disk""" + metadata = {"shuffles": {}} + data_splits: dict[DataSplit, int] = {} + trainset_path = self.path(self.project_config).parent + for s in self.shuffles: + if s.split is None: + s = s.load_split(cfg=self.project_config, trainset_path=trainset_path) + + split_index = data_splits.get(s.split) + if split_index is None: + split_index = len(data_splits) + 1 + data_splits[s.split] = split_index + + metadata["shuffles"][s.index] = { + "train_fraction": s.train_fraction, + "split": split_index, + "engine": s.engine.aliases[0], + } + + with open(self.path(self.project_config), "w") as file: + file.write("\n".join(self.file_header) + "\n") + YAML().dump(metadata, file) + + @staticmethod + def load( + config: str | Path | dict, + load_splits: bool = False, + ) -> TrainingDatasetMetadata: + """Loads the metadata from disk + + Args: + config: the config for the DeepLabCut project (or its path) + load_splits: whether to load the data split for each shuffle + """ + if isinstance(config, (str, Path)): + cfg = auxiliaryfunctions.read_config(config) + else: + cfg = config + + metadata_path = TrainingDatasetMetadata.path(cfg) + with open(metadata_path, "r") as file: + metadata = YAML(typ="safe", pure=True).load(file) + + shuffles = [] + for shuffle_index, shuffle_metadata in metadata["shuffles"].items(): + shuffle = ShuffleMetadata( + train_fraction=shuffle_metadata["train_fraction"], + index=shuffle_index, + engine=Engine(shuffle_metadata["engine"]), + split=None, + ) + if load_splits: + shuffle = shuffle.load_split(cfg, metadata_path.parent) + + shuffles.append(shuffle) + + shuffles.sort(key=lambda s: s.index) + return TrainingDatasetMetadata(project_config=cfg, shuffles=tuple(shuffles)) + + @staticmethod + def create(config: str | Path | dict) -> TrainingDatasetMetadata: + """Function to create the metadata file + + Assumes that all existing shuffles use the TensorFlow engine, as this file + should have already been created for PyTorch shuffles. + + Args; + config: the config for the DeepLabCut project (or its path) + default_engine: the default engine to set for shuffles in the project + + Returns: + the metadata for the existing shuffles in the project + """ + if isinstance(config, (str, Path)): + cfg = auxiliaryfunctions.read_config(config) + else: + cfg = config + + trainset_path = TrainingDatasetMetadata.path(cfg).parent + shuffle_docs = [ + f + for f in trainset_path.iterdir() + if re.match(r"Documentation_data-.+shuffle[0-9]+\.pickle", f.name) + ] + + shuffles = [] + existing_splits: dict[tuple[tuple[int, ...], tuple[int, ...]], int] = {} + for doc_path in shuffle_docs: + shuffle_index = int(doc_path.stem.split("shuffle")[-1]) + with open(doc_path, "rb") as f: + _, train_idx, test_idx, train_fraction = pickle.load(f) + + engine = Engine.TF + train_idx = tuple(sorted([int(idx) for idx in train_idx])) + test_idx = tuple(sorted([int(idx) for idx in test_idx])) + split_idx = existing_splits.get((train_idx, test_idx)) + if split_idx is None: + split_idx = len(existing_splits) + 1 + existing_splits[(train_idx, test_idx)] = split_idx + + shuffles.append( + ShuffleMetadata( + train_fraction=train_fraction, + index=shuffle_index, + engine=engine, + split=DataSplit(train_indices=train_idx, test_indices=test_idx), + ) + ) + + shuffles = tuple(sorted(shuffles, key=lambda s: s.index)) + return TrainingDatasetMetadata( + project_config=cfg, + shuffles=shuffles, + ) + + @staticmethod + def path(cfg: dict) -> Path: + """ + Args: + cfg: the config for the DeepLabCut project + + Returns: + the path to the training dataset metadata file + """ + meta_path = auxiliaryfunctions.get_training_set_folder(cfg) / "metadata.yaml" + return Path(cfg["project_path"]) / meta_path + + +def update_metadata( + cfg: dict, + train_fraction: float, + shuffle: int, + engine: Engine, + train_indices: list[int], + test_indices: list[int], + overwrite: bool = False, +) -> None: + """Updates the metadata for a training-dataset + + Args: + cfg: the config for the DeepLabCut project + train_fraction: the train_fraction of the new shuffle + shuffle: the index of the shuffle to add + engine: the engine for the shuffle + train_indices: the indices of images in the training set + test_indices: the indices of images in the test set + overwrite: whether to overwrite a shuffle with the same index and train fraction + if one exists + + Raises: + ValueError: if overwrite=False and there is already a shuffle with the given + index in the metadata file. + """ + metadata = TrainingDatasetMetadata.load(cfg) + new_shuffle = ShuffleMetadata( + train_fraction=train_fraction, + index=shuffle, + engine=engine, + split=DataSplit( + train_indices=tuple(sorted([int(i) for i in train_indices])), + test_indices=tuple(sorted([int(i) for i in test_indices])), + ) + ) + metadata = metadata.add(shuffle=new_shuffle, overwrite=overwrite) + metadata.save() + + +def get_shuffle_engine( + cfg: dict, + trainingsetindex: int, + shuffle: int, + modelprefix: str = "", +) -> Engine: + """ + Args: + cfg: the config for the DeepLabCut project + trainingsetindex: the training set index used + shuffle: the shuffle for which to get the engine + modelprefix: the model prefix, if there is one + + Returns: + the engine that the shuffle was created with + + Raises: + ValueError if the engine for the shuffle cannot be determined or the shuffle + doesn't exist + """ + if not TrainingDatasetMetadata.path(cfg).exists(): + metadata = TrainingDatasetMetadata.create(cfg) + metadata.save() + + metadata = TrainingDatasetMetadata.load(cfg) + shuffle_metadata = metadata.get(trainingsetindex, shuffle) + if modelprefix: + # try to get the engine by checking which models folder exists + engines = find_engines_from_model_folders( + cfg, trainingsetindex, shuffle, modelprefix + ) + if len(engines) == 0: + raise ValueError( + f"Couldn't find any shuffles with trainingsetindex={trainingsetindex}, " + f"shuffle={shuffle} and modelprefix={modelprefix}. Please check that " + f"such a shuffle is defined." + ) + + if len(engines) == 1: + return engines.pop() + + if shuffle_metadata.engine in engines: + engine = shuffle_metadata.engine + else: + engine = engines.pop() # take a random engine + + logging.warning( + f"Found multiple engines for trainingsetindex={trainingsetindex}, " + f"shuffle={shuffle} and modelprefix={modelprefix}. Using engine={engine}. " + f"To select another engine, please specify it in your API call." + ) + return engine + + return shuffle_metadata.engine + + +def find_engines_from_model_folders( + cfg: dict, + trainingsetindex: int, + shuffle: int, + modelprefix: str = "", +) -> set[Engine]: + """Determines which engines are used with a given shuffle. + + This method can be useful when using modelprefix, as the engine for a shuffle stored + under a "modelprefix" might not be the same as the base shuffle (for which the + engine is stored in the training-datasets folder). + + Args: + cfg: the config for the DeepLabCut project + trainingsetindex: the training set index used + shuffle: the shuffle for which to get the engine + modelprefix: the model prefix, if there is one + + Returns: + the engines for which a model folder exists for the given shuffle + """ + project_path = Path(cfg["project_path"]) + train_fraction = cfg["TrainingFraction"][trainingsetindex] + + existing_engines = set() + for engine in Engine: + expected_model_folder = project_path / auxiliaryfunctions.get_model_folder( + trainFraction=train_fraction, + shuffle=shuffle, + cfg=cfg, + engine=engine, + modelprefix=modelprefix, + ) + if expected_model_folder.exists(): + existing_engines.add(engine) + + return existing_engines diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index f166d82d8a..c0147c2df8 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -20,7 +20,9 @@ import numpy as np from tqdm import tqdm -from deeplabcut.compat import Engine, get_project_engine +import deeplabcut.compat as compat +import deeplabcut.generate_training_dataset.metadata as metadata +from deeplabcut.core.engine import Engine from deeplabcut.generate_training_dataset import ( merge_annotateddatasets, read_image_shape_fast, @@ -217,6 +219,11 @@ def create_multianimaltraining_dataset( full_training_path = Path(project_path, trainingsetfolder) auxiliaryfunctions.attempt_to_make_folder(full_training_path, recursive=True) + # Create the trainset metadata file, if it doesn't yet exist + if not metadata.TrainingDatasetMetadata.path(cfg).exists(): + trainset_metadata = metadata.TrainingDatasetMetadata.create(cfg) + trainset_metadata.save() + Data = merge_annotateddatasets(cfg, full_training_path) if Data is None: return @@ -227,7 +234,7 @@ def create_multianimaltraining_dataset( # load the engine to use to create the shuffle if engine is None: - engine = get_project_engine(cfg) + engine = compat.get_project_engine(cfg) if not ( any(net in net_type for net in ("resnet", "eff", "dlc", "mob")) @@ -374,6 +381,15 @@ def create_multianimaltraining_dataset( testIndices, trainFraction, ) + metadata.update_metadata( + cfg=cfg, + train_fraction=trainFraction, + shuffle=shuffle, + engine=engine, + train_indices=trainIndices, + test_indices=testIndices, + overwrite=not userfeedback, + ) datafilename = datafilename.split(".mat")[0] + ".pickle" import pickle diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 2d7032d0ae..d54dfcf9e2 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -25,11 +25,9 @@ import pandas as pd import yaml -from deeplabcut.compat import ( - Engine, - get_project_engine, - return_train_network_path, -) +import deeplabcut.compat as compat +import deeplabcut.generate_training_dataset.metadata as metadata +from deeplabcut.core.engine import Engine from deeplabcut.utils import ( auxiliaryfunctions, conversioncode, @@ -893,7 +891,7 @@ def create_training_dataset( scorer = cfg["scorer"] project_path = cfg["project_path"] if engine is None: - engine = get_project_engine(cfg) + engine = compat.get_project_engine(cfg) # Create path for training sets & store data there trainingsetfolder = auxiliaryfunctions.get_training_set_folder( @@ -903,6 +901,11 @@ def create_training_dataset( Path(os.path.join(project_path, str(trainingsetfolder))), recursive=True ) + # Create the trainset metadata file, if it doesn't yet exist + if not metadata.TrainingDatasetMetadata.path(cfg).exists(): + trainset_metadata = metadata.TrainingDatasetMetadata.create(cfg) + trainset_metadata.save() + Data = merge_annotateddatasets( cfg, Path(os.path.join(project_path, trainingsetfolder)), @@ -1009,7 +1012,7 @@ def create_training_dataset( for trainFraction, shuffle, (trainIndices, testIndices) in splits: if len(trainIndices) > 0: if userfeedback: - trainposeconfigfile, _, _ = return_train_network_path( + trainposeconfigfile, _, _ = compat.return_train_network_path( config, shuffle=shuffle, trainingsetindex=cfg["TrainingFraction"].index(trainFraction), @@ -1060,6 +1063,15 @@ def create_training_dataset( testIndices, trainFraction, ) + metadata.update_metadata( + cfg=cfg, + train_fraction=trainFraction, + shuffle=shuffle, + engine=engine, + train_indices=trainIndices, + test_indices=testIndices, + overwrite=not userfeedback, + ) ################################################################################ # Creating file structure for training & diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 07094fcf90..73166738bb 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -15,8 +15,9 @@ from PySide6.QtGui import QIcon import deeplabcut -from deeplabcut import compat +from deeplabcut.core.engine import Engine from deeplabcut.generate_training_dataset import get_existing_shuffle_indices +from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine from deeplabcut.gui.dlc_params import DLCParams from deeplabcut.gui.components import ( DefaultTab, @@ -65,7 +66,7 @@ def _generate_layout_attributes(self, layout): nnet_label = QtWidgets.QLabel("Network architecture") self.net_choice = QtWidgets.QComboBox() - if self.root.project_engine == compat.Engine.TF: + if self.root.project_engine == Engine.TF: nets = DLCParams.NNETS.copy() if not self.root.is_multianimal: nets.remove("dlcrnet_ms5") @@ -201,7 +202,7 @@ def _confirm_overwrite(self, shuffle: int, existing_indices: list[int]) -> bool: whether the user confirmed overwriting the shuffle """ try: - engine = compat.get_shuffle_engine( + engine = get_shuffle_engine( self.root.cfg, self.root.trainingset_index, shuffle ) engine_str = f" (with engine '{engine.aliases[0]}')" diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py index b49fa6f925..2e515b4d5a 100644 --- a/deeplabcut/gui/tabs/train_network.py +++ b/deeplabcut/gui/tabs/train_network.py @@ -16,6 +16,7 @@ from PySide6.QtGui import QIcon import deeplabcut.compat as compat +from deeplabcut.core.engine import Engine from deeplabcut.gui.components import ( DefaultTab, ShuffleSpinBox, @@ -35,7 +36,7 @@ class IntTrainAttribute: class TrainNetwork(DefaultTab): - def __init__(self, root, parent, h1_description, engine: compat.Engine = compat.Engine.TF): + def __init__(self, root, parent, h1_description, engine: Engine = Engine.TF): super(TrainNetwork, self).__init__(root, parent, h1_description) self.train_attributes = get_train_attributes(engine=engine) self._set_page() @@ -112,8 +113,8 @@ def train_network(self): msg.exec_() -def get_train_attributes(engine: compat.Engine) -> list[IntTrainAttribute]: - if engine == compat.Engine.TF: +def get_train_attributes(engine: Engine) -> list[IntTrainAttribute]: + if engine == Engine.TF: return [ IntTrainAttribute( label="Display iterations", @@ -144,7 +145,7 @@ def get_train_attributes(engine: compat.Engine) -> list[IntTrainAttribute]: max=100, ), ] - elif engine == compat.Engine.PYTORCH: + elif engine == Engine.PYTORCH: return[ IntTrainAttribute( label="Display iterations", diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index d2ef3c640c..2b099baf81 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -19,6 +19,7 @@ import deeplabcut from deeplabcut import auxiliaryfunctions, VERSION, compat +from deeplabcut.core.engine import Engine from deeplabcut.gui import BASE_DIR, components, utils from deeplabcut.gui.tabs import * from deeplabcut.gui.widgets import StreamReceiver, StreamWriter @@ -152,7 +153,7 @@ def cfg(self): return cfg @property - def project_engine(self) -> compat.Engine: + def project_engine(self) -> Engine: return compat.get_project_engine(self.cfg) @property diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index a2c2de54a2..18c00f7d42 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -22,7 +22,7 @@ import pandas as pd from tqdm import tqdm -from deeplabcut.compat import Engine +from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( convert_detections2tracklets, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index a6d322cca0..83a8b2dbbb 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -22,7 +22,7 @@ import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions import deeplabcut.utils.auxfun_multianimal as auxfun_multianimal -from deeplabcut.compat import Engine +from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_model_snapshots, list_videos_in_folder, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 2a3c7fafc2..fc2743ca9e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -21,7 +21,7 @@ import torch import torch.nn as nn -from deeplabcut.compat import Engine +from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( build_bottom_up_postprocessor, diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index 119fc10e97..920d2f7889 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -18,7 +18,7 @@ import pandas as pd import deeplabcut -from deeplabcut.compat import Engine +from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch.data.base import Loader from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.helper import CombinedPropertyMeta diff --git a/deeplabcut/pose_estimation_pytorch/runners/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py index 6b8784f713..1360d69350 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/utils.py +++ b/deeplabcut/pose_estimation_pytorch/runners/utils.py @@ -17,7 +17,7 @@ from typing import List, Tuple import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils -from deeplabcut.compat import Engine +from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch.runners.base import Task from deeplabcut.utils import auxiliaryfunctions diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 9a2e4f9881..b753e2c3c5 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -30,8 +30,9 @@ import yaml from ruamel.yaml import YAML -import deeplabcut.compat as compat +from deeplabcut.core.engine import Engine from deeplabcut.core.trackingutils import TRACK_METHODS +from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine from deeplabcut.utils import auxfun_videos, auxfun_multianimal @@ -499,7 +500,7 @@ def get_video_list(filename, videopath, videtype): ## Various functions to get filenames, foldernames etc. based on configuration parameters. -def get_training_set_folder(cfg): +def get_training_set_folder(cfg: dict) -> Path: """Training Set folder for config file based on parameters""" Task = cfg["Task"] date = cfg["date"] @@ -538,7 +539,7 @@ def get_model_folder( trainFraction: float, shuffle: int, cfg: dict, - engine: compat.Engine = compat.Engine.TF, + engine: Engine = Engine.TF, modelprefix: str = "", ) -> Path: """ @@ -568,7 +569,7 @@ def get_evaluation_folder( trainFraction, shuffle, cfg, - engine: compat.Engine = compat.Engine.TF, + engine: Engine = Engine.TF, modelprefix="", ): """ @@ -646,7 +647,7 @@ def get_scorer_name( cfg: dict, shuffle: int, trainFraction: float, - engine: compat.Engine | None = None, + engine: Engine | None = None, trainingsiterations: str | int = "unknown", modelprefix: str = "", ): @@ -655,7 +656,7 @@ def get_scorer_name( Returns tuple of DLCscorer, DLCscorerlegacy (old naming convention) """ if engine is None: - engine = compat.get_shuffle_engine( + engine = get_shuffle_engine( cfg=cfg, trainingsetindex=cfg["TrainingFraction"].index(trainFraction), shuffle=shuffle, @@ -699,7 +700,7 @@ def get_scorer_name( ) ) # ABBREVIATE NETWORK NAMES -- esp. for mobilenet! - if engine == compat.Engine.PYTORCH: + if engine == Engine.PYTORCH: netname = "".join([p.capitalize() for p in dlc_cfg["net_type"].split("_")]) elif "resnet" in dlc_cfg["net_type"]: if dlc_cfg.get("multi_stage", False): diff --git a/examples/openfield-Pranav-2018-10-30/config.yaml b/examples/openfield-Pranav-2018-10-30/config.yaml index ece5b4a5e7..0b5d8f1155 100644 --- a/examples/openfield-Pranav-2018-10-30/config.yaml +++ b/examples/openfield-Pranav-2018-10-30/config.yaml @@ -1,15 +1,20 @@ - # Project definitions (do not edit) +# Project definitions (do not edit) Task: openfield scorer: Pranav date: Oct30 multianimalproject: identity: - # Project path (change when moving around) -project_path: - /Users/niels/Documents/upamathis/repos/DLCdev/examples/openfield-Pranav-2018-10-30 - # Annotation data set configuration (and individual video cropping parameters) +# Project path (change when moving around) +project_path: + + +# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow) +engine: pytorch + + +# Annotation data set configuration (and individual video cropping parameters) video_sets: WILL BE AUTOMATICALLY UPDATED BY DEMO CODE: crop: 0, 640, 0, 480 @@ -20,12 +25,13 @@ bodyparts: - tailbase - # Fraction of video to start/stop when extracting frames for labeling/refinement +# Fraction of video to start/stop when extracting frames for labeling/refinement start: 0 stop: 1 numframes2pick: 20 - # Plotting configuration + +# Plotting configuration skeleton: [] skeleton_color: black pcutoff: 0.4 @@ -33,7 +39,8 @@ dotsize: 8 alphavalue: 0.7 colormap: jet - # Training,Evaluation and Analysis configuration + +# Training,Evaluation and Analysis configuration TrainingFraction: - 0.95 iteration: 0 @@ -42,15 +49,17 @@ default_augmenter: imgaug snapshotindex: -1 batch_size: 4 - # Cropping Parameters (for analysis and outlier frame detection) + +# Cropping Parameters (for analysis and outlier frame detection) cropping: false - #if cropping is true for analysis, then set the values here: +#if cropping is true for analysis, then set the values here: x1: 0 x2: 640 y1: 277 y2: 624 - # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) + +# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) corner2move2: - 50 - 50 diff --git a/examples/testscript_pytorch_single_animal.py b/examples/testscript_pytorch_single_animal.py index 231c4303fb..951e4313cf 100644 --- a/examples/testscript_pytorch_single_animal.py +++ b/examples/testscript_pytorch_single_animal.py @@ -1,12 +1,14 @@ """ Testscript for single animal PyTorch projects """ +import shutil import time from pathlib import Path from typing import Any import deeplabcut import deeplabcut.utils.auxiliaryfunctions as af -from deeplabcut.compat import Engine +from deeplabcut.core.engine import Engine from deeplabcut.generate_training_dataset import get_existing_shuffle_indices +from deeplabcut.utils import auxiliaryfunctions def run( @@ -23,13 +25,15 @@ def run( times = [time.time()] log_step(f"Testing with net type {net_type}") log_step("Creating the training dataset") - deeplabcut.create_training_dataset(str(config_path), net_type=net_type, engine=engine) + deeplabcut.create_training_dataset( + str(config_path), net_type=net_type, engine=engine + ) existing_shuffles = get_existing_shuffle_indices( config_path, train_fraction=train_fraction, engine=engine ) shuffle_index = existing_shuffles[-1] - log_step(f"Starting training for train_frac {train_fraction}, shuffle {shuffle_index}") + log_step(f"Starting training for frac={train_fraction}, shuffle={shuffle_index}") deeplabcut.train_network( config=str(config_path), shuffle=shuffle_index, @@ -72,6 +76,26 @@ def run( ) +def copy_project_for_test() -> Path: + data_path = Path.cwd() / "openfield-Pranav-2018-10-30" + test_path = Path.cwd() / "pytorch-testscript1234-openfield-Pranav-2018-10-30" + if not test_path.exists(): + shutil.copytree(data_path, test_path) + + project_config = auxiliaryfunctions.read_config(str(test_path / "config.yaml")) + videos = list(project_config["video_sets"].keys()) + video = videos[0] + crop = project_config["video_sets"][video] + project_config["video_sets"] = {str(test_path / "videos" / "m3v1mp4.mp4"): crop} + auxiliaryfunctions.write_config(str(test_path / "config.yaml"), project_config) + return test_path + + +def cleanup(test_path: Path) -> None: + if test_path.exists(): + shutil.rmtree(test_path) + + def main( net_types: list[str], epochs: int = 1, @@ -81,34 +105,37 @@ def main( create_labeled_videos: bool = False, ) -> None: engine = Engine.PYTORCH - project_path = Path.cwd() / "openfield-Pranav-2018-10-30" - config_path = project_path / "config.yaml" - cfg = af.read_config(config_path) - trainset_index = 0 - train_frac = cfg["TrainingFraction"][trainset_index] - for net_type in net_types: - try: - run( - config_path=config_path, - train_fraction=train_frac, - trainset_index=trainset_index, - net_type=net_type, - videos=[str(project_path / "videos" / "m3v1mp4.mp4")], - device=device, - train_kwargs=dict( - display_iters=1, - epochs=epochs, - save_epochs=save_epochs, - batch_size=batch_size, - ), - engine=engine, - create_labeled_videos=create_labeled_videos, - ) - except Exception as err: - log_step(f"FAILED TO RUN {net_type}") - log_step(str(err)) - log_step("Continuing to next model") - raise err + project_path = copy_project_for_test() + try: + config_path = project_path / "config.yaml" + cfg = af.read_config(config_path) + trainset_index = 0 + train_frac = cfg["TrainingFraction"][trainset_index] + for net_type in net_types: + try: + run( + config_path=config_path, + train_fraction=train_frac, + trainset_index=trainset_index, + net_type=net_type, + videos=[str(project_path / "videos" / "m3v1mp4.mp4")], + device=device, + train_kwargs=dict( + display_iters=1, + epochs=epochs, + save_epochs=save_epochs, + batch_size=batch_size, + ), + engine=engine, + create_labeled_videos=create_labeled_videos, + ) + except Exception as err: + log_step(f"FAILED TO RUN {net_type}") + log_step(str(err)) + log_step("Continuing to next model") + raise err + finally: + cleanup(project_path) def log_step(message: Any) -> None: diff --git a/tests/generate_training_dataset/test_trainset_metadata.py b/tests/generate_training_dataset/test_trainset_metadata.py new file mode 100644 index 0000000000..51c1b71af7 --- /dev/null +++ b/tests/generate_training_dataset/test_trainset_metadata.py @@ -0,0 +1,375 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Tests for deeplabcut/generate_training_dataset/metadata.py""" +from __future__ import annotations +import pickle + +import pytest +from ruamel.yaml import YAML + +import deeplabcut.generate_training_dataset.metadata as metadata +from deeplabcut.core.engine import Engine +from deeplabcut.utils import auxiliaryfunctions + +SHUFFLE_DATA = [ + {"train_fraction": 0.5, "split": 1, "engine": "torch"}, + {"train_fraction": 0.5, "split": 1, "engine": "tf"}, + {"train_fraction": 0.6, "split": 2, "engine": "torch"}, + {"train_fraction": 0.6, "split": 3, "engine": "torch"}, +] +SPLITS_DATA = { + 1: {"train": [0, 1], "test": [2, 3]}, + 2: {"train": [0, 1, 2], "test": [3, 4]}, + 3: {"train": [4, 3, 2], "test": [1, 0]}, +} + +BASE_SPLIT = metadata.DataSplit(train_indices=(1, 2), test_indices=(3, 4)) +# Splits that should be equal to the base +EQ_SPLIT = metadata.DataSplit(train_indices=(1, 2), test_indices=(3, 4)) +# Splits that should not be equal to the base +ADD_SPLIT = metadata.DataSplit(train_indices=(1, 2, 5), test_indices=(3, 4)) +ADD_SPLIT2 = metadata.DataSplit(train_indices=(1, 2), test_indices=(3, 4, 5)) +SUBS_SPLIT = metadata.DataSplit(train_indices=(1, 3), test_indices=(2, 4)) +DEL_SPLIT = metadata.DataSplit(train_indices=(1,), test_indices=(3, 4)) +DEL_SPLIT2 = metadata.DataSplit(train_indices=(1, 2), test_indices=(3,)) + +SHUFFLES = { + 1: metadata.ShuffleMetadata(0.5, 1, Engine.PYTORCH, BASE_SPLIT), + 2: metadata.ShuffleMetadata(0.5, 2, Engine.PYTORCH, ADD_SPLIT), + 3: metadata.ShuffleMetadata(0.5, 3, Engine.TF, BASE_SPLIT), + 4: metadata.ShuffleMetadata(0.5, 4, Engine.PYTORCH, DEL_SPLIT), +} + + +@pytest.mark.parametrize( + "data", + [ + { + "shuffles": {idx + 1: SHUFFLE_DATA[idx] for idx in [0, 1, 2]}, + "splits": {idx: SPLITS_DATA[idx] for idx in [1, 2]}, + }, + { + "shuffles": {idx + 1: SHUFFLE_DATA[idx] for idx in [0]}, + "splits": {idx: SPLITS_DATA[idx] for idx in [1, 2]}, + }, + ], +) +@pytest.mark.parametrize("load_splits", [True, False]) +def test_load_metadata(tmpdir, data: dict, load_splits: bool): + """Tests that loading the metadata from files doesn't fail""" + # write data to tmp file + cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir) + with open(meta_path, "w") as f: + YAML().dump(data, f) + + print(cfg_path) + print(meta_path) + print(data["shuffles"]) + print(data["splits"]) + print() + + for idx, s in data["shuffles"].items(): + split = data["splits"][s["split"]] + train, test = split["train"], split["test"] + _create_doc_data(cfg, trainset_dir, s["train_fraction"], idx, train, test) + + trainset_meta = metadata.TrainingDatasetMetadata.load( + str(cfg_path), load_splits=load_splits + ) + for s in trainset_meta.shuffles: + print(s) + + assert len(data["shuffles"]) == len(trainset_meta.shuffles) + + for s in trainset_meta.shuffles: + shuffle_in = data["shuffles"][s.index] + split_idx = data["splits"][shuffle_in["split"]] + assert s.train_fraction == shuffle_in["train_fraction"] + assert s.engine == Engine(shuffle_in["engine"]) + if load_splits: + assert s.split is not None + assert s.split.train_indices == tuple(split_idx["train"]) + assert s.split.test_indices == tuple(split_idx["test"]) + else: + assert s.split is None + s_with_split = s.load_split(cfg, trainset_dir) + assert s_with_split.split.train_indices == tuple(split_idx["train"]) + assert s_with_split.split.test_indices == tuple(split_idx["test"]) + + +@pytest.mark.parametrize("data", [ + { + "shuffles": (SHUFFLES[1], ), + "expected": { + "shuffles": {1: {"train_fraction": 0.5, "split": 1, "engine": "pytorch"}}, + } + }, + { + "shuffles": (SHUFFLES[1], SHUFFLES[3]), + "expected": { + "shuffles": { + 1: {"train_fraction": 0.5, "split": 1, "engine": "pytorch"}, + 3: {"train_fraction": 0.5, "split": 1, "engine": "tensorflow"}, + }, + } + }, + { + "shuffles": (SHUFFLES[1], SHUFFLES[2]), + "expected": { + "shuffles": { + 1: {"train_fraction": 0.5, "split": 1, "engine": "pytorch"}, + 2: {"train_fraction": 0.5, "split": 2, "engine": "pytorch"}, + }, + }, + }, + { + "shuffles": (SHUFFLES[1], SHUFFLES[2], SHUFFLES[3]), + "expected": { + "shuffles": { + 1: {"train_fraction": 0.5, "split": 1, "engine": "pytorch"}, + 2: {"train_fraction": 0.5, "split": 2, "engine": "pytorch"}, + 3: {"train_fraction": 0.5, "split": 1, "engine": "tensorflow"}, + }, + }, + }, +]) +def test_save_metadata_simple(tmpdir, data): + """Tests that saving the metadata creates the expected file""" + cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir) + trainset_meta = metadata.TrainingDatasetMetadata(cfg, data["shuffles"]) + print(trainset_meta) + + trainset_meta.save() + with open(meta_path, "r") as f: + meta = YAML().load(f) + print(data) + print(meta) + assert data["expected"] == meta + + +@pytest.mark.parametrize("shuffles", [ + [SHUFFLES[i] for i in indices] + for indices in [[1], [1, 2], [1, 2, 3], [1, 2, 4], [1, 3, 4], [1, 2, 3, 4]] +]) +def test_save_metadata(tmpdir, shuffles): + """Tests that saving the metadata and reloading it leads to the same instance""" + cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir) + for s in shuffles: + train, test = s.split.train_indices, s.split.test_indices, + _create_doc_data(cfg, trainset_dir, s.train_fraction, s.index, train, test) + + trainset_meta = metadata.TrainingDatasetMetadata(cfg, tuple(shuffles)) + print(trainset_meta) + trainset_meta.save() + reloaded = metadata.TrainingDatasetMetadata.load(cfg) + print(reloaded) + print() + + for s in trainset_meta.shuffles: + print(s) + print() + for s in reloaded.shuffles: + print(s) + print() + reloaded_with_splits = [s.load_split(cfg, trainset_dir) for s in reloaded.shuffles] + assert len(reloaded.shuffles) == len(trainset_meta.shuffles) + assert len(reloaded_with_splits) == len(trainset_meta.shuffles) + assert tuple(reloaded_with_splits) == trainset_meta.shuffles + + +def test_add_shuffle(tmpdir): + """Tests that a shuffle can be added correctlt""" + cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir) + trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1], )) + trainset_meta_added = trainset_meta.add(SHUFFLES[2]) + assert len(trainset_meta.shuffles) == 1 + assert len(trainset_meta_added.shuffles) == 2 + assert trainset_meta_added.shuffles == (SHUFFLES[1], SHUFFLES[2]) + + +def test_add_shuffle_twice(tmpdir): + """Tests that a shuffle can be added correctlt""" + cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir) + trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1], )) + trainset_meta_added = trainset_meta.add(SHUFFLES[2]) + trainset_meta_added_2 = trainset_meta.add(SHUFFLES[2]) + assert len(trainset_meta.shuffles) == 1 + assert trainset_meta.shuffles == (SHUFFLES[1], ) + assert len(trainset_meta_added.shuffles) == len(trainset_meta_added_2.shuffles) + assert trainset_meta_added.shuffles == trainset_meta_added_2.shuffles + + +def test_add_shuffle_sorts_to_correct_order(tmpdir): + """Tests that a shuffle can be added correctlt""" + cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir) + trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1], SHUFFLES[3])) + trainset_meta_added = trainset_meta.add(SHUFFLES[2]) + assert len(trainset_meta.shuffles) == 2 + assert len(trainset_meta_added.shuffles) == 3 + assert trainset_meta_added.shuffles == (SHUFFLES[1], SHUFFLES[2], SHUFFLES[3]) + + +@pytest.mark.parametrize("shuffles", [ + indices for indices in [[1], [1, 2], [1, 2, 3], [1, 2, 4], [1, 3, 4], [1, 2, 3, 4]] +]) +@pytest.mark.parametrize("shuffle_to_add", [1, 2, 3, 4]) +def test_add_shuffle(tmpdir, shuffles, shuffle_to_add): + """Tests """ + cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir) + trainset_meta = metadata.TrainingDatasetMetadata( + cfg, tuple([SHUFFLES[i] for i in shuffles]) + ) + if shuffle_to_add in shuffles: + with pytest.raises(RuntimeError): + trainset_meta_added = trainset_meta.add( + SHUFFLES[shuffle_to_add], overwrite=False + ) + + trainset_meta_added = trainset_meta.add( + SHUFFLES[shuffle_to_add], overwrite=True + ) + assert len(trainset_meta_added.shuffles) == len(shuffles) + assert [s.index for s in trainset_meta_added.shuffles] == shuffles + else: + trainset_meta_added = trainset_meta.add( + SHUFFLES[shuffle_to_add], overwrite=False + ) + indices = [s.index for s in trainset_meta_added.shuffles] + assert len(trainset_meta_added.shuffles) == len(shuffles) + 1 + assert indices == list(sorted(shuffles + [shuffle_to_add])) + + +@pytest.mark.parametrize( + "split1, split2, equal", + [ + (BASE_SPLIT, EQ_SPLIT, True), + (BASE_SPLIT, ADD_SPLIT, False), + (BASE_SPLIT, ADD_SPLIT2, False), + (BASE_SPLIT, SUBS_SPLIT, False), + (BASE_SPLIT, DEL_SPLIT, False), + (BASE_SPLIT, DEL_SPLIT2, False), + ], +) +def test_data_split_equality(split1, split2, equal): + """Tests that equality functions as expected for DataSplits""" + print(split1) + print(split2) + print(equal) + assert (split1 == split2) == equal + + +@pytest.mark.parametrize("split_idx", [1, 4, 20, 1000]) +@pytest.mark.parametrize("indices", [(2, 1), (10, 1), (1, 21, 20), (1, 2, 4, 3)]) +@pytest.mark.parametrize("sorted_indices", [(1, 2), (10, 12), (3, 4), (1, 1000, 1200)]) +def test_data_split_requires_sorted(split_idx, indices, sorted_indices): + """Tests that equality functions as expected for DataSplits""" + with pytest.raises(RuntimeError): + metadata.DataSplit( + train_indices=tuple(indices), test_indices=tuple(sorted_indices) + ) + + with pytest.raises(RuntimeError): + metadata.DataSplit( + train_indices=tuple(sorted_indices), test_indices=tuple(indices) + ) + + with pytest.raises(RuntimeError): + metadata.DataSplit( + train_indices=tuple(indices), test_indices=tuple(indices) + ) + + metadata.DataSplit( + train_indices=tuple(sorted_indices), test_indices=tuple(sorted_indices) + ) + + +@pytest.mark.parametrize("shuffles", [ + ( + {"idx": 3, "train": [1], "test": [2], "train_fraction": 0.5}, + ), + ( + {"idx": 1, "train": [1], "test": [2], "train_fraction": 0.5}, + {"idx": 4, "train": [1, 3], "test": [2], "train_fraction": 0.66}, + {"idx": 5, "train": [1, 2, 3], "test": [4, 5], "train_fraction": 0.6}, + ), +]) +def test_create_metadata_from_shuffles(tmpdir, shuffles): + """Tests that equality functions as expected for DataSplits""" + cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir) + print(trainset_dir) + for s in shuffles: + doc = f"Documentation_data-ex_{s['train_fraction']}shuffle{s['idx']}.pickle" + doc_path = trainset_dir.join(doc) + with open(doc_path, "wb") as f: + pickle.dump( + [[], s["train"], s["test"], s['train_fraction']], f, + pickle.HIGHEST_PROTOCOL + ) + + trainset_metadata = metadata.TrainingDatasetMetadata.create(cfg) + print() + print(trainset_metadata) + assert len(trainset_metadata.shuffles) == len(shuffles) + + for shuffle_data, shuffle in zip(shuffles, trainset_metadata.shuffles): + print(shuffle.index) + assert shuffle_data["idx"] == shuffle.index + assert shuffle_data["train_fraction"] == shuffle.train_fraction + assert tuple(shuffle_data["train"]) == shuffle.split.train_indices + assert tuple(shuffle_data["test"]) == shuffle.split.test_indices + print() + + +def _create_project_with_config( + tmp, + task: str = "example", + date: str = "Feb21", + scorer: str = "wayneRooney", + iteration: int = 0, + engine: str | None = None, +): + project_dir = tmp.mkdir("ex-ample-2024-02-21") + cfg = { + "Task": task, + "date": date, + "scorer": scorer, + "iteration": iteration, + "project_path": str(project_dir), + } + if engine is not None: + cfg["engine"] = engine + + cfg_path = project_dir.join("config.yaml") + with open(cfg_path, "w") as file: + YAML().dump(cfg, file) + + it = f"iteration-{iteration}" + dir_name = "UnaugmentedDataSet_" + task + date + trainset_dir = project_dir.mkdir("training-datasets").mkdir(it).mkdir(dir_name) + + meta_path = trainset_dir.join("metadata.yaml") + return cfg, cfg_path, trainset_dir, meta_path + + +def _create_doc_data( + cfg, + trainset_dir, + train_frac, + shuffle, + train_indices, + test_indices, +) -> None: + _, doc_path = auxiliaryfunctions.get_data_and_metadata_filenames( + trainset_dir, train_frac, shuffle, cfg + ) + auxiliaryfunctions.save_metadata( + doc_path, {}, list(train_indices), list(test_indices), train_frac + ) diff --git a/tests/pose_estimation_pytorch/other/test_dataset.py b/tests/pose_estimation_pytorch/other/test_dataset.py index efb30bccf0..93f67b7555 100644 --- a/tests/pose_estimation_pytorch/other/test_dataset.py +++ b/tests/pose_estimation_pytorch/other/test_dataset.py @@ -19,7 +19,7 @@ import deeplabcut.pose_estimation_pytorch as dlc import deeplabcut.utils.auxiliaryfunctions as dlc_auxfun -from deeplabcut.compat import Engine +from deeplabcut.core.engine import Engine from deeplabcut.generate_training_dataset import create_training_dataset From 3e99792d97c11dff82e47fe48d5b7b04584b3687 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 1 Mar 2024 12:14:28 +0100 Subject: [PATCH 071/293] niels/dataloader_improvements (#170) * moved bdpt, indv, metadata to pytorch config as project might change but model arch does not * use trainset index * cleaned DLCLoader * moved shaokai's function to DLCLoader; bug fixes; cleaned bbox computation; * set engine to TF the first time an existing project is loaded * fixed tests * added multi-animal testscript and synthetic data generation * bug fix: re-id heatmaps * bug fix: config creation for unique bodyparts * bug fix: only add non-empty annotations * bug fix: no annotations for an image --- benchmark/lightning_pose_ood_evaluation.py | 10 +- benchmark/madlc_test_inference.py | 15 +- .../apis/analyze_videos.py | 18 +- .../pose_estimation_pytorch/apis/evaluate.py | 15 +- .../pose_estimation_pytorch/apis/train.py | 4 +- .../config/make_pose_config.py | 19 +- .../pose_estimation_pytorch/data/base.py | 37 +- .../data/cocoloader.py | 47 ++- .../pose_estimation_pytorch/data/dataset.py | 68 ++- .../pose_estimation_pytorch/data/dlcloader.py | 325 ++++++++++----- .../pose_estimation_pytorch/data/utils.py | 82 +++- .../models/target_generators/dekr_targets.py | 20 +- .../target_generators/heatmap_targets.py | 18 +- .../models/target_generators/pafs_targets.py | 2 +- .../pose_estimation_pytorch/modelzoo/utils.py | 3 +- deeplabcut/pose_estimation_pytorch/utils.py | 153 ------- deeplabcut/utils/auxiliaryfunctions.py | 5 + examples/testscript_pytorch_multi_animal.py | 84 ++++ examples/testscript_pytorch_single_animal.py | 177 +++----- examples/utils.py | 392 ++++++++++++++++++ .../other/test_data_helper.py | 3 +- .../other/test_dataset.py | 14 +- 22 files changed, 967 insertions(+), 544 deletions(-) create mode 100644 examples/testscript_pytorch_multi_animal.py create mode 100644 examples/utils.py diff --git a/benchmark/lightning_pose_ood_evaluation.py b/benchmark/lightning_pose_ood_evaluation.py index 7843ebc0cf..bec902cf7b 100644 --- a/benchmark/lightning_pose_ood_evaluation.py +++ b/benchmark/lightning_pose_ood_evaluation.py @@ -11,7 +11,6 @@ from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners from deeplabcut.pose_estimation_pytorch.data.utils import map_id_to_annotations from deeplabcut.pose_estimation_pytorch.runners import Task -from deeplabcut.pose_estimation_pytorch.utils import df_to_generic from benchmark_lightning_pose import LP_DLC_BENCHMARKS from utils import Project, Shuffle @@ -60,8 +59,8 @@ def evaluate_ood( snapshots = [snapshots[i] for i in snapshot_indices] loader = DLCLoader( - project_root=str(shuffle.project.path), - model_config_path=str(shuffle.pytorch_cfg_path), + shuffle.project.config_path(), + trainset_index=0, shuffle=shuffle.index, ) parameters = loader.get_dataset_parameters() @@ -87,8 +86,9 @@ def evaluate_ood( for image_path, image_predictions in zip(image_paths, predictions) } - gt_data = df_to_generic(str(shuffle.project.path), df_ood, image_id_offset=1) - annotations_with_bbox = DLCLoader._get_all_bboxes(gt_data["images"], gt_data["annotations"]) + params = loader.get_dataset_parameters() + gt_data = loader.to_coco(str(shuffle.project.path), df_ood, params) + annotations_with_bbox = DLCLoader._compute_bboxes(gt_data["images"], gt_data["annotations"]) gt_data["annotations"] = annotations_with_bbox gt_keypoints = load_ground_truth(gt_data, loader.get_dataset_parameters()) diff --git a/benchmark/madlc_test_inference.py b/benchmark/madlc_test_inference.py index 55450c20d0..cfc8ef1f92 100644 --- a/benchmark/madlc_test_inference.py +++ b/benchmark/madlc_test_inference.py @@ -11,7 +11,7 @@ from ruamel.yaml import YAML from tqdm import tqdm -from deeplabcut.pose_estimation_pytorch import DLCLoader +from deeplabcut.pose_estimation_pytorch import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners from deeplabcut.utils.visualization import make_labeled_images_from_dataframe @@ -29,11 +29,14 @@ def run_inference_on_all_images( with open(pytorch_config_path, "r") as file: pytorch_config = YAML(typ='safe', pure=True).load(pytorch_config_path) - loader = DLCLoader( - project_root=str(project.path), - model_config_path=str(pytorch_config_path), + parameters = PoseDatasetParameters( + bodyparts=pytorch_config["metadata"]["bodyparts"], + unique_bpts=pytorch_config["metadata"]["unique_bodyparts"], + individuals=pytorch_config["metadata"]["individuals"], + with_center_keypoints=pytorch_config.get("with_center_keypoints", False), + color_mode=pytorch_config.get("color_mode", "RGB"), + cropped_image_size=pytorch_config.get("output_size", (256, 256)), ) - parameters = loader.get_dataset_parameters() shuffle_name = snapshot.parent.parent.name test_data_dir = project.root / "test-images" / project.name / "labeled-data" video_folders = [ @@ -120,7 +123,7 @@ def run_inference_on_all_images( poses = np.concatenate([poses, unique_poses], axis=1) df = pd.DataFrame(poses, index=index, columns=columns) - df.to_hdf(output_path, "df_with_missing") + df.to_hdf(output_path, key="df_with_missing") if plot: test_config_path = str( diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 18c00f7d42..d5d4bb62e7 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -32,7 +32,6 @@ get_runners, list_videos_in_folder, ) -from deeplabcut.pose_estimation_pytorch.data import DLCLoader from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner, Task from deeplabcut.refine_training_dataset.stitch import stitch_tracklets @@ -221,12 +220,6 @@ def analyze_videos( engine=Engine.PYTORCH, modelprefix=modelprefix, ) - # Get general project parameters - bodyparts = auxiliaryfunctions.get_bodyparts(cfg) - unique_bodyparts = auxiliaryfunctions.get_unique_bodyparts(cfg) - individuals = cfg.get("individuals", ["animal"]) - max_num_animals = len(individuals) - num_keypoints = len(bodyparts) # Read the inference configuration, load the model pytorch_config = auxiliaryfunctions.read_plainconfig( @@ -236,6 +229,13 @@ def analyze_videos( pose_cfg = auxiliaryfunctions.read_plainconfig(pose_cfg_path) pose_task = Task(pytorch_config.get("method", "BU")) + # Get general project parameters + bodyparts = pytorch_config["metadata"]["bodyparts"] + unique_bodyparts = pytorch_config["metadata"]["unique_bodyparts"] + individuals = pytorch_config["metadata"]["individuals"] + with_identity = pytorch_config["metadata"]["with_identity"] + max_num_animals = len(individuals) + if device is not None: pytorch_config["device"] = device @@ -244,8 +244,6 @@ def analyze_videos( # TODO: Choose which detector to use detector_path = _get_detector_path(model_folder, -1, cfg) - with_identity = DLCLoader.has_identity_head(pytorch_config) - print(f"Analyzing videos with {model_path}") pose_runner, detector_runner = get_runners( pytorch_config=pytorch_config, @@ -403,7 +401,7 @@ def create_df_from_prediction( ) df = df.join(df_u, how="outer") - df.to_hdf(output_h5, "df_with_missing", format="table", mode="w") + df.to_hdf(output_h5, key="df_with_missing", format="table", mode="w") return df diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 5f02f71f19..a742ad4f24 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -140,7 +140,7 @@ def evaluate( ) # TODO: Evaluate identity predictions - if DLCLoader.has_identity_head(loader.model_cfg): + if loader.model_cfg["metadata"]["with_identity"]: pred_id_scores = { filename: pred["identity_scores"] for filename, pred in predictions.items() @@ -165,7 +165,7 @@ def evaluate( def evaluate_snapshot( cfg: dict, shuffle: int = 0, - trainingsetindex: int = -1, + trainingsetindex: int = 0, snapshotindex: int = -1, device: str | None = None, transform: A.Compose | None = None, @@ -212,9 +212,10 @@ def evaluate_snapshot( pose_task = Task(pytorch_config.get("method", "bu")) loader = DLCLoader( - project_root=pytorch_config["project_path"], - model_config_path=model_config_path, + config=Path(cfg["project_path"]) / "config.yaml", shuffle=shuffle, + trainset_index=trainingsetindex, + modelprefix=modelprefix, ) parameters = loader.get_dataset_parameters() names = runner_utils.get_paths( @@ -234,7 +235,7 @@ def evaluate_snapshot( max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, num_unique_bodyparts=parameters.num_unique_bpts, - with_identity=loader.with_identity, + with_identity=pytorch_config["metadata"]["with_identity"], transform=transform, detector_path=detector_path, detector_transform=None, @@ -273,10 +274,10 @@ def evaluate_snapshot( names["model_path"][:-3], ) df_predictions = pd.concat(predictions.values(), axis=0) - df_predictions = df_predictions.reindex(loader.df_dlc.index) + df_predictions = df_predictions.reindex(loader.df.index) output_filename = Path(results_filename) output_filename.parent.mkdir(parents=True, exist_ok=True) - df_predictions.to_hdf(str(output_filename), "df_with_missing") + df_predictions.to_hdf(str(output_filename), key="df_with_missing") df_scores = pd.DataFrame([scores]).set_index( ["Training epochs", "%Training dataset", "Shuffle number", "pcutoff"] diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 1911e7e379..23ab31a8fb 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -193,9 +193,9 @@ def train_network( utils.fix_seeds(pytorch_config["seed"]) loader = DLCLoader( - project_root=pytorch_config["project_path"], - model_config_path=model_config_path, + config=config, shuffle=shuffle, + trainset_index=trainingsetindex, ) pose_task = Task(pytorch_config.get("method", "bu")) diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index 1841c54e23..04ce22b666 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -165,8 +165,14 @@ def add_metadata(project_config: dict, config: dict, pose_config_path: str) -> d the configuration with a `meta` key added """ config = copy.deepcopy(config) - config["pose_config_path"] = pose_config_path - config["project_path"] = project_config["project_path"] + config["metadata"] = { + "project_path": project_config["project_path"], + "pose_config_path": pose_config_path, + "bodyparts": auxiliaryfunctions.get_bodyparts(project_config), + "unique_bodyparts": auxiliaryfunctions.get_unique_bodyparts(project_config), + "individuals": project_config.get("individuals", ["animal"]), + "with_identity": project_config.get("identity", False), + } return config @@ -309,14 +315,13 @@ def add_unique_bodypart_head( the configuration with an added unique bodypart head """ config = copy.deepcopy(config) - unique_bodypart_head_config = read_config_as_dict( - configs_dir / "base" / "head_bodyparts.yaml" - ) - config["model"]["heads"]["unique_bodypart"] = replace_default_values( - unique_bodypart_head_config, + unique_head_config = replace_default_values( + read_config_as_dict(configs_dir / "base" / "head_bodyparts.yaml"), num_bodyparts=num_unique_bodyparts, backbone_output_channels=backbone_output_channels, ) + unique_head_config["target_generator"]["label_keypoint_key"] = "keypoints_unique" + config["model"]["heads"]["unique_bodypart"] = unique_head_config return config diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 25e4e116a0..db91174db6 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -11,6 +11,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from pathlib import Path import albumentations as A import numpy as np @@ -21,6 +22,7 @@ ) from deeplabcut.pose_estimation_pytorch.data.utils import ( _compute_crop_bounds, + bbox_from_keypoints, map_id_to_annotations, ) from deeplabcut.pose_estimation_pytorch.runners import Task @@ -37,16 +39,15 @@ class Loader(ABC): create_dataset(images: dict = None, annotations: dict = None, transform: object = None, mode: str = "train", task: Task = Task.BOTTOM_UP) -> PoseDataset: Creates and returns a PoseDataset given a set of images, annotations, and other parameters. - _get_all_bboxes(images, annotations, method: str = 'gt') -> dict: + _compute_bboxes(images, annotations, method: str = 'gt') -> dict: Retrieves all bounding boxes based on the specified method. get_dataset_parameters(*args, **kwargs) -> dict: Returns a dictionary containing dataset parameters derived from the configuration. """ - def __init__(self, project_root: str, model_config_path: str) -> None: - self.project_root = project_root - self.model_config_path = model_config_path - self.model_cfg = auxiliaryfunctions.read_plainconfig(model_config_path) + def __init__(self, model_config_path: str | Path) -> None: + self.model_config_path = Path(model_config_path) + self.model_cfg = auxiliaryfunctions.read_plainconfig(str(model_config_path)) self._loaded_data: dict[str, dict[str, list[dict]]] = {} @abstractmethod @@ -231,7 +232,11 @@ def filter_annotations(annotations: list[dict], task: Task) -> list[dict]: return filtered_annotations @staticmethod - def _get_all_bboxes(images, annotations, method: str = "gt"): + def _compute_bboxes( + images: list[dict], + annotations: list[dict], + method: str = "gt", + ): """TODO: Nastya method of bbox computation (detection bbox, seg. mask, ...) Retrieves all bounding boxes based on the given method. @@ -260,18 +265,32 @@ def _get_all_bboxes(images, annotations, method: str = "gt"): if "bbox" not in annotation: # or do something else? raise ValueError( - "Bounding box not found in annotation, please chose another method" + f"Bounding box not found in annotation {annotation}, please " + "chose another bbox computation method" ) return annotations elif method == "detection bbox": - return annotations + raise NotImplementedError elif method == "keypoints": + bbox_margin = 20 # TODO: should not be hardcoded + min_area = 1 # TODO: should not be hardcoded + img_id_to_annotations = map_id_to_annotations(annotations) + for img in images: + anns = [annotations[idx] for idx in img_id_to_annotations[img["id"]]] + for a in anns: + a["bbox"] = bbox_from_keypoints( + keypoints=a["keypoints"], + image_h=img["height"], + image_w=img["width"], + margin=bbox_margin, + ) + a["area"] = max(min_area, (a["bbox"][2] * a["bbox"][3]).item()) return annotations elif method == "segmentation mask": - return annotations + raise NotImplementedError else: raise ValueError(f"Unknown method: {method}") diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py index 9f9d6203b7..37debc0b75 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py @@ -13,7 +13,6 @@ import json import os import warnings -from dataclasses import dataclass from pathlib import Path import numpy as np @@ -26,7 +25,6 @@ ) -@dataclass class COCOLoader(Loader): """ Attributes: @@ -44,13 +42,19 @@ class COCOLoader(Loader): ) """ - project_root: str - model_config_path: str - train_json_filename: str = "train.json" - test_json_filename: str | None = "test.json" + def __init__( + self, + project_root: str | Path, + model_config_path: str | Path, + train_json_filename: str = "train.json", + test_json_filename: str = "test.json", + ): + super().__init__(Path(model_config_path)) + self.project_root = Path(project_root) + self.train_json_filename = train_json_filename + self.test_json_filename = test_json_filename + self._dataset_parameters = None - def __post_init__(self) -> None: - super().__init__(self.project_root, self.model_config_path) self.train_json = self.load_json(self.project_root, self.train_json_filename) self.test_json = None if self.test_json_filename: @@ -63,18 +67,20 @@ def get_dataset_parameters(self) -> PoseDatasetParameters: Returns: An instance of the PoseDatasetParameters with the parameters set. """ - num_individuals, bodyparts = self.get_project_parameters(self.train_json) - return PoseDatasetParameters( - bodyparts=bodyparts, - unique_bpts=[], - individuals=[f"individual{i}" for i in range(num_individuals)], - with_center_keypoints=self.model_cfg.get("with_center_keypoints", False), - color_mode=self.model_cfg.get("color_mode", "RGB"), - cropped_image_size=self.model_cfg.get("output_size", (256, 256)), - ) + if self._dataset_parameters is None: + num_individuals, bodyparts = self.get_project_parameters(self.train_json) + self._dataset_parameters = PoseDatasetParameters( + bodyparts=bodyparts, + unique_bpts=[], + individuals=[f"individual{i}" for i in range(num_individuals)], + with_center_keypoints=self.model_cfg.get("with_center_keypoints", False), + color_mode=self.model_cfg.get("color_mode", "RGB"), + cropped_image_size=self.model_cfg.get("output_size", (256, 256)), + ) + return self._dataset_parameters @staticmethod - def load_json(project_root: str, filename: str) -> dict: + def load_json(project_root: str | Path, filename: str) -> dict: """Load a JSON file from the annotations directory. Args: @@ -149,7 +155,7 @@ def validate_categories(coco_json: dict) -> dict: return coco_json @staticmethod - def validate_images(project_root: str, coco_json: dict) -> dict: + def validate_images(project_root: str | Path, coco_json: dict) -> dict: """Goes over images and annotations to look for potential errors This code tries to ensure that training a model on this project does not crash @@ -242,9 +248,10 @@ def load_data(self, mode: str = "train") -> dict: annotation["bbox"] = np.array(annotation["bbox"], dtype=float) annotation["individual"] = "unknown" - annotations_with_bbox = self._get_all_bboxes( + annotations_with_bbox = self._compute_bboxes( data["images"], data["annotations"], + method="gt", ) data["annotations"] = annotations_with_bbox return data diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 38e7b6cc13..b30d31515f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -65,7 +65,7 @@ def max_num_animals(self) -> int: class PoseDataset(Dataset): """A pose dataset""" - images: list[dict[str, str]] + images: list[dict] annotations: list[dict] parameters: PoseDatasetParameters transform: A.BaseCompose | None = None @@ -75,6 +75,9 @@ class PoseDataset(Dataset): def __post_init__(self): self.image_path_id_map = map_image_path_to_id(self.images) self.annotation_idx_map = map_id_to_annotations(self.annotations) + self.img_id_to_index = { + img["id"]: index for index, img in enumerate(self.images) + } def __len__(self): # TODO: TD should only return the number of annotations that aren't unique_bodyparts @@ -98,25 +101,14 @@ def _get_raw_item(self, index: int) -> tuple[str, list[dict], int]: If `self.crop` is True, it returns the image path and a list with a single annotation. Otherwise, it returns the image path and a list of annotations for all instances in the image. """ - image_path = self.images[index]["file_name"] - image_id = self.image_path_id_map[image_path] - annotations = [ - self.annotations[annotations_id] - for annotations_id in self.annotation_idx_map[image_id] - ] - return image_path, annotations, image_id + img = self.images[index] + anns = [self.annotations[idx] for idx in self.annotation_idx_map[img["id"]]] + return img["file_name"], anns, img["id"] def _get_raw_item_crop(self, index: int) -> tuple[str, list[dict], int]: - annotations = self.annotations[index] - image_id = annotations["image_id"] - annotations = [annotations] - - image_id2path = { - image_id: image_path - for (image_path, image_id) in self.image_path_id_map.items() - } - - return image_id2path[image_id], annotations, image_id + ann = self.annotations[index] + img = self.images[self.img_id_to_index[ann["image_id"]]] + return img["file_name"], [ann], img["id"] def __getitem__(self, index: int) -> dict: """ @@ -144,14 +136,14 @@ def __getitem__(self, index: int) -> dict: }, } """ - image_path, annotations, image_id = self._get_data_based_on_task(index) + image_path, anns, image_id = self._get_data_based_on_task(index) image, original_size = self._load_image(image_path) ( keypoints, keypoints_unique, bboxes, annotations_merged, - ) = self.extract_keypoints_and_bboxes(annotations, image.shape) + ) = self.extract_keypoints_and_bboxes(anns, image.shape) offsets = np.zeros((self.parameters.max_num_animals, 2)) scales = (1, 1) if self.task == Task.TOP_DOWN: @@ -172,20 +164,7 @@ def __getitem__(self, index: int) -> dict: ) keypoints[:, :, 0] = (keypoints[:, :, 0] - offsets[0]) / scales[0] keypoints[:, :, 1] = (keypoints[:, :, 1] - offsets[1]) / scales[1] - - # coords = [ - # (bboxes[0][0], bboxes[0][0] + bboxes[0][2]), # bboxes=xywh - # (bboxes[0][1], bboxes[0][1] + bboxes[0][3]), - # ] - # image, keypoints, offsets, scales = self.crop( - # image, - # keypoints, - # coords, - # self.parameters.cropped_image_size, - # ) - bboxes = np.zeros( - (0, 4) - ) # No more bounding boxes as we cropped around them + bboxes = np.zeros((0, 4)) # No more bboxes as we cropped around them transformed = self.apply_transform_all_keypoints( image, keypoints, keypoints_unique, bboxes @@ -240,18 +219,19 @@ def _prepare_final_annotation_dict( keypoints: np.ndarray, keypoints_unique: np.ndarray, bboxes: np.array, - annotations_merged: dict, + anns: dict, ) -> dict[str, np.ndarray]: num_animals = self.parameters.max_num_animals - is_crowd = np.array(annotations_merged["iscrowd"]) - cat_ids = np.array(annotations_merged["category_id"]) return { "keypoints": pad_to_length(keypoints[..., :2], num_animals, -1), "keypoints_unique": keypoints_unique[..., :2], - "area": pad_to_length(annotations_merged["area"], num_animals, 0), + "area": pad_to_length(anns["area"], num_animals, 0), "boxes": pad_to_length(bboxes, num_animals, 0), - "is_crowd": pad_to_length(is_crowd, num_animals, 0).astype(int), - "labels": pad_to_length(cat_ids, num_animals, -1).astype(int), + "is_crowd": pad_to_length(anns["iscrowd"], num_animals, 0).astype(int), + "labels": pad_to_length(anns["category_id"], num_animals, -1).astype(int), + "individual_ids": pad_to_length( + anns["individual_id"], num_animals, -1 + ).astype(int), } def _load_image(self, image_path): @@ -372,11 +352,11 @@ def crop( return _crop_image_keypoints(image, keypoints, coords, output_size) def extract_keypoints_and_bboxes( - self, annotations: list[dict], image_shape: tuple[int, int, int] - ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, list]]: + self, anns: list[dict], image_shape: tuple[int, int, int] + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, np.ndarray]]: """ Args: - annotations: COCO-style annotations + anns: COCO-style annotations image_shape: the (h, w, c) shape of the image for which to get annotations Returns: @@ -386,7 +366,7 @@ def extract_keypoints_and_bboxes( annotations_merged, where each key contains n_annotation values """ return _extract_keypoints_and_bboxes( - annotations, + anns, image_shape, self.parameters.num_joints, self.parameters.num_unique_bpts, diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index 920d2f7889..d381fa4611 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -8,114 +8,81 @@ # # Licensed under GNU Lesser General Public License v3.0 # +"""Class implementing the Loader for DeepLabCut projects""" from __future__ import annotations -import logging -import os import pickle -from dataclasses import dataclass +from pathlib import Path +import numpy as np import pandas as pd -import deeplabcut +import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch.data.base import Loader from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters -from deeplabcut.pose_estimation_pytorch.data.helper import CombinedPropertyMeta -from deeplabcut.pose_estimation_pytorch.utils import df_to_generic -from deeplabcut.utils.auxiliaryfunctions import ( - get_bodyparts, - get_model_folder, - get_unique_bodyparts, -) +from deeplabcut.pose_estimation_pytorch.data.utils import read_image_shape_fast -@dataclass -class DLCLoader(Loader, metaclass=CombinedPropertyMeta): +class DLCLoader(Loader): """A Loader for DeepLabCut projects""" - project_root: str - model_config_path: str - shuffle: int = 0 - image_id_offset: int = 0 - # TODO: read train fraction index - - properties = { - "cfg": ( - deeplabcut.auxiliaryfunctions.read_config, - lambda self: os.path.join(self.project_root, "config.yaml"), - ), - "model_folder": ( - lambda x: os.path.join( - x[0], get_model_folder(x[1], x[2], x[3], engine=Engine.PYTORCH) - ), - lambda self: ( - self.project_root, - self.cfg["TrainingFraction"][0], - self.shuffle, - self.cfg, - ), - ), - "_datasets_folder": ( - lambda x: os.path.join( - x[0], deeplabcut.auxiliaryfunctions.get_training_set_folder(x[1]) - ), - lambda self: (self.project_root, self.cfg), - ), - "_path_dlc_data": ( - lambda x: os.path.join(x[0], f"CollectedData_{x[1]}.h5"), - lambda self: (self._datasets_folder, self.cfg["scorer"]), - ), - "_path_dlc_doc": ( - lambda x: os.path.join( - x[0], f"Documentation_data-{x[1]}_{x[2]}shuffle{x[3]}.pickle" - ), - lambda self: ( - self._datasets_folder, - self.cfg["Task"], - int(100 * self.cfg["TrainingFraction"][0]), - self.shuffle, - ), - ), - } - - def __post_init__(self): - super().__init__(self.project_root, self.model_config_path) - self.split, self.df_dlc, self.df_train, self.df_test = self._load_dlc_data() - self.with_identity = self.has_identity_head(self.model_cfg) + def __init__( + self, + config: str | Path, + trainset_index: int = 0, + shuffle: int = 0, + modelprefix: str = "", + ): + """ + Args: + config: path to the DeepLabCut project config + trainset_index: the index of the TrainingsetFraction for which to load data + shuffle: the index of the shuffle for which to load data + modelprefix: the modelprefix for the shuffle + """ + self._project_root = Path(config).parent + self._project_config = af.read_config(str(config)) + self._model_folder = af.get_model_folder( + self._project_config["TrainingFraction"][trainset_index], + shuffle, + self._project_config, + engine=Engine.PYTORCH, + modelprefix=modelprefix, + ) + super().__init__( + self._project_root + / self._model_folder + / "train" + / Engine.PYTORCH.pose_cfg_name + ) + self._split = self.load_split(self._project_config, trainset_index, shuffle) + self._df = self.load_ground_truth(self._project_config) + self._dfs = { + split: self.drop_duplicates(df) + for split, df in self.split_data(self._df, self._split).items() + } + + @property + def df(self): + """Returns: The ground truth dataframe. Should not be modified.""" + return self._df def get_dataset_parameters(self) -> PoseDatasetParameters: - """ - Retrieves dataset parameters based on the instance's configuration. + """Retrieves dataset parameters based on the instance's configuration. Returns: An instance of the PoseDatasetParameters with the parameters set. """ return PoseDatasetParameters( - bodyparts=get_bodyparts(self.cfg), - unique_bpts=get_unique_bodyparts(self.cfg), - individuals=self.cfg.get("individuals", ["animal"]), + bodyparts=self.model_cfg["metadata"]["bodyparts"], + unique_bpts=self.model_cfg["metadata"]["unique_bodyparts"], + individuals=self.model_cfg["metadata"]["individuals"], with_center_keypoints=self.model_cfg.get("with_center_keypoints", False), color_mode=self.model_cfg.get("color_mode", "RGB"), cropped_image_size=self.model_cfg.get("output_size", (256, 256)), ) - def _load_dlc_data(self): - split = self._load_split(self._path_dlc_doc) - df_dlc = pd.read_hdf(self._path_dlc_data) - df_train, df_test = self.split_data(df_dlc, split) - df_dlc, df_train, df_test = self.drop_duplicates(df_dlc, df_train, df_test) - - return split, df_dlc, df_train, df_test - - @staticmethod - def drop_duplicates(dlc_df, df_train, df_test): - dlc_df = dlc_df[~dlc_df.index.duplicated(keep="first")] - df_train = df_train[~df_train.index.duplicated(keep="first")] - if df_test is not None: - df_test = df_test[~df_test.index.duplicated(keep="first")] - return dlc_df, df_train, df_test - def load_data(self, mode: str = "train") -> dict: """Loads DeepLabCut data into COCO-style annotations @@ -123,7 +90,7 @@ def load_data(self, mode: str = "train") -> dict: COCO-like format Args: - mode: mode indicating whether to use 'train' or 'test' data. Defaults to "train". + mode: mode indicating whether to use 'train' or 'test' data. Raises: AttributeError: if the specified mode (train or test) does not exist. @@ -131,35 +98,43 @@ def load_data(self, mode: str = "train") -> dict: Returns: the coco-style annotations """ - if mode == "train": - data_dlc_format = self.df_train - elif mode == "test": - data_dlc_format = self.df_test - # to do: add validation - else: + if mode not in ["train", "test"]: raise AttributeError(f"Unknown mode: {mode}") + if mode not in self._dfs: + raise ValueError(f"No split for: {mode} (found {self._dfs.keys()})") + if self._dfs[mode] is None: + raise ValueError(f"No data in {mode} split for this shuffle!") - data = df_to_generic(self.project_root, data_dlc_format, self.image_id_offset) - annotations_with_bbox = self._get_all_bboxes( - data["images"], data["annotations"] + params = self.get_dataset_parameters() + data = self.to_coco(str(self._project_root), self._dfs[mode], params) + with_bbox = self._compute_bboxes( + data["images"], data["annotations"], method="keypoints" ) - data["annotations"] = annotations_with_bbox - + data["annotations"] = with_bbox return data @staticmethod - def _load_split(path_dlc_doc: str) -> dict[str, list[int]]: - """Summary: - Split the annotation dataframe into train and test dataframes based on project's split - that is downloaded from the project's directory + def load_split( + config: dict, + trainset_index: int = 0, + shuffle: int = 0, + ) -> dict[str, list[int]]: + """Loads the train/test split for a DeepLabCut shuffle Args: - path_dlc_doc: the path to the DLC documentation file + config: the DeepLabCut project config + trainset_index: the TrainingsetFraction for which to load data + shuffle: the index of the shuffle for which to load data Return: the {"train": [train_ids], "test": [test_ids]} data split """ - with open(path_dlc_doc, "rb") as f: + trainset_dir = Path(config["project_path"]) / af.get_training_set_folder(config) + train_frac = int(100 * config["TrainingFraction"][trainset_index]) + shuffle_id = f"{config['Task']}_{train_frac}shuffle{shuffle}.pickle" + doc_path = trainset_dir / f"Documentation_data-{shuffle_id}" + + with open(doc_path, "rb") as f: meta = pickle.load(f) train_ids = [int(i) for i in meta[1]] @@ -167,10 +142,35 @@ def _load_split(path_dlc_doc: str) -> dict[str, list[int]]: return {"train": train_ids, "test": test_ids} + @staticmethod + def load_ground_truth(config: dict) -> pd.DataFrame: + """Loads the ground truth dataset for a DeepLabCut project. + + Args: + config: the DeepLabCut project configuration file + + Returns: + the annotated DeepLabCut data for the current iteration + + Raises: + ValueError: if the data contained in the ground truth HDF does not contain + a dataframe. + """ + trainset_dir = Path(config["project_path"]) / af.get_training_set_folder(config) + dataset_path = f"CollectedData_{config['scorer']}.h5" + df = pd.read_hdf(trainset_dir / dataset_path) + if not isinstance(df, pd.DataFrame): + raise ValueError( + f"The ground truth data in {trainset_dir} must contain a DataFrame! " + f"Found {df}" + ) + return df + @staticmethod def split_data( - dlc_df: pd.DataFrame, split: dict[str, list[int]] - ) -> tuple[pd.DataFrame, pd.DataFrame]: + dlc_df: pd.DataFrame, + split: dict[str, list[int]], + ) -> dict[str, pd.DataFrame | None]: """ Splits a DeepLabCut DataFrame into train/test dataframes @@ -179,18 +179,115 @@ def split_data( split: the train/test indices Returns: - df_train, df_test + a dictionary containing the same keys as the split dictionary, where the + values are the rows of dlc_df with index in the split, or None if there are + no indices in that split """ - train_images = dlc_df.index[split["train"]] - df_train = dlc_df.loc[train_images] + split_dfs = {} + for k, indices in split.items(): + if len(indices) == 0: + split_dfs[k] = None + else: + split_dfs[k] = dlc_df.iloc[indices] + return split_dfs - df_test = None - if len(split["test"]) != 0: - test_images = dlc_df.index[split["test"]] - df_test = dlc_df.loc[test_images] - - return df_train, df_test + @staticmethod + def drop_duplicates(df: pd.DataFrame) -> pd.DataFrame: + """Returns: the DataFrame with no duplicate rows""" + return df[~df.index.duplicated(keep="first")] @staticmethod - def has_identity_head(pytorch_config: dict) -> bool: - return "identity" in pytorch_config.get("model", {}).get("heads", {}) + def to_coco( + project_root: str | Path, + df: pd.DataFrame, + parameters: PoseDatasetParameters, + ) -> dict: + """Formerly Shaokai's function + + Args: + project_root: the path to the project root + df: the DLC-format annotation dataframe to convert to a COCO-format dict + parameters: the parameters for pose estimation + + Returns: + the coco format data + """ + with_individuals = "individuals" in df.columns.names + if ( + not with_individuals and + (len(parameters.individuals) > 1 or len(parameters.unique_bpts) > 0) + ): + raise ValueError( + "The DataFrame contains single-animal annotations (for a single, " + "individual), but the parameters suggest this is a multi-animal project" + f": {parameters} (with multiple individuals or unique bodyparts)" + ) + + categories = [ + { + "id": 1, + "name": "animals", + "supercategory": "animal", + "keypoints": parameters.bodyparts, + }, + ] + individuals = [idv for idv in parameters.individuals] + if len(parameters.unique_bpts) > 0: + individuals += ["single"] + categories.append( + { + "id": 2, + "name": "unique_bodypart", + "supercategory": "animal", + "keypoints": parameters.unique_bpts, + } + ) + + anns, images = [], [] + base_path = Path(project_root) + for idx, row in df.iterrows(): + image_id = len(images) + 1 + rel_path = Path(*idx) if isinstance(idx, tuple) else Path(idx) + path = str(base_path / rel_path) + _, height, width = read_image_shape_fast(path) + images.append( + { + "id": image_id, + "file_name": path, + "width": width, + "height": height, + } + ) + + for idv_idx, idv in enumerate(individuals): + category_id = 1 + individual_id = idv_idx + if with_individuals: + if idv == "single": + category_id = 2 + individual_id = -1 + data = row.xs(idv, level="individuals") + else: + data = row + + raw_keypoints = data.to_numpy().reshape((-1, 2)) + keypoints = np.zeros((len(raw_keypoints), 3)) + keypoints[:, :2] = raw_keypoints + is_visible = ~pd.isnull(raw_keypoints).all(axis=1) + keypoints[:, 2] = np.where(is_visible, 2, 0) + num_keypoints = is_visible.sum() + if num_keypoints > 0: + anns.append( + { + "id": len(anns) + 1, + "image_id": image_id, + "category_id": category_id, + "individual": idv, + "individual_id": individual_id, + "num_keypoints": num_keypoints, + "keypoints": keypoints, + "iscrowd": 0, + } + ) + + return {"annotations": anns, "categories": categories, "images": images} diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index f78e07df43..a9b4aff125 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -11,15 +11,64 @@ from __future__ import annotations from collections import defaultdict -from functools import reduce +from functools import reduce, lru_cache +from pathlib import Path import albumentations as A import numpy as np import torch +from PIL import Image from torchvision.ops import box_convert from torchvision.transforms import functional as F +@lru_cache(maxsize=None) +def read_image_shape_fast(path: str | Path) -> tuple[int, int, int]: + """Blazing fast and does not load the image into memory""" + with Image.open(path) as img: + width, height = img.size + return len(img.getbands()), height, width + + +def bbox_from_keypoints( + keypoints: np.ndarray, + image_h: int, + image_w: int, + margin: int, +) -> np.ndarray: + """ + Computes bounding boxes from keypoints. + + Args: + keypoints: (..., num_keypoints, xy) the keypoints from which to get bboxes + image_h: the height of the image + image_w: the width of the image + margin: the bounding box margin + + Returns: + the bounding boxes for the keypoints, of shape (..., 4) in the xywh format + """ + squeeze = False + if len(keypoints.shape) == 2: + squeeze = True + keypoints = np.expand_dims(keypoints, axis=0) + + bboxes = np.full((keypoints.shape[0], 4), np.nan) + bboxes[:, :2] = np.nanmin(keypoints[..., :2], axis=1) - margin # X1, Y1 + bboxes[:, 2:4] = np.nanmax(keypoints[..., :2], axis=1) + margin # X2, Y2 + bboxes = np.clip( + bboxes, + a_min=[0, 0, 0, 0], + a_max=[image_w - 1, image_h - 1, image_w - 1, image_h - 1], + ) + bboxes[..., 2] = bboxes[..., 2] - bboxes[..., 0] # to width + bboxes[..., 3] = bboxes[..., 3] - bboxes[..., 1] # to height + if squeeze: + return bboxes[0] + + return bboxes + + def merge_list_of_dicts( list_of_dicts: list[dict], keys_to_include: list[str] ) -> dict[str, list]: @@ -297,14 +346,14 @@ def _compute_crop_bounds( def _extract_keypoints_and_bboxes( - annotations: list[dict], + anns: list[dict], image_shape: tuple[int, int, int], num_joints: int, num_unique_bodyparts: int, -) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, list]]: +) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, np.ndarray]]: """ Args: - annotations: COCO-style annotations + anns: COCO-style annotations image_shape: the (h, w, c) shape of the image for which to get annotations num_joints: the number of joints in the annotations @@ -313,15 +362,15 @@ def _extract_keypoints_and_bboxes( """ keypoints = [] original_bboxes = [] - annotations_to_merge = [] + anns_to_merge = [] unique_keypoints = None - for i, annotation in enumerate(annotations): + for i, annotation in enumerate(anns): keypoints_individual = _annotation_to_keypoints(annotation) if annotation["individual"] != "single": bbox_individual = annotation["bbox"] original_bboxes.append(bbox_individual) keypoints.append(keypoints_individual) - annotations_to_merge.append(annotation) + anns_to_merge.append(annotation) else: unique_keypoints = keypoints_individual @@ -332,17 +381,16 @@ def _extract_keypoints_and_bboxes( original_bboxes = safe_stack(original_bboxes, (0, 4)) bboxes = _compute_crop_bounds(original_bboxes, image_shape) - annotations_merged = merge_list_of_dicts( - annotations_to_merge, keys_to_include=["area", "category_id", "iscrowd"] - ) - if len(annotations_merged["area"]) == len(keypoints): - area = np.array(annotations_merged["area"]) - area[area < 1] = 1 # TODO: see comments in calc_area_from_keypoints - else: - area = calc_area_from_keypoints(keypoints) + keys_to_merge = ["area", "category_id", "iscrowd", "individual_id"] + anns_merged = {k: [] for k in keys_to_merge} + if len(anns_to_merge) > 0: + anns_merged = merge_list_of_dicts(anns_to_merge, keys_to_include=keys_to_merge) + + if len(anns_merged["area"]) != len(keypoints): + raise ValueError(f"Missing area values! {anns_merged}, {keypoints.shape}") - annotations_merged["area"] = area - return keypoints, unique_keypoints, bboxes, annotations_merged + anns_merged = {k: np.array(v) for k, v in anns_merged.items()} + return keypoints, unique_keypoints, bboxes, anns_merged def calc_area_from_keypoints(keypoints: np.ndarray) -> np.ndarray: diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index 2d7166a3ef..0661e522a2 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -96,18 +96,14 @@ def forward( sgm, ct_sgm = (self.pos_dist_thresh / 2) * scale, self.pos_dist_thresh * scale radius = self.pos_dist_thresh * scale - heatmaps = np.zeros( - (batch_size, self.num_heatmaps, output_h, output_w), dtype=np.float32 - ) - heatmap_weights = 2 * np.ones( - (batch_size, self.num_heatmaps, output_h, output_w), dtype=np.float32 - ) - offset_map = np.zeros( - (batch_size, self.num_joints * 2, output_h, output_w), dtype=np.float32 - ) - weight_map = np.zeros( - (batch_size, self.num_joints * 2, output_h, output_w), dtype=np.float32 - ) + heatmap_shape = batch_size, self.num_heatmaps, output_h, output_w + heatmaps = np.zeros(heatmap_shape, dtype=np.float32) + heatmap_weights = 2 * np.ones(heatmap_shape, dtype=np.float32) + + offset_shape = batch_size, self.num_joints * 2, output_h, output_w + offset_map = np.zeros(offset_shape, dtype=np.float32) + weight_map = np.zeros(offset_shape, dtype=np.float32) + area_map = np.zeros((batch_size, output_h, output_w), dtype=np.float32) for b in range(batch_size): for person_id, p in enumerate(coords[b]): diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py index 2234e74857..02c1be66b6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py @@ -132,14 +132,22 @@ def forward( if self.heatmap_mode == HeatmapGenerator.Mode.KEYPOINT: # transpose the individuals and keypoints to iterate over bodyparts coords = coords.transpose((0, 2, 1, 3)) - - heatmap = np.zeros((batch_size, height, width, self.num_heatmaps), dtype=np.float32) + if self.heatmap_mode == HeatmapGenerator.Mode.INDIVIDUAL: + # re-order the individuals to always have the same order + # TODO: Optimize + sorted_coords = -np.ones_like(coords) + for i, batch_individuals in enumerate(labels["individual_ids"]): + for j, individual_id in enumerate(batch_individuals): + if individual_id >= 0: + sorted_coords[i, individual_id] = coords[i, j] + coords = sorted_coords + + map_size = batch_size, height, width + heatmap = np.zeros((*map_size, self.num_heatmaps), dtype=np.float32) locref_map, locref_mask = None, None if self.generate_locref: - locref_map = np.zeros( - (batch_size, height, width, self.num_heatmaps * 2), dtype=np.float32 - ) + locref_map = np.zeros((*map_size, self.num_heatmaps * 2), dtype=np.float32) locref_mask = np.zeros_like(locref_map, dtype=int) grid = np.mgrid[:height, :width].transpose((1, 2, 0)) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py index 1ea045c5ed..073c4fddc5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py @@ -105,7 +105,7 @@ def forward( partaffinityfield_map[b, mask, l * 2 + 0] = vec_x_norm * temp partaffinityfield_map[b, mask, l * 2 + 1] = vec_y_norm * temp - partaffinityfield_map = partaffinityfield_map.transpose(0, 3, 1, 2) + partaffinityfield_map = partaffinityfield_map.transpose((0, 3, 1, 2)) return { "paf": { "target": torch.tensor( diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py index 914a82af9a..97fd70f967 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py @@ -38,7 +38,7 @@ def _get_config_model_paths( dlc_root_path = auxiliaryfunctions.get_deeplabcut_path() modelzoo_path = os.path.join(dlc_root_path, "modelzoo") - model_config = auxiliaryfunctions.read_config( + model_config = auxiliaryfunctions.read_plainconfig( os.path.join(modelzoo_path, "model_configs", f"{pose_model_type}.yaml") ) project_config = auxiliaryfunctions.read_config( @@ -97,6 +97,7 @@ def raise_warning_if_called_directly(): def _update_config(config, max_individuals, device): + print(config) num_bodyparts = len(config["bodyparts"]) config["detector"]["runner"]["max_individuals"] = max_individuals config["multianimalproject"] = max_individuals > 1 diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index 1761865c35..d8c183c9c1 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -15,163 +15,10 @@ import random import numpy as np -import pandas as pd import torch -from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( - read_image_shape_fast, -) -from deeplabcut.core.trackingutils import calc_bboxes_from_keypoints from deeplabcut.utils.auxiliaryfunctions import read_plainconfig -# Shaokai's function - - -def df_to_generic(proj_root: str, df: pd.DataFrame, image_id_offset: int = 0) -> dict: - """ - Convert a pandas DataFrame containing pose estimation data to a dictionary in COCO format. - - Args: - proj_root (str): The root directory of the project. - df (pd.DataFrame): The DataFrame containing the pose estimation data. - image_id_offset (int, optional): The offset to add to the image IDs. Defaults to 0. - - Returns: - dict: A dictionary in COCO format containing the images, annotations, and categories. - """ - try: - individuals = df.columns.get_level_values("individuals").unique().tolist() - except KeyError: - new_cols = pd.MultiIndex.from_tuples( - [(col[0], "animal", col[1], col[2]) for col in df.columns], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - df.columns = new_cols - - individuals = df.columns.get_level_values("individuals").unique().tolist() - - unique_bpts = [] - - if "single" in individuals: - unique_bpts.extend( - df.xs("single", level="individuals", axis=1) - .columns.get_level_values("bodyparts") - .unique() - ) - multi_bpts = ( - df.xs(individuals[0], level="individuals", axis=1) - .columns.get_level_values("bodyparts") - .unique() - .tolist() - ) - - coco_categories = [] - - # assuming all individuals have the same name and same category id - # TODO: Should have 1 category ID for unique bodyparts and 1 for bodyparts - - individual = individuals[0] - - category = {"name": individual, "id": 0, "supercategory": "animal"} - - if individual == "single": - category["keypoints"] = unique_bpts - else: - category["keypoints"] = multi_bpts - - coco_categories.append(category) - - coco_images = [] - coco_annotations = [] - - annotation_id = 0 - image_id = -1 - for _, file_name in enumerate(df.index): - data = df.loc[file_name] - - # skipping all nan - # if np.isnan(data.to_numpy()).all(): - # continue - - image_id += 1 - - for individual_id, individual in enumerate(individuals): - category_id = 1 # 0 is for background by default - try: - kpts = ( - data.xs(individual, level="individuals").to_numpy().reshape((-1, 2)) - ) - except: - # somehow there are duplicates. So only use the first occurrence - data = data.iloc[0] - kpts = ( - data.xs(individual, level="individuals").to_numpy().reshape((-1, 2)) - ) - - keypoints = np.zeros((len(kpts), 3)) - - keypoints[:, :2] = kpts - - is_visible = ~pd.isnull(kpts).all(axis=1) - - keypoints[:, 2] = np.where(is_visible, 2, 0) - - num_keypoints = is_visible.sum() - - bbox_margin = 20 - - xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( - [keypoints], slack=bbox_margin - )[0][:4] - - w = xmax - xmin - h = ymax - ymin - area = w * h - bbox = np.nan_to_num([xmin, ymin, w, h]) - keypoints = np.nan_to_num(keypoints.flatten()) - - annotation_id += 1 - annotation = { - "image_id": image_id + image_id_offset, - "num_keypoints": num_keypoints, - "keypoints": keypoints, - "id": annotation_id, - "category_id": category_id, - "individual": individual, - "area": area, - "bbox": bbox, - "iscrowd": 0, - } - - # adds an annotation even if no keypoint is annotated for the current individual - # This is not standard for COCO but is useful because each image will then have - # the same number of annotations (i.e possible to train with batches without overcomplicating the code) - coco_annotations.append(annotation) - - # I think width and height are important - - if isinstance(file_name, tuple): - image_path = os.path.join(proj_root, *list(file_name)) - else: - image_path = os.path.join(proj_root, file_name) - - _, height, width = read_image_shape_fast(image_path) - - image = { - "file_name": image_path, - "width": width, - "height": height, - "id": image_id + image_id_offset, - } - coco_images.append(image) - - ret_obj = { - "images": coco_images, - "annotations": coco_annotations, - "categories": coco_categories, - } - return ret_obj - def create_folder(path_to_folder): """Creates all folders contained in the path. diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index b753e2c3c5..8cc6828aa6 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -198,6 +198,11 @@ def read_config(configname): with open(path, "r") as f: cfg = ruamelFile.load(f) curr_dir = str(Path(configname).parent.resolve()) + + if cfg.get("engine") is None: + cfg["engine"] = Engine.TF.aliases[0] + write_config(configname, cfg) + if cfg["project_path"] != curr_dir: cfg["project_path"] = curr_dir write_config(configname, cfg) diff --git a/examples/testscript_pytorch_multi_animal.py b/examples/testscript_pytorch_multi_animal.py new file mode 100644 index 0000000000..86214cb26a --- /dev/null +++ b/examples/testscript_pytorch_multi_animal.py @@ -0,0 +1,84 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +""" Testscript for single animal PyTorch projects """ +from pathlib import Path + +import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.compat import Engine + +from utils import cleanup, create_fake_project, log_step, run + + +def main( + net_types: list[str], + epochs: int = 1, + save_epochs: int = 1, + batch_size: int = 1, + device: str = "cpu", + create_labeled_videos: bool = False, + delete_after_test_run: bool = False, +) -> None: + project_path = Path("../synthetic-data-niels-multi-animal").resolve() + config_path = project_path / "config.yaml" + + create_fake_project( + path=project_path, + multianimal=True, + num_bodyparts=3, + num_frames=20, + num_individuals=2, + num_unique=4, + identity=False, + frame_shape=(128, 256), + ) + + engine = Engine.PYTORCH + cfg = af.read_config(config_path) + trainset_index = 0 + train_frac = cfg["TrainingFraction"][trainset_index] + for net_type in net_types: + try: + run( + config_path=config_path, + train_fraction=train_frac, + trainset_index=trainset_index, + net_type=net_type, + videos=[], + device=device, + train_kwargs=dict( + display_iters=1, + epochs=epochs, + save_epochs=save_epochs, + batch_size=batch_size, + ), + engine=engine, + create_labeled_videos=create_labeled_videos, + ) + except Exception as err: + log_step(f"FAILED TO RUN {net_type}") + log_step(str(err)) + log_step("Continuing to next model") + raise err + + if delete_after_test_run: + cleanup(project_path) + + +if __name__ == "__main__": + main( + net_types=["resnet_50", "dekr_w18"], # , "hrnet_w18", "hrnet_w32"], + batch_size=8, + epochs=1, + save_epochs=1, + device="cpu", # "cpu", "cuda:0", "mps" + create_labeled_videos=False, + delete_after_test_run=True, + ) diff --git a/examples/testscript_pytorch_single_animal.py b/examples/testscript_pytorch_single_animal.py index 951e4313cf..d691b3fe59 100644 --- a/examples/testscript_pytorch_single_animal.py +++ b/examples/testscript_pytorch_single_animal.py @@ -1,155 +1,78 @@ """ Testscript for single animal PyTorch projects """ -import shutil -import time from pathlib import Path -from typing import Any -import deeplabcut import deeplabcut.utils.auxiliaryfunctions as af -from deeplabcut.core.engine import Engine -from deeplabcut.generate_training_dataset import get_existing_shuffle_indices -from deeplabcut.utils import auxiliaryfunctions +from deeplabcut.compat import Engine - -def run( - config_path: Path, - train_fraction: float, - trainset_index: int, - net_type: str, - videos: list[str], - device: str, - train_kwargs: dict, - engine: Engine = Engine.PYTORCH, - create_labeled_videos: bool = False, -) -> None: - times = [time.time()] - log_step(f"Testing with net type {net_type}") - log_step("Creating the training dataset") - deeplabcut.create_training_dataset( - str(config_path), net_type=net_type, engine=engine - ) - existing_shuffles = get_existing_shuffle_indices( - config_path, train_fraction=train_fraction, engine=engine - ) - shuffle_index = existing_shuffles[-1] - - log_step(f"Starting training for frac={train_fraction}, shuffle={shuffle_index}") - deeplabcut.train_network( - config=str(config_path), - shuffle=shuffle_index, - trainingsetindex=trainset_index, - device=device, - **train_kwargs, - ) - times.append(time.time()) - log_step(f"Train time: {times[-1] - times[-2]} seconds") - - log_step(f"Starting evaluation for train_frac {train_fraction}, shuffle {shuffle_index}") - deeplabcut.evaluate_network( - config=str(config_path), - Shuffles=[shuffle_index], - trainingsetindex=trainset_index, - device=device, - ) - times.append(time.time()) - log_step(f"Evaluation time: {times[-1] - times[-2]} seconds") - - log_step(f"Analyzing videos for {train_fraction}, shuffle {shuffle_index}") - deeplabcut.analyze_videos( - config=str(config_path), - videos=videos, - shuffle=shuffle_index, - trainingsetindex=trainset_index, - device=device, - ) - times.append(time.time()) - log_step(f"Video analysis time: {times[-1] - times[-2]} seconds") - log_step(f"Total test time: {times[-1] - times[0]} seconds") - - if create_labeled_videos: - log_step(f"Creating a labeled video for {train_fraction}, shuffle {shuffle_index}") - deeplabcut.create_labeled_video( - config=str(config_path), - videos=videos, - shuffle=shuffle_index, - trainingsetindex=trainset_index, - ) - - -def copy_project_for_test() -> Path: - data_path = Path.cwd() / "openfield-Pranav-2018-10-30" - test_path = Path.cwd() / "pytorch-testscript1234-openfield-Pranav-2018-10-30" - if not test_path.exists(): - shutil.copytree(data_path, test_path) - - project_config = auxiliaryfunctions.read_config(str(test_path / "config.yaml")) - videos = list(project_config["video_sets"].keys()) - video = videos[0] - crop = project_config["video_sets"][video] - project_config["video_sets"] = {str(test_path / "videos" / "m3v1mp4.mp4"): crop} - auxiliaryfunctions.write_config(str(test_path / "config.yaml"), project_config) - return test_path - - -def cleanup(test_path: Path) -> None: - if test_path.exists(): - shutil.rmtree(test_path) +from utils import cleanup, copy_project_for_test, create_fake_project, log_step, run def main( + synthetic_data: bool, net_types: list[str], epochs: int = 1, save_epochs: int = 1, batch_size: int = 1, device: str = "cpu", create_labeled_videos: bool = False, + delete_after_test_run: bool = False, ) -> None: engine = Engine.PYTORCH - project_path = copy_project_for_test() - try: - config_path = project_path / "config.yaml" - cfg = af.read_config(config_path) - trainset_index = 0 - train_frac = cfg["TrainingFraction"][trainset_index] - for net_type in net_types: - try: - run( - config_path=config_path, - train_fraction=train_frac, - trainset_index=trainset_index, - net_type=net_type, - videos=[str(project_path / "videos" / "m3v1mp4.mp4")], - device=device, - train_kwargs=dict( - display_iters=1, - epochs=epochs, - save_epochs=save_epochs, - batch_size=batch_size, - ), - engine=engine, - create_labeled_videos=create_labeled_videos, - ) - except Exception as err: - log_step(f"FAILED TO RUN {net_type}") - log_step(str(err)) - log_step("Continuing to next model") - raise err - finally: - cleanup(project_path) - + if synthetic_data: + project_path = Path("../synthetic-data-niels-single-animal").resolve() + videos = [] + create_fake_project( + path=project_path, + multianimal=False, + num_bodyparts=6, + num_frames=20, + frame_shape=(128, 256), + ) -def log_step(message: Any) -> None: - print(100 * "-") - print(str(message)) - print(100 * "-") + else: + project_path = copy_project_for_test() + videos = [str(project_path / "videos" / "m3v1mp4.mp4")] + + config_path = project_path / "config.yaml" + cfg = af.read_config(config_path) + trainset_index = 0 + train_frac = cfg["TrainingFraction"][trainset_index] + for net_type in net_types: + try: + run( + config_path=config_path, + train_fraction=train_frac, + trainset_index=trainset_index, + net_type=net_type, + videos=videos, + device=device, + train_kwargs=dict( + display_iters=1, + epochs=epochs, + save_epochs=save_epochs, + batch_size=batch_size, + ), + engine=engine, + create_labeled_videos=create_labeled_videos, + ) + except Exception as err: + log_step(f"FAILED TO RUN {net_type}") + log_step(str(err)) + log_step("Continuing to next model") + raise err + + if delete_after_test_run: + cleanup(project_path) if __name__ == "__main__": main( + synthetic_data=True, net_types=["resnet_50", "hrnet_w18", "hrnet_w32"], batch_size=8, epochs=1, save_epochs=1, device="cpu", # "cpu", "cuda:0", "mps" create_labeled_videos=False, + delete_after_test_run=True, ) diff --git a/examples/utils.py b/examples/utils.py new file mode 100644 index 0000000000..85d6cbc0cf --- /dev/null +++ b/examples/utils.py @@ -0,0 +1,392 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import shutil +import string +import time +from pathlib import Path +from typing import Any + +import deeplabcut +import deeplabcut.utils.auxiliaryfunctions as af +import numpy as np +import pandas as pd +from deeplabcut.compat import Engine +from deeplabcut.generate_training_dataset import get_existing_shuffle_indices +from PIL import Image + + +def log_step(message: Any) -> None: + print(100 * "-") + print(str(message)) + print(100 * "-") + + +def cleanup(test_path: Path) -> None: + if test_path.exists(): + shutil.rmtree(test_path) + + +def sample_pose_random( + gen: np.random.Generator, + num_individuals: int, + num_bodyparts: int, + num_unique: int, + img_h: int, + img_w: int, +) -> np.ndarray: + """Fully random pose sampling""" + xs = gen.choice(img_w, size=(num_individuals, num_bodyparts), replace=False) + ys = gen.choice(img_h, size=(num_individuals, num_bodyparts), replace=False) + pose = np.stack([xs, ys], axis=-1) + + image_data = pose.reshape(-1) + if num_unique > 0: + unique_pose = np.stack( + [ + gen.choice(img_w, size=(1, num_unique), replace=False), + gen.choice(img_h, size=(1, num_unique), replace=False) + ], + axis=-1 + ) + image_data = np.concatenate([image_data, unique_pose.reshape(-1)]) + return image_data + + +def sample_pose_from_center( + gen: np.random.Generator, + num_individuals: int, + num_bodyparts: int, + num_unique: int, + img_h: int, + img_w: int, + radius: int = 25, +) -> np.ndarray: + """Sample the center of each individual, then sample the other keypoints""" + center_xs = gen.choice( + np.arange(radius, img_w - radius), + size=num_individuals + 1, # in case unique bodyparts + replace=False, + ) + center_ys = gen.choice( + np.arange(radius, img_h - radius), + size=num_individuals, # in case unique bodyparts + replace=False, + ) + + pose = np.zeros((num_individuals, num_bodyparts, 2)) + for i, (xc, yc) in enumerate(zip(center_xs, center_ys)): + if i < num_individuals: + x_start, x_end = xc - radius + 1, xc + radius - 1 + y_start, y_end = yc - radius + 1, yc + radius - 1 + pose[i, :, 0] = np.linspace(start=x_start, stop=x_end, num=num_bodyparts) + pose[i, :, 1] = np.linspace(start=y_start, stop=y_end, num=num_bodyparts) + + image_data = pose.reshape(-1) + if num_unique > 0: + xc, yc = center_xs[-1], center_ys[-1] + x_start, x_end = xc - radius + 1, xc + radius - 1 + y_start, y_end = yc - radius + 1, yc + radius - 1 + unique_pose = np.zeros((1, num_unique, 2)) + unique_pose[0, :, 0] = np.linspace(start=x_start, stop=x_end, num=num_unique) + unique_pose[0, :, 1] = np.linspace(start=y_start, stop=y_end, num=num_unique) + image_data = np.concatenate([image_data, unique_pose.reshape(-1)]) + return image_data + + +def gen_fake_data( + scorer: str, + video_name: str, + individuals: list[str], + bodyparts: list[str], + unique: list[str], + num_frames: int, + img_h: int, + img_w: int, +) -> pd.DataFrame: + kpt_entries = ["x", "y"] + col_names = ["scorer", "individuals", "bodyparts", "coords"] + col_values = [] + for i in individuals: + for b in bodyparts: + col_values += [(scorer, i, b, entry) for entry in kpt_entries] + + for unique_bpt in unique: + col_values += [(scorer, "single", unique_bpt, entry) for entry in kpt_entries] + + index_data = [] + pose_data = [] + gen = np.random.default_rng(seed=0) + for frame_index in range(num_frames): + index_data.append(("labeled-data", video_name, f"img{frame_index:04}.png")) + pose_data.append( + sample_pose_from_center( + gen, + num_individuals=len(individuals), + num_bodyparts=len(bodyparts), + num_unique=len(unique), + img_h=img_h, + img_w=img_w, + radius=25, + ) + ) + + pose = np.stack(pose_data) + + pose[0, :] = np.nan # add missing row in a frame + for idv in range(len(individuals)): + idv_start, idv_end = 2 * len(bodyparts) * idv, 2 * len(bodyparts) * (idv + 1) + if num_frames > idv + 1: + pose[idv + 1, idv_start:idv_end] = np.nan + + for bpt in range(len(bodyparts)): + frame_idx = 1 + len(individuals) + bpt + idv_idx = bpt % len(individuals) + offset = 2 * len(bodyparts) * idv_idx + bpt_start, bpt_end = 2 * bpt + offset, 2 * (bpt + 1) + offset + if num_frames + 1 > frame_idx: + pose[frame_idx, bpt_start:bpt_end] = np.nan + + return pd.DataFrame( + pose, + index=pd.MultiIndex.from_tuples(index_data), + columns=pd.MultiIndex.from_tuples(col_values, names=col_names), + ) + + +def gen_fake_image( + project_root: Path, + row: pd.Series, + individuals: list[str], + bodyparts: list[str], + unique: list[str], + img_h: int, + img_w: int, + radius: int = 5, +): + image_array = np.zeros((img_h, img_w, 3), dtype=np.uint8) + for i, idv in enumerate(individuals): + r = int(255 * (i + 1) / len(individuals)) + if "individuals" in row.index.names: + idv_data = row.droplevel("scorer").loc[idv] + else: + idv_data = row.droplevel("scorer") + + keypoints = idv_data.to_numpy().reshape((-1, 2)) + if not np.all(np.isnan(keypoints)): + idv_center = np.nanmean(keypoints, axis=0) + x, y = int(idv_center[0]), int(idv_center[1]) + xmin, xmax = max(0, x - radius), min(img_w - 1, x + radius) + ymin, ymax = max(0, y - radius), min(img_h - 1, y + radius) + image_array[ymin:ymax, xmin:xmax, 0] = r + + for j, bpt in enumerate(bodyparts): + g = int(255 * (j + 1) / len(bodyparts)) + + bpt_data = idv_data.loc[bpt] + if np.all(~pd.isnull(bpt_data)): + x, y = int(bpt_data.x), int(bpt_data.y) + xmin, xmax = max(0, x - radius), min(img_w - 1, x + radius) + ymin, ymax = max(0, y - radius), min(img_h - 1, y + radius) + image_array[ymin:ymax, xmin:xmax, 0] = r + image_array[ymin:ymax, xmin:xmax, 1] = g + + if len(unique) > 0: + unique_data = row.droplevel("scorer").loc["single"] + for i, unique_bpt in enumerate(unique): + bpt_data = unique_data.loc[unique_bpt] + if np.all(~pd.isnull(bpt_data)): + x, y = int(bpt_data.x), int(bpt_data.y) + xmin, xmax = max(0, x - radius), min(img_w - 1, x + radius) + ymin, ymax = max(0, y - radius), min(img_h - 1, y + radius) + image_array[ymin:ymax, xmin:xmax, 2] = int(255 * (i + 1) / len(unique)) + + img = Image.fromarray(image_array) + img.save(project_root / Path(*row.name)) + + +def create_fake_project( + path: Path, + multianimal: bool, + num_bodyparts: int, + num_frames: int = 10, + num_individuals: int = 1, + num_unique: int = 0, + identity: bool = False, + frame_shape: tuple[int, int] = (480, 640), +) -> None: + if path.exists(): + raise ValueError(f"Cannot create a fake project at an existing path") + + scorer = "synthetic" + video_name = "cat" + bodyparts = [i for i in string.ascii_lowercase[:num_bodyparts]] + unique = [f"unique_{i}" for i in string.ascii_lowercase[:num_unique]] + individuals = [f"animal_{i}" for i in range(num_individuals)] + + path.mkdir(parents=True, exist_ok=False) + config = { + "Task": "synthetic", + "scorer": scorer, + "date": "Nov11", + "multianimalproject": multianimal, + "identity": identity, + "project_path": str(path / "config.yaml"), + "TrainingFraction": [0.8], + "iteration": 0, + "default_net_type": "resnet_50", + "default_augmenter": "default", + "default_track_method": "ellipse", + "snapshotindex": "all", + "batch_size": 8, + "pcutoff": 0.6, + "video_sets": { + str(path / "videos" / video_name): { + "crop": (0, frame_shape[1], 0, frame_shape[0]), + }, + }, + "start": 0, + "stop": 1, + "numframes2pick": 10, + } + if not multianimal: + config["bodyparts"] = bodyparts + assert num_individuals == 1 + assert num_unique == 0 + else: + config["bodyparts"] = "MULTI!" + config["multianimalbodyparts"] = bodyparts + config["uniquebodyparts"] = unique + config["individuals"] = individuals + + af.write_config(str(path / "config.yaml"), config) + image_dir = path / "labeled-data" / video_name + image_dir.mkdir(parents=True, exist_ok=False) + + df = gen_fake_data( + scorer=scorer, + video_name=video_name, + individuals=individuals, + bodyparts=bodyparts, + unique=unique, + num_frames=num_frames, + img_h=frame_shape[0], + img_w=frame_shape[1], + ) + print("SYNTHETIC DATA:") + print(df) + print("\n") + if not multianimal: + df.columns = df.columns.droplevel("individuals") + + df.to_hdf(image_dir / f"CollectedData_{scorer}.h5", key="df_with_missing") + df.to_csv(image_dir / f"CollectedData_{scorer}.csv") + + for idx in range(num_frames): + gen_fake_image( + path, + df.iloc[idx], + individuals=individuals, + bodyparts=bodyparts, + unique=unique, + img_h=frame_shape[0], + img_w=frame_shape[1], + ) + + +def copy_project_for_test() -> Path: + data_path = Path.cwd() / "openfield-Pranav-2018-10-30" + test_path = Path.cwd() / "pytorch-testscript1234-openfield-Pranav-2018-10-30" + if not test_path.exists(): + shutil.copytree(data_path, test_path) + + project_config = af.read_config(str(test_path / "config.yaml")) + videos = list(project_config["video_sets"].keys()) + video = videos[0] + crop = project_config["video_sets"][video] + project_config["video_sets"] = {str(test_path / "videos" / "m3v1mp4.mp4"): crop} + af.write_config(str(test_path / "config.yaml"), project_config) + return test_path + + +def run( + config_path: Path, + train_fraction: float, + trainset_index: int, + net_type: str, + videos: list[str], + device: str, + train_kwargs: dict, + engine: Engine = Engine.PYTORCH, + create_labeled_videos: bool = False, +) -> None: + times = [time.time()] + log_step(f"Testing with net type {net_type}") + log_step("Creating the training dataset") + deeplabcut.create_training_dataset(str(config_path), net_type=net_type, engine=engine) + existing_shuffles = get_existing_shuffle_indices( + config_path, train_fraction=train_fraction, engine=engine + ) + shuffle_index = existing_shuffles[-1] + + log_step(f"Starting training for train_frac {train_fraction}, shuffle {shuffle_index}") + deeplabcut.train_network( + config=str(config_path), + shuffle=shuffle_index, + trainingsetindex=trainset_index, + device=device, + **train_kwargs, + ) + times.append(time.time()) + log_step(f"Train time: {times[-1] - times[-2]} seconds") + + log_step(f"Starting evaluation for train_frac {train_fraction}, shuffle {shuffle_index}") + deeplabcut.evaluate_network( + config=str(config_path), + Shuffles=[shuffle_index], + trainingsetindex=trainset_index, + device=device, + ) + times.append(time.time()) + log_step(f"Evaluation time: {times[-1] - times[-2]} seconds") + + if len(videos) > 0: + log_step(f"Analyzing videos for {train_fraction}, shuffle {shuffle_index}") + deeplabcut.analyze_videos( + config=str(config_path), + videos=videos, + shuffle=shuffle_index, + trainingsetindex=trainset_index, + device=device, + ) + times.append(time.time()) + log_step(f"Video analysis time: {times[-1] - times[-2]} seconds") + log_step(f"Total test time: {times[-1] - times[0]} seconds") + + if create_labeled_videos: + log_step(f"Making labeled video, {train_fraction}, shuffle={shuffle_index}") + deeplabcut.create_labeled_video( + config=str(config_path), + videos=videos, + shuffle=shuffle_index, + trainingsetindex=trainset_index, + ) + + +if __name__ == "__main__": + create_fake_project( + path=Path("../synthetic-data-niels"), + multianimal=True, + num_individuals=2, + num_bodyparts=3, + num_unique=2, + identity=False, + num_frames=20, + ) diff --git a/tests/pose_estimation_pytorch/other/test_data_helper.py b/tests/pose_estimation_pytorch/other/test_data_helper.py index 92453ef8e4..bfb83dde7f 100644 --- a/tests/pose_estimation_pytorch/other/test_data_helper.py +++ b/tests/pose_estimation_pytorch/other/test_data_helper.py @@ -17,7 +17,6 @@ import numpy as np import pytest -from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDataset from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader from deeplabcut.pose_estimation_pytorch.data.utils import merge_list_of_dicts from deeplabcut.generate_training_dataset import create_training_dataset @@ -34,7 +33,7 @@ def mock_aux() -> Mock: def _get_loader(project_root): if not (Path(project_root) / "training-datasets").exists(): create_training_dataset(config=str(Path(project_root) / "config.yaml")) - return DLCLoader(project_root, model_config_path="", shuffle=1) + return DLCLoader(Path(project_root) / "config.yaml", shuffle=1) @pytest.mark.skip diff --git a/tests/pose_estimation_pytorch/other/test_dataset.py b/tests/pose_estimation_pytorch/other/test_dataset.py index 93f67b7555..789958500a 100644 --- a/tests/pose_estimation_pytorch/other/test_dataset.py +++ b/tests/pose_estimation_pytorch/other/test_dataset.py @@ -26,7 +26,16 @@ def mock_aux() -> Mock: aux_functions = Mock() aux_functions.read_plainconfig = Mock() - aux_functions.read_plainconfig.return_value = {} + aux_functions.read_plainconfig.return_value = { + "metadata": { + "project_path": "", + "pose_config_path": "", + "bodyparts": ["snout", "leftear", "rightear", "tailbase"], + "unique_bodyparts": [], + "individuals": ["animal"], + "with_identity": False, + } + } return aux_functions @@ -41,7 +50,7 @@ def _get_dataset(path, transform, mode="train"): engine=Engine.PYTORCH, ) - loader = dlc.DLCLoader(path, model_config_path="", shuffle=1) + loader = dlc.DLCLoader(Path(project_root) / "config.yaml", shuffle=1) dataset = loader.create_dataset(transform=transform, mode=mode) return dataset @@ -70,6 +79,7 @@ def _get_openfield_dataset(transform=None): "boxes", "is_crowd", "labels", + "individual_ids", } From 22a14afc7b2bbb180dbe0470f6dfcdf2158a91a1 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 15 Mar 2024 19:03:49 +0100 Subject: [PATCH 072/293] niels/usability_improvements (#171) * bug fix: model comparison incorrectly got largest shuffle index * added computation of metrics during training; tags for logger * improved benchmarking * bug fix: get_largestshuffle_index * improved loading snapshots. removed unused methods. * fixed tests * evaluate: use snapshot uid instead of epochs in eval df * train_network: max_snapshots bug fix * bug fix: schedulers * important bug fixes: bbox computation + checking keypoints are within image * bug fix, training during eval: gt individuals need to be matched to pred * Update README.md * benchmark: small bug fixes * added eval interval * HRNet incre modules; improvements to model loading for inference * added pretrained key to hrnets * implemented bbox metrics with pycocotools * improved scripts * added crop sampling * bug fix: crop sampling created str array which crashed * bug fix metadata - two shuffles can have same index if trainset fraction is diff * add options for torch dataloaders --- benchmark/README.md | 122 +++++ benchmark/benchmark_create_shuffle.py | 15 +- benchmark/benchmark_lightning_pose.py | 149 ++++-- benchmark/benchmark_madlc.py | 107 ++++ benchmark/benchmark_run_experiments.py | 437 ++++++++++++---- benchmark/benchmark_train.py | 129 +++-- benchmark/coco/evaluate.py | 12 +- benchmark/coco/make_config.py | 12 +- benchmark/coco/train.py | 57 +-- benchmark/create_train_test_splits.py | 28 +- benchmark/lightning_pose_ood_evaluation.py | 26 +- benchmark/lightning_pose_tf_eval.py | 94 ++-- benchmark/madlc_evaluation.py | 2 +- benchmark/madlc_test_inference.py | 27 +- benchmark/projects.py | 2 + benchmark/utils.py | 111 +++-- deeplabcut/compat.py | 6 +- .../generate_training_dataset/metadata.py | 41 +- .../trainingsetmanipulation.py | 18 +- deeplabcut/modelzoo/webapp/inference.py | 8 +- .../apis/analyze_videos.py | 142 +++--- .../pose_estimation_pytorch/apis/evaluate.py | 219 ++++---- .../pose_estimation_pytorch/apis/train.py | 186 +++---- .../pose_estimation_pytorch/apis/utils.py | 377 +++----------- .../config/__init__.py | 7 +- .../config/backbones/hrnet_w18.yaml | 14 +- .../config/backbones/hrnet_w32.yaml | 14 +- .../config/backbones/hrnet_w48.yaml | 14 +- .../config/backbones/resnet_101.yaml | 23 +- .../config/backbones/resnet_50.yaml | 23 +- .../config/base/base.yaml | 72 +-- .../config/base/detector.yaml | 53 +- .../config/dekr/dekr_w18.yaml | 5 +- .../config/dekr/dekr_w32.yaml | 5 +- .../config/dekr/dekr_w48.yaml | 5 +- .../pose_estimation_pytorch/config/utils.py | 26 +- .../pose_estimation_pytorch/data/__init__.py | 1 + .../pose_estimation_pytorch/data/base.py | 21 +- .../pose_estimation_pytorch/data/dataset.py | 22 +- .../pose_estimation_pytorch/data/dlcloader.py | 33 +- .../data/postprocessor.py | 8 +- .../data/preprocessor.py | 4 +- .../data/transforms.py | 217 +++++++- .../pose_estimation_pytorch/data/utils.py | 12 +- .../metrics/__init__.py | 11 + .../pose_estimation_pytorch/metrics/bbox.py | 145 ++++++ .../{apis => metrics}/scoring.py | 0 .../models/backbones/base.py | 15 +- .../models/backbones/hrnet.py | 54 +- .../models/detectors/fasterRCNN.py | 86 ++-- .../pose_estimation_pytorch/models/model.py | 32 +- .../models/necks/__init__.py | 2 +- .../models/predictors/utils.py | 2 +- .../modelzoo/inference.py | 10 +- .../runners/__init__.py | 4 +- .../pose_estimation_pytorch/runners/base.py | 28 +- .../runners/inference.py | 10 +- .../pose_estimation_pytorch/runners/logger.py | 33 +- .../runners/schedulers.py | 14 +- .../runners/snapshots.py | 174 +++++++ .../pose_estimation_pytorch/runners/train.py | 345 ++++++++++--- .../pose_estimation_pytorch/runners/utils.py | 467 ------------------ deeplabcut/pose_estimation_pytorch/task.py | 41 ++ deeplabcut/pose_estimation_pytorch/utils.py | 46 +- docs/pytorch/user_guide.md | 2 +- examples/testscript_pytorch_multi_animal.py | 106 ++-- examples/testscript_pytorch_single_animal.py | 94 ++-- examples/utils.py | 161 +++--- .../test_trainset_metadata.py | 80 ++- .../config/test_make_pose_config.py | 16 +- .../data/test_preprocessor.py | 2 +- .../{apis => metrics}/test_scoring.py | 2 +- .../other/test_api_utils.py | 29 +- .../other/test_dataset.py | 1 + .../runners/test_task.py | 2 +- 75 files changed, 2834 insertions(+), 2086 deletions(-) create mode 100644 benchmark/README.md create mode 100644 benchmark/benchmark_madlc.py create mode 100644 deeplabcut/pose_estimation_pytorch/metrics/__init__.py create mode 100644 deeplabcut/pose_estimation_pytorch/metrics/bbox.py rename deeplabcut/pose_estimation_pytorch/{apis => metrics}/scoring.py (100%) create mode 100644 deeplabcut/pose_estimation_pytorch/runners/snapshots.py delete mode 100644 deeplabcut/pose_estimation_pytorch/runners/utils.py create mode 100644 deeplabcut/pose_estimation_pytorch/task.py rename tests/pose_estimation_pytorch/{apis => metrics}/test_scoring.py (99%) diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000000..003d79768e --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,122 @@ +# Benchmarking with DeepLabCut + +This folder contains a few scripts that can be very useful when benchmarking datasets +with DeepLabCut. But first, some definitions: + +**Shuffle:** As always in DeepLabCut, a shuffle is an experiment. It has an index, and + +**Split:** A split (or data split) is a partition of labeled images into a train +and test set. Each shuffle has a split. + +## Creating Data Splits + +Data splits can be created with the `create_train_test_splits.py` file. This script can +create an arbitrary number of train/test splits for a project (or group of projects), +which can be very useful for +[k-fold cross-validation](https://en.wikipedia.org/wiki/Cross-validation_(statistics)). +The main method has the following signature: + +```python +def main( + projects: list[Project], + seeds: list[int], + num_splits: int, + train_fractions: list[float], + output_file: Path, +) -> None: + """Creates train/test splits for DeepLabCut projects + + Args: + projects: projects for which to create train/test splits + seeds: random seed to use for each project (must be the same len as projects) + num_splits: the number of train/test splits to create for each project + train_fractions: the train fractions for which to create train/test splits + output_file: the file where the splits should be output + """ +``` + +This outputs a `JSON` file which can be used by the benchmarking scripts to create +shuffles. + +## Benchmarking Models + +### Without Data Splits + +You might not care about train/test splits (e.g., because the test split is a actually +a validation split and you have a completely different test set, where the images are +in a different project), you can always create your shuffles yourself using the +DeepLabCut API. You can then modify the `pytorch_config.py` files for your shuffles if +you want to modify the base configurations. (e.g., a different number of deconvolutional +layers). + +Then, you can use the `benchmark_train.py` file to train models on your shuffles. + +All you need to do is define your `RunParameters`, and call `main`. This will +sequentially launch each training run. The first element is simply the shuffle which +should be benchmarked. The next few elements of `RunParameters` describe which +DeepLabCut API methods you want to call (train, evaluate, video analysis, ...). +Then, a bunch of other parameters allow you to select exactly how your model should be +trained and evaluated (batch size, ...). + + +```python +@dataclass +class RunParameters: + """Parameters on what to run for each shuffle""" + + shuffle: Shuffle + train: bool = False + evaluate: bool = False + analyze_videos: bool = False + track: bool = False + create_labeled_video: bool = False + device: str = "cuda:0" + train_params: TrainParameters | None = None + detector_train_params: TrainParameters | None = None + snapshot_path: Path | None = None + detector_path: Path | None = None + eval_params: EvalParameters | None = None + video_analysis_params: VideoAnalysisParameters | None = None + + def __post_init__(self): + if ( + self.analyze_videos is None or self.track or self.create_labeled_video + ) and self.video_analysis_params is None: + raise ValueError(f"Must specify video_analysis_params") + + +def main(runs: list[RunParameters]) -> None: + """Runs benchmarking scripts for DeepLabCut + + Args: + runs: + """ + for run in runs: + run.shuffle.project.update_iteration_in_config() + + if wandb.run is not None: # TODO: Finish wandb run in DLC + wandb.finish() + + print(f"Running {run.shuffle}") + try: + run_dlc(run) + except Exception as err: + print(f"Failed to run {run}: {err}") + raise err +``` + +### With Data Splits + +When benchmarking with data splits, a nice feature would be able to benchmark without +having to modify all `pytorch_config.yaml` files manually (as usually, you'll want to +train exactly the same model architecture on different data splits). This can be done +with the `benchmark_run_experiments.py` script. + +Here, you can define different variants of models with `ModelConfig` and train these +models on each one of your data splits. These parameters are made to customize +backbones and single animal heads, so they really can only be used for single animal or +top-down training. If you want to be able to easily update other parameters, you can +either make your own classes or simply pass the updates as a dictionary. + +There are also classes to update training parameters (batch size, epochs), augmentation, +optimizer, scheduler and logging parameters. diff --git a/benchmark/benchmark_create_shuffle.py b/benchmark/benchmark_create_shuffle.py index bd75ff57dd..bb6c055281 100644 --- a/benchmark/benchmark_create_shuffle.py +++ b/benchmark/benchmark_create_shuffle.py @@ -21,6 +21,7 @@ specifying train/test indices, which can then be passed in the ShuffleCreationParameters to create new shuffles with the splits. """ + from __future__ import annotations from dataclasses import dataclass @@ -28,8 +29,13 @@ import deeplabcut -from projects import MA_DLC_BENCHMARKS, MA_DLC_DATA_ROOT, SA_DLC_BENCHMARKS, SA_DLC_DATA_ROOT -from utils import Project, create_shuffles +from projects import ( + MA_DLC_BENCHMARKS, + MA_DLC_DATA_ROOT, + SA_DLC_BENCHMARKS, + SA_DLC_DATA_ROOT, +) +from utils import create_shuffles, Project @dataclass @@ -45,6 +51,7 @@ class ShuffleCreationParameters: they can be used by passing the path to the file containing the splits. See the create_train_test_splits.py file for more information about this. """ + project: Project train_fraction: float net_types: tuple[str, ...] | list[str] @@ -52,7 +59,9 @@ class ShuffleCreationParameters: splits_file: Path | None = None def __post_init__(self): - self.trainset_index = self.project.cfg["TrainingFraction"].index(self.train_fraction) + self.trainset_index = self.project.cfg["TrainingFraction"].index( + self.train_fraction + ) def main(shuffles_to_create: list[ShuffleCreationParameters]) -> None: diff --git a/benchmark/benchmark_lightning_pose.py b/benchmark/benchmark_lightning_pose.py index 808a932aff..1f545e5df5 100644 --- a/benchmark/benchmark_lightning_pose.py +++ b/benchmark/benchmark_lightning_pose.py @@ -1,25 +1,32 @@ """Code to make an ablation study with different image augmentation parameters""" + from __future__ import annotations from pathlib import Path - from deeplabcut.utils import get_bodyparts from benchmark_run_experiments import ( - main, + AffineAugmentation, + AUG_INFERENCE, + AUG_TRAIN, BackboneConfig, + CropSampling, + DEFAULT_OPTIMIZER, + DEFAULT_SCHEDULER, HeadConfig, + HRNET_BACKBONE, + HRNET_BACKBONE_INCRE, + HRNET_BACKBONE_INTER, ImageAugmentations, + main, ModelConfig, - WandBConfig, - DEFAULT_OPTIMIZER, - DEFAULT_SCHEDULER, + RESNET_BACKBONE, RESNET_OPTIMIZER, RESNET_SCHEDULER, + WandBConfig, ) from utils import Project - LP_DLC_DATA_ROOT = Path("/home/niels/datasets/lightning-pose") LP_DLC_BENCHMARKS = { "mirrorFish": Project( @@ -30,64 +37,110 @@ "mirrorMouse": Project( root=LP_DLC_DATA_ROOT, name="mirror-mouse-rick-2022-12-02", - iteration=2, # ITERATION 0 IS THE PAPER DATA + iteration=1, # ITERATION 0 IS THE PAPER DATA + ), + "iblPaw": Project( + root=LP_DLC_DATA_ROOT, + name="ibl-paw-mic-2023-01-09", + iteration=1, # ITERATION 0 IS THE PAPER DATA ), } if __name__ == "__main__": - project_benchmarked = LP_DLC_BENCHMARKS["mirrorFish"] - splits_file = (LP_DLC_DATA_ROOT / "lightning_pose_splits.json") - cfg = project_benchmarked.cfg - num_bodyparts = len(get_bodyparts(cfg)) + # Project parameters + PROJECT_NAME = "mirrorFish" + PROJECT_BENCHMARKED = LP_DLC_BENCHMARKS[PROJECT_NAME] + SPLITS_PATH = LP_DLC_DATA_ROOT / "lightning_pose_splits.json" + CFG = PROJECT_BENCHMARKED.cfg + NUM_BPT = len(get_bodyparts(CFG)) - FULL_AUG = ImageAugmentations( - covering=True, - gaussian_noise=12.75, - hist_eq=True, - motion_blur=True, - rotation=30, - scale_jitter=(0.5, 1.25), - translation=40, - ) + # Train parameters + EPOCHS = 200 + SAVE_EPOCHS = 25 + RESNET_BATCH_SIZE = 8 + HRNET_BATCH_SIZE = 4 + + # logging params + WANDB_PROJECT = "dlc3-benchmark-dev" + BASE_TAGS = (f"project={PROJECT_NAME}", "server=m0") + GROUP_UID = "base" model_configs = [ ModelConfig( net_type="resnet_50", - batch_size=8, - epochs=125, - save_epochs=25, - augmentations=FULL_AUG, - backbone_config=BackboneConfig( - model_name="resnet50_gn", - output_stride=16, - freeze_bn_stats=True, - freeze_bn_weights=False, - ), - head_config=HeadConfig( - plateau_targets=True, - heatmap_config=dict( - channels=[2048, num_bodyparts], - kernel_size=[3], - strides=[2], - final_conv=None, - ), - locref_config=dict( - channels=[2048, 2 * num_bodyparts], - kernel_size=[3], - strides=[2], - final_conv=None, - ), + batch_size=RESNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + backbone_config=RESNET_BACKBONE, + head_config=HeadConfig.build_plateau_head( + c_in=2048, + c_out=NUM_BPT, + deconv=[(NUM_BPT, 3, 2)], + final_conv=False, ), optimizer_config=RESNET_OPTIMIZER, scheduler_config=RESNET_SCHEDULER, - wandb_config=WandBConfig(project="dlc3-mirror-mouse-dev", run_name="resnet_single_deconv"), - ) + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-resnet50", + group=f"{PROJECT_NAME}-{GROUP_UID}-resnet50", + tags=(*BASE_TAGS, "arch=resnet50", "ndeconv=1"), + ), + ), + ModelConfig( + net_type="hrnet_w32", + batch_size=HRNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + backbone_config=HRNET_BACKBONE_INTER, + head_config=HeadConfig.build_plateau_head( + c_in=480, + c_out=NUM_BPT, + deconv=[(NUM_BPT, 3, 2)], + final_conv=False, + ), + optimizer_config=DEFAULT_OPTIMIZER, + scheduler_config=DEFAULT_SCHEDULER, + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-inter", + group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-inter", + tags=(*BASE_TAGS, "arch=hrnet32-inter", "ndeconv=1"), + ), + ), + ModelConfig( + net_type="hrnet_w32", + batch_size=HRNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + backbone_config=HRNET_BACKBONE, + head_config=HeadConfig.build_plateau_head( + c_in=32, + c_out=NUM_BPT, + deconv=[(NUM_BPT, 3, 2)], + final_conv=False, + ), + optimizer_config=DEFAULT_OPTIMIZER, + scheduler_config=DEFAULT_SCHEDULER, + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-base-hrnet32", + group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32", + tags=(*BASE_TAGS, "arch=hrnet32", "ndeconv=1"), + ), + ), ] main( - project=project_benchmarked, - splits_file=splits_file, + project=PROJECT_BENCHMARKED, + splits_file=SPLITS_PATH, trainset_index=0, train_fraction=0.81, models_to_train=model_configs, diff --git a/benchmark/benchmark_madlc.py b/benchmark/benchmark_madlc.py new file mode 100644 index 0000000000..88ee3b0680 --- /dev/null +++ b/benchmark/benchmark_madlc.py @@ -0,0 +1,107 @@ +"""Benchmark script for maDLC models""" +from __future__ import annotations + +import torch +import wandb +from deeplabcut.utils import get_bodyparts + +from benchmark_run_experiments import ( + AUG_INFERENCE, + AUG_TRAIN, + CropSampling, + DEFAULT_OPTIMIZER, + DEFAULT_SCHEDULER, + DetectorConfig, + HeadConfig, + main, + ModelConfig, + WandBConfig, +) +from projects import MA_DLC_BENCHMARKS, MA_DLC_DATA_ROOT + + +if __name__ == "__main__": + PROJECT_NAME = "trimouse" # "trimouse", "fish", "marmosets", "parenting" + PROJECT_BENCHMARKED = MA_DLC_BENCHMARKS[PROJECT_NAME] + SPLIT_FILE = MA_DLC_DATA_ROOT / "maDLC_benchmarking_splits.json" + NUM_BPT = len(get_bodyparts(PROJECT_BENCHMARKED.cfg)) + + # Train parameters + DETECTOR_EPOCHS = 1 + DETECTOR_SAVE_EPOCHS = 1 + DETECTOR_BATCH_SIZE = 1 + + DEFAULT_OPTIMIZER = {"type": "AdamW", "params": {"lr": 5e-4}} + DEFAULT_SCHEDULER["params"] = {"lr_list": [[1e-4], [1e-5]], "milestones": [2, 4]} + + EPOCHS = 5 + SAVE_EPOCHS = 5 + DEKR_BATCH_SIZE = 8 + TD_HRNET_BATCH_SIZE = 8 + + # logging params + WANDB_PROJECT = "dlc3-benchmark-dev" + BASE_TAGS = (f"project={PROJECT_NAME}", "server=m0") + GROUP_UID = "base" + + model_configs = [ + ModelConfig( + net_type="dekr_w32", + batch_size=DEKR_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + optimizer_config=DEFAULT_OPTIMIZER, + scheduler_config=DEFAULT_SCHEDULER, + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-dekr32", + group=f"{PROJECT_NAME}-{GROUP_UID}-dekr32", + tags=(*BASE_TAGS, "arch=dekr32", "ndeconv=1"), + ), + ), + ( + DetectorConfig( + batch_size=DETECTOR_BATCH_SIZE, + epochs=DETECTOR_EPOCHS, + save_epochs=DETECTOR_SAVE_EPOCHS, + train_aug=None, + inference_aug=None, + optimizer_config=None, + scheduler_config=None, + ), + ModelConfig( + net_type="top_down_hrnet_w32", + batch_size=TD_HRNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + backbone_config=None, + head_config=HeadConfig.build_plateau_head( + c_in=32, + c_out=NUM_BPT, + deconv=[], + final_conv=True, + ), + optimizer_config=DEFAULT_OPTIMIZER, + scheduler_config=DEFAULT_SCHEDULER, + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-td-hrnet32", + group=f"{PROJECT_NAME}-{GROUP_UID}-td-hrnet32", + tags=(*BASE_TAGS, "arch=td-hrnet32", "ndeconv=0"), + ), + ), + ) + ] + + main( + project=PROJECT_BENCHMARKED, + splits_file=SPLIT_FILE, + trainset_index=0, + train_fraction=0.95, + models_to_train=[model_configs[1]], + splits_to_train=(0, ), + ) diff --git a/benchmark/benchmark_run_experiments.py b/benchmark/benchmark_run_experiments.py index 350f12d73e..484c3f5dfb 100644 --- a/benchmark/benchmark_run_experiments.py +++ b/benchmark/benchmark_run_experiments.py @@ -1,4 +1,5 @@ """Code to make an ablation study with different image augmentation parameters""" + from __future__ import annotations from dataclasses import asdict, dataclass @@ -6,48 +7,63 @@ import torch import wandb - from deeplabcut.utils import get_bodyparts -from benchmark_train import EvalParameters, RunParameters, TrainParameters, run_dlc +from benchmark_train import EvalParameters, run_dlc, RunParameters, TrainParameters from projects import SA_DLC_BENCHMARKS, SA_DLC_DATA_ROOT -from utils import Project, Shuffle, create_shuffles +from utils import create_shuffles, Project, Shuffle @dataclass class WandBConfig: project: str run_name: str + save_code: bool = True + tags: tuple[str, ...] | None = None + group: str | None = None def data(self) -> dict: - return { - "type": "WandbLogger", - "project_name": self.project, - "run_name": self.run_name, - } + return dict( + type="WandbLogger", + project_name=self.project, + run_name=self.run_name, + save_code=self.save_code, + tags=self.tags, + group=self.group, + ) @dataclass class BackboneConfig: """ Attributes: - model_name: one of "resnet50", "resnet50_gn" - output_stride: 8, 16 or 32 - freeze_bn_weights: - freeze_bn_stats: + model_name: the timm model name ("resnet50", "resnet50_gn", "hrnet_w18", ...) + output_stride: 8, 16 or 32 (HRNet only supports 32) + freeze_bn_weights: freeze batch norm weights + freeze_bn_stats: freeze batch norm stats + kwargs: any keyword-arguments for the backbone type that was selected, e.g. + HRNet: ``only_high_res: bool`` only use the high-resolution branch as the + image features (otherwise, in DEKR style all branches are interpolated + to the same shape and concatenated). """ + model_name: str = "resnet50" output_stride: int | None = None freeze_bn_weights: bool | None = None freeze_bn_stats: bool | None = None drop_path_rate: float | None = None drop_block_rate: float | None = None + kwargs: dict | None = None def to_dict(self) -> dict: config = asdict(self) + config.pop("kwargs") for k in list(config.keys()): if config[k] is None: config.pop(k) + if self.kwargs is not None: + for k, v in self.kwargs.items(): + config[k] = v return config @@ -67,7 +83,11 @@ def to_dict(self) -> dict: locref_std=7.2801, ) target_generator = dict( - type="HeatmapPlateauGenerator" if self.plateau_targets else "HeatmapGaussianGenerator", + type=( + "HeatmapPlateauGenerator" + if self.plateau_targets + else "HeatmapGaussianGenerator" + ), num_heatmaps=output_channels, pos_dist_thresh=17, heatmap_mode="KEYPOINT", @@ -76,9 +96,7 @@ def to_dict(self) -> dict: ) criterion = dict(heatmap=dict(type="WeightedBCECriterion", weight=1.0)) if self.locref_config is not None: - criterion["locref"] = dict( - type="WeightedHuberCriterion", weight=0.05 - ) + criterion["locref"] = dict(type="WeightedHuberCriterion", weight=0.05) return dict( type="HeatmapHead", @@ -89,6 +107,77 @@ def to_dict(self) -> dict: locref_config=self.locref_config, ) + @staticmethod + def build_plateau_head( + c_in: int, + c_out: int, + deconv: list[tuple[int, int, int]], # channel, kernel, stride + final_conv: bool = False, + ) -> HeadConfig: + heatmap = dict(channels=[c_in], kernel_size=[], strides=[], final_conv=None) + locref = dict(channels=[c_in], kernel_size=[], strides=[], final_conv=None) + for c, k, s in deconv: + for config in (heatmap, locref): + config["channels"].append(c) + config["kernel_size"].append(k) + config["strides"].append(s) + + if final_conv: + heatmap["final_conv"] = dict(out_channels=c_out, kernel_size=1) + locref["final_conv"] = dict(out_channels=2 * c_out, kernel_size=1) + else: + assert deconv[-1][0] == c_out + locref["channels"][-1] = 2 * c_out + + return HeadConfig( + plateau_targets=True, + heatmap_config=heatmap, + locref_config=locref, + ) + + +@dataclass +class AffineAugmentation: + """An affine image augmentation""" + + p: float = 0.9 + rotation: int = 0 + scale: tuple[float, float] | None = None + translation: int = 0 + + def data(self) -> dict: + affine = {} + if self.p > 0: + affine["p"] = self.p + if self.scale is not None: + affine["scaling"] = self.scale + if self.rotation > 0: + affine["rotation"] = self.rotation + if self.translation > 0: + affine["translation"] = self.translation + return affine + + +@dataclass +class CropSampling: + """Random crop around keypoints""" + width: int + height: int + max_shift: float = 0.4 + mode: str = "uniform" # "uniform", "keypoints", "density", "hybrid" + + def __post_init__(self): + assert self.mode in ("uniform", "keypoints", "density", "hybrid") + assert 0 <= self.max_shift <= 1 + + def data(self) -> dict: + return { + "width": self.width, + "height": self.height, + "max_shift": self.max_shift, + "mode": self.mode, + } + @dataclass class ImageAugmentations: @@ -102,15 +191,15 @@ class ImageAugmentations: scale_jitter: (0.5, 1.25) translation: 40 """ + normalize: bool = True + affine: AffineAugmentation | None = None covering: bool = False - gaussian_noise: float = 0.0 + gaussian_noise: float | bool = False hist_eq: bool = False motion_blur: bool = False resize: dict | None = None - rotation: int = 0 - scale_jitter: tuple[float, float] | None = None - translation: int = 0 + crop_sampling: CropSampling | None = None def data(self) -> dict: augmentations = { @@ -119,21 +208,41 @@ def data(self) -> dict: "gaussian_noise": self.gaussian_noise, "hist_eq": self.hist_eq, "motion_blur": self.motion_blur, - "rotation": self.rotation, - "scale_jitter": False, - "translation": self.translation, } - if self.resize: + if self.affine is not None: + augmentations["affine"] = self.affine.data() + if self.resize is not None: augmentations["resize"] = self.resize - if self.scale_jitter: - augmentations["scale_jitter"] = self.scale_jitter + if self.crop_sampling is not None: + augmentations["crop_sampling"] = self.crop_sampling.data() return augmentations +@dataclass +class DetectorConfig(TrainParameters): + train_aug: ImageAugmentations | None = None + inference_aug: ImageAugmentations | None = None + optimizer_config: dict | None = None + scheduler_config: dict | None = None + + def train_kwargs(self) -> dict: + kwargs = super().train_kwargs() + if self.train_aug is not None: + kwargs["data"]["train"] = self.train_aug.data() + if self.inference_aug is not None: + kwargs["data"]["inference"] = self.inference_aug.data() + if self.optimizer_config is not None: + kwargs["runner"]["optimizer"] = self.optimizer_config + if self.scheduler_config is not None: + kwargs["runner"]["scheduler"] = self.scheduler_config + return kwargs + + @dataclass class ModelConfig(TrainParameters): net_type: str = "resnet_50" - augmentations: ImageAugmentations | None = None + train_aug: ImageAugmentations | None = None + inference_aug: ImageAugmentations | None = None backbone_config: BackboneConfig | None = None head_config: HeadConfig | None = None optimizer_config: dict | None = None @@ -142,8 +251,14 @@ class ModelConfig(TrainParameters): def train_kwargs(self) -> dict: kwargs = super().train_kwargs() - if self.augmentations is not None: - kwargs["data"] = self.augmentations.data() + if self.train_aug is not None: + data = kwargs.get("data", {}) + data["train"] = self.train_aug.data() + kwargs["data"] = data + if self.inference_aug is not None: + data = kwargs.get("data", {}) + data["inference"] = self.inference_aug.data() + kwargs["data"] = data if self.backbone_config is not None: kwargs["model"] = dict(backbone=self.backbone_config.to_dict()) if self.head_config is not None: @@ -153,9 +268,13 @@ def train_kwargs(self) -> dict: if self.wandb_config is not None: kwargs["logger"] = self.wandb_config.data() if self.optimizer_config is not None: - kwargs["optimizer"] = self.optimizer_config + runner = kwargs.get("runner", {}) + runner["optimizer"] = self.optimizer_config + kwargs["runner"] = runner if self.scheduler_config is not None: - kwargs["scheduler"] = self.scheduler_config + runner = kwargs.get("runner", {}) + runner["scheduler"] = self.scheduler_config + kwargs["runner"] = runner return kwargs @@ -164,37 +283,65 @@ def main( splits_file: Path, trainset_index: int, train_fraction: float, - models_to_train: list[ModelConfig], + models_to_train: list[ModelConfig | tuple[DetectorConfig, ModelConfig]], splits_to_train: tuple[int, ...] = (0, 1, 2), + eval_params: EvalParameters | None = None, ): + if eval_params is None: + eval_params = EvalParameters(snapshotindex="all", plotting=False) + project.update_iteration_in_config() for config in models_to_train: if wandb.run is not None: # TODO: Finish wandb run in DLC wandb.finish() + if isinstance(config, tuple): + detector_config, model_config = config + assert isinstance(detector_config, DetectorConfig) + assert isinstance(model_config, ModelConfig) + else: + detector_config = None + model_config = config + assert isinstance(model_config, ModelConfig) + + run_name = "" + tags: tuple[str, ...] = () + if model_config.wandb_config is not None: + run_name = model_config.wandb_config.run_name + tags = model_config.wandb_config.tags + print(100 * "-") - print(f"Backbone config: {config.backbone_config}") - print(f"Head config: {config.head_config}") - print(f"Augmentation: {config.augmentations}") + if detector_config is not None: + print(f"Detector config: {detector_config}") + print(f"Backbone config: {model_config.backbone_config}") + print(f"Head config: {model_config.head_config}") + print(f"Train Augmentation: {model_config.train_aug}") + print(f"Inference Augmentation: {model_config.inference_aug}") shuffle_indices = create_shuffles( - project, splits_file, trainset_index, config.net_type + project, splits_file, trainset_index, model_config.net_type ) shuffles_to_train = [shuffle_indices[i] for i in splits_to_train] print(f"training shuffles {shuffles_to_train}") - for shuffle_idx in shuffles_to_train: + for split_idx, shuffle_idx in zip(splits_to_train, shuffles_to_train): if wandb.run is not None: # TODO: Finish wandb run in DLC wandb.finish() + if detector_config is not None: + print(" DetectorParameters") + for k, v in asdict(detector_config).items(): + print(f" {k}: {v}") print(" ModelParameters") - for k, v in asdict(config).items(): + for k, v in asdict(model_config).items(): print(f" {k}: {v}") print(" Train kwargs") - for k, v in config.train_kwargs().items(): + for k, v in model_config.train_kwargs().items(): print(f" {k}: {v}") - if config.wandb_config is not None: - config.wandb_config.run_name += f"-it{project.iteration}-shuf{shuffle_idx}" + if model_config.wandb_config is not None: + i = project.iteration + model_config.wandb_config.run_name = f"{run_name}-it{i}-shuf{shuffle_idx}" + model_config.wandb_config.tags = (*tags, f"split={split_idx}") run_dlc( parameters=RunParameters( @@ -207,99 +354,183 @@ def main( train=True, evaluate=True, device="cuda:0", - train_params=config, - eval_params=EvalParameters(snapshotindex="all", plotting=False) + train_params=model_config, + detector_train_params=detector_config, + eval_params=eval_params, ) ) +AUG_INFERENCE = ImageAugmentations(normalize=True) +AUG_TRAIN = ImageAugmentations( + normalize=True, + covering=True, + gaussian_noise=12.75, + hist_eq=True, + motion_blur=True, + affine=AffineAugmentation( + p=0.9, + rotation=30, + scale=(0.5, 1.25), + translation=40, + ), +) +RESNET_BACKBONE = BackboneConfig( + model_name="resnet50_gn", + output_stride=16, + freeze_bn_stats=True, + freeze_bn_weights=False, +) +HRNET_BACKBONE = BackboneConfig( # output strides [4, 8, 16, 32] + model_name="hrnet_w32", + freeze_bn_stats=True, + freeze_bn_weights=False, +) +HRNET_BACKBONE_INTER = BackboneConfig( # output strides [4, 8, 16, 32] + model_name="hrnet_w32", + freeze_bn_stats=True, + freeze_bn_weights=False, + kwargs=dict(interpolate_branches=True), +) +HRNET_BACKBONE_INCRE = BackboneConfig( # output strides [4, 8, 16, 32] + model_name="hrnet_w32", + freeze_bn_stats=True, + freeze_bn_weights=False, + kwargs=dict(increased_channel_count=True), +) + RESNET_OPTIMIZER = {"type": "AdamW", "params": {"lr": 1e-3}} RESNET_SCHEDULER = { "type": "LRListScheduler", - "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [90, 120]}, + "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [160, 190]}, } DEFAULT_OPTIMIZER = {"type": "AdamW", "params": {"lr": 5e-4}} DEFAULT_SCHEDULER = { "type": "LRListScheduler", - "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [90, 120]}, + "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [160, 190]}, } if __name__ == "__main__": - project_benchmarked = SA_DLC_BENCHMARKS["fly"] - splits_file = (SA_DLC_DATA_ROOT / "saDLC_benchmarking_splits.json") - cfg = project_benchmarked.cfg - num_bodyparts = len(get_bodyparts(cfg)) - - FULL_AUG = ImageAugmentations( - covering=True, - gaussian_noise=12.75, - hist_eq=True, - motion_blur=True, - rotation=30, - scale_jitter=(0.5, 1.25), - translation=40, - ) + # Project parameters + PROJECT_NAME = "fly" + PROJECT_BENCHMARKED = SA_DLC_BENCHMARKS[PROJECT_NAME] + SPLIT_FILE = SA_DLC_DATA_ROOT / "saDLC_benchmarking_splits.json" + CFG = PROJECT_BENCHMARKED.cfg + NUM_BPT = len(get_bodyparts(CFG)) + + # Train parameters + EPOCHS = 200 + SAVE_EPOCHS = 25 + RESNET_BATCH_SIZE = 8 + HRNET_BATCH_SIZE = 4 + + # logging params + WANDB_PROJECT = "dlc3-benchmark-dev" + BASE_TAGS = (f"project={PROJECT_NAME}", "server=m0") + GROUP_UID = "base" + + # resize openfield + if PROJECT_NAME == "openfield": + AUG_TRAIN.resize = dict(height=640, width=640, keep_ratio=True) + model_configs = [ ModelConfig( net_type="resnet_50", - batch_size=8, - epochs=125, - save_epochs=25, - augmentations=FULL_AUG, - backbone_config=BackboneConfig( - model_name="resnet50_gn", - freeze_bn_stats=True, - freeze_bn_weights=False, - ), - head_config=HeadConfig( - plateau_targets=True, - heatmap_config=dict( - channels=[2048, num_bodyparts], - kernel_size=[3], - strides=[2], - final_conv=None, - ), - locref_config=dict( - channels=[2048, 2 * num_bodyparts], - kernel_size=[3], - strides=[2], - final_conv=None, - ), + batch_size=RESNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + backbone_config=RESNET_BACKBONE, + head_config=HeadConfig.build_plateau_head( + c_in=2048, + c_out=NUM_BPT, + deconv=[(NUM_BPT, 3, 2)], + final_conv=False, ), optimizer_config=RESNET_OPTIMIZER, scheduler_config=RESNET_SCHEDULER, - wandb_config=WandBConfig(project="dlc3_hrnet", run_name="resnet_single_deconv"), + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-resnet50", + group=f"{PROJECT_NAME}-{GROUP_UID}-resnet50", + tags=(*BASE_TAGS, "arch=resnet50", "ndeconv=1"), + ), + ), + ModelConfig( + net_type="hrnet_w32", + batch_size=HRNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + backbone_config=HRNET_BACKBONE, + head_config=HeadConfig.build_plateau_head( + c_in=32, + c_out=NUM_BPT, + deconv=[(NUM_BPT, 3, 2)], + final_conv=False, + ), + optimizer_config=DEFAULT_OPTIMIZER, + scheduler_config=DEFAULT_SCHEDULER, + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32", + group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32", + tags=(*BASE_TAGS, "arch=hrnet32", "ndeconv=1"), + ), ), ModelConfig( net_type="hrnet_w32", - batch_size=8, - epochs=125, - save_epochs=25, - augmentations=FULL_AUG, - backbone_config=BackboneConfig( - model_name="hrnet_w32", - freeze_bn_stats=True, - freeze_bn_weights=False, + batch_size=HRNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + backbone_config=HRNET_BACKBONE_INCRE, + head_config=HeadConfig.build_plateau_head( + c_in=128, + c_out=NUM_BPT, + deconv=[(NUM_BPT, 3, 2)], + final_conv=False, + ), + optimizer_config=DEFAULT_OPTIMIZER, + scheduler_config=DEFAULT_SCHEDULER, + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-incre", + group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-incre", + tags=(*BASE_TAGS, "arch=hrnet32-incre", "ndeconv=1"), ), - head_config=HeadConfig( - plateau_targets=False, - heatmap_config=dict( - channels=[32], - kernel_size=[], - strides=[], - final_conv=dict(out_channels=num_bodyparts, kernel_size=1), - ), - locref_config=None + ), + ModelConfig( + net_type="hrnet_w32", + batch_size=HRNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + train_aug=AUG_TRAIN, + inference_aug=AUG_INFERENCE, + backbone_config=HRNET_BACKBONE_INTER, + head_config=HeadConfig.build_plateau_head( + c_in=480, + c_out=NUM_BPT, + deconv=[(NUM_BPT, 3, 2)], + final_conv=False, ), optimizer_config=DEFAULT_OPTIMIZER, scheduler_config=DEFAULT_SCHEDULER, - wandb_config=WandBConfig(project="dlc3_hrnet", run_name="hrnet_gauss"), + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-inter", + group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-inter", + tags=(*BASE_TAGS, "arch=hrnet32-inter", "ndeconv=1"), + ), ), ] main( - project=project_benchmarked, - splits_file=splits_file, + project=PROJECT_BENCHMARKED, + splits_file=SPLIT_FILE, trainset_index=0, train_fraction=0.8, models_to_train=model_configs, diff --git a/benchmark/benchmark_train.py b/benchmark/benchmark_train.py index 880c2493b7..3a3278cd16 100644 --- a/benchmark/benchmark_train.py +++ b/benchmark/benchmark_train.py @@ -21,15 +21,15 @@ specifying train/test indices, which can then be passed in the ShuffleCreationParameters to create new shuffles with the splits. """ + from __future__ import annotations from dataclasses import dataclass from pathlib import Path -import wandb - import deeplabcut import deeplabcut.pose_estimation_pytorch.apis as api +import wandb from projects import MA_DLC_BENCHMARKS, SA_DLC_BENCHMARKS from utils import Shuffle @@ -38,36 +38,31 @@ @dataclass class TrainParameters: """Parameters to train models""" - batch_size: int = 16 + + seed: int = 42 + batch_size: int = 1 display_iters: int = 500 - epochs: int | None = 100 - save_epochs: int | None = 25 - snapshot_path: Path | None = None - detector_batch_size: int | None = None - detector_max_epochs: int | None = None - detector_save_epochs: int | None = None + epochs: int | None = None + save_epochs: int = 25 + max_snapshots: int = 5 def train_kwargs(self) -> dict: - kwargs = { - "batch_size": self.batch_size, - "display_iters": self.display_iters, - } + kwargs = dict( + train_settings=dict( + batch_size=self.batch_size, + display_iters=self.display_iters, + seed=self.seed, + ), + ) if self.epochs is not None: - kwargs["epochs"] = self.epochs + kwargs["train_settings"]["epochs"] = self.epochs if self.save_epochs is not None: - kwargs["save_epochs"] = self.save_epochs - if self.snapshot_path is not None: - kwargs["snapshot_path"] = str(self.snapshot_path) - - detector_kwargs = {} - if self.detector_batch_size is not None: - detector_kwargs["batch_size"] = self.detector_batch_size - if self.detector_max_epochs is not None: - detector_kwargs["epochs"] = self.detector_max_epochs - if self.detector_save_epochs is not None: - detector_kwargs["save_epochs"] = self.detector_save_epochs - if len(detector_kwargs) > 0: - kwargs["detector"] = detector_kwargs + runner_kwargs = kwargs.get("runner", {}) + runner_kwargs["snapshots"] = dict( + save_epochs=self.save_epochs, + max_snapshots=self.max_snapshots, + ) + kwargs["runner"] = runner_kwargs return kwargs @@ -75,12 +70,16 @@ def train_kwargs(self) -> dict: @dataclass class EvalParameters: """Parameters for evaluation""" + snapshotindex: int | list[int] | str | None = (None,) + detector_snapshotindex: int | None = None plotting: str | bool = False show_errors: bool = True def eval_kwargs(self) -> dict: return { + "snapshotindex": self.snapshotindex, + "detector_snapshot_index": self.detector_snapshotindex, "plotting": self.plotting, "show_errors": self.show_errors, } @@ -89,6 +88,7 @@ def eval_kwargs(self) -> dict: @dataclass class VideoAnalysisParameters: """Parameters to run video analysis""" + videos: list[str] videotype: str output_folder: str = "" @@ -97,6 +97,7 @@ class VideoAnalysisParameters: @dataclass class RunParameters: """Parameters on what to run for each shuffle""" + shuffle: Shuffle train: bool = False evaluate: bool = False @@ -105,14 +106,16 @@ class RunParameters: create_labeled_video: bool = False device: str = "cuda:0" train_params: TrainParameters | None = None + detector_train_params: TrainParameters | None = None + snapshot_path: Path | None = None + detector_path: Path | None = None eval_params: EvalParameters | None = None video_analysis_params: VideoAnalysisParameters | None = None def __post_init__(self): if ( - (self.analyze_videos is None or self.track or self.create_labeled_video) - and self.video_analysis_params is None - ): + self.analyze_videos is None or self.track or self.create_labeled_video + ) and self.video_analysis_params is None: raise ValueError(f"Must specify video_analysis_params") @@ -123,6 +126,14 @@ def run_dlc(parameters: RunParameters) -> None: parameters: the parameters specifying what to run, and which parameters to use """ if parameters.train: + train_params = parameters.train_params.train_kwargs() + if parameters.detector_train_params is not None: + train_params["detector"] = parameters.detector_train_params.train_kwargs() + if parameters.snapshot_path is not None: + train_params["snapshot_path"] = parameters.snapshot_path + if parameters.detector_path is not None: + train_params["detector_path"] = parameters.detector_path + api.train_network( str(parameters.shuffle.project.config_path()), shuffle=parameters.shuffle.index, @@ -130,35 +141,32 @@ def run_dlc(parameters: RunParameters) -> None: transform=None, modelprefix=parameters.shuffle.model_prefix, device=parameters.device, - **parameters.train_params.train_kwargs(), + **train_params, ) if parameters.evaluate: - snapshot_indices = parameters.eval_params.snapshotindex - if isinstance(snapshot_indices, int) or isinstance(snapshot_indices, str): - snapshot_indices = [snapshot_indices] - - for idx in snapshot_indices: - api.evaluate_network( - config=str(parameters.shuffle.project.config_path()), - shuffles=[parameters.shuffle.index], - trainingsetindex=parameters.shuffle.trainset_index, - snapshotindex=idx, - device=parameters.device, - transform=None, - modelprefix=parameters.shuffle.model_prefix, - **parameters.eval_params.eval_kwargs(), - ) + api.evaluate_network( + config=str(parameters.shuffle.project.config_path()), + shuffles=[parameters.shuffle.index], + trainingsetindex=parameters.shuffle.trainset_index, + device=parameters.device, + transform=None, + modelprefix=parameters.shuffle.model_prefix, + **parameters.eval_params.eval_kwargs(), + ) if parameters.analyze_videos: - destfolder = parameters.shuffle.project.path / parameters.video_analysis_params.output_folder + destfolder = ( + parameters.shuffle.project.path + / parameters.video_analysis_params.output_folder + ) api.analyze_videos( config=str(parameters.shuffle.project.config_path()), videos=parameters.video_analysis_params.videos, videotype=parameters.video_analysis_params.videotype, trainingsetindex=parameters.shuffle.trainset_index, destfolder=str(destfolder), - snapshotindex=5, + snapshot_index=5, device=parameters.device, modelprefix=parameters.shuffle.model_prefix, batchsize=parameters.train_params.batch_size, @@ -168,7 +176,10 @@ def run_dlc(parameters: RunParameters) -> None: ) if parameters.track: - destfolder = parameters.shuffle.project.path / parameters.video_analysis_params.output_folder + destfolder = ( + parameters.shuffle.project.path + / parameters.video_analysis_params.output_folder + ) api.convert_detections2tracklets( config=str(parameters.shuffle.project.config_path()), videos=parameters.video_analysis_params.videos, @@ -189,7 +200,10 @@ def run_dlc(parameters: RunParameters) -> None: ) if parameters.create_labeled_video: - destfolder = parameters.shuffle.project.path / parameters.video_analysis_params.output_folder + destfolder = ( + parameters.shuffle.project.path + / parameters.video_analysis_params.output_folder + ) deeplabcut.create_labeled_video( config=str(parameters.shuffle.project.config_path()), videos=parameters.video_analysis_params.videos, @@ -240,11 +254,18 @@ def main(runs: list[RunParameters]) -> None: create_labeled_video=False, device="cuda:0", train_params=TrainParameters( - batch_size=8, epochs=125, save_epochs=25, + batch_size=8, + epochs=200, + save_epochs=25, + max_snapshots=5, + ), + detector_train_params=TrainParameters( + batch_size=8, + epochs=200, + save_epochs=25, + max_snapshots=5, ), - eval_params=EvalParameters( - snapshotindex="all", plotting=False - ) + eval_params=EvalParameters(snapshotindex="all", plotting=False), ), ] ) diff --git a/benchmark/coco/evaluate.py b/benchmark/coco/evaluate.py index de31dd556e..29e0019f37 100644 --- a/benchmark/coco/evaluate.py +++ b/benchmark/coco/evaluate.py @@ -1,4 +1,5 @@ """Evaluating COCO models""" + from __future__ import annotations import argparse @@ -7,11 +8,10 @@ import numpy as np import torch - from deeplabcut.pose_estimation_pytorch import COCOLoader from deeplabcut.pose_estimation_pytorch.apis.evaluate import evaluate -from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners -from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners +from deeplabcut.pose_estimation_pytorch.task import Task def pycocotools_evaluation( @@ -74,14 +74,14 @@ def main( if device is not None: pytorch_config["device"] = device - pose_runner, detector_runner = get_runners( - pytorch_config=pytorch_config, + pose_runner, detector_runner = get_inference_runners( + model_config=pytorch_config, snapshot_path=snapshot_path, max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, num_unique_bodyparts=parameters.num_unique_bpts, with_identity=False, - transform=None, # Load transform from config + transform=None, detector_path=detector_path, detector_transform=None, ) diff --git a/benchmark/coco/make_config.py b/benchmark/coco/make_config.py index 6403ed9fde..12bfe4a40f 100644 --- a/benchmark/coco/make_config.py +++ b/benchmark/coco/make_config.py @@ -1,13 +1,13 @@ """Creates a base model configuration file to train a model on a COCO dataset """ + from __future__ import annotations import argparse from pathlib import Path import torch - import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.generate_training_dataset import MakeInference_yaml from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config @@ -35,7 +35,7 @@ def get_base_config( top_down = False if model_architecture.startswith("top_down_"): top_down = True - model_architecture = model_architecture[len("top_down_"):] + model_architecture = model_architecture[len("top_down_") :] return make_pytorch_pose_config( project_config=cfg, @@ -111,4 +111,10 @@ def main( parser.add_argument("--train_file", default="train.json") parser.add_argument("--multi_animal", action="store_true") args = parser.parse_args() - main(args.project_root, args.train_file, args.output, args.model_arch, args.multi_animal) + main( + args.project_root, + args.train_file, + args.output, + args.model_arch, + args.multi_animal, + ) diff --git a/benchmark/coco/train.py b/benchmark/coco/train.py index 7d0d693d83..7c6dfded45 100644 --- a/benchmark/coco/train.py +++ b/benchmark/coco/train.py @@ -1,4 +1,5 @@ """File to train a model on a COCO dataset""" + from __future__ import annotations import argparse @@ -6,18 +7,17 @@ from pathlib import Path import torch - -from deeplabcut.pose_estimation_pytorch import COCOLoader +from deeplabcut.pose_estimation_pytorch import COCOLoader, utils from deeplabcut.pose_estimation_pytorch.apis.train import train -from deeplabcut.pose_estimation_pytorch.runners import Task from deeplabcut.pose_estimation_pytorch.runners.logger import setup_file_logging +from deeplabcut.pose_estimation_pytorch.task import Task def main( project_root: str, train_file: str, test_file: str, - pytorch_config: str, + model_config_path: str, device: str | None, epochs: int | None, save_epochs: int | None, @@ -26,60 +26,53 @@ def main( snapshot_path: str | None, detector_path: str | None, ): - model_folder = Path(pytorch_config).parent.parent - log_path = Path(pytorch_config).parent / "log.txt" + model_folder = Path(model_config_path).parent.parent + log_path = Path(model_config_path).parent / "log.txt" setup_file_logging(log_path) loader = COCOLoader( project_root=project_root, - model_config_path=pytorch_config, + model_config_path=model_config_path, train_json_filename=train_file, test_json_filename=test_file, ) - pytorch_config = loader.model_cfg - if device is not None: - pytorch_config["device"] = device + utils.fix_seeds(loader.model_cfg["train_settings"]["seed"]) + updates = {} if epochs is not None: - pytorch_config["epochs"] = epochs + updates["train_settings"]["epochs"] = epochs if save_epochs is not None: - pytorch_config["save_epochs"] = save_epochs + updates["train_settings"]["save_epochs"] = save_epochs + if detector_epochs is not None: + updates["detector"]["train_settings"]["epochs"] = detector_epochs + if detector_save_epochs is not None: + updates["detector"]["train_settings"]["save_epochs"] = detector_save_epochs + loader.update_model_cfg(updates) - pose_task = Task(pytorch_config.get("method", "bu")) - if pytorch_config.get("method", "bu").lower() == "td": + pose_task = Task(loader.model_cfg["method"]) + if pose_task == Task.TOP_DOWN: logger_config = None - if pytorch_config.get("logger"): - logger_config = copy.deepcopy(pytorch_config["logger"]) + if loader.model_cfg.get("logger"): + logger_config = copy.deepcopy(loader.model_cfg["logger"]) logger_config["run_name"] += "-detector" - if detector_epochs is not None: - pytorch_config["detector"]["epochs"] = detector_epochs - if detector_save_epochs is not None: - pytorch_config["detector"]["save_epochs"] = detector_save_epochs - if detector_epochs > 0: train( loader=loader, - model_folder=str(model_folder), - run_config=pytorch_config["detector"], + run_config=loader.model_cfg["detector"], task=Task.DETECT, - device=pytorch_config["device"], - transform_config=pytorch_config["data_detector"], + device=device, logger_config=logger_config, snapshot_path=detector_path, - transform=None, # Load transform from config ) train( loader=loader, - model_folder=str(model_folder), - run_config=pytorch_config, + run_config=loader.model_cfg, task=pose_task, - device=pytorch_config["device"], - transform_config=pytorch_config["data"], - logger_config=pytorch_config.get("logger"), + device=device, + logger_config=loader.model_cfg.get("logger"), snapshot_path=snapshot_path, - transform=None, # Load transform from config ) diff --git a/benchmark/create_train_test_splits.py b/benchmark/create_train_test_splits.py index 96f3f3264c..46534d5885 100644 --- a/benchmark/create_train_test_splits.py +++ b/benchmark/create_train_test_splits.py @@ -1,13 +1,14 @@ """Creates train/test splits for DeepLabCut Single Animal benchmarks""" + from __future__ import annotations import json from pathlib import Path -import torch import deeplabcut -import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions +import deeplabcut.utils.auxiliaryfunctions as af import numpy as np +import torch from projects import SA_DLC_BENCHMARKS from utils import Project @@ -29,10 +30,12 @@ def create_splits( samples = gen.choice(num_samples, size=num_train_indices, replace=False) train_indices = np.sort(samples).tolist() test_indices = [i for i in range(num_samples) if i not in train_indices] - splits[train_frac].append({ - "train": train_indices, - "test": test_indices, - }) + splits[train_frac].append( + { + "train": train_indices, + "test": test_indices, + } + ) print(f" Split {i}:") print(f" train: {train_indices}") print(f" test: {test_indices}") @@ -46,13 +49,24 @@ def main( train_fractions: list[float], output_file: Path, ) -> None: + """Creates train/test splits for DeepLabCut projects + + Args: + projects: projects for which to create train/test splits + seeds: random seed to use for each project (must be the same len as projects) + num_splits: the number of train/test splits to create for each project + train_fractions: the train fractions for which to create train/test splits + output_file: the file where the splits should be output + """ + assert len(projects) == len(seeds), "you must pass one seed for each project!" + output_file = output_file.resolve() splits_data = {} for project, seed in zip(projects, seeds): save_dir = output_file.parent / f"data-splits-{project.name}" save_dir.mkdir(exist_ok=True) - cfg = auxiliaryfunctions.read_config(str(project.config_path())) + cfg = af.read_config(str(project.config_path())) # saves .h5 and .csv files containing the full dataframe used df = deeplabcut.generate_training_dataset.merge_annotateddatasets(cfg, save_dir) diff --git a/benchmark/lightning_pose_ood_evaluation.py b/benchmark/lightning_pose_ood_evaluation.py index bec902cf7b..8f64060a31 100644 --- a/benchmark/lightning_pose_ood_evaluation.py +++ b/benchmark/lightning_pose_ood_evaluation.py @@ -1,23 +1,29 @@ """Evaluate LightningPose OOD data""" + from __future__ import annotations + from pathlib import Path import numpy as np import pandas as pd -from tqdm import tqdm - from deeplabcut.pose_estimation_pytorch import DLCLoader, PoseDatasetParameters -from deeplabcut.pose_estimation_pytorch.apis.scoring import pair_predicted_individuals_with_gt, get_scores -from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners from deeplabcut.pose_estimation_pytorch.data.utils import map_id_to_annotations -from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( + get_scores, + pair_predicted_individuals_with_gt, +) +from deeplabcut.pose_estimation_pytorch.task import Task +from tqdm import tqdm from benchmark_lightning_pose import LP_DLC_BENCHMARKS from utils import Project, Shuffle def load_ground_truth(gt_data, parameters: PoseDatasetParameters): - annotations = DLCLoader.filter_annotations(gt_data["annotations"], task=Task.BOTTOM_UP) + annotations = DLCLoader.filter_annotations( + gt_data["annotations"], task=Task.BOTTOM_UP + ) img_to_ann_map = map_id_to_annotations(annotations) ground_truth_dict = {} @@ -67,8 +73,8 @@ def evaluate_ood( best_results = {"rmse": 1_000_000} for snapshot in snapshots: - runner, detector_runner = get_runners( - pytorch_config=shuffle.pytorch_cfg, + runner, detector_runner = get_inference_runners( + model_config=shuffle.pytorch_cfg, snapshot_path=str(snapshot), max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, @@ -88,7 +94,9 @@ def evaluate_ood( params = loader.get_dataset_parameters() gt_data = loader.to_coco(str(shuffle.project.path), df_ood, params) - annotations_with_bbox = DLCLoader._compute_bboxes(gt_data["images"], gt_data["annotations"]) + annotations_with_bbox = DLCLoader._compute_bboxes( + gt_data["images"], gt_data["annotations"] + ) gt_data["annotations"] = annotations_with_bbox gt_keypoints = load_ground_truth(gt_data, loader.get_dataset_parameters()) diff --git a/benchmark/lightning_pose_tf_eval.py b/benchmark/lightning_pose_tf_eval.py index d1df541a07..1e59fc2090 100644 --- a/benchmark/lightning_pose_tf_eval.py +++ b/benchmark/lightning_pose_tf_eval.py @@ -3,23 +3,22 @@ Transmitted on January 3rd, 2024 Forwarded on January 5th, 2024 """ + import argparse import os from pathlib import Path -import pandas as pd +import deeplabcut.utils.auxiliaryfunctions as af import numpy as np +import pandas as pd import tensorflow as tf -import yaml from tqdm import tqdm - -import deeplabcut from deeplabcut.pose_estimation_tensorflow import pairwisedistances -from deeplabcut.utils.auxfun_videos import imread, imresize -from deeplabcut.pose_estimation_tensorflow.core import predict from deeplabcut.pose_estimation_tensorflow.config import load_config +from deeplabcut.pose_estimation_tensorflow.core import predict from deeplabcut.pose_estimation_tensorflow.datasets.utils import data_to_input -from deeplabcut.utils import auxiliaryfunctions, conversioncode +from deeplabcut.utils import conversioncode +from deeplabcut.utils.auxfun_videos import imread, imresize DATA_DIR = "/home/niels/datasets/lightning-pose" @@ -60,7 +59,7 @@ def evaluate_network( # tf.logging.set_verbosity(tf.logging.WARN) # Read file path for pose_config file. >> pass it on - cfg = auxiliaryfunctions.read_config(config) + cfg = af.read_config(config) if gputouse is not None: # gpu selectinon os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse) @@ -83,11 +82,7 @@ def evaluate_network( ################################################## modelfolder = os.path.join( cfg["project_path"], - str( - auxiliaryfunctions.get_model_folder( - trainFraction, shuffle, cfg, modelprefix=modelprefix - ) - ), + str(af.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)), ) path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml" @@ -128,12 +123,14 @@ def evaluate_network( # Compute predictions over images ################################################## # setting weights to corresponding snapshot. - dlc_cfg["init_weights"] = os.path.join(str(modelfolder), "train", Snapshots[snapindex]) + dlc_cfg["init_weights"] = os.path.join( + str(modelfolder), "train", Snapshots[snapindex] + ) # read how many training siterations that corresponds to. trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1] # Name for deeplabcut net (based on its parameters) - DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name( + DLCscorer, DLCscorerlegacy = af.get_scorer_name( cfg, shuffle, trainFraction, @@ -189,9 +186,7 @@ def evaluate_network( # compute metrics conversioncode.guarantee_multiindex_rows(DataMachine) - DataCombined = pd.concat( - [data.T, DataMachine.T], axis=0, sort=False - ).T + DataCombined = pd.concat([data.T, DataMachine.T], axis=0, sort=False).T rmse, rmse_pcutoff = pairwisedistances( DataCombined, @@ -209,7 +204,7 @@ def evaluate_network( num_images = len(pred_data) lp_pixel_error = pixel_error( data.to_numpy().reshape((num_images, -1, 2)), - pred_data.to_numpy().reshape((num_images, -1, 2)) + pred_data.to_numpy().reshape((num_images, -1, 2)), ) print(f"Test error LP {np.nanmean(lp_pixel_error)}") return np.nanmean(lp_pixel_error) @@ -218,10 +213,10 @@ def evaluate_network( def run_main(args): batch_size = 8 - if args.dataset == 'mirror-mouse': - scorer = 'rick' - date = '2022-12-02' - date_str = 'Dec2' + if args.dataset == "mirror-mouse": + scorer = "rick" + date = "2022-12-02" + date_str = "Dec2" global_scale = 0.64 if args.train_frames == 75: shuffle_list = [750, 751, 752, 753, 754] @@ -231,10 +226,10 @@ def run_main(args): shuffle_list = [10, 11, 12, 13, 14] trainingsetindex = 1 trainingset = 89 - elif args.dataset == 'mirror-fish': - scorer = 'rick' - date = '2023-10-26' - date_str = 'Oct26' + elif args.dataset == "mirror-fish": + scorer = "rick" + date = "2023-10-26" + date_str = "Oct26" global_scale = 0.7 if args.train_frames == 75: shuffle_list = [750, 751, 752, 753, 754] @@ -244,10 +239,10 @@ def run_main(args): shuffle_list = [10, 11, 12, 13, 14] trainingsetindex = 1 trainingset = 95 - elif args.dataset == 'ibl-pupil': - scorer = 'mic' - date = '2022-12-06' - date_str = 'Dec6' + elif args.dataset == "ibl-pupil": + scorer = "mic" + date = "2022-12-06" + date_str = "Dec6" global_scale = 1.28 if args.train_frames == 75: shuffle_list = [750, 751, 752, 753, 754] @@ -257,10 +252,10 @@ def run_main(args): shuffle_list = [10, 11, 12, 13, 14] trainingsetindex = 1 trainingset = 89 - elif args.dataset == 'ibl-paw': - scorer = 'mic' - date = '2023-01-09' - date_str = 'Jan9' + elif args.dataset == "ibl-paw": + scorer = "mic" + date = "2023-01-09" + date_str = "Jan9" global_scale = 1.28 if args.train_frames == 75: shuffle_list = [750, 751, 752, 753, 754] @@ -273,15 +268,22 @@ def run_main(args): else: raise NotImplementedError - project_dir = os.path.join(DATA_DIR, '%s-%s-%s' % (args.dataset, scorer, date)) - config_path = os.path.join(project_dir, 'config.yaml') + project_dir = os.path.join(DATA_DIR, "%s-%s-%s" % (args.dataset, scorer, date)) + config_path = os.path.join(project_dir, "config.yaml") shuffle_results = [] for shuffle in shuffle_list: model_folder = os.path.join( - project_dir, 'dlc-models', 'iteration-0', '%s%s-trainset%ishuffle%i' % ( - args.dataset, date_str, trainingset, shuffle, - ) + project_dir, + "dlc-models", + "iteration-0", + "%s%s-trainset%ishuffle%i" + % ( + args.dataset, + date_str, + trainingset, + shuffle, + ), ) # evaluate model on OOD data @@ -289,8 +291,8 @@ def run_main(args): shuffle_results.append( evaluate_network( config_path, - csv_file=os.path.join(project_dir, 'CollectedData_new.csv'), - resultsfilename=os.path.join(model_folder, 'predictions_new.csv'), + csv_file=os.path.join(project_dir, "CollectedData_new.csv"), + resultsfilename=os.path.join(model_folder, "predictions_new.csv"), shuffle=shuffle, trainingsetindex=trainingsetindex, gputouse=args.gpu_id, @@ -303,16 +305,16 @@ def run_main(args): print(f" STD: {np.std(shuffle_results):.2f}") -if __name__ == '__main__': +if __name__ == "__main__": """(dlc) python eval_lp_ood.py --dataset=mirror-fish --gpu_id=0 --train_frames=75""" """(dlc) python eval_lp_ood.py --dataset=mirror-mouse --gpu_id=0 --train_frames=75""" parser = argparse.ArgumentParser() # base params - parser.add_argument('--dataset', type=str) - parser.add_argument('--gpu_id', default=0, type=int) - parser.add_argument('--train_frames', type=int) + parser.add_argument("--dataset", type=str) + parser.add_argument("--gpu_id", default=0, type=int) + parser.add_argument("--train_frames", type=int) namespace, _ = parser.parse_known_args() run_main(namespace) diff --git a/benchmark/madlc_evaluation.py b/benchmark/madlc_evaluation.py index 434a1ecde5..ac72448eec 100644 --- a/benchmark/madlc_evaluation.py +++ b/benchmark/madlc_evaluation.py @@ -30,7 +30,7 @@ def check_bodyparts(gt_bodyparts: set[str], predicted_bodyparts: set[str]) -> No ) if len(missing_bodyparts) > 0: print( - f"WARNING: Some GT bodyparts have no predictions: {list(missing_bodyparts)}" + f"WARNING: Some GT bodyparts have no predictions: {list(missing_bodyparts)}" ) diff --git a/benchmark/madlc_test_inference.py b/benchmark/madlc_test_inference.py index cfc8ef1f92..df729cf7ce 100644 --- a/benchmark/madlc_test_inference.py +++ b/benchmark/madlc_test_inference.py @@ -2,18 +2,18 @@ This script can be used to run inference on the test images of a DeepLabCut project. """ + from __future__ import annotations from pathlib import Path import numpy as np import pandas as pd -from ruamel.yaml import YAML -from tqdm import tqdm - from deeplabcut.pose_estimation_pytorch import PoseDatasetParameters -from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners from deeplabcut.utils.visualization import make_labeled_images_from_dataframe +from ruamel.yaml import YAML +from tqdm import tqdm from projects import MA_DLC_BENCHMARKS from utils import Project, Shuffle @@ -27,7 +27,7 @@ def run_inference_on_all_images( ) -> None: pytorch_config_path = snapshot.parent / "pytorch_config.yaml" with open(pytorch_config_path, "r") as file: - pytorch_config = YAML(typ='safe', pure=True).load(pytorch_config_path) + pytorch_config = YAML(typ="safe", pure=True).load(pytorch_config_path) parameters = PoseDatasetParameters( bodyparts=pytorch_config["metadata"]["bodyparts"], @@ -39,11 +39,7 @@ def run_inference_on_all_images( ) shuffle_name = snapshot.parent.parent.name test_data_dir = project.root / "test-images" / project.name / "labeled-data" - video_folders = [ - p - for p in test_data_dir.iterdir() - if p.is_dir() - ] + video_folders = [p for p in test_data_dir.iterdir() if p.is_dir()] images = [] for video_folder in video_folders: images += [ @@ -52,8 +48,8 @@ def run_inference_on_all_images( if p.suffix == ".png" ] - runner, detector_runner = get_runners( - pytorch_config=pytorch_config, + runner, detector_runner = get_inference_runners( + model_config=pytorch_config, snapshot_path=str(snapshot), max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, @@ -127,13 +123,10 @@ def run_inference_on_all_images( if plot: test_config_path = str( - project.root - / "test-images" - / project.name - / "config.yaml" + project.root / "test-images" / project.name / "config.yaml" ) with open(test_config_path, "r") as file: - test_config = YAML(typ='safe', pure=True).load(file) + test_config = YAML(typ="safe", pure=True).load(file) image_output_folder = output_path.parent / "images" image_output_folder.mkdir(exist_ok=True) diff --git a/benchmark/projects.py b/benchmark/projects.py index 21947eb270..143de3db2c 100644 --- a/benchmark/projects.py +++ b/benchmark/projects.py @@ -1,7 +1,9 @@ """DeepLabCut projects to benchmark""" + from __future__ import annotations from pathlib import Path + from utils import Project MA_DLC_DATA_ROOT = Path("/home/niels/datasets/ma_dlc") diff --git a/benchmark/utils.py b/benchmark/utils.py index 1e95009ec9..046ab566b0 100644 --- a/benchmark/utils.py +++ b/benchmark/utils.py @@ -1,4 +1,5 @@ """Util methods and classes for DeepLabCut Benchmarking""" + from __future__ import annotations import json @@ -9,9 +10,9 @@ import deeplabcut as dlc import deeplabcut.pose_estimation_pytorch.apis.utils as api_utils -import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.core.engine import Engine +from deeplabcut.pose_estimation_pytorch.task import Task @dataclass @@ -22,6 +23,7 @@ class Project: name: the name of the project iteration: the iteration of the project """ + root: Path name: str iteration: int @@ -32,9 +34,7 @@ def __post_init__(self) -> None: @property def cfg(self) -> dict: if self._cfg is None: - self._cfg = dlc.utils.auxiliaryfunctions.read_config( - self.config_path() - ) + self._cfg = dlc.utils.auxiliaryfunctions.read_config(self.config_path()) return self._cfg @property @@ -63,22 +63,19 @@ def update_iteration_in_config(self) -> None: ) def get_shuffle_folder(self, model_prefix: str | None = None): - base_dir = self.root / self.name + base = self.root / self.name if model_prefix is not None: - base_dir = base_dir / model_prefix - - model_dir = base_dir / "dlc-models" / f"iteration-{self.iteration}" - return model_dir + base = base / model_prefix + return base / Engine.PYTORCH.model_folder_name / f"iteration-{self.iteration}" def get_shuffle_path( - self, - shuffle_index: int, - trainset_index: int, - model_prefix: str | None = None + self, shuffle_index: int, trainset_index: int, model_prefix: str | None = None ) -> Path: base_dir = self.get_shuffle_folder(model_prefix=model_prefix) - train_fraction = (100 * self.cfg["TrainingFraction"][trainset_index]) - shuffle_name = f"{self.shuffle_prefix}-trainset{train_fraction}shuffle{shuffle_index}" + train_fraction = 100 * self.cfg["TrainingFraction"][trainset_index] + shuffle_name = ( + f"{self.shuffle_prefix}-trainset{train_fraction}shuffle{shuffle_index}" + ) return base_dir / shuffle_name @@ -128,60 +125,84 @@ def trainset_index(self) -> int: return self.project.cfg["TrainingFraction"].index(self.train_fraction) def snapshots(self, detector: bool = False) -> list[Path]: - start_str = "snapshot" + task = Task(self.pytorch_cfg["method"]) if detector: - start_str = "detector-snapshot" - - all_snapshots = [ - p for p in (self.model_folder / "train").iterdir() - if p.name.startswith(start_str) and p.suffix == ".pt" + task = Task.DETECT + return [ + s.path + for s in api_utils.get_model_snapshots( + index="all", + model_folder=self.model_folder / "train", + task=task, + ) ] - return sorted(all_snapshots, key=lambda s: int(s.stem.split("-")[-1])) def scorer(self, index: int | None = None, epochs: int | None = None) -> str: - if (index is None and epochs is None) or (index is not None and epochs is not None): - raise ValueError(f"Exactly one of (index, epochs) must be given: had {index}, {epochs}") + if (index is None and epochs is None) or ( + index is not None and epochs is not None + ): + raise ValueError( + f"Exactly one of (index, epochs) must be given: had {index}, {epochs}" + ) if index is None: index = self.epochs_to_snapshot_index(epochs) - - dlc_scorer, _ = runner_utils.get_dlc_scorer( - str(self.project.path), + snapshot = api_utils.get_model_snapshots( + index=index, + model_folder=self.model_folder / "train", + task=Task(self.pytorch_cfg["method"]), + )[0] + dlc_scorer, _ = af.get_scorer_name( self.project.cfg, - self.train_fraction, self.index, - self.model_prefix_, - index, + self.train_fraction, + trainingsiterations=api_utils.get_scorer_uid(snapshot, None), + engine=Engine.PYTORCH, + modelprefix=self.model_prefix_, ) return dlc_scorer def ground_truth(self) -> pd.DataFrame: - path_gt = self.project.path / self.trainset_folder / f"CollectedData_{self.project.cfg['scorer']}.h5" + path_gt = ( + self.project.path + / self.trainset_folder + / f"CollectedData_{self.project.cfg['scorer']}.h5" + ) df_ground_truth = pd.read_hdf(path_gt) if not isinstance(df_ground_truth, pd.DataFrame): - raise ValueError(f"Ground truth data did not contain a dataframe: {df_ground_truth}") + raise ValueError( + f"Ground truth data did not contain a dataframe: {df_ground_truth}" + ) return api_utils.ensure_multianimal_df_format(df_ground_truth) - def predictions(self, index: int | None = None, epochs: int | None = None) -> pd.DataFrame: - if (index is None and epochs is None) or (index is not None and epochs is not None): - raise ValueError(f"Exactly one of (index, epochs) must be given: had {index}, {epochs}") + def predictions( + self, index: int | None = None, epochs: int | None = None + ) -> pd.DataFrame: + if (index is None and epochs is None) or ( + index is not None and epochs is not None + ): + raise ValueError( + f"Exactly one of (index, epochs) must be given: had {index}, {epochs}" + ) if index is None: index = self.epochs_to_snapshot_index(epochs) path_eval = ( - self.project.path / - "evaluation-results" / - f"iteration-{self.project.iteration}" / - self.model_folder.name + self.project.path + / Engine.PYTORCH.results_folder_name + / f"iteration-{self.project.iteration}" + / self.model_folder.name ) scorer = self.scorer(index=index, epochs=epochs) epochs = scorer.split("_")[-1] path_predictions = path_eval / f"{scorer}-snapshot-{epochs}.h5" df_predictions = pd.read_hdf(path_predictions) if not isinstance(df_predictions, pd.DataFrame): - raise ValueError(f"Predictions data did not contain a dataframe: {df_predictions}") + raise ValueError( + f"Predictions data did not contain a dataframe: {df_predictions}" + ) return df_predictions @@ -203,7 +224,7 @@ def _lazy_load_metadata(self) -> None: self._metadata = _get_model_folder( project_path=self.project.path, project_config=self.project.cfg, - trainset_folder=self.trainset_folder, + trainset_folder=str(self.trainset_folder), train_fraction=self.train_fraction, shuffle_index=self.index, ) @@ -272,6 +293,8 @@ def create_shuffles( if len(shuffle_indices) == 0: next_index = 1 + elif len(shuffle_indices) == 1: + next_index = shuffle_indices[0] + 1 else: next_index = max(*shuffle_indices) + 1 @@ -295,6 +318,7 @@ def create_shuffles( testIndices=test_indices, net_type=net_type, augmenter_type="imgaug", + engine=Engine.PYTORCH, ) return shuffles_to_create @@ -307,7 +331,10 @@ def _get_model_folder( shuffle_index: int, ) -> tuple[dict, list[int], list[int]]: _, metadata_filename = af.get_data_and_metadata_filenames( - trainset_folder, train_fraction, shuffle_index, project_config, + trainset_folder, + train_fraction, + shuffle_index, + project_config, ) metadata = af.load_metadata(str(project_path / metadata_filename)) return metadata[0], [int(i) for i in metadata[1]], [int(i) for i in metadata[2]] diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index b937fa56d7..5083e872db 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -41,7 +41,7 @@ def train_network( config: str, shuffle: int = 1, trainingsetindex: int = 0, - max_snapshots_to_keep: int = 5, + max_snapshots_to_keep: int | None = None, displayiters: int | None = None, saveiters: int | None = None, maxiters: int | None = None, @@ -62,6 +62,9 @@ def train_network( if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import train_network + if max_snapshots_to_keep is None: + max_snapshots_to_keep = 5 + return train_network( config, shuffle=shuffle, @@ -84,6 +87,7 @@ def train_network( shuffle=shuffle, trainingsetindex=trainingsetindex, modelprefix=modelprefix, + max_snapshots_to_keep=max_snapshots_to_keep, **torch_kwargs, ) diff --git a/deeplabcut/generate_training_dataset/metadata.py b/deeplabcut/generate_training_dataset/metadata.py index a24dabce1f..5619322ae7 100644 --- a/deeplabcut/generate_training_dataset/metadata.py +++ b/deeplabcut/generate_training_dataset/metadata.py @@ -47,6 +47,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class ShuffleMetadata: """Class representing the metadata for a shuffle""" + name: str train_fraction: float index: int engine: Engine @@ -75,6 +76,7 @@ def load_split(self, cfg: dict, trainset_path: Path) -> "ShuffleMetadata": with open(doc_path, "rb") as f: _, train_idx, test_idx, _ = pickle.load(f) return ShuffleMetadata( + name=self.name, train_fraction=self.train_fraction, index=self.index, engine=self.engine, @@ -106,9 +108,10 @@ class TrainingDatasetMetadata: trainset_metadata.save() # Adding a new shuffle to the metadata file - config = "/data/my-dlc-project/config.yaml" + config = "/data/my-dlc-project-2008-06-17/config.yaml" trainset_metadata = TrainingDatasetMetadata.load(config) new_shuffle = ShuffleMetadata( + name="my-dlc-projectJun17-trainset60shuffle5", train_fraction=0.6, index=5, engine=compat.Engine.PYTORCH, @@ -130,11 +133,13 @@ def __post_init__(self) -> None: Raises: ValueError if the indices are not sorted in increasing order """ - shuffle_indices = np.array([s.index for s in self.shuffles]) - if not np.all(shuffle_indices[:-1] < shuffle_indices[1:]): - raise RuntimeError( - f"The shuffles given must be sorted in order of ascending index" - ) + indices = [[s.train_fraction, s.index] for s in self.shuffles] + for (frac1, idx1), (frac2, idx2) in zip(indices[:-1], indices[1:]): + if not (frac1 < frac2 or (frac1 == frac2 and idx1 < idx2)): + raise RuntimeError( + "The shuffles given must be sorted in order of ascending training " + f"fraction and index. Found {self.shuffles}" + ) def add( self, @@ -209,8 +214,9 @@ def save(self) -> None: split_index = len(data_splits) + 1 data_splits[s.split] = split_index - metadata["shuffles"][s.index] = { + metadata["shuffles"][s.name] = { "train_fraction": s.train_fraction, + "index": s.index, "split": split_index, "engine": s.engine.aliases[0], } @@ -240,10 +246,11 @@ def load( metadata = YAML(typ="safe", pure=True).load(file) shuffles = [] - for shuffle_index, shuffle_metadata in metadata["shuffles"].items(): + for shuffle_name, shuffle_metadata in metadata["shuffles"].items(): shuffle = ShuffleMetadata( + name=shuffle_name, train_fraction=shuffle_metadata["train_fraction"], - index=shuffle_index, + index=shuffle_metadata["index"], engine=Engine(shuffle_metadata["engine"]), split=None, ) @@ -252,7 +259,7 @@ def load( shuffles.append(shuffle) - shuffles.sort(key=lambda s: s.index) + shuffles.sort(key=lambda s: (s.train_fraction, s.index)) return TrainingDatasetMetadata(project_config=cfg, shuffles=tuple(shuffles)) @staticmethod @@ -281,12 +288,13 @@ def create(config: str | Path | dict) -> TrainingDatasetMetadata: if re.match(r"Documentation_data-.+shuffle[0-9]+\.pickle", f.name) ] + prefix = cfg["Task"] + cfg["date"] shuffles = [] existing_splits: dict[tuple[tuple[int, ...], tuple[int, ...]], int] = {} for doc_path in shuffle_docs: - shuffle_index = int(doc_path.stem.split("shuffle")[-1]) + index = int(doc_path.stem.split("shuffle")[-1]) with open(doc_path, "rb") as f: - _, train_idx, test_idx, train_fraction = pickle.load(f) + _, train_idx, test_idx, train_frac = pickle.load(f) engine = Engine.TF train_idx = tuple(sorted([int(idx) for idx in train_idx])) @@ -298,14 +306,15 @@ def create(config: str | Path | dict) -> TrainingDatasetMetadata: shuffles.append( ShuffleMetadata( - train_fraction=train_fraction, - index=shuffle_index, + name=f"{prefix}-trainset{int(100 * train_frac)}shuffle{index}", + train_fraction=train_frac, + index=index, engine=engine, split=DataSplit(train_indices=train_idx, test_indices=test_idx), ) ) - shuffles = tuple(sorted(shuffles, key=lambda s: s.index)) + shuffles = tuple(sorted(shuffles, key=lambda s: (s.train_fraction, s.index))) return TrainingDatasetMetadata( project_config=cfg, shuffles=shuffles, @@ -349,8 +358,10 @@ def update_metadata( ValueError: if overwrite=False and there is already a shuffle with the given index in the metadata file. """ + prefix = cfg["Task"] + cfg["date"] metadata = TrainingDatasetMetadata.load(cfg) new_shuffle = ShuffleMetadata( + name=f"{prefix}-trainset{int(100 * train_fraction)}shuffle{shuffle}", train_fraction=train_fraction, index=shuffle, engine=engine, diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index d54dfcf9e2..4892e58657 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -1172,21 +1172,7 @@ def create_training_dataset( def get_largestshuffle_index(config): """Returns the largest shuffle for all dlc-models in the current iteration.""" - cfg = auxiliaryfunctions.read_config(config) - project_path = cfg["project_path"] - iterate = "iteration-" + str(cfg["iteration"]) - dlc_model_path = os.path.join(project_path, "dlc-models", iterate) - if os.path.isdir(dlc_model_path): - models = os.listdir(dlc_model_path) - # sort the model directories - models.sort(key=lambda f: int("".join(filter(str.isdigit, f)))) - - # get the shuffle index and offset by 1. - max_shuffle_index = int(models[-1].split("shuffle")[-1]) + 1 - else: - max_shuffle_index = 0 - - return max_shuffle_index + return get_existing_shuffle_indices(config)[-1] def get_existing_shuffle_indices( @@ -1406,7 +1392,7 @@ def create_training_model_comparison( else: pass - largestshuffleindex = get_largestshuffle_index(config) + largestshuffleindex = get_existing_shuffle_indices(cfg)[-1] + 1 shuffle_list = [] for shuffle in range(num_shuffles): diff --git a/deeplabcut/modelzoo/webapp/inference.py b/deeplabcut/modelzoo/webapp/inference.py index 476192f080..d140d5347c 100644 --- a/deeplabcut/modelzoo/webapp/inference.py +++ b/deeplabcut/modelzoo/webapp/inference.py @@ -12,7 +12,7 @@ import numpy as np -from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( _get_config_model_paths, _update_config, @@ -49,13 +49,13 @@ def __init__( max_individuals: int, ): - pose_runner, detector_runner = get_runners( + pose_runner, detector_runner = get_inference_runners( config, snapshot_path=pose_model_path, - detector_path=detector_model_path, - num_bodyparts=num_bodyparts, max_individuals=max_individuals, + num_bodyparts=num_bodyparts, num_unique_bodyparts=0, + detector_path=detector_model_path, ) self.pose_runner = pose_runner self.detector_runner = detector_runner diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index d5d4bb62e7..582da5746e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -11,7 +11,7 @@ from __future__ import annotations import copy -import os +import logging import pickle import time from pathlib import Path @@ -27,13 +27,14 @@ convert_detections2tracklets, ) from deeplabcut.pose_estimation_pytorch.apis.utils import ( - get_detector_snapshots, get_model_snapshots, - get_runners, + get_inference_runners, + get_scorer_uid, list_videos_in_folder, ) from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity -from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner, Task +from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner +from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.refine_training_dataset.stitch import stitch_tracklets from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, VideoReader @@ -138,7 +139,8 @@ def analyze_videos( videotype: str | None = None, shuffle: int = 1, trainingsetindex: int = 0, - snapshotindex: int | None = None, + snapshot_index: int | str | None = None, + detector_snapshot_index: int | str | None = None, device: str | None = None, destfolder: str | None = None, batchsize: int | None = None, @@ -154,7 +156,6 @@ def analyze_videos( - allow batch size greater than 1 - other options such as save_as_csv - pass detector path or detector runner - - add TQDM to runner The index of the trained network is specified by parameters in the config file (in particular the variable 'snapshot_index'). @@ -174,13 +175,16 @@ def analyze_videos( destfolder: specifies the destination folder for analysis data. If ``None``, the path of the video is used. Note that for subsequent analysis this folder also needs to be passed - snapshotindex: index (starting at 0) of the snapshot to use to analyze the + snapshot_index: index (starting at 0) of the snapshot to use to analyze the videos. To evaluate the last one, use -1. For example if we have - snapshot-0.pt - snapshot-50.pt - snapshot-100.pt + - snapshot-best.pt and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None, - the snapshotindex is loaded from the project configuration. + the snapshot index is loaded from the project configuration. + detector_snapshot_index: (only for top-down models) index of the detector + snapshot to use, used in the same way as ``snapshot_index`` modelprefix: directory containing the deeplabcut models to use when evaluating the network. By default, they are assumed to exist in the project folder. batchsize: the batch size to use for inference. Takes the value from the @@ -210,44 +214,62 @@ def analyze_videos( model_folder = project_path / auxiliaryfunctions.get_model_folder( train_fraction, shuffle, cfg, modelprefix=modelprefix, engine=Engine.PYTORCH, ) - model_path = _get_model_path(model_folder, snapshotindex, cfg) - model_epochs = int(model_path.stem.split("-")[-1]) - dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( - cfg, - shuffle, - train_fraction, - trainingsiterations=model_epochs, - engine=Engine.PYTORCH, - modelprefix=modelprefix, - ) + train_folder = model_folder / "train" # Read the inference configuration, load the model - pytorch_config = auxiliaryfunctions.read_plainconfig( - model_folder / "train" / "pytorch_config.yaml" - ) + model_cfg_path = train_folder / Engine.PYTORCH.pose_cfg_name + model_cfg = auxiliaryfunctions.read_plainconfig(model_cfg_path) + pose_task = Task(model_cfg["method"]) + pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" pose_cfg = auxiliaryfunctions.read_plainconfig(pose_cfg_path) - pose_task = Task(pytorch_config.get("method", "BU")) + + if snapshot_index is None: + snapshot_index = config["snapshotindex"] + if snapshot_index == "all": + logging.warning( + "snapshotindex is set to 'all' (in the config.yaml file or as given to " + "`analyze_videos`). Running video analysis with all snapshots is very " + "costly! Use the function 'evaluate_network' to choose the best the " + "snapshot. For now, changing snapshot index to -1. To evaluate another " + "snapshot, you can change the value in the config file or call " + "`analyze_videos` with your desired snapshot index." + ) + snapshot_index = -1 + snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0] # Get general project parameters - bodyparts = pytorch_config["metadata"]["bodyparts"] - unique_bodyparts = pytorch_config["metadata"]["unique_bodyparts"] - individuals = pytorch_config["metadata"]["individuals"] - with_identity = pytorch_config["metadata"]["with_identity"] + bodyparts = model_cfg["metadata"]["bodyparts"] + unique_bodyparts = model_cfg["metadata"]["unique_bodyparts"] + individuals = model_cfg["metadata"]["individuals"] + with_identity = model_cfg["metadata"]["with_identity"] max_num_animals = len(individuals) if device is not None: - pytorch_config["device"] = device + model_cfg["device"] = device - detector_path = None + print(f"Analyzing videos with {snapshot.path}") + detector_path, detector_snapshot = None, None if pose_task == Task.TOP_DOWN: - # TODO: Choose which detector to use - detector_path = _get_detector_path(model_folder, -1, cfg) - - print(f"Analyzing videos with {model_path}") - pose_runner, detector_runner = get_runners( - pytorch_config=pytorch_config, - snapshot_path=str(model_path), + if detector_snapshot_index is None: + detector_snapshot_index = -1 + detector_snapshot = get_model_snapshots( + detector_snapshot_index, train_folder, Task.DETECT + )[0] + detector_path = detector_snapshot.path + print(f" -> Using detector {detector_path}") + + dlc_scorer, _ = auxiliaryfunctions.get_scorer_name( + cfg, + shuffle, + train_fraction, + trainingsiterations=get_scorer_uid(snapshot, detector_snapshot), + engine=Engine.PYTORCH, + modelprefix=modelprefix, + ) + pose_runner, detector_runner = get_inference_runners( + model_config=model_cfg, + snapshot_path=snapshot.path, max_individuals=max_num_animals, num_bodyparts=len(bodyparts), num_unique_bodyparts=len(unique_bodyparts), @@ -283,7 +305,7 @@ def analyze_videos( runtime.append(time.time()) metadata = _generate_metadata( cfg=cfg, - pytorch_config=pytorch_config, + pytorch_config=model_cfg, dlc_scorer=dlc_scorer, train_fraction=train_fraction, batch_size=batchsize, @@ -485,54 +507,6 @@ def _generate_metadata( return {"data": metadata} -def _get_model_path(model_folder: Path, snapshot_index: int, config: dict) -> Path: - trained_models = get_model_snapshots(model_folder / "train") - - if snapshot_index is None: - snapshot_index = config["snapshotindex"] - - if snapshot_index == "all": - print( - "snapshotindex is set to 'all' in the config.yaml file. Running video " - "analysis with all snapshots is very costly! Use the function " - "'evaluate_network' to choose the best the snapshot. For now, changing " - "snapshot index to -1. To evaluate another snapshot, you can change the " - "value in the config file or call `analyze_videos` with your desired " - "snapshot index." - ) - snapshot_index = -1 - - assert isinstance( - snapshot_index, int - ), f"snapshotindex must be an integer but was '{snapshot_index}'" - return trained_models[snapshot_index] - - -def _get_detector_path( - model_folder: Path, snapshot_index: int | str, config: dict | None -) -> Path: - trained_models = get_detector_snapshots(model_folder / "train") - - if snapshot_index is None: - snapshot_index = config["snapshotindex"] - - if snapshot_index == "all": - print( - "snapshotindex is set to 'all' in the config.yaml file. Running video " - "analysis with all snapshots is very costly! Use the function " - "'evaluate_network' to choose the best the snapshot. For now, changing " - "snapshot index to -1. To evaluate another snapshot, you can change the " - "value in the config file or call `analyze_videos` with your desired " - "snapshot index." - ) - snapshot_index = -1 - - assert isinstance( - snapshot_index, int - ), f"snapshotindex must be an integer but was '{snapshot_index}'" - return trained_models[snapshot_index] - - def _generate_output_data( pose_config: dict, predictions: list[dict[str, np.ndarray]], diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index a742ad4f24..7c70851372 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -19,19 +19,27 @@ import pandas as pd from tqdm import tqdm -import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils +from deeplabcut.core.engine import Engine +from deeplabcut.pose_estimation_pytorch import utils +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + build_predictions_dataframe, + ensure_multianimal_df_format, + get_model_snapshots, + get_inference_runners, + get_scorer_uid, +) from deeplabcut.pose_estimation_pytorch.data import Loader, DLCLoader -from deeplabcut.pose_estimation_pytorch.apis.scoring import ( +from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( compute_identity_scores, get_scores, pair_predicted_individuals_with_gt, ) -from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_predictions_dataframe, - ensure_multianimal_df_format, - get_runners, +from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner +from deeplabcut.pose_estimation_pytorch.runners.snapshots import ( + Snapshot, + TorchSnapshotManager, ) -from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner, Task +from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.visualization import plot_evaluation_results @@ -164,88 +172,57 @@ def evaluate( def evaluate_snapshot( cfg: dict, - shuffle: int = 0, - trainingsetindex: int = 0, - snapshotindex: int = -1, - device: str | None = None, + loader: DLCLoader, + snapshot: Snapshot, + scorer: str, transform: A.Compose | None = None, plotting: bool | str = False, show_errors: bool = True, - modelprefix: str = "", - detector_path: str | None = None, + detector_snapshot: Snapshot | None = None, ) -> pd.DataFrame: """Evaluates a snapshot. - The evaluation results are stored in the .h5 and TODO .csv file under the subdirectory + The evaluation results are stored in the .h5 and .csv file under the subdirectory 'evaluation_results'. Args: cfg: the content of the project's config file - shuffle: shuffle index - trainingsetindex: the training set fraction to use - modelprefix: model prefix - snapshotindex: index (starting at 0) of the snapshot we want to load. To - evaluate the last one, use -1. To evaluate all snapshots, use "all". For - example if we have 3 models saved - - snapshot-0.pt - - snapshot-50.pt - - snapshot-100.pt - and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None, - the snapshotindex is loaded from the project configuration. - device: the device to run evaluation on + loader: the loader for the shuffle to evaluate + snapshot: the snapshot to evaluate + scorer: the scorer name to use for the snapshot transform: transformation pipeline for evaluation ** Should normalise the data the same way it was normalised during training ** plotting: Plots the predictions on the train and test images. If provided it must be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting to ``True`` defaults as ``"bodypart"`` for multi-animal projects. show_errors: whether to compare predictions and ground truth - detector_path: Only for TD models. If defined, evaluation metrics are computed - using the detections made by this detector + detector_snapshot: Only for TD models. If defined, evaluation metrics are + computed using the detections made by this snapshot """ - train_fraction = cfg["TrainingFraction"][trainingsetindex] - model_folder = runner_utils.get_model_folder( - cfg["project_path"], cfg, train_fraction, shuffle, modelprefix - ) - model_config_path = str(Path(model_folder) / "train" / "pytorch_config.yaml") - pytorch_config = auxiliaryfunctions.read_plainconfig(model_config_path) - if device is not None: - pytorch_config["device"] = device - - pose_task = Task(pytorch_config.get("method", "bu")) - loader = DLCLoader( - config=Path(cfg["project_path"]) / "config.yaml", - shuffle=shuffle, - trainset_index=trainingsetindex, - modelprefix=modelprefix, - ) + pose_task = Task(loader.model_cfg.get("method", "bu")) parameters = loader.get_dataset_parameters() - names = runner_utils.get_paths( - project_path=cfg["project_path"], - train_fraction=train_fraction, - model_prefix=modelprefix, - shuffle=shuffle, - cfg=cfg, - train_iterations=snapshotindex, - task=pose_task, - ) pcutoff = cfg.get("pcutoff") - pose_runner, detector_runner = get_runners( - pytorch_config=pytorch_config, - snapshot_path=names["model_path"], + detector_path = None + if detector_snapshot is not None: + detector_path = detector_snapshot.path + + pose_runner, detector_runner = get_inference_runners( + model_config=loader.model_cfg, + snapshot_path=snapshot.path, max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, num_unique_bodyparts=parameters.num_unique_bpts, - with_identity=pytorch_config["metadata"]["with_identity"], + with_identity=loader.model_cfg["metadata"]["with_identity"], transform=transform, detector_path=detector_path, - detector_transform=None, + detector_transform=None ) predictions = {} scores = { - "Training epochs": int(names["dlc_scorer"].split("_")[-1]), - "%Training dataset": train_fraction, - "Shuffle number": shuffle, + "Training epochs": snapshot.uid(), + "%Training dataset": loader.train_fraction, + "Shuffle number": loader.shuffle, "pcutoff": pcutoff, } for split in ["train", "test"]: @@ -258,7 +235,7 @@ def evaluate_snapshot( detector_runner=detector_runner, ) df_split_predictions = build_predictions_dataframe( - scorer=names["dlc_scorer"], + scorer=scorer, predictions=predictions_for_split, parameters=parameters, image_name_to_index=image_to_dlc_df_index, @@ -267,51 +244,43 @@ def evaluate_snapshot( for k, v in results.items(): scores[f"{split} {k}"] = round(v, 2) - results_filename = runner_utils.get_results_filename( - names["evaluation_folder"], - names["dlc_scorer"], - names["dlc_scorer_legacy"], - names["model_path"][:-3], - ) + results_filename = f"{scorer}.h5" df_predictions = pd.concat(predictions.values(), axis=0) df_predictions = df_predictions.reindex(loader.df.index) - output_filename = Path(results_filename) + output_filename = loader.evaluation_folder / results_filename output_filename.parent.mkdir(parents=True, exist_ok=True) - df_predictions.to_hdf(str(output_filename), key="df_with_missing") + df_predictions.to_hdf(output_filename, key="df_with_missing") df_scores = pd.DataFrame([scores]).set_index( ["Training epochs", "%Training dataset", "Shuffle number", "pcutoff"] ) - scores_filepath = Path(results_filename).with_suffix(".csv") + scores_filepath = output_filename.with_suffix(".csv") scores_filepath = scores_filepath.with_stem(scores_filepath.stem + "-results") save_evaluation_results(df_scores, scores_filepath, show_errors, pcutoff) if plotting: - snapshot_name = Path(names["model_path"]).stem - folder_name = ( - f"{names['evaluation_folder']}/" - f"LabeledImages_{names['dlc_scorer']}_{snapshot_name}" - ) - Path(folder_name).mkdir(parents=True, exist_ok=True) + folder_name = f"LabeledImages_{scorer}" + folder_path = loader.evaluation_folder / folder_name + folder_path.mkdir(parents=True, exist_ok=True) if isinstance(plotting, str): plot_mode = plotting else: plot_mode = "bodypart" - df_ground_truth = ensure_multianimal_df_format(loader.df_dlc) + df_ground_truth = ensure_multianimal_df_format(loader.df) for mode in ["train", "test"]: df_combined = predictions[mode].merge( df_ground_truth, left_index=True, right_index=True ) - plot_unique_bodyparts = False # TODO: get from parameters + unique_bodyparts = loader.get_dataset_parameters().unique_bpts plot_evaluation_results( df_combined=df_combined, project_root=cfg["project_path"], scorer=cfg["scorer"], - model_name=names["dlc_scorer"], - output_folder=folder_name, + model_name=scorer, + output_folder=str(folder_path), in_train_set=mode == "train", - plot_unique_bodyparts=plot_unique_bodyparts, + plot_unique_bodyparts=len(unique_bodyparts) > 0, mode=plot_mode, colormap=cfg["colormap"], dot_size=cfg["dotsize"], @@ -332,7 +301,7 @@ def evaluate_network( show_errors: bool = True, transform: A.Compose = None, modelprefix: str = "", - detector_path: str | None = None, + detector_snapshot_index: int | None = -1, ) -> None: """Evaluates a snapshot. @@ -361,31 +330,32 @@ def evaluate_network( ** Should normalise the data the same way it was normalised during training ** modelprefix: directory containing the deeplabcut models to use when evaluating the network. By default, they are assumed to exist in the project folder. - detector_path: Only for TD models. If defined, evaluation metrics are computed - using the detections made by this detector + detector_snapshot_index: Only for TD models. If defined, uses the detector with + the given index for pose estimation. Examples: If you want to evaluate on shuffle 1 without plotting predictions. + >>> import deeplabcut >>> deeplabcut.evaluate_network( - '/analysis/project/reaching-task/config.yaml', shuffles=[1], - ) + >>> '/analysis/project/reaching-task/config.yaml', shuffles=[1], + >>> ) If you want to evaluate shuffles 0 and 1 and plot the predictions. >>> deeplabcut.evaluate_network( - '/analysis/project/reaching-task/config.yaml', - shuffles=[0, 1], - plotting=True, - ) + >>> '/analysis/project/reaching-task/config.yaml', + >>> shuffles=[0, 1], + >>> plotting=True, + >>> ) If you want to plot assemblies for a maDLC project >>> deeplabcut.evaluate_network( - '/analysis/project/reaching-task/config.yaml', - shuffles=[1], - plotting="individual", - ) + >>> '/analysis/project/reaching-task/config.yaml', + >>> shuffles=[1], + >>> plotting="individual", + >>> ) """ cfg = auxiliaryfunctions.read_config(config) @@ -401,33 +371,52 @@ def evaluate_network( for train_set_index in train_set_indices: for shuffle in shuffles: - if isinstance(snapshotindex, str) and snapshotindex.lower() == "all": - model_folder = runner_utils.get_model_folder( - project_path=str(Path(config).parent), + loader = DLCLoader( + config=Path(cfg["project_path"]) / "config.yaml", + shuffle=shuffle, + trainset_index=train_set_index, + modelprefix=modelprefix, + ) + loader.evaluation_folder.mkdir(exist_ok=True, parents=True) + + if device is not None: + loader.model_cfg["device"] = device + loader.model_cfg["device"] = utils.resolve_device(loader.model_cfg) + + task = Task(loader.model_cfg["method"]) + snapshots = get_model_snapshots( + snapshotindex, + model_folder=loader.model_folder, + task=task, + ) + + detector_snapshot = None + if task == Task.TOP_DOWN: + if detector_snapshot_index is not None: + detector_snapshot = get_model_snapshots( + detector_snapshot_index, loader.model_folder, Task.DETECT, + )[0] + else: + print("Using GT bounding boxes to compute evaluation metrics") + + for snapshot in snapshots: + scorer, _ = auxiliaryfunctions.get_scorer_name( cfg=cfg, - train_fraction=cfg["TrainingFraction"][train_set_index], shuffle=shuffle, - model_prefix=modelprefix, + trainFraction=loader.train_fraction, + engine=Engine.PYTORCH, + trainingsiterations=get_scorer_uid(snapshot, detector_snapshot), + modelprefix=modelprefix, ) - all_snapshots = runner_utils.get_snapshots(Path(model_folder)) - snapshot_indices = list(range(len(all_snapshots))) - elif isinstance(snapshotindex, int): - snapshot_indices = [snapshotindex] - else: - raise ValueError(f"Invalid snapshotindex: {snapshotindex}") - - for snapshot in snapshot_indices: - _ = evaluate_snapshot( + evaluate_snapshot( + loader=loader, cfg=cfg, - shuffle=shuffle, - trainingsetindex=train_set_index, - snapshotindex=snapshot, - device=device, + scorer=scorer, + snapshot=snapshot, transform=transform, plotting=plotting, show_errors=show_errors, - modelprefix=modelprefix, - detector_path=detector_path, + detector_snapshot=detector_snapshot, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 23ab31a8fb..6cbbbf0ee4 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -13,28 +13,16 @@ import argparse import copy import logging -from pathlib import Path import albumentations as A from torch.utils.data import DataLoader -import deeplabcut.pose_estimation_pytorch.runners.utils as runner_utils +import deeplabcut.pose_estimation_pytorch.config as torch_config import deeplabcut.pose_estimation_pytorch.utils as utils -from deeplabcut import auxiliaryfunctions -from deeplabcut.pose_estimation_pytorch.apis.utils import ( - build_inference_transform, - build_optimizer, - build_transforms, -) -from deeplabcut.pose_estimation_pytorch.runners.schedulers import build_scheduler -from deeplabcut.pose_estimation_pytorch.config import ( - pretty_print_config, - read_config_as_dict, - update_config, -) -from deeplabcut.pose_estimation_pytorch.data import Loader, DLCLoader +from deeplabcut.pose_estimation_pytorch.data import build_transforms, DLCLoader, Loader from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel -from deeplabcut.pose_estimation_pytorch.runners import Task, build_training_runner +from deeplabcut.pose_estimation_pytorch.runners import build_training_runner +from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.pose_estimation_pytorch.runners.logger import ( LOGGER, destroy_file_logging, @@ -44,85 +32,97 @@ def train( loader: Loader, - model_folder: str, run_config: dict, task: Task, - device: str, - transform_config: dict, + device: str = "cpu", logger_config: dict | None = None, snapshot_path: str | None = None, transform: A.BaseCompose | None = None, + inference_transform: A.BaseCompose | None = None, + max_snapshots_to_keep: int | None = None, ) -> None: """Builds a model from a configuration and fits it to a dataset Args: loader: the loader containing the data to train on/validate with - model_folder: the folder where the models should be saved run_config: the model and run configuration task: the task to train the model for - device: the device to train on - transform_config: the configuration of the data augmentation to use. Ignored if - a transform is given + device: the torch device to train on (such as "cpu", "cuda", "mps") logger_config: the configuration of a logger to use snapshot_path: if continuing to train from a snapshot, the path containing the weights to load - transform: if None, a transform is loaded with the given configuration. - Otherwise, this transform is used. + transform: if defined, overwrites the transform defined in the model config + inference_transform: if defined, overwrites the inference transform defined in + the model config + max_snapshots_to_keep: the maximum number of snapshots to store for each model """ if task == Task.DETECT: model = DETECTORS.build(run_config["model"]) else: model = PoseModel.build(run_config["model"]) + if max_snapshots_to_keep is not None: + run_config["snapshots"]["max_snapshots"] = max_snapshots_to_keep + logger = None if logger_config is not None: logger = LOGGER.build(dict(**logger_config, model=model)) logger.log_config(run_config) - model.to(device) # Move model before giving its parameters to the optimizer - optimizer = build_optimizer(run_config["optimizer"], model) - scheduler = build_scheduler(run_config["scheduler"], optimizer) + if device is None: + device = utils.resolve_device(run_config) + model.to(device) # Move model before giving its parameters to the optimizer runner = build_training_runner( + runner_config=run_config["runner"], + model_folder=loader.model_folder, task=task, model=model, - optimizer=optimizer, device=device, - scheduler=scheduler, snapshot_path=snapshot_path, logger=logger, ) - batch_size = run_config.get("batch_size", 1) - epochs = run_config.get("epochs", 200) - save_epochs = run_config.get("save_epochs", 50) - display_iters = run_config.get("display_iters", 50) - if transform is None: - logging.info(f"No transform passed to augment images for {task}, using default") - transform = build_transforms(transform_config, augment_bbox=True) - valid_transform = build_inference_transform(transform_config, augment_bbox=True) + transform = build_transforms(run_config["data"]["train"]) + if inference_transform is None: + inference_transform = build_transforms(run_config["data"]["inference"]) + logging.info("Data Transforms:") logging.info(f" Training: {transform}") - logging.info(f" Validation: {valid_transform}") + logging.info(f" Validation: {inference_transform}") train_dataset = loader.create_dataset(transform=transform, mode="train", task=task) valid_dataset = loader.create_dataset( - transform=valid_transform, mode="test", task=task + transform=inference_transform, mode="test", task=task ) logging.info( f"Using {len(train_dataset)} images to train {task} and {len(valid_dataset)}" f" for testing" ) - train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) - valid_dataloader = DataLoader(valid_dataset, batch_size=1, shuffle=False) + + batch_size = run_config["train_settings"]["batch_size"] + num_workers = run_config["train_settings"]["dataloader_workers"] + pin_memory = run_config["train_settings"]["dataloader_pin_memory"] + train_dataloader = DataLoader( + train_dataset, + batch_size=batch_size, + shuffle=True, + num_workers=num_workers, + pin_memory=pin_memory, + ) + valid_dataloader = DataLoader( + valid_dataset, + batch_size=1, + shuffle=False, + num_workers=num_workers, + pin_memory=pin_memory, + ) runner.fit( train_dataloader, valid_dataloader, - model_folder=model_folder, - epochs=epochs, - save_epochs=save_epochs, - display_iters=display_iters, + epochs=run_config["train_settings"]["epochs"], + display_iters=run_config["train_settings"]["display_iters"], ) @@ -130,103 +130,73 @@ def train_network( config: str, shuffle: int = 1, trainingsetindex: int = 0, - transform: A.BaseCompose | None = None, - transform_cropped: A.BaseCompose | None = None, modelprefix: str = "", - snapshot_path: str | None = "", - detector_path: str | None = "", + device: str | None = None, + snapshot_path: str | None = None, + detector_path: str | None = None, + max_snapshots_to_keep: int | None = None, **kwargs, ) -> None: """Trains a network for a project - TODO: max_snapshots_to_keep - Args: config : path to the yaml config file of the project shuffle : index of the shuffle we want to train on trainingsetindex : training set index - transform: Augmentation pipeline for the images - if None, the augmentation pipeline is built from config files - Advice if you want to use custom transformations: - Keep in mind that in order for transfer learning to be efficient, your - data statistical distribution should resemble the one used to pretrain your backbone - - In most cases (e.g backbone was pretrained on ImageNet), that means it should be Normalized with - A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) - transform_cropped: Augmentation pipeline for the cropped images around animals - if None, the augmentation pipeline is built from config files - Advice if you want to use custom transformations: - Keep in mind that in order for transfer learning to be efficient, your - data statistical distribution should resemble the one used to pretrain your backbone - In most cases (e.g backbone was pretrained on ImageNet), that means it should be Normalized with - A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225]) modelprefix: directory containing the deeplabcut configuration files to use to train the network (and where snapshots will be saved). By default, they are assumed to exist in the project folder. + device: the torch device to train on (such as "cpu", "cuda", "mps") snapshot_path: if resuming training, used to specify the snapshot from which to resume - detector_path: if resuming training of a top down model, used to specify the detector snapshot from - which to resume + detector_path: if resuming training of a top-down model, used to specify the + detector snapshot from which to resume + max_snapshots_to_keep: the maximum number of snapshots to save for each model **kwargs : could be any entry of the pytorch_config dictionary. Examples are to see the full list see the pytorch_cfg.yaml file in your project folder """ - cfg = auxiliaryfunctions.read_config(config) - train_fraction = cfg["TrainingFraction"][trainingsetindex] - model_folder = runner_utils.get_model_folder( - str(Path(config).parent), cfg, train_fraction, shuffle, modelprefix - ) - train_folder = Path(model_folder) / "train" - log_path = train_folder / "log.txt" - model_config_path = str(train_folder / "pytorch_config.yaml") - - setup_file_logging(log_path) - - pytorch_config = read_config_as_dict(model_config_path) - pytorch_config = update_config(pytorch_config, kwargs) - logging.info("Training with configuration:") - pretty_print_config(pytorch_config, print_fn=logging.info) - # write updated configuration - auxiliaryfunctions.write_plainconfig(model_config_path, pytorch_config) - - if transform is None: - logging.info("No transform specified... using default") - transform = build_transforms(dict(pytorch_config["data"]), augment_bbox=True) - - utils.fix_seeds(pytorch_config["seed"]) loader = DLCLoader( config=config, shuffle=shuffle, trainset_index=trainingsetindex, + modelprefix=modelprefix, ) + loader.update_model_cfg(kwargs) + setup_file_logging(loader.model_folder / "train.txt") + + logging.info("Training with configuration:") + torch_config.pretty_print(loader.model_cfg, print_fn=logging.info) - pose_task = Task(pytorch_config.get("method", "bu")) - if pose_task == Task.TOP_DOWN and pytorch_config["detector"]["epochs"] > 0: + # fix seed for reproducibility + utils.fix_seeds(loader.model_cfg["train_settings"]["seed"]) + + # get the pose task + pose_task = Task(loader.model_cfg.get("method", "bu")) + if ( + pose_task == Task.TOP_DOWN + and loader.model_cfg["detector"]["train_settings"]["epochs"] > 0 + ): logger_config = None - if pytorch_config.get("logger"): - logger_config = copy.deepcopy(pytorch_config["logger"]) + if loader.model_cfg.get("logger"): + logger_config = copy.deepcopy(loader.model_cfg["logger"]) logger_config["run_name"] += "-detector" - train( loader=loader, - model_folder=model_folder, - run_config=pytorch_config["detector"], + run_config=loader.model_cfg["detector"], task=Task.DETECT, - device=pytorch_config["device"], - transform_config=pytorch_config["data_detector"], + device=device, logger_config=logger_config, snapshot_path=detector_path, - transform=transform_cropped, + max_snapshots_to_keep=max_snapshots_to_keep, ) train( loader=loader, - model_folder=model_folder, - run_config=pytorch_config, + run_config=loader.model_cfg, task=pose_task, - device=pytorch_config["device"], - transform_config=pytorch_config["data"], - logger_config=pytorch_config.get("logger"), + device=device, + logger_config=loader.model_cfg.get("logger"), snapshot_path=snapshot_path, - transform=transform, + max_snapshots_to_keep=max_snapshots_to_keep, ) destroy_file_logging() diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index fc2743ca9e..f67ab422a6 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -10,16 +10,13 @@ # from __future__ import annotations -import warnings +import logging from pathlib import Path from typing import Callable import albumentations as A -import cv2 import numpy as np import pandas as pd -import torch -import torch.nn as nn from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters @@ -32,19 +29,18 @@ build_bottom_up_preprocessor, build_top_down_preprocessor, ) -from deeplabcut.pose_estimation_pytorch.data.transforms import ( - CoarseDropout, - ElasticTransform, - Grayscale, - KeepAspectRatioResize, - KeypointAwareCrop, -) +from deeplabcut.pose_estimation_pytorch.data.transforms import build_transforms from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel from deeplabcut.pose_estimation_pytorch.runners import ( build_inference_runner, InferenceRunner, - Task, ) +from deeplabcut.pose_estimation_pytorch.runners.snapshots import ( + Snapshot, + TorchSnapshotManager, +) +from deeplabcut.pose_estimation_pytorch.task import Task +from deeplabcut.pose_estimation_pytorch.utils import resolve_device from deeplabcut.utils import auxiliaryfunctions, auxfun_videos @@ -60,6 +56,8 @@ def return_train_network_path( shuffle: The shuffle index to select for training trainingsetindex: Which TrainingsetFraction to use (note that TrainingFraction is a list in config.yaml) + modelprefix: the modelprefix for the model + Returns: the path to the training pytorch pose configuration file the path to the test pytorch pose configuration file @@ -78,224 +76,58 @@ def return_train_network_path( ) -def build_optimizer(optimizer_cfg: dict, model: nn.Module) -> torch.optim.Optimizer: - """Builds an optimizer from configuration file - - Args: - optimizer_cfg: the optimizer configuration - model: the model to optimize - - Returns: - the optimizer - """ - get_optimizer = getattr(torch.optim, optimizer_cfg["type"]) - return get_optimizer(params=model.parameters(), **optimizer_cfg["params"]) - - -def build_transforms(aug_cfg: dict, augment_bbox: bool = False) -> A.BaseCompose: +def get_model_snapshots( + index: int | str, model_folder: Path, task: Task, +) -> list[Snapshot]: """ - Returns the transformation pipeline based on config - - Args: - aug_cfg : dict containing all transforms information - augment_bbox : whether the returned augmentation pipelines should keep track of bboxes or not - Returns: - transform: callable element that can augment images, keypoints and bboxes - """ - transforms = [] - - crop_sampling = aug_cfg.get("crop_sampling", False) - if crop_sampling: - # Add smart, keypoint-aware image cropping - transforms.append( - A.PadIfNeeded( - min_height=crop_sampling["height"], - min_width=crop_sampling["width"], - border_mode=cv2.BORDER_CONSTANT, - always_apply=True, - ) - ) - transforms.append( - KeypointAwareCrop( - crop_sampling["width"], - crop_sampling["height"], - crop_sampling["max_shift"], - crop_sampling["method"], - ) - ) - - if resize_aug := aug_cfg.get("resize", False): - transforms += build_resize_transforms(resize_aug) - - if aug_cfg.get("hflip"): - warnings.warn( - "Be careful! Do not train pose models with horizontal flips if you have" - " symmetric keypoints!" - ) - hflip_proba = 0.5 - if isinstance(aug_cfg["hflip"], float): - hflip_proba = aug_cfg["hflip"] - transforms.append(A.HorizontalFlip(p=hflip_proba)) - - scale_jitter = aug_cfg.get("scale_jitter") - rotation = aug_cfg.get("rotation") - translation = aug_cfg.get("translation") - if scale_jitter or rotation or translation: - scale = None - if scale_jitter: - scale = scale_jitter[0], scale_jitter[1] - rotate = None - if rotation: - rotate = (-rotation, rotation) - translation_px = None - if translation: - translation_px = (0, translation) - transforms.append( - A.Affine( - scale=scale, - rotate=rotate, - translate_px=translation_px, - p=0.5, - keep_ratio=True, - ) - ) - - if aug_cfg.get("hist_eq", False): - transforms.append(A.Equalize(p=0.5)) - if aug_cfg.get("motion_blur", False): - transforms.append(A.MotionBlur(p=0.5)) - if aug_cfg.get("covering", False): - transforms.append( - CoarseDropout( - max_holes=10, - max_height=0.05, - min_height=0.01, - max_width=0.05, - min_width=0.01, - p=0.5, - ) - ) - if aug_cfg.get("elastic_transform", False): - transforms.append(ElasticTransform(sigma=5, p=0.5)) - if aug_cfg.get("grayscale", False): - transforms.append(Grayscale(alpha=(0.5, 1.0))) - if aug_cfg.get("gaussian_noise", False): - opt = aug_cfg.get("gaussian_noise", False) # std - # TODO inherit custom gaussian transform to support per_channel = 0.5 - if type(opt) == int or type(opt) == float: - transforms.append( - A.GaussNoise( - var_limit=(0, opt**2), - mean=0, - per_channel=True, # Albumentations doesn't support per_channel = 0.5 - p=0.5, - ) - ) - else: - transforms.append( - A.GaussNoise( - var_limit=(0, (0.05 * 255) ** 2), mean=0, per_channel=True, p=0.5 - ) - ) - - if aug_cfg.get("auto_padding"): - transforms.append(build_auto_padding(**aug_cfg["auto_padding"])) - - if aug_cfg.get("normalize_images"): - transforms.append( - A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) - ) - - bbox_params = None - if augment_bbox: - bbox_params = A.BboxParams(format="coco", label_fields=["bbox_labels"]) - - return A.Compose( - transforms, - keypoint_params=A.KeypointParams( - "xy", remove_invisible=False, label_fields=["class_labels"] - ), - bbox_params=bbox_params, - ) - - -def build_inference_transform( - transform_cfg: dict, augment_bbox: bool = True -) -> A.BasicTransform | A.BaseCompose: - """Build transform pipeline for inference - - Mainly about normalising the images a giving them a specific shape - Args: - transform_cfg (dict): dict containing information about the transforms to apply - should be the same as the one used for build_transforms to ensure matching - distributions between train and test - augment_bbox (bool): should always be True for inference + index: Passing an index returns the snapshot with that index (where snapshots + based on their number of training epochs, and the last snapshot is the + "best" model based on validation metrics if one exists). Passing "best" + returns the best snapshot from the training run. Passing "all" returns all + snapshots. + model_folder: The path to the folder containing the snapshots + task: The task for which to return the snapshot Returns: - Union[A.BasicTransform, A.BaseCompose]: the transformation pipeline - """ - list_transforms = [] - if resize_aug := transform_cfg.get("resize"): - list_transforms += build_resize_transforms(resize_aug) - - if transform_cfg.get("auto_padding"): - list_transforms.append(build_auto_padding(**transform_cfg["auto_padding"])) - - if transform_cfg.get("normalize_images"): - list_transforms.append( - A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) - ) - - bbox_params = None - if augment_bbox: - bbox_params = A.BboxParams(format="coco", label_fields=["bbox_labels"]) + If index=="all", returns all snapshots. Otherwise, returns a list containing a + single snapshot, with the desired index. - return A.Compose( - list_transforms, - keypoint_params=A.KeypointParams("xy", remove_invisible=False), - bbox_params=bbox_params, - ) - - -def get_model_snapshots(model_folder: Path) -> list[Path]: + Raises: + ValueError: If the index given is not valid + ValueError: If index=="best" but there is no saved best model """ - Assumes that all snapshots are named using the pattern "snapshot-{idx}.pt" - - Args: - model_folder: the path to the folder containing the snapshots + snapshot_manager = TorchSnapshotManager(model_folder=model_folder, task=task) + if isinstance(index, str) and index.lower() == "best": + best_snapshot = snapshot_manager.best() + if best_snapshot is None: + raise ValueError(f"No best snapshot found in {model_folder}") + snapshots = [best_snapshot] + elif isinstance(index, str) and index.lower() == "all": + snapshots = snapshot_manager.snapshots(include_best=True) + elif isinstance(index, int): + snapshots = [snapshot_manager.snapshots(include_best=True)[index]] + else: + raise ValueError(f"Invalid snapshotindex: {index}") - Returns: - the paths of snapshots in the folder, sorted by index in ascending order - """ - return sorted( - [ - file - for file in model_folder.iterdir() - if ((file.suffix == ".pt") and ("detector" not in str(file))) - ], - key=lambda p: int(p.stem.split("-")[-1]), - ) + return snapshots -def get_detector_snapshots(model_folder: Path) -> list[Path]: +def get_scorer_uid(snapshot: Snapshot, detector_snapshot: Snapshot | None) -> str: """ - Assumes that all snapshots are named using the pattern "detector-snapshot-{idx}.pt" - Args: - model_folder: the path to the folder containing the snapshots + snapshot: the snapshot for which to get the scorer UID + detector_snapshot: if a top-down model is used with a detector, the detector + snapshot for which to get the scorer UID Returns: - the paths of detector snapshots in the folder, sorted by index in ascending order + the uid to use for the scorer """ - return sorted( - [ - file - for file in model_folder.iterdir() - if ((file.suffix == ".pt") and ("detector" in str(file))) - ], - key=lambda p: int(p.stem.split("-")[-1]), - ) + snapshot_id = snapshot.uid() + if detector_snapshot is not None: + detect_id = detector_snapshot.uid() + snapshot_id = f"detector_{detect_id}_snapshot_{snapshot_id}" + return snapshot_id def list_videos_in_folder( @@ -327,79 +159,6 @@ def list_videos_in_folder( return videos -def build_auto_padding( - min_height: int | None = None, - min_width: int | None = None, - pad_height_divisor: int | None = 1, - pad_width_divisor: int | None = 1, - position: str = "random", # TODO: Which default to set? - border_mode: str = "reflect_101", # TODO: Which default to set? - border_value: float | None = None, - border_mask_value: float | None = None, -) -> A.PadIfNeeded: - """ - Create an albumentations PadIfNeeded transform from a config - - Args: - min_height: the minimum height of the image - min_width: the minimum width of the image - pad_height_divisor: if not None, ensures height is dividable by value of this argument - pad_width_divisor: if not None, ensures width is dividable by value of this argument - position: position of the image, one of the possible PadIfNeeded - border_mode: 'constant' or 'reflect_101' (see cv2.BORDER modes) - border_value: padding value if border_mode is 'constant' - border_mask_value: padding value for mask if border_mode is 'constant' - - Raises: - ValueError: - Only one of 'min_height' and 'pad_height_divisor' parameters must be set - Only one of 'min_width' and 'pad_width_divisor' parameters must be set - - Returns: - the auto-padding transform - """ - border_modes = { - "constant": cv2.BORDER_CONSTANT, - "reflect_101": cv2.BORDER_REFLECT_101, - } - if border_mode not in border_modes: - raise ValueError( - f"Unknown border mode for auto_padding: {border_mode} " - f"(valid values are: {border_modes.keys()})" - ) - - return A.PadIfNeeded( - min_height=min_height, - min_width=min_width, - pad_height_divisor=pad_height_divisor, - pad_width_divisor=pad_width_divisor, - position=position, - border_mode=border_modes[border_mode], - value=border_value, - mask_value=border_mask_value, - ) - - -def build_resize_transforms(resize_cfg: dict) -> list[A.BasicTransform]: - height, width = resize_cfg["height"], resize_cfg["width"] - - transforms = [] - if resize_cfg.get("keep_ratio", True): - transforms.append(KeepAspectRatioResize(width=width, height=height, mode="pad")) - transforms.append( - A.PadIfNeeded( - min_height=height, - min_width=width, - border_mode=cv2.BORDER_CONSTANT, - position=A.PadIfNeeded.PositionType.TOP_LEFT, - ) - ) - else: - transforms.append(A.Resize(height, width)) - - return transforms - - def ensure_multianimal_df_format(df_predictions: pd.DataFrame) -> pd.DataFrame: """ Convert dataframe to 'multianimal' format (with an "individuals" columns index) @@ -474,26 +233,28 @@ def build_predictions_dataframe( ) -def get_runners( - pytorch_config: dict, - snapshot_path: str, +def get_inference_runners( + model_config: dict, + snapshot_path: str | Path, max_individuals: int, num_bodyparts: int, num_unique_bodyparts: int, + device: str | None = None, with_identity: bool = False, transform: A.BaseCompose | None = None, - detector_path: str | None = None, - detector_transform: A.BaseCompose | None = None, + detector_path: str | Path | None = None, + detector_transform: A.BaseCompose | None = None ) -> tuple[InferenceRunner, InferenceRunner | None]: """Builds the runners for pose estimation Args: - pytorch_config: the pytorch configuration file + model_config: the pytorch configuration file snapshot_path: the path of the snapshot from which to load the weights max_individuals: the maximum number of individuals per image num_bodyparts: the number of bodyparts predicted by the model num_unique_bodyparts: the number of unique_bodyparts predicted by the model with_identity: whether the pose model has an identity head + device: if defined, overwrites the device selection from the model config transform: the transform for pose estimation. if None, uses the transform defined in the config. detector_path: the path to the detector snapshot from which to load weights, @@ -505,15 +266,17 @@ def get_runners( a runner for pose estimation a runner for detection, if detector_path is not None """ - pose_task = Task(pytorch_config.get("method", "bu")) - device = pytorch_config["device"] + pose_task = Task(model_config["method"]) + if device is None: + device = resolve_device(model_config) + if transform is None: - transform = build_inference_transform(pytorch_config["data"]) + transform = build_transforms(model_config["data"]["inference"]) detector_runner = None if pose_task == Task.BOTTOM_UP: pose_preprocessor = build_bottom_up_preprocessor( - color_mode="RGB", transform=transform # TODO: read from Loader + color_mode=model_config["data"]["colormode"], transform=transform, ) pose_postprocessor = build_bottom_up_postprocessor( max_individuals=max_individuals, @@ -523,7 +286,7 @@ def get_runners( ) else: pose_preprocessor = build_top_down_preprocessor( - color_mode="RGB", # TODO: read from Loader + color_mode=model_config["data"]["colormode"], transform=transform, cropped_image_size=(256, 256), ) @@ -535,17 +298,19 @@ def get_runners( if detector_path is not None: if detector_transform is None: - detector_transform = build_inference_transform( - pytorch_config["data_detector"] - ) + detector_transform = build_transforms(model_config["detector"]["data"]) + + detector_config = model_config["detector"]["model"] + if "pretrained" in detector_config: + detector_config["pretrained"] = False detector_runner = build_inference_runner( task=Task.DETECT, - model=DETECTORS.build(pytorch_config["detector"]["model"]), + model=DETECTORS.build(detector_config), device=device, snapshot_path=detector_path, preprocessor=build_bottom_up_preprocessor( - color_mode="RGB", # TODO: read from Loader + color_mode=model_config["detector"]["data"], transform=detector_transform, ), postprocessor=build_detector_postprocessor( @@ -555,7 +320,7 @@ def get_runners( pose_runner = build_inference_runner( task=pose_task, - model=PoseModel.build(pytorch_config["model"]), + model=PoseModel.build(model_config["model"], no_pretrained_backbone=True), device=device, snapshot_path=snapshot_path, preprocessor=pose_preprocessor, diff --git a/deeplabcut/pose_estimation_pytorch/config/__init__.py b/deeplabcut/pose_estimation_pytorch/config/__init__.py index b650872648..89d079ac75 100644 --- a/deeplabcut/pose_estimation_pytorch/config/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/config/__init__.py @@ -8,10 +8,13 @@ # # Licensed under GNU Lesser General Public License v3.0 # -from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config +from deeplabcut.pose_estimation_pytorch.config.make_pose_config import ( + make_pytorch_pose_config, +) from deeplabcut.pose_estimation_pytorch.config.utils import ( available_models, - pretty_print_config, + pretty_print, read_config_as_dict, update_config, + write_config, ) diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml index fff5c9ad16..5ca29a598c 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml @@ -1,11 +1,17 @@ data: - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 + inference: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 + train: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 model: backbone: type: HRNet model_name: hrnet_w18 pretrained: true - only_high_res: true + interpolate_branches: false + increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 18 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml index d3392c76ab..daf3c6a80c 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml @@ -1,11 +1,17 @@ data: - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 + inference: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 + train: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 model: backbone: type: HRNet model_name: hrnet_w32 pretrained: true - only_high_res: true + interpolate_branches: false + increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 32 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml index ed52e9f927..a621526c3e 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml @@ -1,11 +1,17 @@ data: - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 + inference: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 + train: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 model: backbone: type: HRNet model_name: hrnet_w48 pretrained: true - only_high_res: true + interpolate_branches: false + increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 48 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml index 0e38912311..560ba96941 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml @@ -4,16 +4,13 @@ model: model_name: resnet101 pretrained: true backbone_output_channels: 2048 -optimizer: - type: AdamW - params: - lr: 0.001 -scheduler: - type: LRListScheduler - params: - lr_list: - - - 0.0001 - - - 0.00001 - milestones: - - 90 - - 120 \ No newline at end of file +runner: + optimizer: + type: AdamW + params: + lr: 0.001 + scheduler: + type: LRListScheduler + params: + lr_list: [ [ 1e-4 ], [ 1e-5 ] ] + milestones: [ 160, 190 ] \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml index d1d25006ca..580e6b577a 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml @@ -4,16 +4,13 @@ model: model_name: resnet50 pretrained: true backbone_output_channels: 2048 -optimizer: - type: AdamW - params: - lr: 0.001 -scheduler: - type: LRListScheduler - params: - lr_list: - - - 0.0001 - - - 0.00001 - milestones: - - 90 - - 120 \ No newline at end of file +runner: + optimizer: + type: AdamW + params: + lr: 0.001 + scheduler: + type: LRListScheduler + params: + lr_list: [ [ 1e-4 ], [ 1e-5 ] ] + milestones: [ 160, 190 ] \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml index 3a1aed5502..c762be5a4a 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -1,34 +1,42 @@ -batch_size: 1 -cfg_path: -colormode: RGB data: - covering: true - gaussian_noise: 12.75 - hist_eq: true - motion_blur: true - normalize_images: true - rotation: 30 - scale_jitter: - - 0.5 - - 1.25 -device: cuda -display_iters: 50 -epochs: 200 -optimizer: - type: AdamW - params: - lr: 0.0001 -runner: - type: PoseRunner -save_epochs: 50 -scheduler: - type: LRListScheduler - params: - lr_list: - - - 0.00001 - - - 0.000001 - milestones: - - 90 - - 120 -seed: 42 + colormode: RGB + inference: + normalize_images: true + train: + affine: + p: 0.9 + rotation: 30 + scaling: [ 0.5, 1.25 ] + translation: 40 + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true +device: auto method: bu +runner: + type: PoseTrainingRunner + key_metric: "test.mAP" + key_metric_asc: true + eval_interval: 1 + optimizer: + type: AdamW + params: + lr: 0.0001 + scheduler: + type: LRListScheduler + params: + lr_list: [ [ 1e-5 ], [ 1e-6 ] ] + milestones: [ 160, 190 ] + snapshots: + max_snapshots: 5 + save_epochs: 25 + save_optimizer_state: false +train_settings: + batch_size: 1 + dataloader_workers: 0 + dataloader_pin_memory: true + display_iters: 500 + epochs: 200 + seed: 42 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml index 8711b9e87c..0b23e6493b 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml @@ -1,22 +1,39 @@ -data_detector: - hflip: true - normalize_images: true detector: + data: + colormode: RGB + inference: + normalize_images: true + train: + hflip: true + normalize_images: true + affine: + p: 0.9 + rotation: 30 + scaling: [ 0.5, 1.25 ] + translation: 40 model: type: FasterRCNN - optimizer: - type: AdamW - params: - lr: 1e-4 - scheduler: - type: LRListScheduler - params: - milestones: [90] - lr_list: [[1e-5]] + variant: fasterrcnn_mobilenet_v3_large_fpn + pretrained: true runner: - type: DetectorRunner - max_individuals: "num_individuals" - batch_size: 1 - epochs: 250 - save_epochs: 50 - display_iters: 500 + type: DetectorTrainingRunner + eval_interval: 1 + optimizer: + type: AdamW + params: + lr: 1e-4 + scheduler: + type: LRListScheduler + params: + milestones: [ 160 ] + lr_list: [ [ 1e-5 ] ] + snapshots: + max_snapshots: 5 + save_epochs: 25 + save_optimizer_state: false + train_settings: + batch_size: 1 + dataloader_workers: 0 + dataloader_pin_memory: true + display_iters: 500 + epochs: 250 diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml index 9c06df3812..92406a3dbf 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml @@ -1,10 +1,10 @@ -with_center_keypoints: true model: backbone: type: HRNet model_name: hrnet_w18 pretrained: true - only_high_res: false + interpolate_branches: true + increased_channel_count: false backbone_output_channels: 270 heads: bodypart: @@ -43,3 +43,4 @@ model: num_blocks: 2 dilation_rate: 1 final_conv_kernel: 1 +with_center_keypoints: true \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml index 2acabf6f3f..6e5bb8cfdb 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml @@ -1,10 +1,10 @@ -with_center_keypoints: true model: backbone: type: HRNet model_name: hrnet_w32 pretrained: true - only_high_res: false + interpolate_branches: true + increased_channel_count: false backbone_output_channels: 480 heads: bodypart: @@ -43,3 +43,4 @@ model: num_blocks: 2 dilation_rate: 1 final_conv_kernel: 1 +with_center_keypoints: true \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml index f10aadf600..7ee1264f03 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml @@ -1,10 +1,10 @@ -with_center_keypoints: true model: backbone: type: HRNet model_name: hrnet_w32 pretrained: true - only_high_res: false + interpolate_branches: true + increased_channel_count: false backbone_output_channels: 720 heads: bodypart: @@ -43,3 +43,4 @@ model: num_blocks: 2 dilation_rate: 1 final_conv_kernel: 1 +with_center_keypoints: true \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py index c3a7262c39..562021be21 100644 --- a/deeplabcut/pose_estimation_pytorch/config/utils.py +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -149,7 +149,7 @@ def update_config(config: dict, updates: dict, copy_original: bool = True) -> di def get_config_folder_path() -> Path: - """Returns: the Path to the folder containing the "configs" for PyTorch DeepLabCut""" + """Returns: the Path to the folder containing the "configs" for DeepLabCut 3.0""" dlc_parent_path = Path(auxiliaryfunctions.get_deeplabcut_path()) return dlc_parent_path / "pose_estimation_pytorch" / "config" @@ -189,7 +189,27 @@ def read_config_as_dict(config_path: str | Path) -> dict: return cfg -def pretty_print_config( +def write_config(config_path: str | Path, config: dict, overwrite: bool = True) -> None: + """Writes a pose configuration file to disk + + Args: + config_path: the path where the config should be saved + config: the config to save + overwrite: whether to overwrite the file if it already exists + + Raises: + FileExistsError if overwrite=True and the file already exists + """ + if not overwrite and Path(config_path).exists(): + raise FileExistsError( + f"Cannot write to {config_path} - set overwrite=True to force" + ) + + with open(config_path, "w") as file: + YAML().dump(config, file) + + +def pretty_print( config: dict, indent: int = 0, print_fn: Callable[[str], None] | None = None, @@ -207,7 +227,7 @@ def pretty_print_config( for k, v in config.items(): if isinstance(v, dict): print_fn(f"{indent * ' '}{k}:") - pretty_print_config(v, indent + 2) + pretty_print(v, indent + 2) else: print_fn(f"{indent * ' '}{k}: {v}") diff --git a/deeplabcut/pose_estimation_pytorch/data/__init__.py b/deeplabcut/pose_estimation_pytorch/data/__init__.py index e65c83f98e..7ded1f256c 100644 --- a/deeplabcut/pose_estimation_pytorch/data/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/data/__init__.py @@ -12,3 +12,4 @@ from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader from deeplabcut.pose_estimation_pytorch.data.dataset import Dataset +from deeplabcut.pose_estimation_pytorch.data.transforms import build_transforms diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index db91174db6..af193b7cc1 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -16,6 +16,7 @@ import albumentations as A import numpy as np +import deeplabcut.pose_estimation_pytorch.config as config from deeplabcut.pose_estimation_pytorch.data.dataset import ( PoseDataset, PoseDatasetParameters, @@ -25,7 +26,7 @@ bbox_from_keypoints, map_id_to_annotations, ) -from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.utils import auxiliaryfunctions @@ -47,9 +48,23 @@ class Loader(ABC): def __init__(self, model_config_path: str | Path) -> None: self.model_config_path = Path(model_config_path) - self.model_cfg = auxiliaryfunctions.read_plainconfig(str(model_config_path)) + self.model_cfg = config.read_config_as_dict(str(model_config_path)) self._loaded_data: dict[str, dict[str, list[dict]]] = {} + @property + def model_folder(self) -> Path: + """Returns: The path of the folder containing the model data""" + return self.model_config_path.parent + + def update_model_cfg(self, updates: dict) -> None: + """Updates the model configuration + + Args: + updates: the items to update in the model configuration + """ + self.model_cfg = config.update_config(self.model_cfg, updates) + config.write_config(self.model_config_path, self.model_cfg) + @abstractmethod def load_data(self, mode: str = "train") -> dict[str, list[dict]]: """Abstract method to convert the project configuration to a standard coco format. @@ -220,7 +235,7 @@ def filter_annotations(annotations: list[dict], task: Task) -> list[dict]: for annotation in annotations: keypoints = annotation["keypoints"].reshape(-1, 3) if ( - task == Task.DETECT and + task in (Task.DETECT, Task.TOP_DOWN) and (annotation["bbox"][2] <= 0 or annotation["bbox"][3] <= 0) ): continue diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index b30d31515f..e7f14b829e 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -26,7 +26,7 @@ map_image_path_to_id, pad_to_length, ) -from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.task import Task @dataclass(frozen=True) @@ -144,7 +144,7 @@ def __getitem__(self, index: int) -> dict: bboxes, annotations_merged, ) = self.extract_keypoints_and_bboxes(anns, image.shape) - offsets = np.zeros((self.parameters.max_num_animals, 2)) + offsets = (0, 0) scales = (1, 1) if self.task == Task.TOP_DOWN: if self.parameters.cropped_image_size is None: @@ -206,9 +206,9 @@ def _prepare_final_data_dict( "image": image.transpose((2, 0, 1)), "image_id": image_id, "path": image_path, - "original_size": original_size, - "offsets": offsets, - "scales": scales, + "original_size": np.array(original_size), + "offsets": np.array(offsets), + "scales": np.array(scales), "annotations": self._prepare_final_annotation_dict( keypoints, keypoints_unique, bboxes, annotations_merged ), @@ -222,11 +222,15 @@ def _prepare_final_annotation_dict( anns: dict, ) -> dict[str, np.ndarray]: num_animals = self.parameters.max_num_animals + if self.task == Task.TOP_DOWN: + num_animals = 1 + return { - "keypoints": pad_to_length(keypoints[..., :2], num_animals, -1), - "keypoints_unique": keypoints_unique[..., :2], - "area": pad_to_length(anns["area"], num_animals, 0), - "boxes": pad_to_length(bboxes, num_animals, 0), + "keypoints": pad_to_length(keypoints[..., :2], num_animals, -1).astype(np.single), + "keypoints_unique": keypoints_unique[..., :2].astype(np.single), + "with_center_keypoints": self.parameters.with_center_keypoints, + "area": pad_to_length(anns["area"], num_animals, 0).astype(np.single), + "boxes": pad_to_length(bboxes, num_animals, 0).astype(np.single), "is_crowd": pad_to_length(anns["iscrowd"], num_animals, 0).astype(int), "labels": pad_to_length(anns["category_id"], num_animals, -1).astype(int), "individual_ids": pad_to_length( diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index d381fa4611..1a892c412f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -43,13 +43,22 @@ def __init__( """ self._project_root = Path(config).parent self._project_config = af.read_config(str(config)) + self._shuffle = shuffle + self._train_frac = self._project_config["TrainingFraction"][trainset_index] self._model_folder = af.get_model_folder( - self._project_config["TrainingFraction"][trainset_index], + self._train_frac, shuffle, self._project_config, engine=Engine.PYTORCH, modelprefix=modelprefix, ) + self._evaluation_folder = af.get_evaluation_folder( + trainFraction=self._train_frac, + shuffle=shuffle, + cfg=self._project_config, + engine=Engine.PYTORCH, + modelprefix=modelprefix, + ) super().__init__( self._project_root / self._model_folder @@ -64,10 +73,30 @@ def __init__( } @property - def df(self): + def df(self) -> pd.DataFrame: """Returns: The ground truth dataframe. Should not be modified.""" return self._df + @property + def evaluation_folder(self) -> Path: + """Returns: The path to the evaluation folder""" + return self._project_root / self._evaluation_folder + + @property + def project_path(self) -> Path: + """Returns: The path to the DeepLabCut project""" + return self._project_root + + @property + def shuffle(self) -> int: + """Returns: the shuffle being loaded""" + return self._shuffle + + @property + def train_fraction(self) -> float: + """Returns: the fraction of the dataset used for training""" + return self._train_frac + def get_dataset_parameters(self) -> PoseDatasetParameters: """Retrieves dataset parameters based on the instance's configuration. diff --git a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py index 0a4ee0ac5c..609ff00ef9 100644 --- a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py @@ -327,7 +327,7 @@ def __call__( return predictions, context updated_predictions = {} - scales, offsets = np.array(context["scales"]), np.array(context["offsets"]) + scales, offsets = context["scales"], context["offsets"] for name, outputs in predictions.items(): if name in self.keys_to_rescale: if self.mode == self.Mode.BBOX_XYWH: @@ -338,8 +338,7 @@ def __call__( rescaled[:, 3] = outputs[:, 3] * scales[1] elif self.mode == self.Mode.KEYPOINT: rescaled = outputs.copy() - rescaled[:, :, 0] = outputs[:, :, 0] * scales[0] + offsets[0] - rescaled[:, :, 1] = outputs[:, :, 1] * scales[1] + offsets[1] + rescaled[..., :2] = outputs[..., :2] * scales + offsets else: # Mode.KEYPOINT_TD if not len(outputs) == len(scales) == len(offsets): raise ValueError( @@ -353,8 +352,7 @@ def __call__( rescaled_individuals = [] for output, scale, offset in zip(outputs, scales, offsets): output_rescaled = output.copy() - output_rescaled[:, 0] = output[:, 0] * scale[0] + offset[0] - output_rescaled[:, 1] = output[:, 1] * scale[1] + offset[1] + output_rescaled[:, :2] = output[:, :2] * scale + offset rescaled_individuals.append(output_rescaled) rescaled = np.stack(rescaled_individuals) diff --git a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py index 9bf4224852..2d2cd6f593 100644 --- a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py +++ b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py @@ -326,8 +326,8 @@ def __call__( offsets.append(offset) scales.append(scale) - context["offsets"] = offsets - context["scales"] = scales + context["offsets"] = np.array(offsets) + context["scales"] = np.array(scales) # can have no bounding boxes if detector made no detections if len(images) == 0: diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 42cdd39f4b..367314ae39 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -10,18 +10,207 @@ # from __future__ import annotations +import warnings from typing import Any, Iterable, Sequence import albumentations as A import cv2 import numpy as np -import warnings from albumentations.augmentations.geometric import functional as F from numpy.typing import NDArray from scipy.spatial.distance import pdist, squareform +def build_transforms(augmentations: dict) -> A.BaseCompose: + transforms = [] + + if crop_sampling := augmentations.get("crop_sampling"): + transforms.append( + A.PadIfNeeded( + min_height=crop_sampling["height"], + min_width=crop_sampling["width"], + border_mode=cv2.BORDER_CONSTANT, + always_apply=True, + ) + ) + transforms.append( + KeypointAwareCrop( + crop_sampling["width"], + crop_sampling["height"], + crop_sampling["max_shift"], + crop_sampling["method"], + ) + ) + + if resize_aug := augmentations.get("resize", False): + transforms += build_resize_transforms(resize_aug) + + if augmentations.get("hflip"): + warnings.warn( + "Be careful! Do not train pose models with horizontal flips if you have" + " symmetric keypoints!" + ) + hflip_proba = 0.5 + if isinstance(augmentations["hflip"], float): + hflip_proba = augmentations["hflip"] + transforms.append(A.HorizontalFlip(p=hflip_proba)) + + if (affine := augmentations.get("affine")) is not None: + scaling = affine.get("scaling") + rotation = affine.get("rotation") + translation = affine.get("translation") + if rotation is not None: + rotation = (-rotation, rotation) + if translation is not None: + translation = (0, translation) + + transforms.append( + A.Affine( + scale=scaling, + rotate=rotation, + translate_px=translation, + p=affine.get("p", 0.9), + keep_ratio=True, + ) + ) + + if augmentations.get("hist_eq", False): + transforms.append(A.Equalize(p=0.5)) + if augmentations.get("motion_blur", False): + transforms.append(A.MotionBlur(p=0.5)) + if augmentations.get("covering", False): + transforms.append( + CoarseDropout( + max_holes=10, + max_height=0.05, + min_height=0.01, + max_width=0.05, + min_width=0.01, + p=0.5, + ) + ) + if augmentations.get("elastic_transform", False): + transforms.append(ElasticTransform(sigma=5, p=0.5)) + if augmentations.get("grayscale", False): + transforms.append(Grayscale(alpha=(0.5, 1.0))) + if noise := augmentations.get("gaussian_noise", False): + # TODO inherit custom gaussian transform to support per_channel = 0.5 + if not isinstance(noise, (int, float)): + noise = 0.05 * 255 + transforms.append( + A.GaussNoise( + var_limit=(0, noise ** 2), + mean=0, + per_channel=True, + # Albumentations doesn't support per_channel = 0.5 + p=0.5, + ) + ) + + if augmentations.get("auto_padding"): + transforms.append(build_auto_padding(**augmentations["auto_padding"])) + + if augmentations.get("normalize_images"): + transforms.append( + A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) + ) + + return A.Compose( + transforms, + keypoint_params=A.KeypointParams( + "xy", remove_invisible=False, label_fields=["class_labels"] + ), + bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]), + ) + + +def build_auto_padding( + min_height: int | None = None, + min_width: int | None = None, + pad_height_divisor: int | None = 1, + pad_width_divisor: int | None = 1, + position: str = "random", # TODO: Which default to set? + border_mode: str = "reflect_101", # TODO: Which default to set? + border_value: float | None = None, + border_mask_value: float | None = None, +) -> A.PadIfNeeded: + """ + Create an albumentations PadIfNeeded transform from a config + + Args: + min_height: the minimum height of the image + min_width: the minimum width of the image + pad_height_divisor: if not None, ensures height is dividable by value of this argument + pad_width_divisor: if not None, ensures width is dividable by value of this argument + position: position of the image, one of the possible PadIfNeeded + border_mode: 'constant' or 'reflect_101' (see cv2.BORDER modes) + border_value: padding value if border_mode is 'constant' + border_mask_value: padding value for mask if border_mode is 'constant' + + Raises: + ValueError: + Only one of 'min_height' and 'pad_height_divisor' parameters must be set + Only one of 'min_width' and 'pad_width_divisor' parameters must be set + + Returns: + the auto-padding transform + """ + border_modes = { + "constant": cv2.BORDER_CONSTANT, + "reflect_101": cv2.BORDER_REFLECT_101, + } + if border_mode not in border_modes: + raise ValueError( + f"Unknown border mode for auto_padding: {border_mode} " + f"(valid values are: {border_modes.keys()})" + ) + + return A.PadIfNeeded( + min_height=min_height, + min_width=min_width, + pad_height_divisor=pad_height_divisor, + pad_width_divisor=pad_width_divisor, + position=position, + border_mode=border_modes[border_mode], + value=border_value, + mask_value=border_mask_value, + ) + + +def build_resize_transforms(resize_cfg: dict) -> list[A.BasicTransform]: + height, width = resize_cfg["height"], resize_cfg["width"] + + transforms = [] + if resize_cfg.get("keep_ratio", True): + transforms.append(KeepAspectRatioResize(width=width, height=height, mode="pad")) + transforms.append( + A.PadIfNeeded( + min_height=height, + min_width=width, + border_mode=cv2.BORDER_CONSTANT, + position=A.PadIfNeeded.PositionType.TOP_LEFT, + ) + ) + else: + transforms.append(A.Resize(height, width)) + return transforms + + class KeypointAwareCrop(A.RandomCrop): + """Random crop for an image around keypoints + + Args: + width: Crop images down to this maximum width. + height: Crop images down to this maximum height. + max_shift: Maximum allowed shift of the cropping center position + as a fraction of the crop size. + crop_sampling: Crop centers sampling method. Must be either: + "uniform" (randomly over the image), + "keypoints" (randomly over the annotated keypoints), + "density" (weighing preferentially dense regions of keypoints), + "hybrid" (alternating randomly between "uniform" and "density"). + """ + def __init__( self, width: int, @@ -29,18 +218,6 @@ def __init__( max_shift: float = 0.4, crop_sampling: str = "hybrid", ): - """ - Args: - width: Crop images down to this maximum width. - height: Crop images down to this maximum height. - max_shift: Maximum allowed shift of the cropping center position - as a fraction of the crop size. - crop_sampling: Crop centers sampling method. Must be either: - "uniform" (randomly over the image), - "keypoints" (randomly over the annotated keypoints), - "density" (weighing preferentially dense regions of keypoints), - "hybrid" (alternating randomly between "uniform" and "density"). - """ super().__init__(height, width, always_apply=True) # Clamp to 40% of crop size to ensure that at least # the center keypoint remains visible after the offset is applied. @@ -74,7 +251,7 @@ def get_params_dependent_on_targets(self, params: dict[str, Any]) -> dict[str, A center = np.random.random(2) else: h, w = img.shape[:2] - kpts = np.asarray(kpts)[:, :2] + kpts = np.array([[k[0], k[1]] for k in kpts]) kpts = kpts[~np.isnan(kpts).all(axis=1)] n_kpts = kpts.shape[0] inds = np.arange(n_kpts) @@ -173,7 +350,7 @@ def get_transform_init_args_names(self): class Grayscale(A.ToGray): def __init__( self, - alpha: float | int | tuple[float] = 1.0, + alpha: float | int | tuple[float, float] = 1.0, always_apply: bool = False, p: float = 0.5, ): @@ -223,7 +400,7 @@ def __init__( self, alpha: float = 20.0, sigma: float = 5.0, # As in DLC TF - alpha_affine: float = 0.0, # Deactivate affine transformation prior to elastic deformation + alpha_affine: float = 0.0, # Deactivate affine prior to elastic deformation interpolation: int = cv2.INTER_CUBIC, # As in imgaug border_mode: int = cv2.BORDER_CONSTANT, # As in imgaug value: float | None = None, @@ -293,11 +470,11 @@ class CoarseDropout(A.CoarseDropout): def __init__( self, max_holes: int = 8, - max_height: int = 8, - max_width: int = 8, + max_height: int | float = 8, + max_width: int | float = 8, min_holes: int | None = None, - min_height: int | None = None, - min_width: int | None = None, + min_height: int | float | None = None, + min_width: int | float | None = None, fill_value: int = 0, mask_fill_value: int | None = None, always_apply: bool = False, diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index a9b4aff125..53c7a58db1 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -54,12 +54,12 @@ def bbox_from_keypoints( keypoints = np.expand_dims(keypoints, axis=0) bboxes = np.full((keypoints.shape[0], 4), np.nan) - bboxes[:, :2] = np.nanmin(keypoints[..., :2], axis=1) - margin # X1, Y1 - bboxes[:, 2:4] = np.nanmax(keypoints[..., :2], axis=1) + margin # X2, Y2 + bboxes[:, :2] = np.floor(np.nanmin(keypoints[..., :2], axis=1)) - margin # X1, Y1 + bboxes[:, 2:4] = np.ceil(np.nanmax(keypoints[..., :2], axis=1)) + margin # X2, Y2 bboxes = np.clip( bboxes, a_min=[0, 0, 0, 0], - a_max=[image_w - 1, image_h - 1, image_w - 1, image_h - 1], + a_max=[image_w, image_h, image_w, image_h], ) bboxes[..., 2] = bboxes[..., 2] - bboxes[..., 0] # to width bboxes[..., 3] = bboxes[..., 3] - bboxes[..., 1] # to height @@ -338,9 +338,9 @@ def _compute_crop_bounds( """ h, w = image_shape[:2] bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) - bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) - bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - np.spacing(0.0) - bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) + bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0] + 1), 0, None) + bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) + bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1] + 1), 0, None) squashed_bbox_mask = np.logical_or(bboxes[:, 2] <= 0, bboxes[:, 3] <= 0) return bboxes[~squashed_bbox_mask] diff --git a/deeplabcut/pose_estimation_pytorch/metrics/__init__.py b/deeplabcut/pose_estimation_pytorch/metrics/__init__.py new file mode 100644 index 0000000000..c1a595de47 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/metrics/__init__.py @@ -0,0 +1,11 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from deeplabcut.pose_estimation_pytorch.metrics.bbox import compute_bbox_metrics diff --git a/deeplabcut/pose_estimation_pytorch/metrics/bbox.py b/deeplabcut/pose_estimation_pytorch/metrics/bbox.py new file mode 100644 index 0000000000..c3edf73a60 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/metrics/bbox.py @@ -0,0 +1,145 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Bounding box metrics + +Metrics are currently computed using pycocotools, which can be installed with `pypi` +(see https://github.com/ppwwyyxx/cocoapi/tree/master). +""" +from __future__ import annotations + +import numpy as np + +try: + from pycocotools.coco import COCO + from pycocotools.cocoeval import COCOeval + with_pycocotools = True +except ModuleNotFoundError as err: + with_pycocotools = False + + +def compute_bbox_metrics( + ground_truth: dict[str, dict], + detections: dict[str, dict], +) -> dict[str, float]: + """Computes bbox mAP and mAR metrics + + Args: + ground_truth: + detections: + + Returns: + the bounding box metrics + + Raises: + ModuleNotFoundError: if ``pycocotools`` is not installed + ValueError: if there are mismatches in the keys of ground_truth and detections + """ + if not with_pycocotools: + raise ModuleNotFoundError("pycocotools not installed! can't compute bbox mAP") + + if len(detections) != len(ground_truth): + raise ValueError() + + coco = COCO() + coco.dataset["annotations"] = [] + coco.dataset["categories"] = [{"id": 1, "name": "animals", "supercategory": "obj"}] + coco.dataset["images"] = [] + predictions = [] + for idx, (img, gt) in enumerate(ground_truth.items()): + img_id = idx + 1 + coco.dataset["images"].append( + { + "id": img_id, + "file_name": img, + "width": gt["width"], + "height": gt["height"], + } + ) + for bbox in gt["bboxes"][:, :4]: + ann_id = len(coco.dataset["annotations"]) + 1 + coco.dataset["annotations"].append( + { + "id": ann_id, + "image_id": img_id, + "category_id": 1, + "area": max(1, (bbox[2] * bbox[3]).item()), + "bbox": bbox, + "iscrowd": 0, + } + ) + + for bbox, score in zip(detections[img]["bboxes"], detections[img]["scores"]): + predictions.append(np.array([img_id, *bbox, score, 1])) + + if len(predictions) == 0: + return { + "mAP@50:95": 0.0, + "mAP@50": 0.0, + "mAP@75": 0.0, + "mAR@50:95": 0.0, + "mAR@50": 0.0, + "mAR@75": 0.0, + } + + predictions = np.stack(predictions, axis=0) + coco.createIndex() + coco_det = coco.loadRes(predictions) + coco_eval = COCOeval(coco, coco_det, iouType="bbox") + coco_eval.evaluate() + coco_eval.accumulate() + return { + name: val + for name, val in [ + _get_metric(coco_eval, recall=False), + _get_metric(coco_eval, recall=False, iou_threshold=0.5), + _get_metric(coco_eval, recall=False, iou_threshold=0.75), + _get_metric(coco_eval, recall=True), + _get_metric(coco_eval, recall=True, iou_threshold=0.5), + _get_metric(coco_eval, recall=True, iou_threshold=0.75), + ] + } + + +def _get_metric( + coco_eval: COCOeval, + recall: bool = False, + iou_threshold: float | None = None, + area_rng: str = "all", + max_dets: int = 100, +) -> tuple[str, float]: + metric_name = 'mAR' if recall else 'mAP' + if iou_threshold is not None: + thresh = f"{int(100 * iou_threshold)}" + else: + low, high = coco_eval.params.iouThrs[0], coco_eval.params.iouThrs[-1] + thresh = f"{int(100 * low)}:{int(100 * high)}" + + aind = [i for i, aRng in enumerate(coco_eval.params.areaRngLbl) if aRng == area_rng] + mind = [i for i, mDet in enumerate(coco_eval.params.maxDets) if mDet == max_dets] + if recall: + s = coco_eval.eval['recall'] + if iou_threshold is not None: + t = np.where(iou_threshold == coco_eval.params.iouThrs)[0] + s = s[t] + s = s[:, :, aind, mind] + else: + s = coco_eval.eval['precision'] + if iou_threshold is not None: + t = np.where(iou_threshold == coco_eval.params.iouThrs)[0] + s = s[t] + s = s[:, :, :, aind, mind] + + if len(s[s > -1]) == 0: + mean_s = -1 + else: + mean_s = np.mean(s[s > -1]) + + return f"{metric_name}@{thresh}", mean_s diff --git a/deeplabcut/pose_estimation_pytorch/apis/scoring.py b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py similarity index 100% rename from deeplabcut/pose_estimation_pytorch/apis/scoring.py rename to deeplabcut/pose_estimation_pytorch/metrics/scoring.py diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index c4dc93a14b..bded12dfb4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -22,10 +22,11 @@ class BaseBackbone(ABC, nn.Module): """Base Backbone class for pose estimation. Attributes: + freeze_bn_weights: freeze weights of batch norm layers during training + freeze_bn_stats: freeze stats of batch norm layers during training """ def __init__(self, freeze_bn_weights: bool = True, freeze_bn_stats: bool = True): - """Initialize the BaseBackbone.""" super().__init__() self.freeze_bn_weights = freeze_bn_weights self.freeze_bn_stats = freeze_bn_stats @@ -42,7 +43,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: """ pass - def freeze_batch_norm_layers(self, weights: bool, stats: bool) -> None: + def freeze_batch_norm_layers(self) -> None: """Freezes batch norm layers Running mean + var are always given to F.batch_norm, except when the layer is @@ -50,17 +51,13 @@ def freeze_batch_norm_layers(self, weights: bool, stats: bool) -> None: https://pytorch.org/docs/stable/_modules/torch/nn/modules/batchnorm.html So to 'freeze' the running stats, the only way is to set the layer to "eval" mode. - - Args: - weights: whether to freeze the batch norm weights - stats: whether to freeze the batch norm stats """ for module in self.modules(): if isinstance(module, nn.BatchNorm2d): - if weights: + if self.freeze_bn_weights: module.weight.requires_grad = False module.bias.requires_grad = False - if stats: + if self.freeze_bn_stats: module.eval() def train(self, mode: bool = True) -> None: @@ -71,4 +68,4 @@ def train(self, mode: bool = True) -> None: """ super().train(mode) if self.freeze_bn_weights or self.freeze_bn_stats: - self.freeze_batch_norm_layers(self.freeze_bn_weights, self.freeze_bn_stats) + self.freeze_batch_norm_layers() diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index f648f1270f..ae1e7902f9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -27,6 +27,21 @@ class HRNet(BaseBackbone): This is obtained using bilinear interpolation and concatenation of all the outputs of the HRNet stages. + The model outputs 4 branches, with strides 4, 8, 16 and 32. + + Args: + model_name: Any HRNet variant available through timm (e.g., 'hrnet_w32', + 'hrnet_w48'). See timm for more options. + pretrained: If True, loads the backbone with ImageNet pretrained weights from + timm. + interpolate_branches: Needed for DEKR. Instead of returning features from the + high-resolution branch, interpolates all other branches to the same shape + and concatenates them. + increased_channel_count: As described by timm, it "allows grabbing increased + channel count features using part of the classification head" (otherwise, + the default features are returned). + kwargs: BaseBackbone kwargs + Attributes: model: the HRNet model """ @@ -35,20 +50,13 @@ def __init__( self, model_name: str = "hrnet_w32", pretrained: bool = True, - only_high_res: bool = False, + interpolate_branches: bool = False, + increased_channel_count: bool = False, **kwargs, ) -> None: - """Constructs an ImageNet pretrained HRNet from timm. - - Args: - model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48'). - pretrained: If True, loads the model with ImageNet pretrained weights. - only_high_res: Whether to only return the high resolution features - kwargs: BaseBackbone kwargs - """ super().__init__(**kwargs) - self.model = _load_hrnet(model_name, pretrained) - self.only_high_res = only_high_res + self.model = _load_hrnet(model_name, pretrained, increased_channel_count) + self.interpolate_branches = interpolate_branches def forward(self, x: torch.Tensor) -> torch.Tensor: """Forward pass through the HRNet backbone. @@ -67,7 +75,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: >>> y = backbone(x) """ y_list = self.model(x) - if self.only_high_res: + if not self.interpolate_branches: return y_list[0] x0_h, x0_w = y_list[0].size(2), y_list[0].size(3) @@ -83,15 +91,21 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x -def _load_hrnet(model_name: str, pretrained: bool) -> nn.Module: - """ - Loads a TIMM HRNet model, while setting incre_modules to None. This is necessary to - get high-resolution features; otherwise model.forward_features() returns - low-resolution maps. +def _load_hrnet( + model_name: str, + pretrained: bool, + increased_channel_count: bool, +) -> nn.Module: + """Loads a TIMM HRNet model. Args: - model_name: the name of the HRNet model to load - pretrained: whether the ImageNet pretrained weights should be loaded + model_name: Any HRNet variant available through timm (e.g., 'hrnet_w32', + 'hrnet_w48'). See timm for more options. + pretrained: If True, loads the backbone with ImageNet pretrained weights from + timm. + increased_channel_count: As described by timm, it "allows grabbing increased + channel count features using part of the classification head" (otherwise, + the default features are returned). Returns: the HRNet model @@ -101,6 +115,6 @@ def _load_hrnet(model_name: str, pretrained: bool) -> nn.Module: model_name, pretrained=pretrained, features_only=True, - feature_location="", # TODO: benchmark with "incre" + feature_location="incre" if increased_channel_count else "", out_indices=(1, 2, 3, 4), ) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index eb7f86bce5..9a51b27292 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -11,9 +11,7 @@ from __future__ import annotations import torch -import torchvision -from torchvision.models.detection import FasterRCNN_ResNet50_FPN_V2_Weights -from torchvision.models.detection.faster_rcnn import FastRCNNPredictor +import torchvision.models.detection as detection from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( DETECTORS, @@ -23,44 +21,64 @@ @DETECTORS.register_module class FasterRCNN(BaseDetector): - """ - Definition of the class object FasterRCNN. - Faster Region-based Convolutional Neural Network (R-CNN) is a popular object detection model - that builds upn the R-CNN framework. - - Ren, Shaoqing, Kaiming He, Ross Girshick, and Jian Sun. "Faster r-cnn: Towards - real-time object detection with region proposal networks." Advances in neural - information processing systems 28 (2015). - - See source: - https://github.com/pytorch/vision/blob/main/torchvision/models/detection/generalized_rcnn.py - https://github.com/pytorch/vision/blob/main/torchvision/models/detection/faster_rcnn.py - See tutorial: https://pytorch.org/tutorials/intermediate/torchvision_tutorial.html#defining-your-model - - See validation loss issue: + """A FasterRCNN detector + + Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks + Ren, Shaoqing, Kaiming He, Ross Girshick, and Jian Sun. "Faster r-cnn: Towards + real-time object detection with region proposal networks." Advances in neural + information processing systems 28 (2015). + + This class is a wrapper of the torchvision implementation of a FasterRCNN (source: + https://github.com/pytorch/vision/blob/main/torchvision/models/detection/faster_rcnn.py). + Any variant implemented in torchvision can be used through this wrapper (see + available models at https://pytorch.org/vision/stable/models.html#object-detection). + + Some of the variants (from fastest to most powerful) available: + - fasterrcnn_mobilenet_v3_large_fpn + - fasterrcnn_resnet50_fpn + - fasterrcnn_resnet50_fpn_v2 + + The torchvision implementation does not allow to get both predictions and losses + with a single forward pass. Therefore, during evaluation only bounding box metrics + (mAP, mAR) are available for the test set. See validation loss issue: - https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12 - https://stackoverflow.com/a/65347721 + + Args: + variant: The FasterRCNN variant to use (see all options at + https://pytorch.org/vision/stable/models.html#object-detection). + pretrained: Whether to load model weights pretrained on COCO + box_score_thresh: during inference, only return proposals with a classification + score greater than box_score_thresh """ def __init__( self, + variant: str = "fasterrcnn_mobilenet_v3_large_fpn", + pretrained: bool = True, box_score_thresh: float = 0.01, - ): - """ - Args: - box_score_thresh: during inference, only return proposals with a - classification score greater than box_score_thresh - """ + ) -> None: + if not variant.lower().startswith("fasterrcnn"): + raise ValueError( + "The version must start with `fasterrcnn`. See available models at " + "https://pytorch.org/vision/stable/models.html#object-detection" + ) + super().__init__() - self.model = torchvision.models.detection.fasterrcnn_resnet50_fpn_v2( - weights=FasterRCNN_ResNet50_FPN_V2_Weights.COCO_V1, - box_score_thresh=box_score_thresh, - ) + model_fn = getattr(detection, variant) + weights = None + if pretrained: + weights = "COCO_V1" + + # Load the model + self.model = model_fn(weights=weights, box_score_thresh=box_score_thresh) # Modify the base predictor to output the correct number of classes num_classes = 2 in_features = self.model.roi_heads.box_predictor.cls_score.in_features - self.model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes) + self.model.roi_heads.box_predictor = detection.faster_rcnn.FastRCNNPredictor( + in_features, num_classes + ) # See source: https://stackoverflow.com/a/65347721 self.model.eager_outputs = lambda losses, detections: (losses, detections) @@ -87,11 +105,11 @@ def get_target(self, labels: dict) -> list[dict[str, torch.Tensor]]: Args: labels: dict of annotations, must contain the keys: - area: tensor containing area information for each annotation. - labels: tensor containing class labels for each annotation. - is_crowd: tensor indicating if each annotation is a crowd (1) or not (0). - image_id: tensor containing image ids for each annotation - boxes: tensor containing bounding box information for each annotation + area: tensor containing area information for each annotation + labels: tensor containing class labels for each annotation + is_crowd: tensor indicating if each annotation is a crowd (1) or not (0) + image_id: tensor containing image ids for each annotation + boxes: tensor containing bounding box information for each annotation Returns: res: list of dictionaries, each representing target information for a single annotation. diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index b7c0010481..189a4ebf08 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -15,13 +15,13 @@ import torch import torch.nn as nn -from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES +from deeplabcut.pose_estimation_pytorch.models.backbones import BaseBackbone, BACKBONES from deeplabcut.pose_estimation_pytorch.models.criterions import ( CRITERIONS, LOSS_AGGREGATORS, ) from deeplabcut.pose_estimation_pytorch.models.heads import BaseHead, HEADS -from deeplabcut.pose_estimation_pytorch.models.necks import NECKS +from deeplabcut.pose_estimation_pytorch.models.necks import BaseNeck, NECKS from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS from deeplabcut.pose_estimation_pytorch.models.target_generators import ( TARGET_GENERATORS, @@ -38,10 +38,9 @@ class PoseModel(nn.Module): def __init__( self, cfg: dict, - backbone: torch.nn.Module, + backbone: BaseBackbone, heads: dict[str, BaseHead], - neck: torch.nn.Module = None, - stride: int = 8, + neck: BaseNeck | None = None, ) -> None: """ Args: @@ -56,7 +55,6 @@ def __init__( self.backbone = backbone self.heads = nn.ModuleDict(heads) self.neck = neck - self.stride = stride def forward(self, x: torch.Tensor) -> dict[str, dict[str, torch.Tensor]]: """ @@ -136,7 +134,19 @@ def get_predictions( } @staticmethod - def build(cfg: dict) -> "PoseModel": + def build(cfg: dict, no_pretrained_backbone: bool = False) -> "PoseModel": + """ + Args: + cfg: the configuration of the model to build + no_pretrained_backbone: does not load pretrained weights for the backbone, + even if the config asks for it (e.g., useful when loading a model for + inference, when fully trained weights will be loaded) + + Returns: + the built pose model + """ + if no_pretrained_backbone and "pretrained" in cfg["backbone"]: + cfg["backbone"]["pretrained"] = False backbone = BACKBONES.build(dict(cfg["backbone"])) neck = None @@ -168,10 +178,4 @@ def build(cfg: dict) -> "PoseModel": head_cfg["predictor"] = PREDICTORS.build(head_cfg["predictor"]) heads[name] = HEADS.build(head_cfg) - return PoseModel( - cfg=cfg, - backbone=backbone, - neck=neck, - heads=heads, - **cfg.get("pose_model", {}) - ) + return PoseModel(cfg=cfg, backbone=backbone, neck=neck, heads=heads) diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py index 5eecc5dbbc..5b3823ab6b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py @@ -8,5 +8,5 @@ # # Licensed under GNU Lesser General Public License v3.0 # -from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS +from deeplabcut.pose_estimation_pytorch.models.necks.base import BaseNeck, NECKS from deeplabcut.pose_estimation_pytorch.models.necks.transformer import Transformer diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py index 5723dcc639..bef18e3341 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py @@ -22,7 +22,7 @@ from tqdm import tqdm from deeplabcut.pose_estimation_pytorch import Loader -from deeplabcut.pose_estimation_pytorch.apis.scoring import ( +from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( get_scores, align_predicted_individuals_to_gt, ) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index 8ea5962e6a..e7198b225e 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -20,14 +20,14 @@ create_df_from_prediction, video_inference, ) -from deeplabcut.pose_estimation_pytorch.apis.utils import get_runners +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( _get_config_model_paths, _update_config, raise_warning_if_called_directly, select_device, ) -from deeplabcut.pose_estimation_pytorch.runners import Task +from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path, read_config from deeplabcut.utils.make_labeled_video import _create_labeled_video @@ -97,13 +97,13 @@ def _video_inference_superanimal( config = {**project_config, **model_config} config = _update_config(config, max_individuals, device) - pose_runner, detector_runner = get_runners( + pose_runner, detector_runner = get_inference_runners( config, snapshot_path=pose_model_path, - detector_path=detector_model_path, - num_bodyparts=len(config["bodyparts"]), max_individuals=max_individuals, + num_bodyparts=len(config["bodyparts"]), num_unique_bodyparts=0, + detector_path=detector_model_path ) pose_task = Task(config.get("method", "BU")) results = {} diff --git a/deeplabcut/pose_estimation_pytorch/runners/__init__.py b/deeplabcut/pose_estimation_pytorch/runners/__init__.py index 4d107c502a..fc6840496b 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/runners/__init__.py @@ -9,7 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # -from deeplabcut.pose_estimation_pytorch.runners.base import Runner, Task +from deeplabcut.pose_estimation_pytorch.runners.base import Runner from deeplabcut.pose_estimation_pytorch.runners.logger import LOGGER from deeplabcut.pose_estimation_pytorch.runners.inference import ( build_inference_runner, @@ -22,4 +22,4 @@ DetectorTrainingRunner, PoseTrainingRunner, TrainingRunner, -) \ No newline at end of file +) diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index 3c683f1447..a3ce833b0b 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -11,7 +11,6 @@ from __future__ import annotations from abc import ABC -from enum import Enum from pathlib import Path from typing import Generic, TypeVar @@ -22,23 +21,6 @@ ModelType = TypeVar("ModelType", bound=nn.Module) -class Task(Enum): - """A task to solve""" - - BOTTOM_UP = "BU" - DETECT = "DT" - TOP_DOWN = "TD" - - @classmethod - def _missing_(cls, value): - if isinstance(value, str): - value = value.upper() - for member in cls: - if member.value == value: - return member - return None - - class Runner(ABC, Generic[ModelType]): """Runner base class @@ -63,7 +45,7 @@ def __init__( @staticmethod def load_snapshot( - snapshot_path: str, + snapshot_path: str | Path, device: str, model: ModelType, optimizer: torch.optim.Optimizer | None = None, @@ -71,7 +53,7 @@ def load_snapshot( """ Args: snapshot_path: the path containing the model weights to load - device: the device on which to load the model + device: the device on which the model should be loaded model: the model for which the weights are loaded optimizer: if defined, the optimizer weights to load @@ -79,8 +61,8 @@ def load_snapshot( the number of epochs the model was trained for """ snapshot = torch.load(snapshot_path, map_location=device) - model.load_state_dict(snapshot["model_state_dict"]) + model.load_state_dict(snapshot["model"]) if optimizer is not None: - optimizer.load_state_dict(snapshot["optimizer_state_dict"]) + optimizer.load_state_dict(snapshot["optimizer"]) - return snapshot["epoch"] + return snapshot["metadata"]["epoch"] diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py index 89fdb5ba8e..4452aad3b6 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -11,6 +11,7 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod +from pathlib import Path from typing import Any, Generic, Iterable import numpy as np @@ -21,7 +22,8 @@ from deeplabcut.pose_estimation_pytorch.data.preprocessor import Preprocessor from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.models.model import PoseModel -from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner, Task +from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner +from deeplabcut.pose_estimation_pytorch.task import Task class InferenceRunner(Runner, Generic[ModelType], metaclass=ABCMeta): @@ -34,7 +36,7 @@ def __init__( self, model: ModelType, device: str = "cpu", - snapshot_path: str | None = None, + snapshot_path: str | Path | None = None, preprocessor: Preprocessor | None = None, postprocessor: Postprocessor | None = None, ): @@ -50,7 +52,7 @@ def __init__( self.preprocessor = preprocessor self.postprocessor = postprocessor - if self.snapshot_path is not None and len(self.snapshot_path) > 0: + if self.snapshot_path is not None and self.snapshot_path != "": self.load_snapshot(self.snapshot_path, self.device, self.model) @abstractmethod @@ -209,7 +211,7 @@ def build_inference_runner( task: Task, model: nn.Module, device: str, - snapshot_path: str, + snapshot_path: str | Path, preprocessor: Preprocessor | None = None, postprocessor: Postprocessor | None = None, ) -> InferenceRunner: diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index 2d38f98693..4a3e48beb5 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -8,12 +8,18 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + import logging from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Optional -import wandb as wb +try: + import wandb + has_wandb = True +except ImportError: + has_wandb = False import deeplabcut.pose_estimation_pytorch.registry as deeplabcut_pose_estimation_pytorch_registry from deeplabcut.pose_estimation_pytorch.models.model import PoseModel @@ -49,9 +55,6 @@ def destroy_file_logging() -> None: handlers = [h for h in root.handlers] for handler in handlers: root.removeHandler(handler) - console_logger = logging.StreamHandler() - console_logger.setLevel(logging.INFO) - root.addHandler(console_logger) class BaseLogger(ABC): @@ -88,7 +91,6 @@ class WandbLogger(BaseLogger): Attributes: run (wandb.Run): The wandb run object associated with the current experiment. - """ def __init__( @@ -96,6 +98,7 @@ def __init__( project_name: str = "deeplabcut", run_name: str = "tmp", model: PoseModel = None, + **wandb_kwargs, ) -> None: """Initialize the WandbLogger class. @@ -103,15 +106,27 @@ def __init__( project_name: The name of the wandb project. Defaults to "deeplabcut". run_name: The name of the wandb run. Defaults to "tmp". model: The model to log. Defaults to None. + wandb_kwargs: extra arguments to pass to ``wb.init`` Example: - logger = WandbLogger(project_name="my_project", run_name="exp1", model=my_model) + logger = WandbLogger(project_name="mice", run_name="exp1", model=my_model) """ - if wb.run is not None: - wb.finish() + if not has_wandb: + raise ValueError( + "Cannot use ``WandbLogger`` as wandb is not installed. Please run" + "``pip install wandb`` if you want to log to wandb" + ) + + super().__init__() + if wandb.run is not None: + wandb.finish() - self.run = wb.init(project=project_name, name=run_name) + self.run = wandb.init( + project=project_name, + name=run_name, + **wandb_kwargs, + ) if model is None: raise ValueError("Specify the model to track!") self.run.watch(model) diff --git a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py index 6e37e901a9..90a62087f8 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py +++ b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py @@ -23,7 +23,7 @@ class LRListScheduler(_LRScheduler): """ def __init__( - self, optimizer, last_epoch=-1, verbose=False, milestones=[10], lr_list=[0.001] + self, optimizer, milestones, lr_list, last_epoch=-1, verbose=False ): """Summary: Constructor of the LRListScheduler. @@ -31,13 +31,10 @@ def __init__( Args: optimizer: optimizer used for learning. - last_epoch: where to start the scheduler. Defaults to -1, starts from beginning. + milestones: number of epochs. + lr_list: learning rate list. + last_epoch: where to start the scheduler. (-1: start from beginning) verbose: prints model summary. Defaults to False. - milestones: number of epochs. Defaults to [10]. - lr_list: learning rate list. Defaults to [0.001]. - - Returns: - None Examples: input: @@ -54,9 +51,6 @@ def get_lr(self): """Summary: Given a milestones, get the corresponding learning rate. - Args: - LRListScheduler - Returns: lr: learning rate value diff --git a/deeplabcut/pose_estimation_pytorch/runners/snapshots.py b/deeplabcut/pose_estimation_pytorch/runners/snapshots.py new file mode 100644 index 0000000000..561ea670c8 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/runners/snapshots.py @@ -0,0 +1,174 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Code to handle storing models""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import torch + +from deeplabcut.pose_estimation_pytorch.task import Task + + +@dataclass(frozen=True) +class Snapshot: + best: bool + epochs: int | None + path: Path + + def uid(self) -> str: + return self.path.stem.split("-")[-1] + + +@dataclass +class TorchSnapshotManager: + """Class handling model checkpoint I/O + + Attributes: + task: The task that the model is performing. + model_folder: The path to the directory where model snapshots should be stored. + key_metric: If defined, the metric is used to save the best model. Otherwise no + best model is used. + key_metric_asc: Whether the key metric is ascending (larger values are better). + max_snapshots: The maximum number of snapshots to store for the training run. + This does not include the best model (e.g., setting max_snapshots=5 will + mean that the 5 latest models will be kept, plus the best model) + save_epochs: The number of epochs between each model save + save_optimizer_state: Whether to store the optimizer state. This makes snapshots + much heavier, but allows to resume training as if it was never stopped. + + Examples: + # Storing snapshots while training + model: nn.Module + loader = DLCLoader(...) + snapshot_manager = TorchSnapshotManager( + Task.BOTTOM_UP, + loader.model_folder, + key_metric="test.mAP", + ) + ... + for epoch in range(num_epochs): + train_epoch(model, data) + snapshot_manager.update({ + "metadata": { + "metrics": {"mAP": ...} + }, + "model": model.state_dict(), + "optimizer": optimizer.state_dict() + }) + """ + task: Task + model_folder: Path + key_metric: str | None = None + key_metric_asc: bool = True + max_snapshots: int = 5 + save_epochs: int = 25 + save_optimizer_state: bool = False + + def __post_init__(self): + assert self.max_snapshots > 0, f"max_snapshots must be a positive integer" + self._best_model_epochs = -1 + self._best_metric = None + + def update(self, epoch: int, state_dict: dict, last: bool = False) -> None: + """Saves the model state dict if the epoch is one that requires a save + + Args: + epoch: the number of epochs the model was trained for + state_dict: the state dict to store + last: whether this is the last epoch in the training run, which forces a + model save no matter the epoch number + + Returns: + the path to the saved snapshot if one + """ + metrics = state_dict["metadata"]["metrics"] + if ( + self.key_metric in metrics and + not np.isnan(metrics[self.key_metric]) and ( + self._best_metric is None or + (self.key_metric_asc and self._best_metric < metrics[self.key_metric]) or + (not self.key_metric_asc and self._best_metric > metrics[self.key_metric]) + ) + ): + print(f"Saving best snapshot at epoch={epoch}") + self._best_metric = metrics[self.key_metric] + save_path = self.snapshot_path(best=True) + parsed_state_dict = { + k: v + for k, v in state_dict.items() + if self.save_optimizer_state or k != "optimizer" + } + torch.save(parsed_state_dict, save_path) + + if not (last or epoch % self.save_epochs == 0): + return + + existing_snapshots = self.snapshots(include_best=False) + if len(existing_snapshots) >= self.max_snapshots: + num_to_delete = 1 + len(existing_snapshots) - self.max_snapshots + to_delete = existing_snapshots[:num_to_delete] + for snapshot in to_delete: + snapshot.path.unlink(missing_ok=False) + + save_path = self.snapshot_path(epoch=epoch) + parsed_state_dict = { + k: v + for k, v in state_dict.items() + if self.save_optimizer_state or k != "optimizer" + } + torch.save(parsed_state_dict, save_path) + + def best(self) -> Snapshot | None: + """Returns: the path to the best snapshot, if it exists""" + best_path = self.snapshot_path(best=True) + if not best_path.exists(): + return None + return Snapshot(best=True, epochs=None, path=best_path) + + def snapshots(self, include_best: bool = True) -> list[Snapshot]: + """ + Args: + include_best: whether to return the path to the best snapshot as well + + Returns: + The paths to snapshots for a training run, sorted by the number of epochs + they were trained for. If the best_snapshot is returned, it's the last one + in the list. + """ + pattern = r"^(" + self.task.snapshot_prefix + r"-\d+\.pt)$" + snapshots = [ + Snapshot(best=False, epochs=int(f.stem.split("-")[-1]), path=f) + for f in self.model_folder.iterdir() if re.match(pattern, f.name) + ] + snapshots.sort(key=lambda s: s.epochs) + + if include_best and (best_snapshot := self.best()) is not None: + snapshots.append(best_snapshot) + + return snapshots + + def snapshot_path(self, epoch: int | None = None, best: bool = False) -> Path: + """ + Args: + epoch: the number of epochs for which a snapshot was trained + best: whether this is the best performing model for the training run + + Returns: + the path where the model should be stored + """ + if epoch is None and not best: + raise ValueError(f"For non-best models, the epochs must be specified") + uid = "best" if best else f"{epoch:03}" + return self.model_folder / f"{self.task.snapshot_prefix}-{uid}.pt" diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index 448b9216f1..9e90791e57 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -21,10 +21,18 @@ import torch.nn as nn from torch.utils.data import DataLoader +from deeplabcut.pose_estimation_pytorch.metrics import compute_bbox_metrics +from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( + get_scores, + pair_predicted_individuals_with_gt, +) from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.models.model import PoseModel -from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner, Task +from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger +from deeplabcut.pose_estimation_pytorch.runners.schedulers import build_scheduler +from deeplabcut.pose_estimation_pytorch.runners.snapshots import TorchSnapshotManager +from deeplabcut.pose_estimation_pytorch.task import Task class TrainingRunner(Runner, Generic[ModelType], metaclass=ABCMeta): @@ -37,33 +45,33 @@ def __init__( self, model: ModelType, optimizer: torch.optim.Optimizer, + snapshot_manager: TorchSnapshotManager, device: str = "cpu", - snapshot_prefix: str = "snapshot", - snapshot_path: str | None = None, + eval_interval: int = 1, + snapshot_path: Path | None = None, scheduler: torch.optim.lr_scheduler.LRScheduler | None = None, logger: BaseLogger | None = None, - save_optimizer_state: bool = False, ): """ Args: model: the model to run actions on optimizer: the optimizer to use when fitting the model + snapshot_manager: the module to use to manage snapshots device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) - snapshot_prefix: the prefix with which to save snapshots - snapshot_path: if defined, the path of a snapshot from which to load pretrained weights - scheduler: Scheduler for adjusting the lr of the optimizer. + eval_interval: how often evaluation is run on the test set (in epochs) + snapshot_path: if defined, the path of a snapshot from which to load + pretrained weights + scheduler: scheduler for adjusting the lr of the optimizer logger: logger to monitor training (e.g WandB logger) - save_optimizer_state: whether to save the optimizer state, which allows to - restart training (warning - this makes the snapshots much heavier) """ super().__init__(model=model, device=device, snapshot_path=snapshot_path) + self.eval_interval = eval_interval self.optimizer = optimizer self.scheduler = scheduler - self.history: dict[str, list] = {"train_loss": [], "eval_loss": []} - self.snapshot_prefix = snapshot_prefix + self.snapshot_manager = snapshot_manager + self.history: dict[str, list] = dict(train_loss=[], eval_loss=[]) self.logger = logger self.starting_epoch = 0 - self.save_optimizer_state = save_optimizer_state if self.snapshot_path is not None and len(self.snapshot_path) > 0: self.starting_epoch = self.load_snapshot( @@ -73,6 +81,17 @@ def __init__( self.optimizer, ) + self._metadata = dict(epoch=self.starting_epoch, metrics=dict(), losses=dict()) + self._epoch_ground_truth = {} + self._epoch_predictions = {} + + def state_dict(self) -> dict: + return { + "metadata": self._metadata, + "model": self.model.state_dict(), + "optimizer": self.optimizer.state_dict() + } + @abstractmethod def step( self, batch: dict[str, Any], mode: str = "train" @@ -90,13 +109,20 @@ def step( A dictionary containing the different losses for the step """ + @abstractmethod + def _compute_epoch_metrics(self) -> dict[str, float]: + """Computes the metrics using the data accumulated during an epoch + + Returns: + A dictionary containing the different losses for the step + """ + raise NotImplementedError + def fit( self, train_loader: DataLoader, valid_loader: DataLoader, - model_folder: str, epochs: int, - save_epochs: int, display_iters: int, ) -> None: """Train model for the specified number of steps. @@ -105,51 +131,33 @@ def fit( train_loader: Data loader, which is an iterator over train instances. Each batch contains image tensor and heat maps tensor input samples. valid_loader: Data loader used for validation of the model. - model_folder: The folder to which logs should be written and snapshots saved epochs: The number of training epochs. - save_epochs: The epoch step at which to save models display_iters: The number of iterations between each loss print Example: runner = Runner(model, optimizer, cfg, device='cuda') runner.fit(train_loader, valid_loader, "example/models" epochs=50) """ - Path(model_folder).mkdir(exist_ok=True, parents=True) self.model.to(self.device) - - for i in range(self.starting_epoch, epochs): + for e in range(self.starting_epoch + 1, epochs + 1): + self._metadata["epoch"] = e train_loss = self._epoch( - train_loader, mode="train", step=i + 1, display_iters=display_iters + train_loader, mode="train", step=e, display_iters=display_iters ) if self.scheduler: self.scheduler.step() - logging.info( - f"Training for epoch {i + 1} done, starting eval on validation data" - ) - valid_loss = self._epoch( - valid_loader, mode="eval", step=i + 1, display_iters=display_iters - ) + lr = self.optimizer.param_groups[0]['lr'] + msg = f"Epoch {e}/{epochs} (lr={lr}), train loss {float(train_loss):.5f}" + if e % self.eval_interval == 0: + logging.info(f"Training for epoch {e} done, starting evaluation") + valid_loss = self._epoch( + valid_loader, mode="eval", step=e, display_iters=display_iters + ) + msg += f", valid loss {float(valid_loss):.5f}" - if (i + 1) % save_epochs == 0 or (i + 1) == epochs: - logging.info(f"Finished epoch {i + 1}; saving model") - save_path = f"{model_folder}/train/{self.snapshot_prefix}-{i + 1}.pt" - state = { - "model_state_dict": self.model.state_dict(), - "epoch": i + 1, - "train_loss": train_loss, - "validation_loss": valid_loss, - } - if self.save_optimizer_state: - state["optimizer_state_dict"] = self.optimizer.state_dict() - torch.save(state, save_path) - - logging.info( - f"Epoch {i + 1}/{epochs}, " - f"train loss {float(train_loss):.5f}, " - f"valid loss {float(valid_loss):.5f}, " - f'lr {self.optimizer.param_groups[0]["lr"]}' - ) + self.snapshot_manager.update(e, self.state_dict(), last=(e == epochs)) + logging.info(msg) def _epoch( self, @@ -182,13 +190,13 @@ def _epoch( raise ValueError(f"Runner mode must be train or eval, found mode={mode}.") epoch_loss = [] - metrics = defaultdict(list) + loss_metrics = defaultdict(list) for i, batch in enumerate(loader): losses_dict = self.step(batch, mode) epoch_loss.append(losses_dict["total_loss"]) for key in losses_dict.keys(): - metrics[key].append(losses_dict[key]) + loss_metrics[key].append(losses_dict[key]) if (i + 1) % display_iters == 0: logging.info( @@ -197,14 +205,31 @@ def _epoch( f"lr: {self.optimizer.param_groups[0]['lr']}" ) + perf_metrics = None + if mode == "eval": + perf_metrics = self._compute_epoch_metrics() + self._metadata["metrics"] = perf_metrics + self._epoch_predictions = {} + self._epoch_ground_truth = {} + if len(perf_metrics): + logging.info(f"Epoch {step} performance:") + for name, score in perf_metrics.items(): + logging.info(f"{name + ':': <20}{score:.3f}") + epoch_loss = np.mean(epoch_loss).item() self.history[f"{mode}_loss"].append(epoch_loss) if self.logger: - for key in metrics: - self.logger.log( - f"{mode} {key}", np.nanmean(metrics[key]).item(), step=step - ) + if perf_metrics: + for name, score in perf_metrics.items(): + if not isinstance(score, (int, float)): + score = 0.0 + self.logger.log(name, score, step=step) + + for key in loss_metrics: + name, val = f"{mode}.{key}", np.nanmean(loss_metrics[key]).item() + self._metadata["losses"][name] = val + self.logger.log(name, val, step=step) return epoch_loss @@ -212,9 +237,7 @@ def _epoch( class PoseTrainingRunner(TrainingRunner[PoseModel]): """Runner to train pose estimation models""" - def __init__( - self, model: PoseModel, optimizer: torch.optim.Optimizer, **kwargs - ): + def __init__(self, model: PoseModel, optimizer: torch.optim.Optimizer, **kwargs): """ Args: model: The neural network for solving pose estimation task. @@ -250,37 +273,121 @@ def step( if mode == "train": self.optimizer.zero_grad() - batch_inputs = batch["image"] - batch_inputs = batch_inputs.to(self.device) - head_outputs = self.model(batch_inputs) + inputs = batch["image"] + inputs = inputs.to(self.device) + outputs = self.model(inputs) - target = self.model.get_target(batch_inputs, head_outputs, batch["annotations"]) - - losses_dict = self.model.get_loss(head_outputs, target) + target = self.model.get_target(inputs, outputs, batch["annotations"]) + losses_dict = self.model.get_loss(outputs, target) if mode == "train": losses_dict["total_loss"].backward() self.optimizer.step() + predictions = { + head_name: {k: v.detach().cpu().numpy() for k, v in pred.items()} + for head_name, pred in self.model.get_predictions(inputs, outputs).items() + } + if mode == "eval": + ground_truth = batch["annotations"]["keypoints"] + if batch["annotations"]["with_center_keypoints"][0]: + ground_truth = ground_truth[..., :-1, :] + + self._update_epoch_predictions( + name="bodyparts", + paths=batch["path"], + gt_keypoints=ground_truth, + pred_keypoints=predictions["bodypart"]["poses"], + offsets=batch["offsets"], + scales=batch["scales"], + ) + if "unique_bodypart" in predictions: + self._update_epoch_predictions( + name="unique_bodyparts", + paths=batch["path"], + gt_keypoints=batch["annotations"]["keypoints_unique"], + pred_keypoints=predictions["unique_bodypart"]["poses"], + offsets=batch["offsets"], + scales=batch["scales"], + ) + return {k: v.detach().cpu().numpy() for k, v in losses_dict.items()} + def _compute_epoch_metrics(self) -> dict[str, float]: + """Computes the metrics using the data accumulated during an epoch + Returns: + A dictionary containing the different losses for the step + """ + poses = pair_predicted_individuals_with_gt( + self._epoch_predictions["bodyparts"], + self._epoch_ground_truth["bodyparts"] + ) + scores = get_scores( + poses=poses, + ground_truth=self._epoch_ground_truth["bodyparts"], + unique_bodypart_poses=self._epoch_predictions.get("unique_bodyparts"), + unique_bodypart_gt=self._epoch_ground_truth.get("unique_bodyparts"), + pcutoff=0.6, + ) + return {f"test.{metric}": value for metric, value in scores.items()} + + def _update_epoch_predictions( + self, + name: str, + paths: torch.Tensor, + gt_keypoints: torch.Tensor, + pred_keypoints: torch.Tensor, + scales: torch.Tensor, + offsets: torch.Tensor, + ) -> None: + """Updates the stored predictions with a new batch""" + epoch_gt_metric = self._epoch_ground_truth.get(name, {}) + epoch_metric = self._epoch_predictions.get(name, {}) + assert len(paths) == len(gt_keypoints) == len(pred_keypoints) + assert len(paths) == len(offsets) == len(scales) + scales = scales.detach().cpu().numpy() + offsets = offsets.detach().cpu().numpy() + + for path, gt, pred, scale, offset in zip( + paths, gt_keypoints, pred_keypoints, scales, offsets, + ): + ground_truth = gt.detach().cpu().numpy() + vis = 2 * np.all(ground_truth >= 0, axis=-1) + gt_with_vis = np.zeros((*ground_truth.shape[:-1], 3)) + gt_with_vis[..., :2] = ground_truth + gt_with_vis[..., 2] = vis + + # rescale to the full image for TD or CTD + gt_with_vis[..., :2] = (gt_with_vis[..., :2] * scale) + offset + pred = pred.copy() + pred[..., :2] = (pred[..., :2] * scale) + offset + + # for TD models, individuals are predicted separately + if path in epoch_gt_metric: + epoch_gt_metric[path] = np.concatenate( + [epoch_gt_metric[path], gt_with_vis], axis=0 + ) + epoch_metric[path] = np.concatenate( + [epoch_metric[path], pred], axis=0 + ) + else: + epoch_gt_metric[path] = gt_with_vis + epoch_metric[path] = pred + + self._epoch_ground_truth[name] = epoch_gt_metric + self._epoch_predictions[name] = epoch_metric + class DetectorTrainingRunner(TrainingRunner[BaseDetector]): """Runner to train object detection models""" - def __init__( - self, - model: BaseDetector, - optimizer: torch.optim.Optimizer, - snapshot_prefix: str = "detector-snapshot", - **kwargs, - ): + def __init__(self, model: BaseDetector, optimizer: torch.optim.Optimizer, **kwargs): """ Args: model: The detector model to train. optimizer: The optimizer to use to train the model. **kwargs: TrainingRunner kwargs """ - super().__init__(model, optimizer, snapshot_prefix=snapshot_prefix, **kwargs) + super().__init__(model, optimizer, **kwargs) def step( self, batch: dict[str, Any], mode: str = "train" @@ -308,13 +415,9 @@ def step( if mode == "train": self.optimizer.zero_grad() - else: - # Override base class - # No losses returned in train mode; - # see https://stackoverflow.com/a/65347721 - # Should be safe as BN is frozen; - # see https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12 self.model.train() + else: + self.model.eval() images = batch["image"] images = images.to(self.device) @@ -327,21 +430,87 @@ def step( if item[key] is not None: item[key] = item[key].to(self.device) - losses, _ = self.model(images, target) - losses["total_loss"] = sum(loss_part for loss_part in losses.values()) + losses, predictions = self.model(images, target) + + # losses only returned during training, not evaluation if mode == "train": + losses["total_loss"] = sum(loss_part for loss_part in losses.values()) losses["total_loss"].backward() self.optimizer.step() + losses = {k: v.detach().cpu().numpy() for k, v in losses.items()} + + elif mode == "eval": + losses["total_loss"] = np.nan + self._update_epoch_predictions( + paths=batch["path"], + sizes=batch["original_size"], + bboxes=batch["annotations"]["boxes"], + predictions=predictions, + offsets=batch["offsets"], + scales=batch["scales"], + ) - return {k: v.detach().cpu().numpy() for k, v in losses.items()} + return losses + + def _compute_epoch_metrics(self) -> dict[str, float]: + """Returns: bounding box metrics, if """ + try: + return { + f"test.{k}": v + for k, v in compute_bbox_metrics( + self._epoch_ground_truth, self._epoch_predictions + ).items() + } + except ModuleNotFoundError: + logging.info( + "Cannot compute bounding box metrics; pycocotools is not installed" + ) + + def _update_epoch_predictions( + self, + paths: torch.Tensor, + sizes: torch.Tensor, + bboxes: torch.Tensor, + predictions: list[dict[str, torch.Tensor]], + scales: torch.Tensor, + offsets: torch.Tensor, + ) -> None: + """Updates the stored predictions with a new batch""" + for img_path, img_size, img_bboxes, img_pred, scale, offset in zip( + paths, sizes, bboxes, predictions, scales, offsets + ): + scale_x, scale_y = scale + scale_factors = np.array([scale_x, scale_y, scale_x, scale_y]) + offset = np.array(offset) + + # rescale ground truth bounding boxes + gt_rescaled = img_bboxes.cpu().numpy() * scale_factors + gt_rescaled[..., :2] = gt_rescaled[..., :2] + offset + + # convert to COCO format (xywh) before rescaling + pred_rescaled = img_pred["boxes"].detach().cpu().numpy() + pred_rescaled[:, 2] -= pred_rescaled[:, 0] + pred_rescaled[:, 3] -= pred_rescaled[:, 1] + pred_rescaled[..., :4] = pred_rescaled[..., :4] * scale_factors + pred_rescaled[..., :2] = pred_rescaled[..., :2] + offset + + self._epoch_ground_truth[img_path] = { + "bboxes": gt_rescaled, + "width": img_size[1], + "height": img_size[0], + } + self._epoch_predictions[img_path] = { + "bboxes": pred_rescaled, + "scores": img_pred["scores"].detach().cpu().numpy(), + } def build_training_runner( + runner_config: dict, + model_folder: Path, task: Task, model: nn.Module, - optimizer: torch.optim.Optimizer, device: str, - scheduler: torch.optim.lr_scheduler.LRScheduler | None = None, snapshot_path: str | None = None, logger: BaseLogger | None = None, ) -> TrainingRunner: @@ -349,10 +518,10 @@ def build_training_runner( Build a runner object according to a pytorch configuration file Args: + runner_config: the configuration for the runner + model_folder: the folder where models should be saved task: the task the runner will perform model: the model to run - optimizer: the optimizer to use to train the model - scheduler: the scheduler to use to train the model device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) snapshot_path: the snapshot from which to load the weights logger: the logger to use, if any @@ -360,10 +529,24 @@ def build_training_runner( Returns: the runner that was built """ + optim_cfg = runner_config["optimizer"] + optim_cls = getattr(torch.optim, optim_cfg["type"]) + optimizer = optim_cls(params=model.parameters(), **optim_cfg["params"]) + scheduler = build_scheduler(runner_config.get("scheduler"), optimizer) kwargs = dict( model=model, optimizer=optimizer, + snapshot_manager=TorchSnapshotManager( + task=task, + model_folder=model_folder, + key_metric=runner_config.get("key_metric"), + key_metric_asc=runner_config.get("key_metric_asc"), + max_snapshots=runner_config["snapshots"]["max_snapshots"], + save_epochs=runner_config["snapshots"]["save_epochs"], + save_optimizer_state=runner_config["snapshots"]["save_optimizer_state"], + ), device=device, + eval_interval=runner_config.get("eval_interval"), snapshot_path=snapshot_path, scheduler=scheduler, logger=logger, diff --git a/deeplabcut/pose_estimation_pytorch/runners/utils.py b/deeplabcut/pose_estimation_pytorch/runners/utils.py deleted file mode 100644 index 1360d69350..0000000000 --- a/deeplabcut/pose_estimation_pytorch/runners/utils.py +++ /dev/null @@ -1,467 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# - -import glob -import os -import re -import warnings -from pathlib import Path -from typing import List, Tuple - -import deeplabcut.pose_estimation_pytorch.utils as pytorch_utils -from deeplabcut.core.engine import Engine -from deeplabcut.pose_estimation_pytorch.runners.base import Task -from deeplabcut.utils import auxiliaryfunctions - - -def verify_paths( - paths: List[str], pattern: str = r"^(.*)?snapshot-(\d+)\.pt$" -) -> List[str]: - """Verify the input list of strings if each string follows the regular expression pattern. - - Args: - paths: List of paths - pattern: Regular expression pattern for the path - - Returns: - valid_paths: List of strings from `paths` that follow the given pattern. - - Raises: - Warning: Thrown if an invalid path is in `paths`. Notifies user of each - incorrectly-formatted string found in `paths`. - - Example: - Inputs: - paths = ['proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-5.pt', - 'proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt', - 'proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-1.pt'] - pattern = r"^(.*)?snapshot-(\d+)\.pt$" - Output: - valid_paths = ['proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-1.pt', - 'proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-5.pt', - 'proj/dlc-models-torch/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt'] - """ - valid_paths = [x for x in paths if re.match(pattern, x)] - invalid_paths = [x for x in paths if x not in valid_paths] - - if len(invalid_paths) > 0: - warnings.warn("Invalid paths found and ignored:" + "\n".join(invalid_paths)) - - return valid_paths - - -def sort_paths( - paths: List[str], pattern: str = r"^(.*)?snapshot-(\d+)\.pt$" -) -> List[str]: - """Sort a list of paths following a specific regular expression pattern. - - Default pattern for each path in list: path/to/snapshot-epoch_number.pt - Paths not following this format will be ignored, not included in the - list of paths sorted, and a warning will be issued providing the list of invalid paths - - Args: - paths: List of string paths (of the snapshots) - pattern: Regular expression pattern for the file path format of model snapshots - - Returns: - sorted_paths: List of valid string paths sorted in ascending epoch number order - - Examples: - 1) Input: - paths = ["/path/to/snapshot-100.pt", - "/path/to/snapshot-10.pt", - "/path/to/snapshot-5.pt", - "/path/to/snapshot-50.pt"] - pattern = r"^(.*)?snapshot-(\d+)\.pt$" - - Output: - sorted_paths = ["/path/to/snapshot-5.pt", - "/path/to/snapshot-10.pt", - "/path/to/snapshot-50.pt", - "/path/to/snapshot-100.pt"] - - 2) Input: - paths = ["path/to/snapshot-5.pt","path/to/snapshot-1.pt"] - pattern = r"^(.*)?snapshot-(\d+)\.pt$" - - Output: - sorted_paths = ["path/to/snapshot-1.pt","path/to/snapshot-5.pt"] - - 3) Input: - paths = ["path/to/snapshots-5.pt","path/to/snapshot-1.pt"] - - Output: sorted_paths = ["path/to/snapshot-1.pt"] - Warning: "Invalid paths found and ignored: path/to/snapshots-5.pt" - - 4) Input: - paths = ["path\to\snapshot-5.pt","path\to\snapshot-1.pt"] - - Output: - sorted_paths = ["path\to\snapshot-1.pt","path\to\snapshot-5.pt"] - - 5) Input: - paths = ["path/to/snapshot-5.weights","path/to/snapshot-1.pt"] - - Output: - sorted_paths = ["path/to/snapshot-1.pt"] - Warning: "Invalid paths found and ignored: path/to/snapshots-5.weights" - """ - verified_paths = verify_paths(paths, pattern) - sorted_paths = sorted( - verified_paths, key=lambda i: int(re.match(pattern, i).group(2)) - ) - return sorted_paths - - -def get_detector_path(model_folder: str, load_epoch: int) -> str: - """Given model_folder, load_epoch number, returns the detector path (str). - - Merely calls the verify_directory function with the detector flag - - Args: - model_folder: String path to the model folder - load_epoch: snapshot epoch number for the model that you want to use - - Returns: - Path of the detector directory with the given epoch id - - Example: - Input: - model_folder = 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/' - load_epoch = 10 - - Output: - 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/detector-snapshot-10.pt' - """ - return get_verified_path(model_folder, load_epoch, mode="detector") - - -def get_dlc_scorer( - project_path: str, - test_cfg: dict, - train_fraction: float, - shuffle: int, - model_prefix: str, - train_iterations: int, -) -> Tuple[str, str]: - """Return dlc_scorer given the ff parameters: - train_faction, shuffle, model_prefix, test_cfg, and train_iterations. - - Args: - project_path: - train_fraction: fraction of the dataset assigned for training - shuffle: shuffle id - model_prefix: keep as default (included for backwards compatibility); default value is "" - test_cfg: contents of the config file in a dict - train_iterations: the iteration number of the snapshot - - Returns: - dlc_scorer: the scorer/network name for the particular set of given parameters - dlc_scorer_legacy: dlc_scorer version that starts with DeepCut instead of DLC - - Example: - Input: - train_fraction = 0.95 - shuffle = 1 - model_prefix = "" - test_cfg = dict from auxiliaryfunctions.read_cfg(configpath) - train_iterations = 10 - Output: - ('DLC_model_w32_behaviordateshuffle1_10','DeepCut_model_w32_behaviordateshuffle1_10') - - """ - model_folder = get_model_folder( - project_path, test_cfg, train_fraction, shuffle, model_prefix - ) - snapshots = get_snapshots(Path(model_folder)) - snapshot = snapshots[train_iterations] - snapshot_epochs = int(snapshot.split("-")[-1]) - - (dlc_scorer, dlc_scorer_legacy) = auxiliaryfunctions.get_scorer_name( - test_cfg, - shuffle, - train_fraction, - trainingsiterations=snapshot_epochs, - engine=Engine.PYTORCH, - modelprefix=model_prefix, - ) - - return dlc_scorer, dlc_scorer_legacy - - -def get_snapshots(model_folder: Path) -> List[str]: - """Get snapshots in a given Path - - Args: - model_folder: path containing the snapshots - - Returns: - List of snapshot paths - """ - snapshots = [ - f.stem - for f in (model_folder / "train").iterdir() - if f.name.startswith("snapshot") and f.suffix == ".pt" - ] - return sorted(snapshots, key=lambda s: int(s.split("-")[-1])) - - -def get_verified_path(directory_path: str, load_epoch: int, mode: str = "model") -> str: - """Helper function for the get_model_path and get_detector_path functions. - - Verifies the directories and returns the specific directory given the parameters: - directory_path, load_epoch, and mode ("model" for - model_path and "detector" for detector_path) - - Args: - directory_path: String path to the model folder - load_epoch: snapshot epoch number for the model that you want to use - mode: "model" for loading dlc-models; "detector" for loading detector snapshots - - Returns: - Path of the directory with the given epoch id and mode (model or detector) - - Raises: - FileNotFoundError: - a) when given diirectory does not exist - b) when the desired snapshot does not exist in the folder - c) when there are no snapshots in the model_folder - d) when there are no snapshots following the valid format in the directory - - Example: - Input: - model_folder = 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/' - load_epoch = 1 - mode = "model" - - Output: - 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt' - """ - if not os.path.exists(directory_path): - raise FileNotFoundError(f"Path {directory_path} does not exist.") - - directory_paths = [] - mode_prefix = "" - - # Assigns the proper prefix and paths given the verification mode: for either model paths or detector paths - if mode == "detector": - mode_prefix = "detector-" - directory_paths = glob.glob( - os.path.join(directory_path, "train", f"{mode_prefix}snapshot*") - ) - # else: - # directory_paths = glob.glob(f"{directory_path}/train/snapshot*") - - # If there are no snapshots inside the given directory, raise a FileNotFoundError - if len(directory_paths) == 0: - raise FileNotFoundError( - f"Path {directory_path} exists, but there are no snapshots in it. " - "Make sure that the {mode}_folder given has a filetree with files " - "of the form <{mode}_path>/{mode_prefix}snapshot*." - ) - - if load_epoch >= len(directory_paths): - raise FileNotFoundError( - f"Model {directory_path}{mode_prefix}snapshot for the given load_epoch does not exist." - "Make sure that the {mode}_folder given has a filetree with the correct model." - ) - sorted_paths = [] - if mode == "detector": - sorted_paths = sort_paths( - directory_paths, r"^(.*)?detector-snapshot-(\d+)\.pt$" - ) - else: - sorted_paths = sort_paths(directory_paths) - - if len(sorted_paths) == 0: - raise FileNotFoundError( - f"Path {directory_path} exists, but the snapshots inside it are all in an invalid format. " - "Make sure that the snapshots are named in the ff format: " - "<{mode}_path>/{mode_prefix}snapshot-epoch_no.pt" - ) - - return sorted_paths[load_epoch] - - -def get_results_filename( - evaluation_folder: str, dlc_scorer: str, dlc_scorerlegacy: str, model_path: str -) -> str: - """Returns the file path of the results given by the ff parameters: - evaluation_folder, dlc_scorer, dlc_scorerlegacy, and model_path. - - Also, checks and informs the user if the network given has already been evaluated. - - Args: - evaluation_folder: path of the evaluation folder - dlc_scorer: dlc_scorer name (str) - dlc_scorerlegacy: dlc_scorerlegacy (str); dlc_scorer name that starts with 'DeepCut' instead of 'DLC' - model_path: path of the model used - - Returns: - results_filename: file path (string) of the results - - Example: - Input: - evaluation_folder = 0.95 - dlc_scorer = 1 - dlc_scorerlegacy = "" - model_path = "proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/" - Output: - 'proj_name/evaluation-results-torch/iteration-0/behaviordate-trainset95shuffle1/DLC_dekr_w32_behaviordateshuffle1_1-snapshot-10.h5' - """ - (_, results_filename, _) = auxiliaryfunctions.check_if_not_evaluated( - evaluation_folder, dlc_scorer, dlc_scorerlegacy, os.path.basename(model_path) - ) - - return results_filename - - -def get_model_folder( - project_path: str, cfg: dict, train_fraction: float, shuffle: int, model_prefix: str -) -> str: - """Returns the model folder path given the ff parameters: - train_faction, shuffle, model_prefix, and test_cfg - - Args: - project_path: - cfg: contents of the config file in a dict - train_fraction: fraction of the dataset assigned for training - shuffle: shuffle id - model_prefix: keep as default (included for backwards compatibility); default value is "" - - Returns: - model_folder: the path of the model folder - - Example: - Input: - train_fraction = 0.95 - shuffle = 1 - model_prefix = "" - test_cfg = dict from auxiliaryfunctions.read_cfg(configpath) - Output: - 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1' - - """ - model_folder = os.path.join( - project_path, - str( - auxiliaryfunctions.get_model_folder( - train_fraction, - shuffle, - cfg, - engine=Engine.PYTORCH, - modelprefix=model_prefix, - ) - ), - ) - - if not os.path.exists(model_folder): - pytorch_utils.create_folder(model_folder) - - return model_folder - - -def get_model_path(model_folder: str, load_epoch: int) -> str: - """Given model_folder and load_epoch number, returns the model path (str). - - Merely calls the verify_directory function - - Args: - model_folder: String path to the model folder - load_epoch: snapshot epoch number for the model that you want to use - - Returns: - Path of the model directory with the given epoch id - - Example: - Input: - model_folder = 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/' - load_epoch = 10 - - Output: - 'proj_name/dlc-models/iteration-0/behaviordate-trainset95shuffle1/train/snapshot-10.pt' - """ - return get_verified_path(model_folder, load_epoch) - - -def get_evaluation_folder( - train_fraction: float, shuffle: int, model_prefix: str, test_cfg: dict -) -> str: - """Returns the evaluation folder path given the ff parameters: - train_faction, shuffle, model_prefix, and test_cfg. - - Args: - train_fraction: fraction of the dataset assigned for training - shuffle: shuffle id - model_prefix: keep as default (included for backwards compatibility); default value is "" - test_cfg: contents of the config file in a dict - - Returns: - evaluation_folder: the path of the evaluation folder - - Example: - Input: - train_fraction = 0.95 - shuffle = 1 - model_prefix = "" - test_cfg = dict from auxiliaryfunctions.read_cfg(configpath) - Output: - 'proj_name/evaluation-results-torch/iteration-0/behaviordate-trainset95shuffle1' - """ - evaluation_folder = os.path.join( - test_cfg["project_path"], - str( - auxiliaryfunctions.get_evaluation_folder( - train_fraction, - shuffle, - test_cfg, - engine=Engine.PYTORCH, - modelprefix=model_prefix, - ) - ), - ) - return evaluation_folder - - -def get_paths( - project_path: str, - train_fraction: float = 0.95, - shuffle: int = 0, - model_prefix: str = "", - cfg: dict = None, - train_iterations: int = 99, - task: Task = Task.BOTTOM_UP, -): - dlc_scorer, dlc_scorer_legacy = get_dlc_scorer( - project_path, cfg, train_fraction, shuffle, model_prefix, train_iterations - ) - evaluation_folder = get_evaluation_folder( - train_fraction, shuffle, model_prefix, cfg - ) - - model_folder = get_model_folder( - project_path, cfg, train_fraction, shuffle, model_prefix - ) - - model_path = get_model_path(model_folder, train_iterations) - - detector_path = None - if task == Task.TOP_DOWN: # always take the last detector - detector_path = get_detector_path(model_folder, -1) - - return { - "dlc_scorer": dlc_scorer, - "dlc_scorer_legacy": dlc_scorer_legacy, - "evaluation_folder": evaluation_folder, - "model_folder": model_folder, - "model_path": model_path, - "detector_path": detector_path, - } diff --git a/deeplabcut/pose_estimation_pytorch/task.py b/deeplabcut/pose_estimation_pytorch/task.py new file mode 100644 index 0000000000..944b9bc441 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/task.py @@ -0,0 +1,41 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Types of tasks that can be run by DeepLabCut pose estimation models""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +@dataclass(frozen=True) +class TaskDataMixin: + aliases: tuple[str] + snapshot_prefix: str + + +class Task(TaskDataMixin, Enum): + """A task to solve""" + + BOTTOM_UP = ("BU", "BottomUp"), "snapshot" + DETECT = ("DT", "Detect"), "snapshot-detector" + TOP_DOWN = ("TD", "TopDown"), "snapshot" + + @classmethod + def _missing_(cls, value): + if isinstance(value, str): + value = value.upper() + for member in cls: + if value in member.aliases: + return member + return None + + def __repr__(self) -> str: + return f"Task.{self.name}" diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index d8c183c9c1..c9a2242652 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -13,6 +13,7 @@ import abc import os import random +from pathlib import Path import numpy as np import torch @@ -44,30 +45,27 @@ def fix_seeds(seed: int) -> None: torch.backends.cudnn.benchmark = False -def is_seq_of(seq, expected_type, seq_type=None): - """Check whether it is a sequence of some type. - Args: - seq: The sequence to be checked. - expected_type: Expected type of sequence items. - seq_type: Expected sequence type. - Returns: - Whether the sequence is valid. - """ - if seq_type is None: - exp_seq_type = abc.Sequence - else: - assert isinstance(seq_type, type) - exp_seq_type = seq_type - if not isinstance(seq, exp_seq_type): - return False - for item in seq: - if not isinstance(item, expected_type): - return False - return True +def resolve_device(model_config: dict) -> str: + """Determines which device should be used from the model config + When the device is set to 'auto': + If an Nvidia GPU is available, selects the device as cuda:0. + Selects 'mps' if available (on macOS) and the net type is compatible. + Otherwise, returns 'cpu'. + Otherwise, simply returns the selected device -def get_pytorch_config(modelfolder): - pytorch_config_path = os.path.join(modelfolder, "train", "pytorch_config.yaml") - pytorch_cfg = read_plainconfig(pytorch_config_path) + Args: + model_config: the configuration for the pose model - return pytorch_cfg + Returns: + the device on which training should be run + """ + device = model_config["device"] + supports_mps = "resnet" in model_config.get("net_type", "resnet") + if device == "auto": + if torch.cuda.is_available(): + return "cuda:0" + elif supports_mps and torch.backends.mps.is_available(): + return "mps" + return "cpu" + return device diff --git a/docs/pytorch/user_guide.md b/docs/pytorch/user_guide.md index 8128447945..6c5ab41ab9 100644 --- a/docs/pytorch/user_guide.md +++ b/docs/pytorch/user_guide.md @@ -44,7 +44,7 @@ You can find all DLC 3.0 API methods and the parameters they can be called with | API Method | Implemented | Parameters not yet implemented | Parameters invalid for pytorch | |--------------------------------|:-----------:|-------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------| -| `train_network` | 🟢 | `max_snapshots_to_keep`, `keepdeconvweights` | `maxiters`, `saveiters`, `allow_growth`, `autotune` | +| `train_network` | 🟢 | `keepdeconvweights` | `maxiters`, `saveiters`, `allow_growth`, `autotune` | | `return_train_network_path` | 🟢 | | | | `evaluate_network` | 🟢 | `comparisonbodyparts`, `rescale`, `per_keypoint_evaluation` | | | `return_evaluate_network_data` | 🔴 | | `TFGPUinference`, `allow_growth` | diff --git a/examples/testscript_pytorch_multi_animal.py b/examples/testscript_pytorch_multi_animal.py index 86214cb26a..ced06fbd21 100644 --- a/examples/testscript_pytorch_multi_animal.py +++ b/examples/testscript_pytorch_multi_animal.py @@ -14,70 +14,98 @@ import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.compat import Engine -from utils import cleanup, create_fake_project, log_step, run +from utils import ( + cleanup, create_fake_project, log_step, run, SyntheticProjectParameters, +) def main( net_types: list[str], + params: SyntheticProjectParameters, epochs: int = 1, save_epochs: int = 1, batch_size: int = 1, + detector_batch_size: int = 1, + max_snapshots_to_keep: int = 5, device: str = "cpu", create_labeled_videos: bool = False, delete_after_test_run: bool = False, ) -> None: project_path = Path("../synthetic-data-niels-multi-animal").resolve() config_path = project_path / "config.yaml" - - create_fake_project( - path=project_path, - multianimal=True, - num_bodyparts=3, - num_frames=20, - num_individuals=2, - num_unique=4, - identity=False, - frame_shape=(128, 256), - ) + create_fake_project(path=project_path, params=params) engine = Engine.PYTORCH cfg = af.read_config(config_path) trainset_index = 0 train_frac = cfg["TrainingFraction"][trainset_index] - for net_type in net_types: - try: - run( - config_path=config_path, - train_fraction=train_frac, - trainset_index=trainset_index, - net_type=net_type, - videos=[], - device=device, - train_kwargs=dict( - display_iters=1, - epochs=epochs, - save_epochs=save_epochs, - batch_size=batch_size, - ), - engine=engine, - create_labeled_videos=create_labeled_videos, - ) - except Exception as err: - log_step(f"FAILED TO RUN {net_type}") - log_step(str(err)) - log_step("Continuing to next model") - raise err + try: + for net_type in net_types: + try: + run( + config_path=config_path, + train_fraction=train_frac, + trainset_index=trainset_index, + net_type=net_type, + videos=[], + device=device, + train_kwargs=dict( + train_settings=dict( + display_iters=50, + epochs=epochs, + batch_size=batch_size, + ), + runner=dict( + device=device, + snapshots=dict( + save_epochs=save_epochs, + max_snapshots=max_snapshots_to_keep, + ) + ), + detector=dict( + train_settings=dict( + display_iters=1, + epochs=epochs, + batch_size=detector_batch_size, + ), + runner=dict( + snapshots=dict( + save_epochs=save_epochs, + max_snapshots=max_snapshots_to_keep, + ) + ) + ), + ), + engine=engine, + create_labeled_videos=create_labeled_videos, + ) + except Exception as err: + log_step(f"FAILED TO RUN {net_type}") + log_step(str(err)) + log_step("Continuing to next model") + raise err - if delete_after_test_run: - cleanup(project_path) + finally: + if delete_after_test_run: + cleanup(project_path) if __name__ == "__main__": main( - net_types=["resnet_50", "dekr_w18"], # , "hrnet_w18", "hrnet_w32"], + net_types=["top_down_resnet_50", "resnet_50", "dekr_w18"], + params=SyntheticProjectParameters( + multianimal=True, + num_bodyparts=2, + num_individuals=3, + num_unique=0, + num_frames=10, + frame_shape=(128, 256), + ), batch_size=8, - epochs=1, + detector_batch_size=2, + epochs=3, save_epochs=1, + max_snapshots_to_keep=2, device="cpu", # "cpu", "cuda:0", "mps" create_labeled_videos=False, delete_after_test_run=True, diff --git a/examples/testscript_pytorch_single_animal.py b/examples/testscript_pytorch_single_animal.py index d691b3fe59..ba93c1b6c3 100644 --- a/examples/testscript_pytorch_single_animal.py +++ b/examples/testscript_pytorch_single_animal.py @@ -4,7 +4,14 @@ import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.compat import Engine -from utils import cleanup, copy_project_for_test, create_fake_project, log_step, run +from utils import ( + cleanup, + copy_project_for_test, + create_fake_project, + log_step, + run, + SyntheticProjectParameters, +) def main( @@ -12,8 +19,12 @@ def main( net_types: list[str], epochs: int = 1, save_epochs: int = 1, + max_snapshots_to_keep: int = 5, batch_size: int = 1, device: str = "cpu", + synthetic_data_params: SyntheticProjectParameters = SyntheticProjectParameters( + multianimal=False, num_bodyparts=6, + ), create_labeled_videos: bool = False, delete_after_test_run: bool = False, ) -> None: @@ -21,13 +32,7 @@ def main( if synthetic_data: project_path = Path("../synthetic-data-niels-single-animal").resolve() videos = [] - create_fake_project( - path=project_path, - multianimal=False, - num_bodyparts=6, - num_frames=20, - frame_shape=(128, 256), - ) + create_fake_project(path=project_path, params=synthetic_data_params) else: project_path = copy_project_for_test() @@ -37,42 +42,61 @@ def main( cfg = af.read_config(config_path) trainset_index = 0 train_frac = cfg["TrainingFraction"][trainset_index] - for net_type in net_types: - try: - run( - config_path=config_path, - train_fraction=train_frac, - trainset_index=trainset_index, - net_type=net_type, - videos=videos, - device=device, - train_kwargs=dict( - display_iters=1, - epochs=epochs, - save_epochs=save_epochs, - batch_size=batch_size, - ), - engine=engine, - create_labeled_videos=create_labeled_videos, - ) - except Exception as err: - log_step(f"FAILED TO RUN {net_type}") - log_step(str(err)) - log_step("Continuing to next model") - raise err + try: + for net_type in net_types: + try: + run( + config_path=config_path, + train_fraction=train_frac, + trainset_index=trainset_index, + net_type=net_type, + videos=videos, + device=device, + train_kwargs=dict( + train_settings=dict( + display_iters=50, + epochs=epochs, + batch_size=batch_size, + ), + runner=dict( + device=device, + snapshots=dict( + save_epochs=save_epochs, + max_snapshots=max_snapshots_to_keep, + ) + ) + ), + engine=engine, + create_labeled_videos=create_labeled_videos, + ) - if delete_after_test_run: - cleanup(project_path) + except Exception as err: + log_step(f"FAILED TO RUN {net_type}") + log_step(str(err)) + log_step("Continuing to next model") + raise err + finally: + if delete_after_test_run: + cleanup(project_path) if __name__ == "__main__": main( synthetic_data=True, - net_types=["resnet_50", "hrnet_w18", "hrnet_w32"], + net_types=["resnet_50", "hrnet_w18", "hrnet_w32", "hrnet_w48"], batch_size=8, - epochs=1, + epochs=3, save_epochs=1, + max_snapshots_to_keep=2, device="cpu", # "cpu", "cuda:0", "mps" + synthetic_data_params=SyntheticProjectParameters( + multianimal=False, + num_bodyparts=4, + num_individuals=1, + num_unique=0, + num_frames=20, + frame_shape=(128, 256), + ), create_labeled_videos=False, delete_after_test_run=True, ) diff --git a/examples/utils.py b/examples/utils.py index 85d6cbc0cf..0f1cb29247 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -8,9 +8,12 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + import shutil import string import time +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -34,6 +37,26 @@ def cleanup(test_path: Path) -> None: shutil.rmtree(test_path) +@dataclass(frozen=True) +class SyntheticProjectParameters: + multianimal: bool + num_bodyparts: int + num_frames: int = 10 + num_individuals: int = 1 + num_unique: int = 0 + identity: bool = False + frame_shape: tuple[int, int] = (480, 640) + + def bodyparts(self) -> list[str]: + return [i for i in string.ascii_lowercase[:self.num_bodyparts]] + + def unique(self) -> list[str]: + return [f"unique_{i}" for i in string.ascii_lowercase[:self.num_unique]] + + def individuals(self) -> list[str]: + return [f"animal_{i}" for i in range(self.num_individuals)] + + def sample_pose_random( gen: np.random.Generator, num_individuals: int, @@ -104,36 +127,31 @@ def sample_pose_from_center( def gen_fake_data( scorer: str, video_name: str, - individuals: list[str], - bodyparts: list[str], - unique: list[str], - num_frames: int, - img_h: int, - img_w: int, + params: SyntheticProjectParameters, ) -> pd.DataFrame: kpt_entries = ["x", "y"] col_names = ["scorer", "individuals", "bodyparts", "coords"] col_values = [] - for i in individuals: - for b in bodyparts: + for i in params.individuals(): + for b in params.bodyparts(): col_values += [(scorer, i, b, entry) for entry in kpt_entries] - for unique_bpt in unique: + for unique_bpt in params.unique(): col_values += [(scorer, "single", unique_bpt, entry) for entry in kpt_entries] index_data = [] pose_data = [] gen = np.random.default_rng(seed=0) - for frame_index in range(num_frames): + for frame_index in range(params.num_frames): index_data.append(("labeled-data", video_name, f"img{frame_index:04}.png")) pose_data.append( sample_pose_from_center( gen, - num_individuals=len(individuals), - num_bodyparts=len(bodyparts), - num_unique=len(unique), - img_h=img_h, - img_w=img_w, + num_individuals=params.num_individuals, + num_bodyparts=params.num_bodyparts, + num_unique=params.num_unique, + img_h=params.frame_shape[0], + img_w=params.frame_shape[1], radius=25, ) ) @@ -141,17 +159,18 @@ def gen_fake_data( pose = np.stack(pose_data) pose[0, :] = np.nan # add missing row in a frame - for idv in range(len(individuals)): - idv_start, idv_end = 2 * len(bodyparts) * idv, 2 * len(bodyparts) * (idv + 1) - if num_frames > idv + 1: + for idv in range(params.num_individuals): + idv_start = 2 * params.num_bodyparts * idv + idv_end = 2 * params.num_bodyparts * (idv + 1) + if params.num_frames > idv + 1: pose[idv + 1, idv_start:idv_end] = np.nan - for bpt in range(len(bodyparts)): - frame_idx = 1 + len(individuals) + bpt - idv_idx = bpt % len(individuals) - offset = 2 * len(bodyparts) * idv_idx + for bpt in range(params.num_bodyparts): + frame_idx = 1 + params.num_individuals + bpt + idv_idx = bpt % params.num_individuals + offset = 2 * params.num_bodyparts * idv_idx bpt_start, bpt_end = 2 * bpt + offset, 2 * (bpt + 1) + offset - if num_frames + 1 > frame_idx: + if params.num_frames + 1 > frame_idx: pose[frame_idx, bpt_start:bpt_end] = np.nan return pd.DataFrame( @@ -164,16 +183,13 @@ def gen_fake_data( def gen_fake_image( project_root: Path, row: pd.Series, - individuals: list[str], - bodyparts: list[str], - unique: list[str], - img_h: int, - img_w: int, + params: SyntheticProjectParameters, radius: int = 5, ): - image_array = np.zeros((img_h, img_w, 3), dtype=np.uint8) - for i, idv in enumerate(individuals): - r = int(255 * (i + 1) / len(individuals)) + img_h, img_w = params.frame_shape + image_array = np.zeros((*params.frame_shape, 3), dtype=np.uint8) + for i, idv in enumerate(params.individuals()): + r = int(255 * (i + 1) / params.num_individuals) if "individuals" in row.index.names: idv_data = row.droplevel("scorer").loc[idv] else: @@ -187,8 +203,8 @@ def gen_fake_image( ymin, ymax = max(0, y - radius), min(img_h - 1, y + radius) image_array[ymin:ymax, xmin:xmax, 0] = r - for j, bpt in enumerate(bodyparts): - g = int(255 * (j + 1) / len(bodyparts)) + for j, bpt in enumerate(params.bodyparts()): + g = int(255 * (j + 1) / params.num_bodyparts) bpt_data = idv_data.loc[bpt] if np.all(~pd.isnull(bpt_data)): @@ -198,46 +214,35 @@ def gen_fake_image( image_array[ymin:ymax, xmin:xmax, 0] = r image_array[ymin:ymax, xmin:xmax, 1] = g - if len(unique) > 0: + if params.num_unique > 0: unique_data = row.droplevel("scorer").loc["single"] - for i, unique_bpt in enumerate(unique): + for i, unique_bpt in enumerate(params.unique()): bpt_data = unique_data.loc[unique_bpt] if np.all(~pd.isnull(bpt_data)): x, y = int(bpt_data.x), int(bpt_data.y) xmin, xmax = max(0, x - radius), min(img_w - 1, x + radius) ymin, ymax = max(0, y - radius), min(img_h - 1, y + radius) - image_array[ymin:ymax, xmin:xmax, 2] = int(255 * (i + 1) / len(unique)) + image_array[ymin:ymax, xmin:xmax, 2] = int( + 255 * (i + 1) / params.num_unique + ) img = Image.fromarray(image_array) img.save(project_root / Path(*row.name)) -def create_fake_project( - path: Path, - multianimal: bool, - num_bodyparts: int, - num_frames: int = 10, - num_individuals: int = 1, - num_unique: int = 0, - identity: bool = False, - frame_shape: tuple[int, int] = (480, 640), -) -> None: +def create_fake_project(path: Path, params: SyntheticProjectParameters) -> None: if path.exists(): raise ValueError(f"Cannot create a fake project at an existing path") scorer = "synthetic" video_name = "cat" - bodyparts = [i for i in string.ascii_lowercase[:num_bodyparts]] - unique = [f"unique_{i}" for i in string.ascii_lowercase[:num_unique]] - individuals = [f"animal_{i}" for i in range(num_individuals)] - path.mkdir(parents=True, exist_ok=False) config = { "Task": "synthetic", "scorer": scorer, "date": "Nov11", - "multianimalproject": multianimal, - "identity": identity, + "multianimalproject": params.multianimal, + "identity": params.identity, "project_path": str(path / "config.yaml"), "TrainingFraction": [0.8], "iteration": 0, @@ -249,22 +254,22 @@ def create_fake_project( "pcutoff": 0.6, "video_sets": { str(path / "videos" / video_name): { - "crop": (0, frame_shape[1], 0, frame_shape[0]), + "crop": (0, params.frame_shape[1], 0, params.frame_shape[0]), }, }, "start": 0, "stop": 1, "numframes2pick": 10, } - if not multianimal: - config["bodyparts"] = bodyparts - assert num_individuals == 1 - assert num_unique == 0 + if not params.multianimal: + config["bodyparts"] = params.bodyparts() + assert params.num_individuals == 1 + assert params.num_unique == 0 else: config["bodyparts"] = "MULTI!" - config["multianimalbodyparts"] = bodyparts - config["uniquebodyparts"] = unique - config["individuals"] = individuals + config["multianimalbodyparts"] = params.bodyparts() + config["uniquebodyparts"] = params.unique() + config["individuals"] = params.individuals() af.write_config(str(path / "config.yaml"), config) image_dir = path / "labeled-data" / video_name @@ -273,32 +278,19 @@ def create_fake_project( df = gen_fake_data( scorer=scorer, video_name=video_name, - individuals=individuals, - bodyparts=bodyparts, - unique=unique, - num_frames=num_frames, - img_h=frame_shape[0], - img_w=frame_shape[1], + params=params, ) print("SYNTHETIC DATA:") print(df) print("\n") - if not multianimal: + if not params.multianimal: df.columns = df.columns.droplevel("individuals") df.to_hdf(image_dir / f"CollectedData_{scorer}.h5", key="df_with_missing") df.to_csv(image_dir / f"CollectedData_{scorer}.csv") - for idx in range(num_frames): - gen_fake_image( - path, - df.iloc[idx], - individuals=individuals, - bodyparts=bodyparts, - unique=unique, - img_h=frame_shape[0], - img_w=frame_shape[1], - ) + for idx in range(params.num_frames): + gen_fake_image(path, df.iloc[idx], params=params, radius=5) def copy_project_for_test() -> Path: @@ -353,6 +345,7 @@ def run( Shuffles=[shuffle_index], trainingsetindex=trainset_index, device=device, + plotting=True, ) times.append(time.time()) log_step(f"Evaluation time: {times[-1] - times[-2]} seconds") @@ -383,10 +376,12 @@ def run( if __name__ == "__main__": create_fake_project( path=Path("../synthetic-data-niels"), - multianimal=True, - num_individuals=2, - num_bodyparts=3, - num_unique=2, - identity=False, - num_frames=20, + params=SyntheticProjectParameters( + multianimal=True, + num_bodyparts=4, + num_individuals=3, + num_unique=1, + num_frames=50, + frame_shape=(128, 256), + ), ) diff --git a/tests/generate_training_dataset/test_trainset_metadata.py b/tests/generate_training_dataset/test_trainset_metadata.py index 51c1b71af7..e6c150cdf4 100644 --- a/tests/generate_training_dataset/test_trainset_metadata.py +++ b/tests/generate_training_dataset/test_trainset_metadata.py @@ -20,10 +20,10 @@ from deeplabcut.utils import auxiliaryfunctions SHUFFLE_DATA = [ - {"train_fraction": 0.5, "split": 1, "engine": "torch"}, - {"train_fraction": 0.5, "split": 1, "engine": "tf"}, - {"train_fraction": 0.6, "split": 2, "engine": "torch"}, - {"train_fraction": 0.6, "split": 3, "engine": "torch"}, + {"name": "pJun17-t50s1", "index": 1, "train_fraction": 0.5, "split": 1, "engine": "torch"}, + {"name": "pJun17-t50s2", "index": 2, "train_fraction": 0.5, "split": 1, "engine": "tf"}, + {"name": "pJun17-t60s1", "index": 1, "train_fraction": 0.6, "split": 2, "engine": "torch"}, + {"name": "pJun17-t60s2", "index": 2, "train_fraction": 0.6, "split": 3, "engine": "torch"}, ] SPLITS_DATA = { 1: {"train": [0, 1], "test": [2, 3]}, @@ -42,10 +42,10 @@ DEL_SPLIT2 = metadata.DataSplit(train_indices=(1, 2), test_indices=(3,)) SHUFFLES = { - 1: metadata.ShuffleMetadata(0.5, 1, Engine.PYTORCH, BASE_SPLIT), - 2: metadata.ShuffleMetadata(0.5, 2, Engine.PYTORCH, ADD_SPLIT), - 3: metadata.ShuffleMetadata(0.5, 3, Engine.TF, BASE_SPLIT), - 4: metadata.ShuffleMetadata(0.5, 4, Engine.PYTORCH, DEL_SPLIT), + 1: metadata.ShuffleMetadata("pJun17-t50s1", 0.5, 1, Engine.PYTORCH, BASE_SPLIT), + 2: metadata.ShuffleMetadata("pJun17-t50s2", 0.5, 2, Engine.PYTORCH, ADD_SPLIT), + 3: metadata.ShuffleMetadata("pJun17-t50s3", 0.5, 3, Engine.TF, BASE_SPLIT), + 4: metadata.ShuffleMetadata("pJun17-t50s4", 0.5, 4, Engine.PYTORCH, DEL_SPLIT), } @@ -53,11 +53,11 @@ "data", [ { - "shuffles": {idx + 1: SHUFFLE_DATA[idx] for idx in [0, 1, 2]}, + "shuffles": {SHUFFLE_DATA[idx]["name"]: SHUFFLE_DATA[idx] for idx in [0, 1, 2]}, "splits": {idx: SPLITS_DATA[idx] for idx in [1, 2]}, }, { - "shuffles": {idx + 1: SHUFFLE_DATA[idx] for idx in [0]}, + "shuffles": {SHUFFLE_DATA[idx]["name"]: SHUFFLE_DATA[idx] for idx in [0]}, "splits": {idx: SPLITS_DATA[idx] for idx in [1, 2]}, }, ], @@ -76,10 +76,12 @@ def test_load_metadata(tmpdir, data: dict, load_splits: bool): print(data["splits"]) print() - for idx, s in data["shuffles"].items(): + for name, s in data["shuffles"].items(): split = data["splits"][s["split"]] train, test = split["train"], split["test"] - _create_doc_data(cfg, trainset_dir, s["train_fraction"], idx, train, test) + _create_doc_data( + cfg, trainset_dir, s["train_fraction"], s["index"], train, test + ) trainset_meta = metadata.TrainingDatasetMetadata.load( str(cfg_path), load_splits=load_splits @@ -90,7 +92,7 @@ def test_load_metadata(tmpdir, data: dict, load_splits: bool): assert len(data["shuffles"]) == len(trainset_meta.shuffles) for s in trainset_meta.shuffles: - shuffle_in = data["shuffles"][s.index] + shuffle_in = data["shuffles"][s.name] split_idx = data["splits"][shuffle_in["split"]] assert s.train_fraction == shuffle_in["train_fraction"] assert s.engine == Engine(shuffle_in["engine"]) @@ -107,26 +109,47 @@ def test_load_metadata(tmpdir, data: dict, load_splits: bool): @pytest.mark.parametrize("data", [ { + "task": "ch", + "date": "Aug1", "shuffles": (SHUFFLES[1], ), "expected": { - "shuffles": {1: {"train_fraction": 0.5, "split": 1, "engine": "pytorch"}}, + "shuffles": { + SHUFFLES[1].name: { + "index": 1, "train_fraction": 0.5, "split": 1, "engine": "pytorch" + } + }, } }, { + "task": "t", + "date": "Jan1", "shuffles": (SHUFFLES[1], SHUFFLES[3]), "expected": { "shuffles": { - 1: {"train_fraction": 0.5, "split": 1, "engine": "pytorch"}, - 3: {"train_fraction": 0.5, "split": 1, "engine": "tensorflow"}, + SHUFFLES[1].name: { + "index": 1, "train_fraction": 0.5, "split": 1, "engine": "pytorch" + }, + SHUFFLES[3].name: { + "index": 3, + "train_fraction": 0.5, + "split": 1, + "engine": "tensorflow", + }, }, } }, { + "task": "t", + "date": "Jan1", "shuffles": (SHUFFLES[1], SHUFFLES[2]), "expected": { "shuffles": { - 1: {"train_fraction": 0.5, "split": 1, "engine": "pytorch"}, - 2: {"train_fraction": 0.5, "split": 2, "engine": "pytorch"}, + SHUFFLES[1].name: { + "index": 1, "train_fraction": 0.5, "split": 1, "engine": "pytorch" + }, + SHUFFLES[2].name: { + "index": 2, "train_fraction": 0.5, "split": 2, "engine": "pytorch" + }, }, }, }, @@ -134,9 +157,18 @@ def test_load_metadata(tmpdir, data: dict, load_splits: bool): "shuffles": (SHUFFLES[1], SHUFFLES[2], SHUFFLES[3]), "expected": { "shuffles": { - 1: {"train_fraction": 0.5, "split": 1, "engine": "pytorch"}, - 2: {"train_fraction": 0.5, "split": 2, "engine": "pytorch"}, - 3: {"train_fraction": 0.5, "split": 1, "engine": "tensorflow"}, + SHUFFLES[1].name: { + "index": 1, "train_fraction": 0.5, "split": 1, "engine": "pytorch" + }, + SHUFFLES[2].name: { + "index": 2, "train_fraction": 0.5, "split": 2, "engine": "pytorch" + }, + SHUFFLES[3].name: { + "index": 3, + "train_fraction": 0.5, + "split": 1, + "engine": "tensorflow", + }, }, }, }, @@ -269,7 +301,9 @@ def test_data_split_equality(split1, split2, equal): @pytest.mark.parametrize("split_idx", [1, 4, 20, 1000]) @pytest.mark.parametrize("indices", [(2, 1), (10, 1), (1, 21, 20), (1, 2, 4, 3)]) @pytest.mark.parametrize("sorted_indices", [(1, 2), (10, 12), (3, 4), (1, 1000, 1200)]) -def test_data_split_requires_sorted(split_idx, indices, sorted_indices): +def test_data_split_requires_sorted( + split_idx: int, indices: tuple[int], sorted_indices: tuple[int] +): """Tests that equality functions as expected for DataSplits""" with pytest.raises(RuntimeError): metadata.DataSplit( @@ -297,8 +331,8 @@ def test_data_split_requires_sorted(split_idx, indices, sorted_indices): ), ( {"idx": 1, "train": [1], "test": [2], "train_fraction": 0.5}, - {"idx": 4, "train": [1, 3], "test": [2], "train_fraction": 0.66}, {"idx": 5, "train": [1, 2, 3], "test": [4, 5], "train_fraction": 0.6}, + {"idx": 4, "train": [1, 3], "test": [2], "train_fraction": 0.66}, ), ]) def test_create_metadata_from_shuffles(tmpdir, shuffles): diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py index 63f1207a16..4497aa0102 100644 --- a/tests/pose_estimation_pytorch/config/test_make_pose_config.py +++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py @@ -12,7 +12,7 @@ import pytest from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config -from deeplabcut.pose_estimation_pytorch.config.utils import pretty_print_config, update_config +from deeplabcut.pose_estimation_pytorch.config.utils import pretty_print, update_config @pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]]) @@ -34,7 +34,7 @@ def test_make_single_animal_config(bodyparts: list[str], net_type: str): "pytorch_config.yaml", net_type=net_type, ) - pretty_print_config(pytorch_pose_config) + pretty_print(pytorch_pose_config) # check heads are there assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() @@ -85,7 +85,7 @@ def test_backbone_plus_paf_config( "pytorch_config.yaml", net_type=net_type, ) - pretty_print_config(pytorch_pose_config) + pretty_print(pytorch_pose_config) graph = [ [i, j] @@ -158,7 +158,7 @@ def test_make_dekr_config( "pytorch_config.yaml", net_type=net_type, ) - pretty_print_config(pytorch_pose_config) + pretty_print(pytorch_pose_config) # check heads are there assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() @@ -218,7 +218,7 @@ def test_make_dlcrnet_config( "pytorch_config.yaml", net_type=net_type, ) - pretty_print_config(pytorch_pose_config) + pretty_print(pytorch_pose_config) paf_graph = [ [i, j] for i in range(len(bodyparts)) @@ -293,10 +293,10 @@ def test_make_tokenpose_config( "pytorch_config.yaml", net_type=net_type, ) - pretty_print_config(pytorch_pose_config) + pretty_print(pytorch_pose_config) # check detector is there assert "detector" in pytorch_pose_config - assert "data_detector" in pytorch_pose_config + assert "data" in pytorch_pose_config["detector"] @pytest.mark.parametrize("data", [ @@ -324,7 +324,7 @@ def test_make_tokenpose_config( def test_update_config(data: dict): result = update_config(config=data["config"], updates=data["updates"]) print("\nResult") - pretty_print_config(result) + pretty_print(result) assert result == data["expected_result"] diff --git a/tests/pose_estimation_pytorch/data/test_preprocessor.py b/tests/pose_estimation_pytorch/data/test_preprocessor.py index cf96a15a4c..8c32edbefc 100644 --- a/tests/pose_estimation_pytorch/data/test_preprocessor.py +++ b/tests/pose_estimation_pytorch/data/test_preprocessor.py @@ -13,7 +13,7 @@ import numpy as np import pytest -from deeplabcut.pose_estimation_pytorch.apis.utils import build_resize_transforms +from deeplabcut.pose_estimation_pytorch.data.transforms import build_resize_transforms from deeplabcut.pose_estimation_pytorch.data.preprocessor import AugmentImage diff --git a/tests/pose_estimation_pytorch/apis/test_scoring.py b/tests/pose_estimation_pytorch/metrics/test_scoring.py similarity index 99% rename from tests/pose_estimation_pytorch/apis/test_scoring.py rename to tests/pose_estimation_pytorch/metrics/test_scoring.py index bc67e22547..649e15bf48 100644 --- a/tests/pose_estimation_pytorch/apis/test_scoring.py +++ b/tests/pose_estimation_pytorch/metrics/test_scoring.py @@ -12,7 +12,7 @@ import numpy as np import pytest -import deeplabcut.pose_estimation_pytorch.apis.scoring as scoring +import deeplabcut.pose_estimation_pytorch.metrics.scoring as scoring @pytest.mark.parametrize( diff --git a/tests/pose_estimation_pytorch/other/test_api_utils.py b/tests/pose_estimation_pytorch/other/test_api_utils.py index a5b97f5ba6..bd2cf125bf 100644 --- a/tests/pose_estimation_pytorch/other/test_api_utils.py +++ b/tests/pose_estimation_pytorch/other/test_api_utils.py @@ -13,7 +13,7 @@ import numpy as np import pytest -import deeplabcut.pose_estimation_pytorch.apis.utils as dlc_api_utils +import deeplabcut.pose_estimation_pytorch.data.transforms as transforms transform_dicts = [ {"auto_padding": {"pad_height_divisor": 64, "pad_width_divisor": 27}}, @@ -55,13 +55,7 @@ def _get_random_params(transform_idx): [_get_random_params(i) for i in range(4)], ) def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals): - transform_bbox_aug = dlc_api_utils.build_transforms( - transform_dict, augment_bbox=True - ) - transform_inference = dlc_api_utils.build_inference_transform( - transform_dict, augment_bbox=True - ) - + transform_bbox_aug = transforms.build_transforms(transform_dict) w, h = size_image for i in range(10): test_image = np.random.randint(0, 255, (h, w, 3), dtype=np.uint8) @@ -71,9 +65,9 @@ def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals keypoints = np.random.randint(0, min(w, h), (num_keypoints, 2)) with pytest.raises(Exception): - transformed = transform_inference(image=test_image) - transformed = transform_inference(image=test_image, bboxes=bboxes.copy()) - transformed = transform_inference( + transformed = transform_bbox_aug(image=test_image) + transformed = transform_bbox_aug(image=test_image, bboxes=bboxes.copy()) + transformed = transform_bbox_aug( image=test_image, keypoints=keypoints.copy(), bboxes=bboxes.copy() ) @@ -84,19 +78,8 @@ def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals bbox_labels=np.arange(num_animals), class_labels=[0 for _ in range(len(keypoints))] ) - transformed_inference = transform_inference( - image=test_image, - keypoints=[], - bboxes=bboxes.copy(), - bbox_labels=np.arange(num_animals), - class_labels=[0 for _ in range(len(keypoints))] - ) if "resize" in transform_dict.keys(): - assert transformed_inference["image"].shape[:2] == ( - transform_dict["resize"]["height"], - transform_dict["resize"]["width"], - ) assert transformed_with_bbox["image"].shape[:2] == ( transform_dict["resize"]["height"], transform_dict["resize"]["width"], @@ -107,9 +90,7 @@ def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals transform_dict["auto_padding"]["pad_height_divisor"], transform_dict["auto_padding"]["pad_width_divisor"], ) - assert transformed_inference["image"].shape[0] % modh == 0 assert transformed_with_bbox["image"].shape[0] % modh == 0 - assert transformed_inference["image"].shape[1] % modw == 0 assert transformed_with_bbox["image"].shape[1] % modw == 0 assert len(transformed_with_bbox["keypoints"]) == len(keypoints) diff --git a/tests/pose_estimation_pytorch/other/test_dataset.py b/tests/pose_estimation_pytorch/other/test_dataset.py index 789958500a..e19b407515 100644 --- a/tests/pose_estimation_pytorch/other/test_dataset.py +++ b/tests/pose_estimation_pytorch/other/test_dataset.py @@ -75,6 +75,7 @@ def _get_openfield_dataset(transform=None): anno_key_set = { "keypoints", "keypoints_unique", + "with_center_keypoints", "area", "boxes", "is_crowd", diff --git a/tests/pose_estimation_pytorch/runners/test_task.py b/tests/pose_estimation_pytorch/runners/test_task.py index 38fdb416af..7a0a9730c2 100644 --- a/tests/pose_estimation_pytorch/runners/test_task.py +++ b/tests/pose_estimation_pytorch/runners/test_task.py @@ -11,7 +11,7 @@ """ Tests the Task enum """ import pytest -from deeplabcut.pose_estimation_pytorch.runners.base import Task +from deeplabcut.pose_estimation_pytorch.task import Task @pytest.mark.parametrize( From 19f1568a9bba57ba5a9314478a964776a96de6dc Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Mon, 18 Mar 2024 16:14:59 +0100 Subject: [PATCH 073/293] fix bbox sanitization (#172) --- deeplabcut/pose_estimation_pytorch/data/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 53c7a58db1..065ac7be47 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -338,9 +338,9 @@ def _compute_crop_bounds( """ h, w = image_shape[:2] bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) - bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0] + 1), 0, None) + bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1] + 1), 0, None) + bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) squashed_bbox_mask = np.logical_or(bboxes[:, 2] <= 0, bboxes[:, 3] <= 0) return bboxes[~squashed_bbox_mask] From 3d22a4326670345252ad1268d3a5fbc2deed5f16 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 19 Mar 2024 15:06:31 +0100 Subject: [PATCH 074/293] documentation updates (#173) * improved augmenter selection * improved pytorch config docs * added documentation for missing parameters * remove unused param from DLCParams * bug fix: add a shuffle --- deeplabcut/compat.py | 20 + .../generate_training_dataset/__init__.py | 5 + .../generate_training_dataset/metadata.py | 18 +- .../trainingsetmanipulation.py | 32 +- deeplabcut/gui/dlc_params.py | 2 - .../gui/tabs/create_training_dataset.py | 6 +- docs/pytorch/pytorch_config.md | 420 ++++++++++++++++-- 7 files changed, 441 insertions(+), 62 deletions(-) diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index 5083e872db..3afe8fc7fe 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -37,6 +37,26 @@ def get_project_engine(cfg: dict) -> Engine: return DEFAULT_ENGINE +def get_available_aug_methods(engine: Engine) -> tuple[str, ...]: + """ + Args: + engine: the engine for which augmentation methods should be returned + + Returns: + the augmentations available for the given engine, where the first one is the + default method to use + + Raises: + RuntimeError: if no augmentations methods are defined for the given engine + """ + if engine == Engine.TF: + return "imgaug", "default", "deterministic", "scalecrop", "tensorpack" + elif engine == Engine.PYTORCH: + return ("albumentations", ) + + raise RuntimeError(f"Unknown augmentation for engine: {engine}") + + def train_network( config: str, shuffle: int = 1, diff --git a/deeplabcut/generate_training_dataset/__init__.py b/deeplabcut/generate_training_dataset/__init__.py index 05b0092d49..60eac17c1d 100644 --- a/deeplabcut/generate_training_dataset/__init__.py +++ b/deeplabcut/generate_training_dataset/__init__.py @@ -13,3 +13,8 @@ from deeplabcut.generate_training_dataset.frame_extraction import * from deeplabcut.generate_training_dataset.trainingsetmanipulation import * from deeplabcut.generate_training_dataset.multiple_individuals_trainingsetmanipulation import * +from deeplabcut.generate_training_dataset.metadata import ( + DataSplit, + ShuffleMetadata, + TrainingDatasetMetadata, +) diff --git a/deeplabcut/generate_training_dataset/metadata.py b/deeplabcut/generate_training_dataset/metadata.py index 5619322ae7..88de3f7b9e 100644 --- a/deeplabcut/generate_training_dataset/metadata.py +++ b/deeplabcut/generate_training_dataset/metadata.py @@ -161,18 +161,26 @@ def add( ValueError: if overwrite=False and there is already a shuffle with the given index in the metadata file. """ - existing_indices = [s.index for s in self.shuffles] + existing_indices = [ + s.index for s in self.shuffles if s.train_fraction == shuffle.train_fraction + ] if shuffle.index in existing_indices: if not overwrite: raise RuntimeError( f"Cannot add {shuffle} to the meta: a shuffle with index " - f"{shuffle.index} already exists: {self.shuffles}." + f"{shuffle.index} and train_fraction {shuffle.train_fraction} " + f"already exists: {self.shuffles}." ) - shuffles = [s for s in self.shuffles if s.index != shuffle.index] + [shuffle] + existing_shuffles = [ + s + for s in self.shuffles + if (s.index != shuffle.index or s.train_fraction != shuffle.train_fraction) + ] + shuffles = existing_shuffles + [shuffle] return TrainingDatasetMetadata( project_config=self.project_config, - shuffles=tuple(sorted(shuffles, key=lambda s: s.index)), + shuffles=tuple(sorted(shuffles, key=lambda s: (s.train_fraction, s.index))), ) def get(self, trainset_index: int = 0, index: int = 0) -> ShuffleMetadata: @@ -359,7 +367,7 @@ def update_metadata( index in the metadata file. """ prefix = cfg["Task"] + cfg["date"] - metadata = TrainingDatasetMetadata.load(cfg) + metadata = TrainingDatasetMetadata.load(cfg, load_splits=True) new_shuffle = ShuffleMetadata( name=f"{prefix}-trainset{int(100 * train_fraction)}shuffle{shuffle}", train_fraction=train_fraction, diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 4892e58657..d6882ee834 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -930,20 +930,30 @@ def create_training_dataset( else: raise ValueError("Invalid network type:", net_type) + augmenters = compat.get_available_aug_methods(engine) + default_augmenter = augmenters[0] if augmenter_type is None: - augmenter_type = cfg.get("default_augmenter", "imgaug") + augmenter_type = cfg.get("default_augmenter", default_augmenter) + if augmenter_type is None: # this could be in config.yaml for old projects! # updating variable if null/None! #backwardscompatability - auxiliaryfunctions.edit_config(config, {"default_augmenter": "imgaug"}) - augmenter_type = "imgaug" - elif augmenter_type not in [ - "default", - "scalecrop", - "imgaug", - "tensorpack", - "deterministic", - ]: - raise ValueError("Invalid augmenter type:", augmenter_type) + augmenter_type = default_augmenter + auxiliaryfunctions.edit_config( + config, {"default_augmenter": augmenter_type} + ) + elif augmenter_type not in augmenters: + # as the default augmenter might not be available for the given engine + augmenter_type = default_augmenter + logging.info( + f"Default augmenter {augmenter_type} not available for engine " + f"{engine}: using {default_augmenter} instead" + ) + + if augmenter_type not in augmenters: + raise ValueError( + f"Invalid augmenter type: {augmenter_type} (available: for " + f"engine={engine}: {augmenters})" + ) if posecfg_template: if net_type != prior_cfg["net_type"]: diff --git a/deeplabcut/gui/dlc_params.py b/deeplabcut/gui/dlc_params.py index 2a267aac67..8a5bb3a253 100644 --- a/deeplabcut/gui/dlc_params.py +++ b/deeplabcut/gui/dlc_params.py @@ -30,8 +30,6 @@ class DLCParams: "efficientnet-b6", ] - IMAGE_AUGMENTERS = ["default", "tensorpack", "imgaug"] - FRAME_EXTRACTION_ALGORITHMS = ["kmeans", "uniform"] OUTLIER_EXTRACTION_ALGORITHMS = ["jump", "fitting", "uncertain", "manual"] diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 73166738bb..01096bdd2d 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -15,6 +15,7 @@ from PySide6.QtGui import QIcon import deeplabcut +import deeplabcut.compat as compat from deeplabcut.core.engine import Engine from deeplabcut.generate_training_dataset import get_existing_shuffle_indices from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine @@ -57,9 +58,10 @@ def _generate_layout_attributes(self, layout): # Augmentation method augmentation_label = QtWidgets.QLabel("Augmentation method") + methods = compat.get_available_aug_methods(self.root.project_engine) self.aug_choice = QtWidgets.QComboBox() - self.aug_choice.addItems(DLCParams.IMAGE_AUGMENTERS) - self.aug_choice.setCurrentText("imgaug") + self.aug_choice.addItems(methods) + self.aug_choice.setCurrentText(methods[0]) self.aug_choice.currentTextChanged.connect(self.log_augmentation_choice) # Neural Network diff --git a/docs/pytorch/pytorch_config.md b/docs/pytorch/pytorch_config.md index 4ba40e91d6..926f3da139 100644 --- a/docs/pytorch/pytorch_config.md +++ b/docs/pytorch/pytorch_config.md @@ -1,66 +1,402 @@ (dlc3-pytorch-config)= # The PyTorch Configuration file -The `pytorch_config.yaml` file specifies everything about how you'll train a model for a -project. +The `pytorch_config.yaml` file specifies the configuration for your PyTorch pose models, +from the model architecture to which optimizer will be used for training, how training +runs will be logged, the data augmentation that will be applied and which metric should +be used to save the "best" model snapshot. -You can create "base" configurations using `deeplabcut.create_training_set` or -`deeplabcut.create_training_model_comparison`. +You can create default configurations for a shuffle using +`deeplabcut.create_training_set` or `deeplabcut.create_training_model_comparison`. This +will create a `pytorch_config.yaml` file for your selected net type. The basic structure +of the file is as follows: -## Model Architectures +```yaml +data: # which data augmentations will be used + ... +device: auto # the default device to use for training and evaluation +metadata: # metadata regarding the project (bodyparts, individuals, paths, ...) - filled automatically + ... +method: bu # indicates how pose predictions are made (bottom-up (`bu`) or top-down (`td`)) +model: # configures the model architecture (which backbone, heads, ...) + ... +net_type: resnet_50 # the type of neural net configured in the file +runner: # configuring the runner used for training + ... +train_settings: # generic training settings, such as batch size and maximum number of epochs + ... +logger: # optional: the configuration for a logger if you want one +``` + +## Sections -### Bottom-Up +### Singleton Parameters -There are a few keys which define your model architecture and how it will be trained: +There are a few singleton parameters defined in the PyTorch configuration file: -- `batch_size`: the batch size to train with (inference always runs with batch size 1) -- `data`: the data augmentations you'll apply to images when training (during inference, -only normalization, rescaling and auto-padding are kept - this is subject to change) -- `device`: the device to use to train (such as `cpu`, `cuda`, `cuda:0`, ...) -- `epochs`: the number of epochs to train for -- `method`: either `bu` for bottom-up or `td` for `top-down` -- `model`: the architecture of the model you'll train (including loss criterions) -- `with_center_keypoints`: for models (like DEKR) that need to know the position of the -center of individuals -- `optimizer`: the optimizer to use (the name of any Torch optimizer works) -- `save_epochs`: the number of epochs between each model snapshot -- `scheduler`: learning rate scheduler +- `device`: The device to use for training/inference. The default is `auto`, which sets +the device to `cuda` if an NVIDIA GPU is available, and `cpu` otherwise. For users +running models on macOS with an M1/M2/M3 chip, this is set to `mps` for certain models +(not all operations are currently supported on Apple GPUs - so some models like HRNets +need to be trained on CPU, while others like ResNets can take advantage of the GPU). +- `method`: Either `bu` for bottom-up models, or `td` for top-down models. +- `net_type`: The type of pose model configured by the file (e.g. `resnet_50`). -### Top-Down +### Data -Top-down models are configured in a very similar way to bottom up ones. The keys used to -configure the pose model are exactly the same, and a few additional keys are added to -configure how you want your detector to be trained: +The data section configures: -- `detector`: the configuration for the detector, with `model`, `optimizer`, -`scheduler`, `batch_size`, `epochs` and `save_epochs` options -- `data_detector`: the data augmentations to use to train the detector (in the same -format as the ones for the pose model) +- `colormode`: in which format images are given to the model (e.g., `RGB`, `BGR`) +- `inference`: which transformations should be applied to images when running evaluation +or inference +- `train`: which transformations should be applied to images when training + +The default configuration for a pose model is: + +```yaml +data: + colormode: RGB # should never be changed + inference: # the augmentations to apply to images during inference + normalize_images: true # this should always be set to true + train: + affine: + p: 0.9 + rotation: 30 + scaling: [ 0.5, 1.25 ] + translation: 40 + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true # this should always be set to true +``` -In this case, the augmentations described in `data` are applied to the pose model. +The following transformations are available for the `train` and `inference` keys. -## Data Augmentations +**Affine**: Applies an affine (rotation, translation, scaling) transformation to the +images. -### Resizing Images +```yaml +affine: + p: 0.9 # float: the probability that an affine transform is applied + rotation: 30 # int: the maximum angle of rotation applied to the image (in degrees) + scaling: [ 0.5, 1.25 ] # [float, float]: the (min, max) scale to use to resize images + translation: 40 # int: the maximum translation to apply to images (in pixels) +``` -Resizes the images while preserving the aspect ratio (first resizes to the maximum -possible size, then adds padding for the missing pixels). +**Auto-Padding**: Pads the image to some desired shape (e.g., a minimum height/width or +such that the height/width are divisible by a given number). Some backbones (such as +HRNets) require the height and width of images to be multiples of 32. Setting up +auto-padding with `pad_height_divisor: 32` and `pad_width_divisor: 32` ensures that is +the case. Note that **not all keys need to be set**! The values shown are the default +values. Only one of 'min_height' and 'pad_height_divisor' parameters must be set, and +only one of 'min_width' and 'pad_width_divisor' parameters must be set. ```yaml -data: - resize: - width: 1300 - height: 800 - keep_ratio: true +auto_padding: + min_height: null # int: if not None, the minimum height of the image + min_width: null # int: if not None, the minimum width of the image + pad_height_divisor: null # int: if not None, ensures image height is dividable by value of this argument. + pad_width_divisor: null # int: if not None, ensures image width is dividable by value of this argument. + position: random # str: position of the image, one of 'A.PadIfNeeded.Position' + border_mode: reflect_101 # str: 'constant' or 'reflect_101' (see cv2.BORDER modes) + border_value: null # str: padding value if border_mode is 'constant' + border_mask_value: null # str: padding value for mask if border_mode is 'constant' ``` -### Logging Results +**Covering**: Based on Albumentations's [CoarseDropout]( +https://albumentations.ai/docs/api_reference/augmentations/dropout/coarse_dropout/#albumentations.augmentations.dropout.coarse_dropout) +augmentation, this "cuts" holes out of the image. As defined in +[Improved Regularization of Convolutional Neural Networks with Cutout]( +https://arxiv.org/abs/1708.04552). + +```yaml +covering: true # bool: if true, applies a coarse dropout with probability 50% +``` + +**Gaussian Noise**: Applies gaussian noise to the input image. Can either be a float +(the standard deviation of the noise) or simply a boolean (the standard deviation of +the noise will be set as 12.75). + +```yaml +gaussian_noise: 12.75 # bool, float: add gaussian noise +``` + + +**Horizontal Flips**: This flips the image horizontally around the y-axis. As the +resulting image is mirrored, it does not preserve labels (the left hand would become the +right hand, and vice-versa). This augmentation should not be used for pose models if you +have symmetric keypoints! However, it is safe to use it to train detectors. + +```yaml +# if float > 0, the probability of applying a horizontal flip +# if true, applies a horizontal flip with probability 0.5 +hflip: true # bool, float +``` -Logs results to Weights & Biases. +**Histogram Equalization**: Applies histogram equalization with probability 50%. + +```yaml +hist_eq: true # bool: whether to apply histogram equalization +``` + +**Motion Blur**: Applies motion blur to the image with probability 50%. + +```yaml +motion_blur: true # bool: whether to apply motion blur +``` + +**Normalization** + +```yaml +normalize_images: true # normalizes images +``` + +**Resizing**: Resizes the images while preserving the aspect ratio (first resizes to the +maximum possible size, then adds padding for the missing pixels). + +```yaml +resize: + height: 640 # int: the height to which all images will be resized + width: 480 # int: the width to which all images will be resized + keep_ratio: true # bool: the +``` + +**Resizing - Crop Sampling**: An alternative way to ensure all images have the same size +is through cropping. The `crop_sampling` crops images down to a maximum width and +height, with options to sample the center of the crop according to the positions of the +keypoints. + +```yaml +crop_sampling: + height: 400 # int: the height of the crop + width: 400 # int: the height of the crop + max_shift: 0.4 # float: maximum allowed shift of the cropping center position as a fraction of the crop size. + method: hybrid # str: how to sample the center of crops (one of 'uniform', 'keypoints', 'density', 'hybrid') +``` + +### Model + +The model configuration is further split into a `backbone`, optionally a `neck` and a +number of heads. + +Changing the `model` configuration should only be done by expert users, and in rare +occasions. When updating a model configuration (e.g. adding more deconvolution layers +to a `HeatmapHead`) must be done in a way where the model configuration still makes +sense for the project (e.g. the number of heatmaps output needs to match the number of +bodyparts in the project). + +An example model configuration for a single-animal HRNet would look something like: + +```yaml +model: + backbone: # the BaseBackbone used by the pose model + type: HRNet + model_name: hrnet_w18 # creates an HRNet W18 backbone + backbone_output_channels: 18 + heads: # configures how the different heads will make predictions + bodypart: # configures how pose will be predicted for bodyparts + type: HeatmapHead + predictor: # the BasePredictor used to make predictions from the head's outputs + type: HeatmapPredictor + ... + target_generator: # the BaseTargetGenerator used to create targets for the head + type: HeatmapPlateauGenerator + ... + criterion: # the loss criterion used for the head + ... + ... # head-specific options, such as `heatmap_config` or `locref_config` for a "HeatmapHead" +``` + +The `backbone`, `neck` and `head` configurations are loaded using the +`deeplabcut.pose_estimation_pytorch.models.backbones.base.BACKBONES`, +`deeplabcut.pose_estimation_pytorch.models.necks.base.NECKS` and +`deeplabcut.pose_estimation_pytorch.models.heads.base.HEADS` registries. You specify +which type to load with the `type` parameter. Any argument for the head can then be used +in the configuration. + +So to use an `HRNet` backbone for your model (as defined in +`deeplabcut.pose_estimation_pytorch.models.backbones.hrnet.HRNet`), you could set: + +```yaml +model: + backbone: + type: HRNet + model_name: hrnet_w32 # creates an HRNet W32 + pretrained: true # the backbone weights for training will be loaded from TIMM (pre-trained on ImageNet) + interpolate_branches: false # don't interpolate & concatenate channels from all branches + increased_channel_count: true # use the incre_modules defined in the TIMM HRNet + backbone_output_channels: 128 # number of channels output by the backbone +``` + +### Runner + +The runner contains elements relating to the runner to use (including the optimizer and +learning rate schedulers). Unless you're experienced with machine learning and training +models **it is not recommended to change the optimizer or scheduler**. + +```yaml +runner: + type: PoseTrainingRunner # should not need to modify this + key_metric: "test.mAP" # the metric to use to select the "best snapshot" + key_metric_asc: true # whether "larger=better" for the key_metric + eval_interval: 1 # the interval between each passes through the evaluation dataset + optimizer: # the optimizer to use to train the model + ... + scheduler: # optional: a learning rate scheduler + ... + snapshots: # parameters for the TorchSnapshotManager + max_snapshots: 5 # the maximum number of snapshots to save (the "best" model does not count as one of them) + save_epochs: 25 # the interval between each snapshot save + save_optimizer_state: false # whether the optimizer state should be saved with the model snapshots (very little reason to set to true) +``` + +**Key metric**: Every time the model is evaluated on the test set, metrics are computed +to see how the model is performing. The key metric is used to determine whether the +current model is the "best" so far. If it is, the snapshot is saved as `...-best.pt`. +For pose models, metrics to choose from would be `test.mAP` (with `key_metric_asc: true` +) or `test.rmse` (with `key_metric_asc: false`). + +**Evaluation interval**: Evaluation slows down training (it takes time to go through all +the evaluation images, make predictions and log results!). So instead of evaluating +after every epoch, you could decide to evaluate every 5 epochs (by setting +`eval_interval: 5`). While this means you get coarser information about how your model +is training, it can speed up training on large datasets. + +**Optimizer**: Any optimizer inheriting `torch.optim.Optimizer`. More information about +optimizers can be found in [PyTorch's documentation]( +https://pytorch.org/docs/stable/optim.html). Examples: + +```yaml + # SGD with initial learning rate 1e-3 and momentum 0.9 + # see https://pytorch.org/docs/stable/generated/torch.optim.SGD.html + optimizer: + type: SGD + params: + lr: 1e-3 + momentum: 0.9 + + # AdamW optimizer with initial learning rate 1e-4 + # see https://pytorch.org/docs/stable/generated/torch.optim.AdamW.html + optimizer: + type: AdamW + params: + lr: 1e-4 +``` + +**Scheduler**: YYou can use [any scheduler]( +https://pytorch.org/docs/stable/optim.html#how-to-adjust-learning-rate) defined in +`torch.optim.lr_scheduler`, where the arguments given are arguments of the scheduler. +The default scheduler is an LRListScheduler, which changes the learning rates at each +milestone to the corresponding values in `lr_list`. Examples: + +```yaml + # reduce to 1e-5 at epoch 160 and 1e-6 at epoch 190 + scheduler: + type: LRListScheduler + params: + lr_list: [ [ 1e-5 ], [ 1e-6 ] ] + milestones: [ 160, 190 ] + + # Decays the learning rate of each parameter group by gamma every step_size epochs + # see https://pytorch.org/docs/stable/generated/torch.optim.lr_scheduler.StepLR.html + scheduler: + type: StepLR + params: + step_size: 100 + gamma: 0.1 +``` + +### Train Settings + +The `train_settings` key contains parameters that are specific to training. For more +information about the `dataloader_workers` and `dataloader_pin_memory` settings, see +[Single- and Multi-process Data Loading]( +https://pytorch.org/docs/stable/data.html#single-and-multi-process-data-loading) +and [memory pinning](https://pytorch.org/docs/stable/data.html#memory-pinning). Setting +`dataloader_workers: 0` uses single-process data loading, while setting it to 1 or more +will use multi-process data loading. You should always keep +`dataloader_pin_memory: true` when training on an NVIDIA GPU. + +```yaml +train_settings: + batch_size: 1 # the batch size used for training + dataloader_workers: 0 # the number of workers for the PyTorch Dataloader + dataloader_pin_memory: true # pin DataLoader memory + display_iters: 500 # the number of iterations (steps) between each log print + epochs: 200 # the maximum number of epochs for which to train the model + seed: 42 # the random seed to set for reproducibility +``` + +### Logger + +To log results to [Weights and Biases](https://wandb.ai/site), you can add a +`WandbLogger`. Just make sure you're logged in to your `wandb` account before starting +your training run (with `wandb login` from your shell). For more information, see their +[tutorials](https://docs.wandb.ai/tutorials) and their documentation for +[`wandb.init`](https://docs.wandb.ai/ref/python/init). + +Logging to `wandb` is a good way to keep track of what you've run, including performance +and metrics. ```yaml logger: - type: 'WandbLogger' - project_name: 'my-dlc3-project' - run_name: 'dekr-w32-shuffle0' + type: WandbLogger + project_name: my-dlc3-project # the name of the project where the run should be logged + run_name: dekr-w32-shuffle0 # the name of the run to log + ... # any other argument you can pass to `wandb.init`, such as `tags: ["dekr", "split=0"]` ``` + +## Training Top-Down Models + +Top-down models are split into two main elements: a detector (localizing individuals in +the images) and a pose model predicting each individual's pose (once localization is +done, obtaining pose is just like getting pose in a single-animal model!). + +The "pose" part of the model configuration is exactly the same as for single-animal or +bottom-up models (configured through the `data`, `model`, `runner` and `train_settings` +). The detector is configured through a detector key, at the top-level of the +configuration. + +### Detector Configuration + +When training top-down models, you also need to configure how the detector will be +trained. All information relating to the detector is placed under the `detector` key. + +```yaml +detector: + data: # which data augmentations will be used, same options as for the pose model + colormode: RGB + inference: # default inference configuration for detectors + normalize_images: true + train: # default train configuration for detectors + affine: + p: 0.9 + rotation: 30 + scaling: [ 0.5, 1.25 ] + translation: 40 + hflip: true + normalize_images: true + model: # the detector to train + type: FasterRCNN + variant: fasterrcnn_mobilenet_v3_large_fpn + pretrained: true + runner: # detector train runner configuration (same keys as for the pose model) + type: DetectorTrainingRunner + ... + train_settings: # detector train settings (same keys as for the pose model) + ... +``` + +Currently, the only detector available is a `FasterRCNN`. However, multiple variants are +available (you can view the different variants on [torchvision's object detection page]( +https://pytorch.org/vision/stable/models.html#object-detection)). It's recommended to +use the fastest detector that brings enough performance. The recommended variants +are the following (from fastest to most powerful, taken from torchvision's +documentation): + +| name | Box MAP (larger = more powerful) | Params (larger = more powerful) | GFLOPS (larger = slower) | +|-----------------------------------|----------------------------------:|--------------------------------:|----------------------------:| +| fasterrcnn_mobilenet_v3_large_fpn | 32.8 | 19.4M | 4.49 | +| fasterrcnn_resnet50_fpn | 37 | 41.8M | 134.38 | +| fasterrcnn_resnet50_fpn_v2 | 46.7 | 43.7M | 280.37 | From e7d53d8eb8f2728daa9f5006b34361afb242d2f7 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Thu, 4 Apr 2024 15:07:24 +0200 Subject: [PATCH 075/293] Prune PAF graph when exceedingly large (#180) --- .../config/make_pose_config.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index 04ce22b666..e774d54936 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -22,7 +22,7 @@ replace_default_values, update_config, ) -from deeplabcut.utils import auxiliaryfunctions +from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal def make_pytorch_pose_config( @@ -83,7 +83,7 @@ def make_pytorch_pose_config( net_type=net_type, num_individuals=len(individuals), bodyparts=bodyparts, - paf_parameters=_get_paf_parameters(project_config, bodyparts) + paf_parameters=_get_paf_parameters(project_config, bodyparts), ) else: model_cfg = create_backbone_with_heatmap_model( @@ -223,7 +223,9 @@ def create_backbone_with_heatmap_model( bodypart_head_name = "head_topdown.yaml" # add a bodypart head - bodypart_head_config = read_config_as_dict(configs_dir / "base" / bodypart_head_name) + bodypart_head_config = read_config_as_dict( + configs_dir / "base" / bodypart_head_name + ) model_config["model"]["heads"] = { "bodypart": replace_default_values( bodypart_head_config, @@ -343,9 +345,7 @@ def add_identity_head( the configuration with an added identity head """ config = copy.deepcopy(config) - id_head_config = read_config_as_dict( - configs_dir / "base" / "head_identity.yaml" - ) + id_head_config = read_config_as_dict(configs_dir / "base" / "head_identity.yaml") config["model"]["heads"]["identity"] = replace_default_values( id_head_config, num_individuals=num_individuals, @@ -354,18 +354,28 @@ def add_identity_head( return config -def _get_paf_parameters(project_config: dict, bodyparts: list[str]) -> dict: +def _get_paf_parameters( + project_config: dict, + bodyparts: list[str], + num_limbs_threshold: int = 105, + paf_graph_degree: int = 6, +) -> dict: """Gets values for PAF parameters from the project configuration""" paf_graph = [ - [i, j] - for i in range(len(bodyparts)) - for j in range(i + 1, len(bodyparts)) + [i, j] for i in range(len(bodyparts)) for j in range(i + 1, len(bodyparts)) ] num_limbs = len(paf_graph) + # If the graph is unnecessarily large (with 15+ keypoints by default), + # we randomly prune it to a size guaranteeing an average node degree of 6; + # see Suppl. Fig S9c in Lauer et al., 2022. + if num_limbs >= num_limbs_threshold: + paf_graph = auxfun_multianimal.prune_paf_graph( + paf_graph, + average_degree=paf_graph_degree, + ) + num_limbs = len(paf_graph) return { "paf_graph": paf_graph, "num_limbs": num_limbs, - "paf_edges_to_keep": project_config.get( - "paf_best", list(range(num_limbs)) - ), + "paf_edges_to_keep": project_config.get("paf_best", list(range(num_limbs))), } From dee2f605d178a9d6175b061ddf8a1df3bb472fd7 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:54:24 +0200 Subject: [PATCH 076/293] Fix naming and use non-native dialog (#181) --- deeplabcut/gui/tabs/create_project.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/create_project.py b/deeplabcut/gui/tabs/create_project.py index 6eaf66fa38..733fffd1d2 100644 --- a/deeplabcut/gui/tabs/create_project.py +++ b/deeplabcut/gui/tabs/create_project.py @@ -98,7 +98,7 @@ def lay_out_video_frame(self): self.copy_box = QtWidgets.QCheckBox("Copy videos to project folder") self.copy_box.setChecked(False) - browse_button = QtWidgets.QPushButton("Browse videos") + browse_button = QtWidgets.QPushButton("Browse folders") browse_button.clicked.connect(self.browse_videos) clear_button = QtWidgets.QPushButton("Clear") clear_button.clicked.connect(video_frame.fancy_list.clear) @@ -116,10 +116,13 @@ def lay_out_video_frame(self): return video_frame def browse_videos(self): + options = QtWidgets.QFileDialog.Options() + options |= QtWidgets.QFileDialog.DontUseNativeDialog folder = QtWidgets.QFileDialog.getExistingDirectory( self, "Please select a folder", self.loc_default, + options, ) if not folder: return From 6a94f9fd7b61a904a626407c58620b40c2232b9e Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:26:43 +0200 Subject: [PATCH 077/293] bug fix: only compute PAFs during evaluation (#183) --- .../models/predictors/paf_predictor.py | 2 +- .../pose_estimation_pytorch/runners/logger.py | 3 ++- .../pose_estimation_pytorch/runners/train.py | 20 ++++++++++--------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index cb7f2127d1..a28d1bf2dc 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -147,7 +147,7 @@ def forward( heatmaps, self.nms_radius, threshold=0.01 ) if ~torch.any(peaks): - return {"poses": []} + return {"poses": torch.zeros((batch_size, 0, self.num_multibodyparts, 5))} locrefs = locrefs.reshape(batch_size, n_channels, 2, height, width) locrefs = locrefs * self.locref_stdev diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index 4a3e48beb5..dec21e171e 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -42,10 +42,11 @@ def setup_file_logging(filepath: Path) -> None: datefmt="%Y-%m-%d %H:%M:%S", level=logging.INFO, format="%(asctime)-15s %(message)s", + force=True, ) console_logger = logging.StreamHandler() console_logger.setLevel(logging.INFO) - root = logging.getLogger("") + root = logging.getLogger() root.addHandler(console_logger) diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index 9e90791e57..c488488883 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -150,11 +150,12 @@ def fit( lr = self.optimizer.param_groups[0]['lr'] msg = f"Epoch {e}/{epochs} (lr={lr}), train loss {float(train_loss):.5f}" if e % self.eval_interval == 0: - logging.info(f"Training for epoch {e} done, starting evaluation") - valid_loss = self._epoch( - valid_loader, mode="eval", step=e, display_iters=display_iters - ) - msg += f", valid loss {float(valid_loss):.5f}" + with torch.no_grad(): + logging.info(f"Training for epoch {e} done, starting evaluation") + valid_loss = self._epoch( + valid_loader, mode="eval", step=e, display_iters=display_iters + ) + msg += f", valid loss {float(valid_loss):.5f}" self.snapshot_manager.update(e, self.state_dict(), last=(e == epochs)) logging.info(msg) @@ -283,11 +284,12 @@ def step( losses_dict["total_loss"].backward() self.optimizer.step() - predictions = { - head_name: {k: v.detach().cpu().numpy() for k, v in pred.items()} - for head_name, pred in self.model.get_predictions(inputs, outputs).items() - } if mode == "eval": + predictions = { + name: {k: v.detach().cpu().numpy() for k, v in pred.items()} + for name, pred in self.model.get_predictions(inputs, outputs).items() + } + ground_truth = batch["annotations"]["keypoints"] if batch["annotations"]["with_center_keypoints"][0]: ground_truth = ground_truth[..., :-1, :] From 3806551b72db66782b504e3e2b464a6582f1bfa7 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:36:11 +0200 Subject: [PATCH 078/293] niels/logger_improvements (#175) - makes usage of TQDM in evaluate_assembly optional (so it doesn't log during training) - only computes rmse_pcutoff if there are keypoints above the cutoff (otherwise a warning was printed) - force the logger updates when starting to train, otherwise it could be buggy - don't use `verbose`for the LR scheduler as it's deprecated - log a single time will all metrics instead of multiple - logs metrics and losses to different prefixes so they are put in the correct panels on wandb - log input images, target and output heatmaps to WandB --- deeplabcut/core/inferenceutils.py | 7 +- .../metrics/scoring.py | 5 +- .../models/backbones/resnet.py | 1 - .../pose_estimation_pytorch/runners/logger.py | 204 ++++++++++++++++-- .../runners/schedulers.py | 12 +- .../pose_estimation_pytorch/runners/train.py | 36 ++-- docs/pytorch/pytorch_config.md | 10 +- examples/testscript_pytorch_multi_animal.py | 11 +- examples/testscript_pytorch_single_animal.py | 11 +- 9 files changed, 255 insertions(+), 42 deletions(-) diff --git a/deeplabcut/core/inferenceutils.py b/deeplabcut/core/inferenceutils.py index 65f0f15ddf..31b215283e 100644 --- a/deeplabcut/core/inferenceutils.py +++ b/deeplabcut/core/inferenceutils.py @@ -835,6 +835,7 @@ def assemble(self, chunk_size=1, n_processes=None): # work nicely with the GUI or interactive sessions. # In that case, we fall back to the serial assembly. if chunk_size == 0 or multiprocessing.get_start_method() == "spawn": + for i, data_dict in enumerate(tqdm(self)): assemblies, unique = self._assemble(data_dict, i) if assemblies: @@ -1079,11 +1080,15 @@ def evaluate_assembly( margin=0, symmetric_kpts=None, greedy_matching=False, + with_tqdm: bool = True, ): # sigma is taken as the median of all COCO keypoint standard deviations all_matched = [] all_unmatched = [] - for ind, ass_true in tqdm(ass_true_dict.items()): + items = ass_true_dict.items() + if with_tqdm: + items = tqdm(items) + for ind, ass_true in items: ass_pred = ass_pred_dict.get(ind, []) matched, unmatched = match_assemblies( ass_pred, diff --git a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py index eac2497686..f91fd13819 100644 --- a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py @@ -166,7 +166,9 @@ def compute_rmse( square_distances = (pred[:, :2] - ground_truth) ** 2 mean_square_errors = np.sum(square_distances, axis=1) rmse = np.nanmean(np.sqrt(mean_square_errors)).item() - rmse_p = np.nanmean(np.sqrt(mean_square_errors[mask])).item() + rmse_p = np.nan + if len(mean_square_errors[mask]) > 0: + rmse_p = np.nanmean(np.sqrt(mean_square_errors[mask])).item() return rmse, rmse_p @@ -211,6 +213,7 @@ def compute_oks( oks_sigma, margin=margin, symmetric_kpts=symmetric_kpts, + with_tqdm=False, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 9bd61ba56a..642a3779fe 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -53,7 +53,6 @@ def __init__( model_name, output_stride=output_stride, pretrained=pretrained, - num_classes=1, # smaller classification layer drop_path_rate=drop_path_rate, drop_block_rate=drop_block_rate, ) diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index dec21e171e..a26d3b439f 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -15,6 +15,13 @@ from pathlib import Path from typing import Any, Optional +import numpy as np +import torch +import torchvision.transforms as transforms +import torchvision.transforms.functional as F +from torch.utils.data import DataLoader +from torchvision.utils import draw_bounding_boxes, draw_keypoints + try: import wandb has_wandb = True @@ -70,12 +77,11 @@ def log_config(self, config: dict = None) -> None: """ @abstractmethod - def log(self, key: str, value: Any, step: Optional[int] = None) -> None: + def log(self, metrics: dict[str, Any], step: Optional[int] = None) -> None: """Logs data from a training run Args: - key: The name of the logged value. - value: Data to log. + metrics: the metrics to log step: The global step in processing. Defaults to None. """ @@ -84,8 +90,155 @@ def save(self) -> None: """Saves the current training logs""" +class ImageLoggerMixin(ABC): + """Mixin for loggers that can log images + + Before starting training, you should call `select_images_to_log`, which will + select a train and a test image for which inputs/outputs will always be logged. + Then logger.log_images should be called at every step - the logger will check if + anything needs to be uploaded, and take care of it. + + Example: + project_name = "example" + run_name = "run-1" + logger = WandbLogger(project_name, run_name) + logger.select_images_to_log(train_loader, test_loader) + + for i in range(epochs): + for batch_inputs in train_loader: + batch_outputs = model(batch_inputs) + batch_targets = model.get_targets(batch_inputs, batch_outputs) + loss = criterion(batch_targets, batch_outputs) + loss.backwards() + optim.step() + + logger.log_images(batch_inputs, batch_outputs, batch_targets) + + for batch_inputs in train_loader: + ... + logger.log_images(batch_inputs, batch_outputs, batch_targets) + """ + + def __init__(self, image_log_interval: int | None = None, *args, **kwargs): + """""" + super().__init__(*args, **kwargs) + self.image_log_interval = image_log_interval + self._logged = {} + self._denormalize = transforms.Compose( + [ + transforms.Normalize(mean=[0, 0, 0], std=[1/0.229, 1/0.224, 1/0.225]), + transforms.Normalize(mean=[-0.485, -0.456, -0.406], std=[1, 1, 1]), + ] + ) + self._softmax = torch.nn.Softmax2d() + + @abstractmethod + def log_images( + self, + inputs: dict[str, Any], + outputs: dict[str, torch.Tensor], + targets: dict[str, dict[str, torch.Tensor]], + step: int, + ) -> None: + """Log images for a batch + + Args: + inputs: the inputs for the model, containing at least an "image" key + outputs: the outputs of each model head + targets: the targets for each model head + step: the current step + """ + pass + + def select_images_to_log(self, train: DataLoader, valid: DataLoader) -> None: + """Selects the train and test images to log + + Args: + train: the training dataloader + valid: the inference dataloader + """ + def _caption(image_path: str) -> str: + p = Path(image_path) + return f"{p.parent.name}.{p.stem}" + + train_image = train.dataset[0]["path"] + test_image = valid.dataset[0]["path"] + self._logged = { + train_image: {"name": "train-0", "caption": _caption(train_image)}, + test_image: {"name": "test-0", "caption": _caption(test_image)}, + } + + def _prepare_image( + self, + image: torch.Tensor, + denormalize: bool = False, + keypoints: torch.Tensor | None = None, + bboxes: torch.Tensor | None = None, + ) -> np.ndarray: + """ + Args: + image: the image to log, of shape (C, H, W), of any data type + denormalize: whether to remove ImageNet channel normalization + keypoints: size (num_instances, K, 2) the K keypoints location + bboxes: size (N, 4) containing bboxes in (xmin, ymin, xmax, ymax) + + Returns: + an uint8 array with keypoints and bounding boxes drawn + """ + if denormalize: + image = self._denormalize(image.unsqueeze(0)).squeeze() + + image = F.convert_image_dtype(image.detach().cpu(), dtype=torch.uint8) + if keypoints is not None and len(keypoints) > 0: + assert len(keypoints.shape) == 3 + keypoints[keypoints < 0] = np.nan + image = draw_keypoints( + image, keypoints=keypoints[..., :2], colors="red", radius=5 + ) + + if bboxes is not None and len(bboxes) > 0: + assert len(bboxes.shape) == 2 + image = draw_bounding_boxes(image, boxes=bboxes[:, :4], width=1) + + return image.permute(1, 2, 0).numpy() + + def _heatmap_softmax(self, heatmaps: torch.Tensor) -> torch.Tensor: + """Applies a softmax to the heatmap channels""" + return self._softmax(heatmaps.detach().cpu()) + + def _prepare_images( + self, + inputs: dict[str, Any], + outputs: dict[str, dict[str, torch.Tensor]], + targets: dict[str, dict[str, dict[str, torch.Tensor]]], + ) -> dict[str, np.ndarray]: + """Prepares images for logging""" + image_logs = {} + paths = inputs["path"] + images_to_log = [(i, p) for i, p in enumerate(paths) if p in self._logged] + for idx, path in images_to_log: + base = self._logged[path]["name"] + keypoints = inputs.get("annotations", {}).get("keypoints") + if keypoints is not None: + keypoints = keypoints[idx] + image_logs[f"{base}.input"] = self._prepare_image( + inputs["image"][idx], keypoints=keypoints, denormalize=True, + ) + + for head, head_outputs in outputs.items(): + if "heatmap" in head_outputs: + head_heatmaps = self._heatmap_softmax(head_outputs["heatmap"][idx]) + head_targets = targets[head]["heatmap"]["target"][idx] + for j, (h, t) in enumerate(zip(head_heatmaps, head_targets)): + h = self._prepare_image(h.unsqueeze(0)) + t = self._prepare_image(t.unsqueeze(0)) + image_logs[f"{base}.heatmap.{j}"] = np.concatenate([h, t]) + + return image_logs + + @LOGGER.register_module -class WandbLogger(BaseLogger): +class WandbLogger(ImageLoggerMixin, BaseLogger): """Wandb logger to track experiments and log data. Refer to: https://docs.wandb.ai/guides for more information on wandb. @@ -98,6 +251,7 @@ def __init__( self, project_name: str = "deeplabcut", run_name: str = "tmp", + image_log_interval: int | None = None, model: PoseModel = None, **wandb_kwargs, ) -> None: @@ -106,6 +260,8 @@ def __init__( Args: project_name: The name of the wandb project. Defaults to "deeplabcut". run_name: The name of the wandb run. Defaults to "tmp". + image_log_interval: How often train/test images are logged in epochs (if + None, train/test inputs are never logged). model: The model to log. Defaults to None. wandb_kwargs: extra arguments to pass to ``wb.init`` @@ -113,13 +269,14 @@ def __init__( logger = WandbLogger(project_name="mice", run_name="exp1", model=my_model) """ + super().__init__(image_log_interval=image_log_interval) + if not has_wandb: raise ValueError( "Cannot use ``WandbLogger`` as wandb is not installed. Please run" "``pip install wandb`` if you want to log to wandb" ) - super().__init__() if wandb.run is not None: wandb.finish() @@ -132,24 +289,43 @@ def __init__( raise ValueError("Specify the model to track!") self.run.watch(model) - def log(self, key: str, value: Any, step: Optional[int] = None) -> None: - """Logs data from runs, such as scalars, images, video, histograms, plots, and tables. + def log(self, metrics: dict[str, Any], step: Optional[int] = None) -> None: + """Logs metrics from runs Args: - key: The name of the logged value. - value: Data to log. + metrics: the metrics to log step: The global step in processing. Defaults to None. Example: logger = WandbLogger() - logger.log(key="loss", value=0.123, step=100) + logger.log({"loss": 0.123}, step=100) + """ + self.run.log(metrics, step=step) + def log_images( + self, + inputs: dict[str, Any], + outputs: dict[str, dict[str, torch.Tensor]], + targets: dict[str, dict[str, dict[str, torch.Tensor]]], + step: int, + ) -> None: + """Log images for a batch + + Args: + inputs: the inputs for the model, containing at least an "image" key + outputs: the outputs of each model head + targets: the targets for each model head + step: the current step """ - if value is None: - raise ValueError( - f"Nothing to log. Value ({value}) expected to be scalar, table or image." + if self.image_log_interval is None or step % self.image_log_interval != 0: + return + + images = self._prepare_images(inputs, outputs, targets) + if len(images) > 0: + self.run.log( + {name: wandb.Image(image) for name, image in images.items()}, + step=step, ) - self.run.log({key: value}, step=step) def save(self): """Syncs all files to wandb with the policy specified. diff --git a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py index 90a62087f8..a8e5615876 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py +++ b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py @@ -22,19 +22,13 @@ class LRListScheduler(_LRScheduler): and milestones modifies the learning rate accordingly during training """ - def __init__( - self, optimizer, milestones, lr_list, last_epoch=-1, verbose=False - ): - """Summary: - Constructor of the LRListScheduler. - Loads the data. - + def __init__(self, optimizer, milestones, lr_list, last_epoch=-1) -> None: + """ Args: optimizer: optimizer used for learning. milestones: number of epochs. lr_list: learning rate list. last_epoch: where to start the scheduler. (-1: start from beginning) - verbose: prints model summary. Defaults to False. Examples: input: @@ -45,7 +39,7 @@ def __init__( """ self.milestones = milestones self.lr_list = lr_list - super().__init__(optimizer, last_epoch, verbose) + super().__init__(optimizer, last_epoch) def get_lr(self): """Summary: diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index c488488883..aa7df60f76 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -29,7 +29,10 @@ from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner -from deeplabcut.pose_estimation_pytorch.runners.logger import BaseLogger +from deeplabcut.pose_estimation_pytorch.runners.logger import ( + BaseLogger, + ImageLoggerMixin, +) from deeplabcut.pose_estimation_pytorch.runners.schedulers import build_scheduler from deeplabcut.pose_estimation_pytorch.runners.snapshots import TorchSnapshotManager from deeplabcut.pose_estimation_pytorch.task import Task @@ -58,7 +61,7 @@ def __init__( optimizer: the optimizer to use when fitting the model snapshot_manager: the module to use to manage snapshots device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) - eval_interval: how often evaluation is run on the test set (in epochs) + eval_interval: how often evaluation is run on the test set in epochs snapshot_path: if defined, the path of a snapshot from which to load pretrained weights scheduler: scheduler for adjusting the lr of the optimizer @@ -72,6 +75,7 @@ def __init__( self.history: dict[str, list] = dict(train_loss=[], eval_loss=[]) self.logger = logger self.starting_epoch = 0 + self.current_epoch = 0 if self.snapshot_path is not None and len(self.snapshot_path) > 0: self.starting_epoch = self.load_snapshot( @@ -139,10 +143,14 @@ def fit( runner.fit(train_loader, valid_loader, "example/models" epochs=50) """ self.model.to(self.device) + if isinstance(self.logger, ImageLoggerMixin): + self.logger.select_images_to_log(train_loader, valid_loader) + for e in range(self.starting_epoch + 1, epochs + 1): + self.current_epoch = e self._metadata["epoch"] = e train_loss = self._epoch( - train_loader, mode="train", step=e, display_iters=display_iters + train_loader, mode="train", display_iters=display_iters ) if self.scheduler: self.scheduler.step() @@ -153,7 +161,7 @@ def fit( with torch.no_grad(): logging.info(f"Training for epoch {e} done, starting evaluation") valid_loss = self._epoch( - valid_loader, mode="eval", step=e, display_iters=display_iters + valid_loader, mode="eval", display_iters=display_iters ) msg += f", valid loss {float(valid_loss):.5f}" @@ -164,7 +172,6 @@ def _epoch( self, loader: torch.utils.data.DataLoader, mode: str = "train", - step: int | None = None, display_iters: int = 500, ) -> float: """Facilitates training over an epoch. Returns the loss over the batches. @@ -174,7 +181,6 @@ def _epoch( Each batch contains image tensor and heat maps tensor input samples. mode: str identifier to instruct the Runner whether to train or evaluate. Possible values are: "train" or "eval". - step: the global step in processing, used to log metrics. Defaults to None. display_iters: the number of iterations between each loss print Raises: @@ -212,8 +218,8 @@ def _epoch( self._metadata["metrics"] = perf_metrics self._epoch_predictions = {} self._epoch_ground_truth = {} - if len(perf_metrics): - logging.info(f"Epoch {step} performance:") + if len(perf_metrics) > 0: + logging.info(f"Epoch {self.current_epoch} performance:") for name, score in perf_metrics.items(): logging.info(f"{name + ':': <20}{score:.3f}") @@ -221,16 +227,19 @@ def _epoch( self.history[f"{mode}_loss"].append(epoch_loss) if self.logger: + metrics_to_log = {} if perf_metrics: for name, score in perf_metrics.items(): if not isinstance(score, (int, float)): score = 0.0 - self.logger.log(name, score, step=step) + metrics_to_log[name] = score for key in loss_metrics: name, val = f"{mode}.{key}", np.nanmean(loss_metrics[key]).item() self._metadata["losses"][name] = val - self.logger.log(name, val, step=step) + metrics_to_log[f"losses/{name}"] = val + + self.logger.log(metrics_to_log, step=self.current_epoch) return epoch_loss @@ -284,6 +293,9 @@ def step( losses_dict["total_loss"].backward() self.optimizer.step() + if isinstance(self.logger, ImageLoggerMixin): + self.logger.log_images(batch, outputs, target, step=self.current_epoch) + if mode == "eval": predictions = { name: {k: v.detach().cpu().numpy() for k, v in pred.items()} @@ -330,7 +342,7 @@ def _compute_epoch_metrics(self) -> dict[str, float]: unique_bodypart_gt=self._epoch_ground_truth.get("unique_bodyparts"), pcutoff=0.6, ) - return {f"test.{metric}": value for metric, value in scores.items()} + return {f"metrics/test.{metric}": value for metric, value in scores.items()} def _update_epoch_predictions( self, @@ -458,7 +470,7 @@ def _compute_epoch_metrics(self) -> dict[str, float]: """Returns: bounding box metrics, if """ try: return { - f"test.{k}": v + f"metrics/test.{k}": v for k, v in compute_bbox_metrics( self._epoch_ground_truth, self._epoch_predictions ).items() diff --git a/docs/pytorch/pytorch_config.md b/docs/pytorch/pytorch_config.md index 926f3da139..fc88fa8bd8 100644 --- a/docs/pytorch/pytorch_config.md +++ b/docs/pytorch/pytorch_config.md @@ -330,11 +330,16 @@ train_settings: ### Logger -To log results to [Weights and Biases](https://wandb.ai/site), you can add a +Training runs are logged to the model folder (where the snapshots are stored) by +default. + +Additionally, you can log results to [Weights and Biases](https://wandb.ai/site), by adding a `WandbLogger`. Just make sure you're logged in to your `wandb` account before starting your training run (with `wandb login` from your shell). For more information, see their [tutorials](https://docs.wandb.ai/tutorials) and their documentation for -[`wandb.init`](https://docs.wandb.ai/ref/python/init). +[`wandb.init`](https://docs.wandb.ai/ref/python/init). You can also log images as they are seen by the model to `wandb` +with the `image_log_interval`. This logs a random train and test image, as well as the +targets and heatmaps for that image. Logging to `wandb` is a good way to keep track of what you've run, including performance and metrics. @@ -342,6 +347,7 @@ and metrics. ```yaml logger: type: WandbLogger + image_log_interval: 5 # how often images are logged to wandb (in epochs) project_name: my-dlc3-project # the name of the project where the run should be logged run_name: dekr-w32-shuffle0 # the name of the run to log ... # any other argument you can pass to `wandb.init`, such as `tags: ["dekr", "split=0"]` diff --git a/examples/testscript_pytorch_multi_animal.py b/examples/testscript_pytorch_multi_animal.py index ced06fbd21..539d5b691a 100644 --- a/examples/testscript_pytorch_multi_animal.py +++ b/examples/testscript_pytorch_multi_animal.py @@ -9,6 +9,8 @@ # Licensed under GNU Lesser General Public License v3.0 # """ Testscript for single animal PyTorch projects """ +from __future__ import annotations + from pathlib import Path import deeplabcut.utils.auxiliaryfunctions as af @@ -28,6 +30,7 @@ def main( detector_batch_size: int = 1, max_snapshots_to_keep: int = 5, device: str = "cpu", + logger: dict | None = None, create_labeled_videos: bool = False, delete_after_test_run: bool = False, ) -> None: @@ -75,6 +78,7 @@ def main( ) ) ), + logger=logger, ), engine=engine, create_labeled_videos=create_labeled_videos, @@ -95,7 +99,7 @@ def main( net_types=["top_down_resnet_50", "resnet_50", "dekr_w18"], params=SyntheticProjectParameters( multianimal=True, - num_bodyparts=2, + num_bodyparts=5, num_individuals=3, num_unique=0, num_frames=10, @@ -107,6 +111,11 @@ def main( save_epochs=1, max_snapshots_to_keep=2, device="cpu", # "cpu", "cuda:0", "mps" + logger={ + "type": "WandbLogger", + "project_name": "testscript-dev", + "run_name": "test-logging", + }, create_labeled_videos=False, delete_after_test_run=True, ) diff --git a/examples/testscript_pytorch_single_animal.py b/examples/testscript_pytorch_single_animal.py index ba93c1b6c3..89e986353f 100644 --- a/examples/testscript_pytorch_single_animal.py +++ b/examples/testscript_pytorch_single_animal.py @@ -1,4 +1,6 @@ """ Testscript for single animal PyTorch projects """ +from __future__ import annotations + from pathlib import Path import deeplabcut.utils.auxiliaryfunctions as af @@ -22,6 +24,7 @@ def main( max_snapshots_to_keep: int = 5, batch_size: int = 1, device: str = "cpu", + logger: dict | None = None, synthetic_data_params: SyntheticProjectParameters = SyntheticProjectParameters( multianimal=False, num_bodyparts=6, ), @@ -64,7 +67,8 @@ def main( save_epochs=save_epochs, max_snapshots=max_snapshots_to_keep, ) - ) + ), + logger=logger, ), engine=engine, create_labeled_videos=create_labeled_videos, @@ -89,6 +93,11 @@ def main( save_epochs=1, max_snapshots_to_keep=2, device="cpu", # "cpu", "cuda:0", "mps" + logger={ + "type": "WandbLogger", + "project_name": "testscript-dev", + "run_name": "test-logging", + }, synthetic_data_params=SyntheticProjectParameters( multianimal=False, num_bodyparts=4, From 8b93e0de6b53d59bb4c9947cc28833e38350b065 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Mon, 22 Apr 2024 15:48:20 +0200 Subject: [PATCH 079/293] niels/batch_rescale (#177) --- benchmark/benchmark_lightning_pose.py | 18 +- benchmark/benchmark_madlc.py | 159 +++++--- benchmark/benchmark_run_experiments.py | 321 ++------------- benchmark/benchmark_train.py | 7 + benchmark/madlc_test_inference.py | 12 +- benchmark/utils.py | 21 + benchmark/utils_augmentation.py | 131 ++++++ benchmark/utils_models.py | 174 ++++++++ .../trainingsetmanipulation.py | 12 +- .../pose_estimation_pytorch/apis/train.py | 8 + .../pose_estimation_pytorch/apis/utils.py | 26 +- .../config/backbones/hrnet_w18.yaml | 4 - .../config/backbones/hrnet_w32.yaml | 4 - .../config/backbones/hrnet_w48.yaml | 4 - .../config/base/base.yaml | 10 +- .../config/base/detector.yaml | 16 +- .../config/dekr/dekr_w18.yaml | 5 + .../config/dekr/dekr_w32.yaml | 5 + .../config/dekr/dekr_w48.yaml | 5 + .../pose_estimation_pytorch/data/collate.py | 191 +++++++++ .../pose_estimation_pytorch/data/dataset.py | 15 +- .../pose_estimation_pytorch/data/dlcloader.py | 378 ++++++++++++++++-- .../pose_estimation_pytorch/data/image.py | 164 ++++++++ .../pose_estimation_pytorch/data/utils.py | 69 +++- .../pose_estimation_pytorch/runners/train.py | 24 +- docs/pytorch/pytorch_config.md | 45 ++- examples/testscript_pytorch_single_animal.py | 6 +- 27 files changed, 1387 insertions(+), 447 deletions(-) create mode 100644 benchmark/utils_augmentation.py create mode 100644 benchmark/utils_models.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/collate.py create mode 100644 deeplabcut/pose_estimation_pytorch/data/image.py diff --git a/benchmark/benchmark_lightning_pose.py b/benchmark/benchmark_lightning_pose.py index 1f545e5df5..c686a1e7cd 100644 --- a/benchmark/benchmark_lightning_pose.py +++ b/benchmark/benchmark_lightning_pose.py @@ -6,26 +6,20 @@ from deeplabcut.utils import get_bodyparts from benchmark_run_experiments import ( - AffineAugmentation, - AUG_INFERENCE, AUG_TRAIN, - BackboneConfig, - CropSampling, DEFAULT_OPTIMIZER, DEFAULT_SCHEDULER, - HeadConfig, HRNET_BACKBONE, HRNET_BACKBONE_INCRE, HRNET_BACKBONE_INTER, - ImageAugmentations, main, - ModelConfig, RESNET_BACKBONE, RESNET_OPTIMIZER, RESNET_SCHEDULER, - WandBConfig, ) -from utils import Project +from utils import Project, WandBConfig +from utils_models import HeadConfig, ModelConfig + LP_DLC_DATA_ROOT = Path("/home/niels/datasets/lightning-pose") LP_DLC_BENCHMARKS = { @@ -73,7 +67,7 @@ epochs=EPOCHS, save_epochs=SAVE_EPOCHS, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, + inference_aug=None, backbone_config=RESNET_BACKBONE, head_config=HeadConfig.build_plateau_head( c_in=2048, @@ -96,7 +90,7 @@ epochs=EPOCHS, save_epochs=SAVE_EPOCHS, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, + inference_aug=None, backbone_config=HRNET_BACKBONE_INTER, head_config=HeadConfig.build_plateau_head( c_in=480, @@ -119,7 +113,7 @@ epochs=EPOCHS, save_epochs=SAVE_EPOCHS, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, + inference_aug=None, backbone_config=HRNET_BACKBONE, head_config=HeadConfig.build_plateau_head( c_in=32, diff --git a/benchmark/benchmark_madlc.py b/benchmark/benchmark_madlc.py index 88ee3b0680..f9d16a0281 100644 --- a/benchmark/benchmark_madlc.py +++ b/benchmark/benchmark_madlc.py @@ -1,72 +1,124 @@ """Benchmark script for maDLC models""" from __future__ import annotations -import torch -import wandb from deeplabcut.utils import get_bodyparts -from benchmark_run_experiments import ( - AUG_INFERENCE, - AUG_TRAIN, - CropSampling, - DEFAULT_OPTIMIZER, - DEFAULT_SCHEDULER, - DetectorConfig, - HeadConfig, - main, - ModelConfig, - WandBConfig, -) +from benchmark_run_experiments import main from projects import MA_DLC_BENCHMARKS, MA_DLC_DATA_ROOT +from utils import WandBConfig +from utils_augmentation import ( + AffineAugmentation, + BatchCollate, + ImageAugmentations, +) +from utils_models import BackboneConfig, HeadConfig, DetectorConfig, ModelConfig if __name__ == "__main__": - PROJECT_NAME = "trimouse" # "trimouse", "fish", "marmosets", "parenting" - PROJECT_BENCHMARKED = MA_DLC_BENCHMARKS[PROJECT_NAME] - SPLIT_FILE = MA_DLC_DATA_ROOT / "maDLC_benchmarking_splits.json" - NUM_BPT = len(get_bodyparts(PROJECT_BENCHMARKED.cfg)) + for PROJECT_NAME, TRAIN_FRACTION in [ + ("parenting", 0.95), + ("trimouse", 0.95), + ("fish", 0.94), + ("marmoset", 0.95), + ]: + PROJECT_BENCHMARKED = MA_DLC_BENCHMARKS[PROJECT_NAME] + SPLIT_FILE = MA_DLC_DATA_ROOT / "maDLC_benchmarking_splits.json" + NUM_BPT = len(get_bodyparts(PROJECT_BENCHMARKED.cfg)) - # Train parameters - DETECTOR_EPOCHS = 1 - DETECTOR_SAVE_EPOCHS = 1 - DETECTOR_BATCH_SIZE = 1 + AUG_TRAIN = ImageAugmentations( + normalize=True, + covering=True, + gaussian_noise=12.75, + hist_eq=True, + motion_blur=True, + affine=AffineAugmentation( + p=0.5, + rotation=30, + scale=(1, 1), + translation=40, + ), + collate=BatchCollate( + min_scale=0.4, + max_scale=1.0, + min_short_side=256, + max_short_side=1152, + multiple_of=32, + ), + ) - DEFAULT_OPTIMIZER = {"type": "AdamW", "params": {"lr": 5e-4}} - DEFAULT_SCHEDULER["params"] = {"lr_list": [[1e-4], [1e-5]], "milestones": [2, 4]} + # Optimization parameters + OPTIMIZER = {"type": "AdamW", "params": {"lr": 1e-4}} + SCHEDULER = { + "type": "LRListScheduler", + "params": {"lr_list": [[1e-5], [1e-6]], "milestones": [140, 190]}, + } - EPOCHS = 5 - SAVE_EPOCHS = 5 - DEKR_BATCH_SIZE = 8 - TD_HRNET_BATCH_SIZE = 8 + # Train parameters + DETECTOR_EPOCHS = 250 + DETECTOR_SAVE_EPOCHS = 50 + DETECTOR_BATCH_SIZE = 8 - # logging params - WANDB_PROJECT = "dlc3-benchmark-dev" - BASE_TAGS = (f"project={PROJECT_NAME}", "server=m0") - GROUP_UID = "base" + EPOCHS = 200 + SAVE_EPOCHS = 25 + RESNET_BATCH_SIZE = 8 + DEKR_BATCH_SIZE = 4 + TD_HRNET_BATCH_SIZE = 16 - model_configs = [ - ModelConfig( + # logging params + WANDB_PROJECT = "dlc3-benchmark-dev" + BASE_TAGS = (f"project={PROJECT_NAME}", "server=m0") + GROUP_UID = "base" + + RESNET_PAF = ModelConfig( + net_type="resnet_50", + batch_size=RESNET_BATCH_SIZE, + epochs=EPOCHS, + save_epochs=SAVE_EPOCHS, + dataloader_workers=2, + dataloader_pin_memory=True, + backbone_config=BackboneConfig( + model_name="resnet50_gn", + output_stride=16, + freeze_bn_stats=True, + freeze_bn_weights=False, + ), + train_aug=AUG_TRAIN, + inference_aug=None, # uses default + optimizer_config=OPTIMIZER, + scheduler_config=SCHEDULER, + wandb_config=WandBConfig( + project=WANDB_PROJECT, + run_name=f"{PROJECT_NAME}-{GROUP_UID}-resnet50PAF", + group=f"{PROJECT_NAME}-{GROUP_UID}-resnet50PAF", + tags=(*BASE_TAGS, "arch=resnet50PAF"), + ), + ) + DEKR_W32 = ModelConfig( net_type="dekr_w32", batch_size=DEKR_BATCH_SIZE, epochs=EPOCHS, save_epochs=SAVE_EPOCHS, + dataloader_workers=2, + dataloader_pin_memory=True, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, - optimizer_config=DEFAULT_OPTIMIZER, - scheduler_config=DEFAULT_SCHEDULER, + inference_aug=None, # uses default + optimizer_config=OPTIMIZER, + scheduler_config=SCHEDULER, wandb_config=WandBConfig( project=WANDB_PROJECT, run_name=f"{PROJECT_NAME}-{GROUP_UID}-dekr32", group=f"{PROJECT_NAME}-{GROUP_UID}-dekr32", - tags=(*BASE_TAGS, "arch=dekr32", "ndeconv=1"), + tags=(*BASE_TAGS, "arch=dekr32"), ), - ), - ( + ) + TD_HRNET_W32 = ( DetectorConfig( batch_size=DETECTOR_BATCH_SIZE, epochs=DETECTOR_EPOCHS, save_epochs=DETECTOR_SAVE_EPOCHS, - train_aug=None, + dataloader_workers=2, + dataloader_pin_memory=True, + train_aug=AUG_TRAIN, inference_aug=None, optimizer_config=None, scheduler_config=None, @@ -76,8 +128,10 @@ batch_size=TD_HRNET_BATCH_SIZE, epochs=EPOCHS, save_epochs=SAVE_EPOCHS, + dataloader_workers=2, + dataloader_pin_memory=True, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, + inference_aug=None, backbone_config=None, head_config=HeadConfig.build_plateau_head( c_in=32, @@ -85,8 +139,8 @@ deconv=[], final_conv=True, ), - optimizer_config=DEFAULT_OPTIMIZER, - scheduler_config=DEFAULT_SCHEDULER, + optimizer_config=OPTIMIZER, + scheduler_config=SCHEDULER, wandb_config=WandBConfig( project=WANDB_PROJECT, run_name=f"{PROJECT_NAME}-{GROUP_UID}-td-hrnet32", @@ -95,13 +149,12 @@ ), ), ) - ] - main( - project=PROJECT_BENCHMARKED, - splits_file=SPLIT_FILE, - trainset_index=0, - train_fraction=0.95, - models_to_train=[model_configs[1]], - splits_to_train=(0, ), - ) + main( + project=PROJECT_BENCHMARKED, + splits_file=SPLIT_FILE, + trainset_index=0, + train_fraction=TRAIN_FRACTION, + models_to_train=[RESNET_PAF, DEKR_W32, TD_HRNET_W32], + splits_to_train=(0, ), + ) diff --git a/benchmark/benchmark_run_experiments.py b/benchmark/benchmark_run_experiments.py index 484c3f5dfb..1996c081a4 100644 --- a/benchmark/benchmark_run_experiments.py +++ b/benchmark/benchmark_run_experiments.py @@ -2,280 +2,27 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import asdict from pathlib import Path -import torch import wandb + from deeplabcut.utils import get_bodyparts -from benchmark_train import EvalParameters, run_dlc, RunParameters, TrainParameters +from benchmark_train import EvalParameters, run_dlc, RunParameters from projects import SA_DLC_BENCHMARKS, SA_DLC_DATA_ROOT -from utils import create_shuffles, Project, Shuffle - - -@dataclass -class WandBConfig: - project: str - run_name: str - save_code: bool = True - tags: tuple[str, ...] | None = None - group: str | None = None - - def data(self) -> dict: - return dict( - type="WandbLogger", - project_name=self.project, - run_name=self.run_name, - save_code=self.save_code, - tags=self.tags, - group=self.group, - ) - - -@dataclass -class BackboneConfig: - """ - Attributes: - model_name: the timm model name ("resnet50", "resnet50_gn", "hrnet_w18", ...) - output_stride: 8, 16 or 32 (HRNet only supports 32) - freeze_bn_weights: freeze batch norm weights - freeze_bn_stats: freeze batch norm stats - kwargs: any keyword-arguments for the backbone type that was selected, e.g. - HRNet: ``only_high_res: bool`` only use the high-resolution branch as the - image features (otherwise, in DEKR style all branches are interpolated - to the same shape and concatenated). - """ - - model_name: str = "resnet50" - output_stride: int | None = None - freeze_bn_weights: bool | None = None - freeze_bn_stats: bool | None = None - drop_path_rate: float | None = None - drop_block_rate: float | None = None - kwargs: dict | None = None - - def to_dict(self) -> dict: - config = asdict(self) - config.pop("kwargs") - for k in list(config.keys()): - if config[k] is None: - config.pop(k) - if self.kwargs is not None: - for k, v in self.kwargs.items(): - config[k] = v - return config - - -@dataclass -class HeadConfig: - plateau_targets: bool - heatmap_config: dict - locref_config: dict | None - - def to_dict(self) -> dict: - output_channels = self.heatmap_config["channels"][-1] - if self.heatmap_config.get("final_conv") is not None: - output_channels = self.heatmap_config["final_conv"]["out_channels"] - predictor = dict( - type="HeatmapPredictor", - location_refinement=self.locref_config is not None, - locref_std=7.2801, - ) - target_generator = dict( - type=( - "HeatmapPlateauGenerator" - if self.plateau_targets - else "HeatmapGaussianGenerator" - ), - num_heatmaps=output_channels, - pos_dist_thresh=17, - heatmap_mode="KEYPOINT", - generate_locref=self.locref_config is not None, - locref_std=7.2801, - ) - criterion = dict(heatmap=dict(type="WeightedBCECriterion", weight=1.0)) - if self.locref_config is not None: - criterion["locref"] = dict(type="WeightedHuberCriterion", weight=0.05) - - return dict( - type="HeatmapHead", - predictor=predictor, - target_generator=target_generator, - criterion=criterion, - heatmap_config=self.heatmap_config, - locref_config=self.locref_config, - ) - - @staticmethod - def build_plateau_head( - c_in: int, - c_out: int, - deconv: list[tuple[int, int, int]], # channel, kernel, stride - final_conv: bool = False, - ) -> HeadConfig: - heatmap = dict(channels=[c_in], kernel_size=[], strides=[], final_conv=None) - locref = dict(channels=[c_in], kernel_size=[], strides=[], final_conv=None) - for c, k, s in deconv: - for config in (heatmap, locref): - config["channels"].append(c) - config["kernel_size"].append(k) - config["strides"].append(s) - - if final_conv: - heatmap["final_conv"] = dict(out_channels=c_out, kernel_size=1) - locref["final_conv"] = dict(out_channels=2 * c_out, kernel_size=1) - else: - assert deconv[-1][0] == c_out - locref["channels"][-1] = 2 * c_out - - return HeadConfig( - plateau_targets=True, - heatmap_config=heatmap, - locref_config=locref, - ) - - -@dataclass -class AffineAugmentation: - """An affine image augmentation""" - - p: float = 0.9 - rotation: int = 0 - scale: tuple[float, float] | None = None - translation: int = 0 - - def data(self) -> dict: - affine = {} - if self.p > 0: - affine["p"] = self.p - if self.scale is not None: - affine["scaling"] = self.scale - if self.rotation > 0: - affine["rotation"] = self.rotation - if self.translation > 0: - affine["translation"] = self.translation - return affine - - -@dataclass -class CropSampling: - """Random crop around keypoints""" - width: int - height: int - max_shift: float = 0.4 - mode: str = "uniform" # "uniform", "keypoints", "density", "hybrid" - - def __post_init__(self): - assert self.mode in ("uniform", "keypoints", "density", "hybrid") - assert 0 <= self.max_shift <= 1 - - def data(self) -> dict: - return { - "width": self.width, - "height": self.height, - "max_shift": self.max_shift, - "mode": self.mode, - } - - -@dataclass -class ImageAugmentations: - """ - The default augmentation only normalizes images. - - Examples: - gaussian_noise: 12.75 - resize: {height: 800, width: 800, keep_ratio: true} - rotation: 30 - scale_jitter: (0.5, 1.25) - translation: 40 - """ - - normalize: bool = True - affine: AffineAugmentation | None = None - covering: bool = False - gaussian_noise: float | bool = False - hist_eq: bool = False - motion_blur: bool = False - resize: dict | None = None - crop_sampling: CropSampling | None = None - - def data(self) -> dict: - augmentations = { - "normalize_images": self.normalize, - "covering": self.covering, - "gaussian_noise": self.gaussian_noise, - "hist_eq": self.hist_eq, - "motion_blur": self.motion_blur, - } - if self.affine is not None: - augmentations["affine"] = self.affine.data() - if self.resize is not None: - augmentations["resize"] = self.resize - if self.crop_sampling is not None: - augmentations["crop_sampling"] = self.crop_sampling.data() - return augmentations - - -@dataclass -class DetectorConfig(TrainParameters): - train_aug: ImageAugmentations | None = None - inference_aug: ImageAugmentations | None = None - optimizer_config: dict | None = None - scheduler_config: dict | None = None - - def train_kwargs(self) -> dict: - kwargs = super().train_kwargs() - if self.train_aug is not None: - kwargs["data"]["train"] = self.train_aug.data() - if self.inference_aug is not None: - kwargs["data"]["inference"] = self.inference_aug.data() - if self.optimizer_config is not None: - kwargs["runner"]["optimizer"] = self.optimizer_config - if self.scheduler_config is not None: - kwargs["runner"]["scheduler"] = self.scheduler_config - return kwargs - - -@dataclass -class ModelConfig(TrainParameters): - net_type: str = "resnet_50" - train_aug: ImageAugmentations | None = None - inference_aug: ImageAugmentations | None = None - backbone_config: BackboneConfig | None = None - head_config: HeadConfig | None = None - optimizer_config: dict | None = None - scheduler_config: dict | None = None - wandb_config: WandBConfig | None = None - - def train_kwargs(self) -> dict: - kwargs = super().train_kwargs() - if self.train_aug is not None: - data = kwargs.get("data", {}) - data["train"] = self.train_aug.data() - kwargs["data"] = data - if self.inference_aug is not None: - data = kwargs.get("data", {}) - data["inference"] = self.inference_aug.data() - kwargs["data"] = data - if self.backbone_config is not None: - kwargs["model"] = dict(backbone=self.backbone_config.to_dict()) - if self.head_config is not None: - model_config = kwargs.get("model", {}) - model_config["heads"] = dict(bodypart=self.head_config.to_dict()) - kwargs["model"] = model_config - if self.wandb_config is not None: - kwargs["logger"] = self.wandb_config.data() - if self.optimizer_config is not None: - runner = kwargs.get("runner", {}) - runner["optimizer"] = self.optimizer_config - kwargs["runner"] = runner - if self.scheduler_config is not None: - runner = kwargs.get("runner", {}) - runner["scheduler"] = self.scheduler_config - kwargs["runner"] = runner - return kwargs +from utils import create_shuffles, Project, Shuffle, WandBConfig +from utils_augmentation import ( + AffineAugmentation, + BatchCollate, + ImageAugmentations, +) +from utils_models import ( + BackboneConfig, + DetectorConfig, + HeadConfig, + ModelConfig, +) def main( @@ -331,9 +78,11 @@ def main( print(" DetectorParameters") for k, v in asdict(detector_config).items(): print(f" {k}: {v}") + print(" ModelParameters") for k, v in asdict(model_config).items(): print(f" {k}: {v}") + print(" Train kwargs") for k, v in model_config.train_kwargs().items(): print(f" {k}: {v}") @@ -361,7 +110,6 @@ def main( ) -AUG_INFERENCE = ImageAugmentations(normalize=True) AUG_TRAIN = ImageAugmentations( normalize=True, covering=True, @@ -369,11 +117,18 @@ def main( hist_eq=True, motion_blur=True, affine=AffineAugmentation( - p=0.9, + p=0.5, rotation=30, - scale=(0.5, 1.25), + scale=(1, 1), translation=40, ), + collate=BatchCollate( + min_scale=0.4, + max_scale=1.0, + min_short_side=256, + max_short_side=1152, + multiple_of=32, + ), ) RESNET_BACKBONE = BackboneConfig( model_name="resnet50_gn", @@ -402,12 +157,12 @@ def main( RESNET_OPTIMIZER = {"type": "AdamW", "params": {"lr": 1e-3}} RESNET_SCHEDULER = { "type": "LRListScheduler", - "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [160, 190]}, + "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [90, 120]}, } DEFAULT_OPTIMIZER = {"type": "AdamW", "params": {"lr": 5e-4}} DEFAULT_SCHEDULER = { "type": "LRListScheduler", - "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [160, 190]}, + "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [90, 120]}, } @@ -420,10 +175,10 @@ def main( NUM_BPT = len(get_bodyparts(CFG)) # Train parameters - EPOCHS = 200 + EPOCHS = 150 SAVE_EPOCHS = 25 RESNET_BATCH_SIZE = 8 - HRNET_BATCH_SIZE = 4 + HRNET_BATCH_SIZE = 8 # logging params WANDB_PROJECT = "dlc3-benchmark-dev" @@ -440,8 +195,10 @@ def main( batch_size=RESNET_BATCH_SIZE, epochs=EPOCHS, save_epochs=SAVE_EPOCHS, + dataloader_workers=2, + dataloader_pin_memory=True, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, + inference_aug=None, backbone_config=RESNET_BACKBONE, head_config=HeadConfig.build_plateau_head( c_in=2048, @@ -463,8 +220,10 @@ def main( batch_size=HRNET_BATCH_SIZE, epochs=EPOCHS, save_epochs=SAVE_EPOCHS, + dataloader_workers=2, + dataloader_pin_memory=True, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, + inference_aug=None, backbone_config=HRNET_BACKBONE, head_config=HeadConfig.build_plateau_head( c_in=32, @@ -486,8 +245,10 @@ def main( batch_size=HRNET_BATCH_SIZE, epochs=EPOCHS, save_epochs=SAVE_EPOCHS, + dataloader_workers=2, + dataloader_pin_memory=True, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, + inference_aug=None, backbone_config=HRNET_BACKBONE_INCRE, head_config=HeadConfig.build_plateau_head( c_in=128, @@ -509,8 +270,10 @@ def main( batch_size=HRNET_BATCH_SIZE, epochs=EPOCHS, save_epochs=SAVE_EPOCHS, + dataloader_workers=2, + dataloader_pin_memory=True, train_aug=AUG_TRAIN, - inference_aug=AUG_INFERENCE, + inference_aug=None, backbone_config=HRNET_BACKBONE_INTER, head_config=HeadConfig.build_plateau_head( c_in=480, diff --git a/benchmark/benchmark_train.py b/benchmark/benchmark_train.py index 3a3278cd16..372c0293db 100644 --- a/benchmark/benchmark_train.py +++ b/benchmark/benchmark_train.py @@ -45,6 +45,8 @@ class TrainParameters: epochs: int | None = None save_epochs: int = 25 max_snapshots: int = 5 + dataloader_workers: int | None = None + dataloader_pin_memory: bool | None = None def train_kwargs(self) -> dict: kwargs = dict( @@ -56,6 +58,11 @@ def train_kwargs(self) -> dict: ) if self.epochs is not None: kwargs["train_settings"]["epochs"] = self.epochs + if self.dataloader_workers is not None: + kwargs["train_settings"]["dataloader_workers"] = self.dataloader_workers + if self.dataloader_pin_memory is not None: + kwargs["train_settings"][ + "dataloader_pin_memory"] = self.dataloader_pin_memory if self.save_epochs is not None: runner_kwargs = kwargs.get("runner", {}) runner_kwargs["snapshots"] = dict( diff --git a/benchmark/madlc_test_inference.py b/benchmark/madlc_test_inference.py index df729cf7ce..1ed0c744a9 100644 --- a/benchmark/madlc_test_inference.py +++ b/benchmark/madlc_test_inference.py @@ -22,6 +22,7 @@ def run_inference_on_all_images( project: Project, snapshot: Path, + save_as_csv: bool, plot: bool, detector_snapshot: Path | None = None, ) -> None: @@ -105,7 +106,7 @@ def run_inference_on_all_images( if parameters.num_unique_bpts > 0: unique_columns = pd.MultiIndex.from_product( [ - [shuffle_name], + [scorer], ["single"], parameters.unique_bpts, ["x", "y", "likelihood"], @@ -120,6 +121,8 @@ def run_inference_on_all_images( df = pd.DataFrame(poses, index=index, columns=columns) df.to_hdf(output_path, key="df_with_missing") + if save_as_csv: + df.to_csv(output_path.with_suffix(".csv")) if plot: test_config_path = str( @@ -157,6 +160,7 @@ def main( shuffle: Shuffle, snapshot_indices: int | list[int] | None = None, detector_snapshot_indices: int | list[int] | None = None, + save_as_csv: bool = False, plot: bool = False, ) -> None: """ @@ -165,6 +169,7 @@ def main( shuffle: snapshot_indices: detector_snapshot_indices: + save_as_csv: plot: Returns: @@ -189,7 +194,9 @@ def main( for detector in detectors: for snapshot in snapshots: - run_inference_on_all_images(shuffle.project, snapshot, plot, detector) + run_inference_on_all_images( + shuffle.project, snapshot, save_as_csv, plot, detector + ) if __name__ == "__main__": @@ -201,5 +208,6 @@ def main( ), snapshot_indices=None, detector_snapshot_indices=-1, + save_as_csv=False, plot=False, ) diff --git a/benchmark/utils.py b/benchmark/utils.py index 046ab566b0..9ce68c6571 100644 --- a/benchmark/utils.py +++ b/benchmark/utils.py @@ -338,3 +338,24 @@ def _get_model_folder( ) metadata = af.load_metadata(str(project_path / metadata_filename)) return metadata[0], [int(i) for i in metadata[1]], [int(i) for i in metadata[2]] + + +@dataclass +class WandBConfig: + project: str + run_name: str + image_log_interval: int | None = None + save_code: bool = True + tags: tuple[str, ...] | None = None + group: str | None = None + + def data(self) -> dict: + return dict( + type="WandbLogger", + project_name=self.project, + run_name=self.run_name, + image_log_interval=self.image_log_interval, + save_code=self.save_code, + tags=self.tags, + group=self.group, + ) diff --git a/benchmark/utils_augmentation.py b/benchmark/utils_augmentation.py new file mode 100644 index 0000000000..a9e3213373 --- /dev/null +++ b/benchmark/utils_augmentation.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class BatchCollate: + """Resize + scale images when batching""" + min_scale: float + max_scale: float + min_short_side: int = 256 + max_short_side: int = 1152 + multiple_of: int | None = None + max_ratio: float = 2.0 + to_square: bool = False + + def data(self) -> dict: + return { + "type": "ResizeFromDataSizeCollate", + "min_scale": self.min_scale, + "max_scale": self.max_scale, + "min_short_side": self.min_short_side, + "max_short_side": self.max_short_side, + "max_ratio": self.max_ratio, + "multiple_of": self.multiple_of, + "to_square": self.to_square, + } + + +@dataclass +class AutoPadding: + """Random crop around keypoints""" + pad_height_divisor: int + pad_width_divisor: int + border_mode: str = "constant" + + def data(self) -> dict: + return { + "pad_height_divisor": self.pad_height_divisor, + "pad_width_divisor": self.pad_width_divisor, + "border_mode": self.border_mode, + } + + +@dataclass +class AffineAugmentation: + """An affine image augmentation""" + + p: float = 0.5 + rotation: int = 0 + scale: tuple[float, float] = (1, 1) + translation: int = 0 + + def data(self) -> dict: + return { + "p": self.p, + "scaling": self.scale, + "rotation": self.rotation, + "translation": self.translation, + } + + +@dataclass +class CropSampling: + """Random crop around keypoints""" + width: int + height: int + max_shift: float = 0.4 + method: str = "uniform" # "uniform", "keypoints", "density", "hybrid" + + def __post_init__(self): + assert self.method in ("uniform", "keypoints", "density", "hybrid") + assert 0 <= self.max_shift <= 1 + + def data(self) -> dict: + return { + "width": self.width, + "height": self.height, + "max_shift": self.max_shift, + "method": self.method, + } + + +@dataclass +class ImageAugmentations: + """ + The default augmentation only normalizes images. + + Examples: + gaussian_noise: 12.75 + resize: {height: 800, width: 800, keep_ratio: true} + rotation: 30 + scale_jitter: (0.5, 1.25) + translation: 40 + """ + + normalize: bool = True + affine: AffineAugmentation | None = None + auto_padding: AutoPadding | None = None + covering: bool = False + gaussian_noise: float | bool = False + hist_eq: bool = False + motion_blur: bool = False + resize: dict | None = None + crop_sampling: CropSampling | None = None + collate: BatchCollate | None = None + + def data(self) -> dict: + augmentations = { + "normalize_images": self.normalize, + "covering": self.covering, + "gaussian_noise": self.gaussian_noise, + "hist_eq": self.hist_eq, + "motion_blur": self.motion_blur, + "auto_padding": False, + "affine": False, + "resize": False, + "crop_sampling": False, + "collate": False, + } + if self.auto_padding is not None: + augmentations["auto_padding"] = self.auto_padding.data() + if self.affine is not None: + augmentations["affine"] = self.affine.data() + if self.resize is not None: + augmentations["resize"] = self.resize + if self.crop_sampling is not None: + augmentations["crop_sampling"] = self.crop_sampling.data() + if self.collate is not None: + augmentations["collate"] = self.collate.data() + return augmentations diff --git a/benchmark/utils_models.py b/benchmark/utils_models.py new file mode 100644 index 0000000000..1e7273b133 --- /dev/null +++ b/benchmark/utils_models.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from dataclasses import dataclass, asdict + +from benchmark_train import TrainParameters +from utils import WandBConfig +from utils_augmentation import ImageAugmentations + + +@dataclass +class BackboneConfig: + """ + Attributes: + model_name: the timm model name ("resnet50", "resnet50_gn", "hrnet_w18", ...) + output_stride: 8, 16 or 32 (HRNet only supports 32) + freeze_bn_weights: freeze batch norm weights + freeze_bn_stats: freeze batch norm stats + kwargs: any keyword-arguments for the backbone type that was selected, e.g. + HRNet: ``only_high_res: bool`` only use the high-resolution branch as the + image features (otherwise, in DEKR style all branches are interpolated + to the same shape and concatenated). + """ + + model_name: str = "resnet50" + output_stride: int | None = None + freeze_bn_weights: bool | None = None + freeze_bn_stats: bool | None = None + drop_path_rate: float | None = None + drop_block_rate: float | None = None + kwargs: dict | None = None + + def to_dict(self) -> dict: + config = asdict(self) + config.pop("kwargs") + for k in list(config.keys()): + if config[k] is None: + config.pop(k) + if self.kwargs is not None: + for k, v in self.kwargs.items(): + config[k] = v + return config + + +@dataclass +class HeadConfig: + plateau_targets: bool + heatmap_config: dict + locref_config: dict | None + + def to_dict(self) -> dict: + output_channels = self.heatmap_config["channels"][-1] + if self.heatmap_config.get("final_conv") is not None: + output_channels = self.heatmap_config["final_conv"]["out_channels"] + predictor = dict( + type="HeatmapPredictor", + location_refinement=self.locref_config is not None, + locref_std=7.2801, + ) + target_generator = dict( + type=( + "HeatmapPlateauGenerator" + if self.plateau_targets + else "HeatmapGaussianGenerator" + ), + num_heatmaps=output_channels, + pos_dist_thresh=17, + heatmap_mode="KEYPOINT", + generate_locref=self.locref_config is not None, + locref_std=7.2801, + ) + criterion = dict(heatmap=dict(type="WeightedBCECriterion", weight=1.0)) + if self.locref_config is not None: + criterion["locref"] = dict(type="WeightedHuberCriterion", weight=0.05) + + return dict( + type="HeatmapHead", + predictor=predictor, + target_generator=target_generator, + criterion=criterion, + heatmap_config=self.heatmap_config, + locref_config=self.locref_config, + ) + + @staticmethod + def build_plateau_head( + c_in: int, + c_out: int, + deconv: list[tuple[int, int, int]], # channel, kernel, stride + final_conv: bool = False, + ) -> HeadConfig: + heatmap = dict(channels=[c_in], kernel_size=[], strides=[], final_conv=None) + locref = dict(channels=[c_in], kernel_size=[], strides=[], final_conv=None) + for c, k, s in deconv: + for config in (heatmap, locref): + config["channels"].append(c) + config["kernel_size"].append(k) + config["strides"].append(s) + + if final_conv: + heatmap["final_conv"] = dict(out_channels=c_out, kernel_size=1) + locref["final_conv"] = dict(out_channels=2 * c_out, kernel_size=1) + else: + assert deconv[-1][0] == c_out + locref["channels"][-1] = 2 * c_out + + return HeadConfig( + plateau_targets=True, + heatmap_config=heatmap, + locref_config=locref, + ) + + +@dataclass +class DetectorConfig(TrainParameters): + train_aug: ImageAugmentations | None = None + inference_aug: ImageAugmentations | None = None + optimizer_config: dict | None = None + scheduler_config: dict | None = None + + def train_kwargs(self) -> dict: + kwargs = super().train_kwargs() + if self.train_aug is not None: + data = kwargs.get("data", {}) + data["train"] = self.train_aug.data() + kwargs["data"] = data + if self.inference_aug is not None: + data = kwargs.get("data", {}) + data["inference"] = self.inference_aug.data() + kwargs["data"] = data + if self.optimizer_config is not None: + kwargs["runner"]["optimizer"] = self.optimizer_config + if self.scheduler_config is not None: + kwargs["runner"]["scheduler"] = self.scheduler_config + return kwargs + + +@dataclass +class ModelConfig(TrainParameters): + net_type: str = "resnet_50" + train_aug: ImageAugmentations | None = None + inference_aug: ImageAugmentations | None = None + backbone_config: BackboneConfig | None = None + head_config: HeadConfig | None = None + optimizer_config: dict | None = None + scheduler_config: dict | None = None + wandb_config: WandBConfig | None = None + + def train_kwargs(self) -> dict: + kwargs = super().train_kwargs() + if self.train_aug is not None: + data = kwargs.get("data", {}) + data["train"] = self.train_aug.data() + kwargs["data"] = data + if self.inference_aug is not None: + data = kwargs.get("data", {}) + data["inference"] = self.inference_aug.data() + kwargs["data"] = data + if self.backbone_config is not None: + kwargs["model"] = dict(backbone=self.backbone_config.to_dict()) + if self.head_config is not None: + model_config = kwargs.get("model", {}) + model_config["heads"] = dict(bodypart=self.head_config.to_dict()) + kwargs["model"] = model_config + if self.wandb_config is not None: + kwargs["logger"] = self.wandb_config.data() + if self.optimizer_config is not None: + runner = kwargs.get("runner", {}) + runner["optimizer"] = self.optimizer_config + kwargs["runner"] = runner + if self.scheduler_config is not None: + runner = kwargs.get("runner", {}) + runner["scheduler"] = self.scheduler_config + kwargs["runner"] = runner + return kwargs diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index d6882ee834..e6a63fb324 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -950,10 +950,14 @@ def create_training_dataset( ) if augmenter_type not in augmenters: - raise ValueError( - f"Invalid augmenter type: {augmenter_type} (available: for " - f"engine={engine}: {augmenters})" - ) + if engine != Engine.PYTORCH: + raise ValueError( + f"Invalid augmenter type: {augmenter_type} (available: for " + f"engine={engine}: {augmenters})" + ) + + logging.info(f"Switching augmentation to {default_augmenter} for PyTorch") + augmenter_type = default_augmenter if posecfg_template: if net_type != prior_cfg["net_type"]: diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 6cbbbf0ee4..944f92b66b 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -15,11 +15,13 @@ import logging import albumentations as A +import numpy as np from torch.utils.data import DataLoader import deeplabcut.pose_estimation_pytorch.config as torch_config import deeplabcut.pose_estimation_pytorch.utils as utils from deeplabcut.pose_estimation_pytorch.data import build_transforms, DLCLoader, Loader +from deeplabcut.pose_estimation_pytorch.data.collate import COLLATE_FUNCTIONS from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel from deeplabcut.pose_estimation_pytorch.runners import build_training_runner from deeplabcut.pose_estimation_pytorch.task import Task @@ -101,6 +103,11 @@ def train( f" for testing" ) + collate_fn = None + if collate_fn_cfg := run_config["data"]["train"].get("collate"): + collate_fn = COLLATE_FUNCTIONS.build(collate_fn_cfg) + logging.info(f"Using custom collate function: {collate_fn_cfg}") + batch_size = run_config["train_settings"]["batch_size"] num_workers = run_config["train_settings"]["dataloader_workers"] pin_memory = run_config["train_settings"]["dataloader_pin_memory"] @@ -108,6 +115,7 @@ def train( train_dataset, batch_size=batch_size, shuffle=True, + collate_fn=collate_fn, num_workers=num_workers, pin_memory=pin_memory, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index f67ab422a6..978cfe3e4e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -10,7 +10,6 @@ # from __future__ import annotations -import logging from pathlib import Path from typing import Callable @@ -20,6 +19,9 @@ from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters +from deeplabcut.pose_estimation_pytorch.data.dlcloader import ( + build_dlc_dataframe_columns, +) from deeplabcut.pose_estimation_pytorch.data.postprocessor import ( build_bottom_up_postprocessor, build_detector_postprocessor, @@ -198,16 +200,6 @@ def build_predictions_dataframe( Returns: """ - kpt_entries = ["x", "y", "likelihood"] - col_names = ["scorer", "individuals", "bodyparts", "coords"] - - col_values = [] - for i in parameters.individuals: - for b in parameters.bodyparts: - col_values += [(scorer, i, b, entry) for entry in kpt_entries] - for unique_bpt in parameters.unique_bpts: - col_values += [(scorer, "single", unique_bpt, entry) for entry in kpt_entries] - prediction_data = [] index_data = [] for image, image_predictions in predictions.items(): @@ -229,7 +221,11 @@ def build_predictions_dataframe( return pd.DataFrame( prediction_data, index=index, - columns=pd.MultiIndex.from_tuples(col_values, names=col_names), + columns=build_dlc_dataframe_columns( + scorer=scorer, + parameters=parameters, + with_likelihood=True, + ), ) @@ -298,7 +294,9 @@ def get_inference_runners( if detector_path is not None: if detector_transform is None: - detector_transform = build_transforms(model_config["detector"]["data"]) + detector_transform = build_transforms( + model_config["detector"]["data"]["inference"] + ) detector_config = model_config["detector"]["model"] if "pretrained" in detector_config: @@ -310,7 +308,7 @@ def get_inference_runners( device=device, snapshot_path=detector_path, preprocessor=build_bottom_up_preprocessor( - color_mode=model_config["detector"]["data"], + color_mode=model_config["detector"]["data"]["colormode"], transform=detector_transform, ), postprocessor=build_detector_postprocessor( diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml index 5ca29a598c..f95a8c8bc7 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml @@ -3,10 +3,6 @@ data: auto_padding: # Required for HRNet backbones pad_width_divisor: 32 pad_height_divisor: 32 - train: - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 model: backbone: type: HRNet diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml index daf3c6a80c..e72374bb92 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml @@ -3,10 +3,6 @@ data: auto_padding: # Required for HRNet backbones pad_width_divisor: 32 pad_height_divisor: 32 - train: - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 model: backbone: type: HRNet diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml index a621526c3e..f87cc5eade 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml @@ -3,10 +3,6 @@ data: auto_padding: # Required for HRNet backbones pad_width_divisor: 32 pad_height_divisor: 32 - train: - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 model: backbone: type: HRNet diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml index c762be5a4a..9a49e7f57b 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -6,8 +6,15 @@ data: affine: p: 0.9 rotation: 30 - scaling: [ 0.5, 1.25 ] translation: 40 + collate: + type: ResizeFromDataSizeCollate + min_scale: 0.4 + max_scale: 1.0 + min_short_side: 128 + max_short_side: 1152 + multiple_of: 32 + to_square: false covering: true gaussian_noise: 12.75 hist_eq: true @@ -39,4 +46,5 @@ train_settings: dataloader_pin_memory: true display_iters: 500 epochs: 200 + pretrained_weights: null seed: 42 diff --git a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml index 0b23e6493b..db5f475ad7 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml @@ -4,13 +4,21 @@ detector: inference: normalize_images: true train: - hflip: true - normalize_images: true affine: - p: 0.9 + p: 0.5 rotation: 30 - scaling: [ 0.5, 1.25 ] + scaling: [ 1.0, 1.0 ] translation: 40 + collate: + type: ResizeFromDataSizeCollate + min_scale: 0.4 + max_scale: 1.0 + min_short_side: 128 + max_short_side: 1152 + multiple_of: 32 + to_square: false + hflip: true + normalize_images: true model: type: FasterRCNN variant: fasterrcnn_mobilenet_v3_large_fpn diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml index 92406a3dbf..8891e67c6c 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml @@ -1,3 +1,8 @@ +data: + inference: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 model: backbone: type: HRNet diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml index 6e5bb8cfdb..c6e9176aba 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml @@ -1,3 +1,8 @@ +data: + inference: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 model: backbone: type: HRNet diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml index 7ee1264f03..9b21e352c0 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml @@ -1,3 +1,8 @@ +data: + inference: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 model: backbone: type: HRNet diff --git a/deeplabcut/pose_estimation_pytorch/data/collate.py b/deeplabcut/pose_estimation_pytorch/data/collate.py new file mode 100644 index 0000000000..701075ee53 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/collate.py @@ -0,0 +1,191 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Custom collate functions""" +from __future__ import annotations + +from abc import ABC, abstractmethod + +import numpy as np +from torch.utils.data import default_collate + +from deeplabcut.pose_estimation_pytorch.data.image import resize_and_random_crop +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry + + +COLLATE_FUNCTIONS = Registry("collate_functions", build_func=build_from_cfg) + + +class CollateFunction(ABC): + """A class that can be called as a collate function""" + + @abstractmethod + def __call__(self, batch) -> dict | list: + """Returns: the collated batch""" + raise NotImplementedError() + + +class ResizeCollate(CollateFunction, ABC): + """A collate function which resizes all images in a batch to the same size + + Args: + max_shift: The maximum shift, in pixels, to add to the random crop (this means + there can be a slight border around the image) + max_size: The maximum size of the long edge of the image when resized. If the + longest side will be greater than this value, resizes such that the longest + side is this size, and the shortest side is smaller than the desired size. + This is useful to keep some information from images with extreme aspect + ratios. + seed: The random seed to use to sample scales/sizes. + """ + + def __init__( + self, + max_shift: int = 10, + max_size: int = 2048, + seed: int = 0, + ) -> None: + self.generator = np.random.default_rng(seed=seed) + self.max_size = max_size + self.max_shift = max_shift + self._current_batch = [] + + @abstractmethod + def _sample_scale(self) -> int | tuple[int, int]: + """Returns: the target shape for images in the batch""" + raise NotImplementedError() + + def __call__(self, batch) -> dict | list: + """Returns: the collated batch""" + self._current_batch = batch + new_size = self._sample_scale() + updated_batch = [] + for item in batch: + image, new_targets = resize_and_random_crop( + image=item["image"], + targets=item, + size=new_size, + max_size=self.max_size, + max_shift=self.max_shift, + ) + new_targets["image"] = image + updated_batch.append(new_targets) + + return default_collate(updated_batch) + + +@COLLATE_FUNCTIONS.register_module +class ResizeFromDataSizeCollate(ResizeCollate): + """A collate function which resizes all images in a batch to the same size + + The target size is obtained by taking the size of the first image in the batch, and + multiplying it by a scale taken uniformly at random from (min_scale, max_scale). + + The aspect ratio of all images in the batch is preserved, with cropping/padding used + to generate images of the correct shapes. + + If to_square: + The images will be resized to squares, where the side is the short side of the + original image. + else: + The images will be resized to a scaled version of the shape of the first image. + + Args: + min_scale: The minimum scale factor to apply to the image size + max_scale: The maximum scale factor to apply to the image size + min_short_side: The smallest size for the target short side. + max_short_side: The largest size for the target short side. + max_ratio: The largest aspect ratio allowed for a target (longSide / shortSide). + If the aspect ratio is larger, it will be clamped to max_ratio. Must be >=1. + multiple_of: If defined, the height and width of all target sizes will be a + multiple of this value. + to_square: Whether images should be resized to squares. + """ + + def __init__( + self, + min_scale: float, + max_scale: float, + min_short_side: int = 128, + max_short_side: int = 1152, + max_ratio: float = 2.0, + multiple_of: int | None = None, + to_square: bool = False, + **kwargs + ) -> None: + super().__init__(**kwargs) + self.min_scale = min_scale + self.max_scale = max_scale + self.min_short_side = min_short_side + self.max_short_side = max_short_side + self.max_ratio = max_ratio + self.multiple_of = multiple_of + self.to_square = to_square + + def _sample_scale(self) -> int | tuple[int, int]: + if len(self._current_batch) == 0: + raise ValueError("Cannot sample frame shape: no items in current batch") + + h, w = self._current_batch[0]["image"].shape[1:] + scale = self.generator.uniform(self.min_scale, self.max_scale) + if self.to_square: + short_side = min(h, w) + size = int(round( + min(self.max_short_side, max(self.min_short_side, scale * short_side)) + )) + if self.multiple_of is not None: + size = _to_multiple(size, self.multiple_of) + return size + + short, long = min(h, w), max(h, w) + ratio = long / short + if ratio > self.max_ratio: + ratio = self.max_ratio + + short_size = int( + round(min(self.max_short_side, max(self.min_short_side, scale * short))) + ) + if h < w: + h = short_size + w = int(ratio * short_size) + else: + h = int(ratio * short_size) + w = short_size + + if self.multiple_of is not None: + w = _to_multiple(w, self.multiple_of) + h = _to_multiple(h, self.multiple_of) + + return h, w + + +@COLLATE_FUNCTIONS.register_module +class ResizeFromListCollate(ResizeCollate): + """A collate function which resizes all images in a batch to the same size + + The target size image size is sampled from a list. If it's a list of integers, + all images will be resized into squares. If it's a list of tuples, that will be the + target (h, w) for images. + + Args: + scales: The target sizes to resize the images to. + """ + + def __init__(self, scales: list[int] | list[tuple[int, int]], **kwargs) -> None: + super().__init__(**kwargs) + self.scales = scales + + def _sample_scale(self) -> int | tuple[int, int]: + return self.generator.choice(self.scales) + + +def _to_multiple(value: int, of: int) -> int: + """Returns: the smallest integer >= ``value`` which is a multiple of ``of``""" + return of * ((value + of - 1) // of) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index e7f14b829e..46ddbe1371 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -164,7 +164,12 @@ def __getitem__(self, index: int) -> dict: ) keypoints[:, :, 0] = (keypoints[:, :, 0] - offsets[0]) / scales[0] keypoints[:, :, 1] = (keypoints[:, :, 1] - offsets[1]) / scales[1] - bboxes = np.zeros((0, 4)) # No more bboxes as we cropped around them + bboxes = bboxes[:1] + bboxes[..., 0] = (bboxes[..., 0] - offsets[0]) / scales[0] + bboxes[..., 1] = (bboxes[..., 1] - offsets[1]) / scales[1] + bboxes[..., 2] = bboxes[..., 2] / scales[0] + bboxes[..., 3] = bboxes[..., 3] / scales[1] + bboxes = np.clip(bboxes, 0, self.parameters.cropped_image_size[0] - 1) transformed = self.apply_transform_all_keypoints( image, keypoints, keypoints_unique, bboxes @@ -225,11 +230,17 @@ def _prepare_final_annotation_dict( if self.task == Task.TOP_DOWN: num_animals = 1 + bbox_widths = np.maximum(1, bboxes[..., 2]) + bbox_heights = np.maximum(1, bboxes[..., 3]) + area = bbox_widths * bbox_heights + if 'individual_id' not in anns: + anns['individual_id'] = -np.ones(len(anns['category_id']), dtype=int) + return { "keypoints": pad_to_length(keypoints[..., :2], num_animals, -1).astype(np.single), "keypoints_unique": keypoints_unique[..., :2].astype(np.single), "with_center_keypoints": self.parameters.with_center_keypoints, - "area": pad_to_length(anns["area"], num_animals, 0).astype(np.single), + "area": pad_to_length(area, num_animals, 0).astype(np.single), "boxes": pad_to_length(bboxes, num_animals, 0).astype(np.single), "is_crowd": pad_to_length(anns["iscrowd"], num_animals, 0).astype(int), "labels": pad_to_length(anns["category_id"], num_animals, -1).astype(int), diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index 1a892c412f..d663f0449b 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -11,11 +11,13 @@ """Class implementing the Loader for DeepLabCut projects""" from __future__ import annotations +import logging import pickle from pathlib import Path import numpy as np import pandas as pd +import scipy.io as sio import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.core.engine import Engine @@ -59,23 +61,29 @@ def __init__( engine=Engine.PYTORCH, modelprefix=modelprefix, ) + self._resolutions = set() + super().__init__( self._project_root / self._model_folder / "train" / Engine.PYTORCH.pose_cfg_name ) - self._split = self.load_split(self._project_config, trainset_index, shuffle) - self._df = self.load_ground_truth(self._project_config) - self._dfs = { - split: self.drop_duplicates(df) - for split, df in self.split_data(self._df, self._split).items() - } + self._dfs, image_sizes = self.load_ground_truth( + self._project_config, + trainset_index=trainset_index, + shuffle=shuffle, + ) + self._resolutions = self._resolutions.union(image_sizes) @property def df(self) -> pd.DataFrame: """Returns: The ground truth dataframe. Should not be modified.""" - return self._df + return self._dfs["full"] + + def image_resolutions(self) -> set[tuple[int, int]]: + """Returns: The collection of image resolutions present in the dataset""" + return self._resolutions @property def evaluation_folder(self) -> Path: @@ -142,6 +150,59 @@ def load_data(self, mode: str = "train") -> dict: data["annotations"] = with_bbox return data + def load_ground_truth( + self, + config: dict, + trainset_index: int, + shuffle: int, + ) -> tuple[dict[str, pd.DataFrame], set[tuple[int, int]]]: + """Loads the ground truth dataset for a DeepLabCut project. + + Args: + config: the DeepLabCut project configuration file + trainset_index: the TrainingsetFraction for which to load data + shuffle: the index of the shuffle for which to load data + + Returns: ground_truth_dataframes, image_resolutions + ground_truth_dataframes: a dictionary containing the different DataFrames + for the annotated DeepLabCut data for the current iteration + image_resolutions: all possible image resolutions in the dataset + + Raises: + ValueError: if the data contained in the ground truth HDF does not contain + a dataframe. + """ + trainset_dir = Path(config["project_path"]) / af.get_training_set_folder(config) + dataset_path = f"CollectedData_{config['scorer']}.h5" + train_frac = int(100 * config["TrainingFraction"][trainset_index]) + project_id = f"{config['Task']}_{config['scorer']}" + dataset_file = trainset_dir / f"{project_id}{train_frac}shuffle{shuffle}" + params = self.get_dataset_parameters() + + # as in TF DeepLabCut, load the training data from the .mat/.pickle file + if config.get("multianimalproject", False): + image_sizes, df_train = _load_pickle_dataset( + dataset_file.with_suffix(".pickle"), config["scorer"], params=params, + ) + else: + image_sizes, df_train = _load_mat_dataset( + dataset_file.with_suffix(".mat"), config["scorer"], params=params, + ) + + # load the full dataset file + df = pd.read_hdf(trainset_dir / dataset_path) + if not isinstance(df, pd.DataFrame): + raise ValueError( + f"The ground truth data in {trainset_dir} must contain a DataFrame! " + f"Found {df}" + ) + + # load the data splits, check that there's nothing suspect + splits = self.load_split(self._project_config, trainset_index, shuffle) + dfs = self.split_data(df, splits) + dfs["full"] = df + return _validate_dataframes(dfs, df_train), image_sizes + @staticmethod def load_split( config: dict, @@ -168,33 +229,8 @@ def load_split( train_ids = [int(i) for i in meta[1]] test_ids = [int(i) for i in meta[2]] - return {"train": train_ids, "test": test_ids} - @staticmethod - def load_ground_truth(config: dict) -> pd.DataFrame: - """Loads the ground truth dataset for a DeepLabCut project. - - Args: - config: the DeepLabCut project configuration file - - Returns: - the annotated DeepLabCut data for the current iteration - - Raises: - ValueError: if the data contained in the ground truth HDF does not contain - a dataframe. - """ - trainset_dir = Path(config["project_path"]) / af.get_training_set_folder(config) - dataset_path = f"CollectedData_{config['scorer']}.h5" - df = pd.read_hdf(trainset_dir / dataset_path) - if not isinstance(df, pd.DataFrame): - raise ValueError( - f"The ground truth data in {trainset_dir} must contain a DataFrame! " - f"Found {df}" - ) - return df - @staticmethod def split_data( dlc_df: pd.DataFrame, @@ -220,11 +256,6 @@ def split_data( split_dfs[k] = dlc_df.iloc[indices] return split_dfs - @staticmethod - def drop_duplicates(df: pd.DataFrame) -> pd.DataFrame: - """Returns: the DataFrame with no duplicate rows""" - return df[~df.index.duplicated(keep="first")] - @staticmethod def to_coco( project_root: str | Path, @@ -276,7 +307,7 @@ def to_coco( base_path = Path(project_root) for idx, row in df.iterrows(): image_id = len(images) + 1 - rel_path = Path(*idx) if isinstance(idx, tuple) else Path(idx) + rel_path = Path(*idx) if isinstance(idx, tuple) else Path(str(idx)) path = str(base_path / rel_path) _, height, width = read_image_shape_fast(path) images.append( @@ -302,7 +333,19 @@ def to_coco( raw_keypoints = data.to_numpy().reshape((-1, 2)) keypoints = np.zeros((len(raw_keypoints), 3)) keypoints[:, :2] = raw_keypoints - is_visible = ~pd.isnull(raw_keypoints).all(axis=1) + is_visible = np.logical_and( + ~pd.isnull(raw_keypoints).all(axis=1), + np.logical_and( + np.logical_and( + 0 < keypoints[..., 0], + keypoints[..., 0] < width, + ), + np.logical_and( + 0 < keypoints[..., 1], + keypoints[..., 1] < height, + ), + ) + ) keypoints[:, 2] = np.where(is_visible, 2, 0) num_keypoints = is_visible.sum() if num_keypoints > 0: @@ -320,3 +363,260 @@ def to_coco( ) return {"annotations": anns, "categories": categories, "images": images} + + +def _load_mat_dataset( + file: Path, + scorer: str, + params: PoseDatasetParameters, +) -> tuple[set[tuple[int, int]], pd.DataFrame]: + """Loads the training dataset stored as a .mat file + + Returns: images_sizes, dlc_dataset + images_sizes: all possible images sizes in the dataset + dlc_dataset: the dataset in a DLC-format DataFrame + """ + if not params.max_num_animals == 1: + raise RuntimeError( + f"Cannot load a multi-animal pose dataset from a `.mat` file ({file})" + ) + + raw_data = sio.loadmat(str(file)) + dataset = raw_data["dataset"] + num_images = dataset.shape[1] + + image_sizes = set() + index, data = [], [] + for i in range(num_images): + item = dataset[0, i] + + # add the image size + c, h, w = item[1][0] + image_sizes.add((h, w)) + + # parse image path + raw_path = item[0][0] + if isinstance(raw_path, str): + image_path = Path(raw_path).parts[-3:] + else: + image_path = tuple([p.strip() for p in raw_path]) + index.append(image_path) + + # parse data + keypoints = np.zeros((1, params.num_joints, 2)) + keypoints.fill(np.nan) + if len(item) >= 3: + joints = item[2][0][0] + for joint_id, x, y in joints: + keypoints[0, joint_id, 0] = x + keypoints[0, joint_id, 1] = y + + joint_id = joints[:, 0] + if joint_id.size != 0: # make sure joint ids are 0-indexed + assert (joint_id < params.num_joints).any() + joints[:, 0] = joint_id + + data.append(keypoints) + + dataframe = pd.DataFrame( + data=np.stack(data, axis=0).reshape((num_images, -1)), + index=pd.MultiIndex.from_tuples(index), + columns=build_dlc_dataframe_columns(scorer, params, False), + ) + dataframe = dataframe.sort_index(axis=0) + return image_sizes, dataframe + + +def _load_pickle_dataset( + file: Path, + scorer: str, + params: PoseDatasetParameters, +) -> tuple[set[tuple[int, int]], pd.DataFrame]: + """Loads the training dataset stored as a .mat file + + Returns: images_sizes, dlc_dataset + images_sizes: all possible images sizes in the dataset + dlc_dataset: the dataset in a DLC-format DataFrame + """ + with open(file, "rb") as f: + raw_data = pickle.load(f) + + num_images = len(raw_data) + image_sizes = set() + index, data = [], [] + data_unique = None + if params.num_unique_bpts > 0: + data_unique = [] + + for image_data in raw_data: + # add image path + index.append(image_data["image"]) + + # add image size + c, h, w = image_data["size"] + image_sizes.add((h, w)) + + # add keypoints + keypoints = np.zeros((params.max_num_animals, params.num_joints, 2)) + keypoints.fill(np.nan) + keypoints_unique = None + for idv_idx, idv_bodyparts in image_data.get("joints", {}).items(): + if idv_idx < params.max_num_animals: + for joint_id, x, y in idv_bodyparts: + bodypart = int(joint_id) + keypoints[idv_idx, bodypart, 0] = x + keypoints[idv_idx, bodypart, 1] = y + + elif idv_idx == params.max_num_animals and data_unique is not None and keypoints_unique is None: + keypoints_unique = np.zeros((params.num_unique_bpts, 2)) + keypoints_unique.fill(np.nan) + for joint_id, x, y in idv_bodyparts: + unique_bpt_id = int(joint_id) - params.num_joints + keypoints_unique[unique_bpt_id, 0] = x + keypoints_unique[unique_bpt_id, 1] = y + + else: + raise ValueError(f"Malformed dataset: {params}, {image_data}") + + data.append(keypoints) + if data_unique is not None: + if keypoints_unique is None: + keypoints_unique = np.zeros((params.num_unique_bpts, 2)) + keypoints_unique.fill(np.nan) + data_unique.append(keypoints_unique) + + data = np.stack(data, axis=0).reshape((num_images, -1)) + if data_unique is not None: + data_unique = np.stack(data_unique, axis=0).reshape((num_images, -1)) + data = np.concatenate([data, data_unique], axis=1) + + dataframe = pd.DataFrame( + data=data, + index=pd.MultiIndex.from_tuples(index), + columns=build_dlc_dataframe_columns(scorer, params, False), + ) + dataframe = dataframe.sort_index(axis=0) + return image_sizes, dataframe + + +def _validate_dataframes( + dfs: dict[str, pd.DataFrame], df_train: pd.DataFrame, strict: bool = False, +) -> dict[str, pd.DataFrame]: + """Validates the training/test DataFrames + + Performs the following validation steps: + 1. Checks that the training data loaded from CollectedData.h5 matches the + training data stored in the ".mat" or ".pickle" file. + 2. Checks that there are no duplicate entries in the DataFrames (if there are + any, removes them) + 3. Checks that there is no data leak between the training and test set (if there + is, prints a warning) + + Args: + dfs: the "full" and split DataFrames loaded from the H5 file + df_train: the training data loaded from the ".mat" or ".pickle" file + strict: Whether to fail if the data does not pass validation (instead of + attempting a fix). + + Returns: + The validated and sanitized DataFrames + + Raises: + ValueError: if strict and there is a small fixable error, or if there are images + that are present in both the training and test set. + """ + error = False + + # checks that all images in the .pickle/.mat file are in the HDF + pickle_train_images = set(df_train.index) + hdf_train_images = set(dfs["train"].index) + missing_images = pickle_train_images - hdf_train_images + extra_images = hdf_train_images - pickle_train_images + if len(missing_images) > 0: + error = True + logging.debug( + f"Found images in the dataset file which were not in H5: {missing_images}" + ) + if len(extra_images) > 0: + error = True + logging.debug( + f"Found images in the H5 file which were not in the dataset: {extra_images}" + ) + + # checks that the data is close for the similar images + train_index = list(hdf_train_images.intersection(pickle_train_images)) + data_h5 = np.nan_to_num(dfs["full"].loc[train_index], nan=-1) + data_pickle_mat = np.nan_to_num(df_train, nan=-1) + if not np.isclose(data_h5, data_pickle_mat, atol=0.1).all(): + error = True + logging.debug( + "Found differences between the training-dataset HDF (.h5) data and the " + "training data found. This might be the case if you refined your data " + "after creating the dataset, and then created a new shuffle." + ) + + # checks that there are no duplicate entries + dfs_clean = {} + for split, df in dfs.items(): + dup = df.index.duplicated(keep="first") + num_dup = dup.sum() + if dup.sum() > 0: + error = True + logging.debug(f"Found {num_dup} duplicates in {split}: {df[dup].index}") + dfs_clean[split] = df[~dup] + else: + dfs_clean[split] = df[~dup] + + # check for leaks + if dfs["test"] is not None: + train_images = set(dfs["train"].index) + test_images = set(dfs["test"].index) + leak = train_images.intersection(test_images) + if len(leak) > 0: + logging.warning( + f"Found images both in the training and test set: {leak}! To resolve " + "this issue please try the following:\n" + f" 1. Check that each video is listed exactly once in your project's" + f"`config.yaml`\n" + f" 2. Make sure all of your videos have different names." + f" 3. You can use `dropduplicatesinannotatinfiles` and " + f"`comparevideolistsanddatafolders` to ensure that there are no more " + f"duplicates" + f" 3. Switch to a new iteration and create a fresh training dataset" + ) + + if error and strict: + raise ValueError(f"Found errors when validating the dataset") + + return dfs + + +def build_dlc_dataframe_columns( + scorer: str, + parameters: PoseDatasetParameters, + with_likelihood: bool, +) -> pd.MultiIndex: + """Builds the columns for a DeepLabCut DataFrame + + Args: + scorer: the scorer name + parameters: the parameters for the project + with_likelihood: whether the DataFrame contains pose likelihood + + Returns: + the multi-index columns for the DataFrame + """ + levels = ["scorer", "individuals", "bodyparts", "coords"] + kpt_entries = ["x", "y"] + if with_likelihood: + kpt_entries.append("likelihood") + + columns = [] + for i in parameters.individuals: + for b in parameters.bodyparts: + columns += [(scorer, i, b, entry) for entry in kpt_entries] + + for unique_bpt in parameters.unique_bpts: + columns += [(scorer, "single", unique_bpt, entry) for entry in kpt_entries] + + return pd.MultiIndex.from_tuples(columns, names=levels) diff --git a/deeplabcut/pose_estimation_pytorch/data/image.py b/deeplabcut/pose_estimation_pytorch/data/image.py new file mode 100644 index 0000000000..f2e7214978 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/data/image.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import copy + +import numpy as np +import torch +import torchvision.transforms.functional as F + +from deeplabcut.pose_estimation_pytorch.data.utils import _compute_crop_bounds + + +def resize_and_random_crop( + image: np.ndarray, + targets: dict, + size: int | tuple[int, int], + max_size: int | None = None, + max_shift: int | None = None, +) -> tuple[torch.tensor, dict]: + """Resizes images while preserving their aspect ratio + + If size is an integer: resizes to square images. + First, resizes the image so that it's short side is equal to `size`. If this + makes its long side greater than `max_size`, resizes the long side to `max_size` + and the short side to the corresponding value to preserve the aspect ratio. + + Then, the image is cropped to a size-by-size square with a random crop. + + If size is a tuple, resize images to (w=size[1], h=size[0]) + First, rescales the image while preserving the aspect ratio such that both its + width and height are greater or equal to the target width/height for the image + (where either the width/height is the target width/height). If this makes its + long side greater than `max_size`, resizes the long side to `max_size`. + + Then, the image is cropped to (w=size[1], h=size[0]) with a random crop. + + Args: + image: an image of shape (C, H, W) + targets: the dictionary containing targets + size: the size of the output image (it will be square) + max_size: if defined, the maximum size of any side of the output image + max_shift: the maximum shift for the crop after resizing + + Returns: image, targets + the resized image as a PyTorch tensor + the updated targets in the resized image + """ + + def get_resize_hw( + original_size: tuple[int, int], tgt_short_side: int, max_long_side: int | None + ) -> tuple[int, int]: + short_side, long_side = min(*original_size), max(*original_size) + tgt_long_side = int((tgt_short_side / short_side) * long_side) + + # if the image's long side will be too big, make the image smaller + if max_long_side is not None and tgt_long_side > max_long_side: + tgt_long_side = max_long_side + tgt_short_side = int((tgt_long_side / long_side) * short_side) + + # height is the short side + if original_size[0] < original_size[1]: + return tgt_short_side, tgt_long_side + + # width is the short side + return tgt_long_side, tgt_short_side + + def get_resize_preserve_ratio( + oh: int, ow: int, tgt_h: int, tgt_w: int, max_long_side: int | None + ) -> tuple[int, int]: + w_scale = ow / tgt_w + h_scale = oh / tgt_h + if h_scale <= w_scale: + h = tgt_h + w = int(ow * (tgt_h / oh)) + else: + h = int(oh * (tgt_w / ow)) + w = tgt_w + + # if the image's long side will be too big, make the image smaller + long_side = max(h, w) + if max_long_side is not None and long_side > max_long_side: + if h <= w: + w = max_long_side + h = int(oh * (max_long_side / ow)) + else: + w = int(ow * (max_long_side / oh)) + h = max_long_side + + return h, w + + oh, ow = image.shape[1:] + if isinstance(size, int): + h, w = get_resize_hw((oh, ow), tgt_short_side=size, max_long_side=max_size) + tgt_h, tgt_w = size, size + else: + h, w = get_resize_preserve_ratio( + oh, ow, size[0], size[1], max_long_side=max_size + ) + tgt_h, tgt_w = size + + scale_x, scale_y = ow / w, oh / h + scaled_image = F.resize(torch.tensor(image), [h, w]) + + # shift the image + if max_shift is None: + max_shift = 0 + extra_x, extra_y = max(0, w - tgt_w), max(0, h - tgt_h) + offset_x = np.random.randint( + max(-tgt_w // 2, -max(0, tgt_w - w) - max_shift), + min(max_shift + extra_x, extra_x + (min(w, tgt_w) // 2)), + ) + offset_y = np.random.randint( + max(-tgt_h // 2, -max(0, tgt_h - h) - max_shift), + min(max_shift + extra_y, extra_y + (min(h, tgt_h) // 2)), + ) + + # 0-pads, then crops if image size is smaller than output size along any edge + scaled_cropped_image = F.crop(scaled_image, offset_y, offset_x, tgt_h, tgt_w) + + # update targets + targets = copy.deepcopy(targets) + + # update scales and offsets + sx, sy = targets["scales"] + ox, oy = targets["offsets"] + targets["offsets"] = ox + (offset_x * sx), oy + (offset_y * sy) + targets["scales"] = sx * scale_x, sy * scale_y + + # update annotations + anns = targets.get("annotations", {}) + + kpt_scale = np.array([scale_x, scale_y]) + kpt_offset = np.array([offset_x, offset_y]) + for kpt_key in ["keypoints", "keypoints_unique"]: + keypoints = anns.get(kpt_key) + if keypoints is not None and len(keypoints) > 0: + scaled_kpts = keypoints.copy() + scaled_kpts[..., :2] = (scaled_kpts[..., :2] / kpt_scale) - kpt_offset + scaled_kpts[(scaled_kpts[..., 0] >= tgt_w)] = -1 + scaled_kpts[(scaled_kpts[..., 1] >= tgt_h)] = -1 + scaled_kpts[(scaled_kpts[..., :2] < 0).any(axis=-1)] = -1 + anns[kpt_key] = scaled_kpts + + bbox_scale = np.array([scale_x, scale_y, scale_x, scale_y]) + bbox_offset = np.array([offset_x, offset_y, 0, 0]) + for bbox_key in ["boxes"]: + boxes = anns.get(bbox_key) + if boxes is not None and len(boxes) > 0: + scaled_boxes = (boxes / bbox_scale) - bbox_offset + scaled_boxes = _compute_crop_bounds( + scaled_boxes, (tgt_h, tgt_w, 3), remove_empty=False, + ) + anns[bbox_key] = scaled_boxes + + area = anns.get("area") + if area is not None: + if "boxes" in anns: # recompute areas from the new bounding boxes + widths = np.maximum(anns["boxes"][..., 2], 1) + heights = np.maximum(anns["boxes"][..., 3], 1) + anns["area"] = widths * heights + else: # just rescale + scaled_area = area * (scale_x * scale_y) + anns["area"] = scaled_area + + return scaled_cropped_image, targets diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 065ac7be47..f56e89d5e4 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -323,7 +323,9 @@ def _crop_and_pad_image_torch( def _compute_crop_bounds( - bboxes: np.ndarray, image_shape: tuple[int, int, int] + bboxes: np.ndarray, + image_shape: tuple[int, int, int], + remove_empty: bool = True, ) -> np.ndarray: """ Compute the boundaries for cropping an image based on a COCO-format bounding box @@ -337,12 +339,19 @@ def _compute_crop_bounds( The bounding boxes, clipped to be entirely inside the image """ h, w = image_shape[:2] - bboxes[:, 0] = np.clip(bboxes[:, 0], 0, w) - bboxes[:, 2] = np.clip(np.minimum(bboxes[:, 2], w - bboxes[:, 0]), 0, None) - bboxes[:, 1] = np.clip(bboxes[:, 1], 0, h) - bboxes[:, 3] = np.clip(np.minimum(bboxes[:, 3], h - bboxes[:, 1]), 0, None) - squashed_bbox_mask = np.logical_or(bboxes[:, 2] <= 0, bboxes[:, 3] <= 0) - return bboxes[~squashed_bbox_mask] + # to xyxy + bboxes[:, 2] = bboxes[:, 0] + bboxes[:, 2] + bboxes[:, 3] = bboxes[:, 1] + bboxes[:, 3] + # clip + bboxes = np.clip(bboxes, 0, np.array([w, h, w, h])) + # to xywh + bboxes[:, 2] = bboxes[:, 2] - bboxes[:, 0] + bboxes[:, 3] = bboxes[:, 3] - bboxes[:, 1] + # filter + if remove_empty: + squashed_bbox_mask = np.logical_or(bboxes[:, 2] <= 0, bboxes[:, 3] <= 0) + bboxes = bboxes[~squashed_bbox_mask] + return bboxes def _extract_keypoints_and_bboxes( @@ -364,8 +373,9 @@ def _extract_keypoints_and_bboxes( original_bboxes = [] anns_to_merge = [] unique_keypoints = None + h, w = image_shape[:2] for i, annotation in enumerate(anns): - keypoints_individual = _annotation_to_keypoints(annotation) + keypoints_individual = _annotation_to_keypoints(annotation, h, w) if annotation["individual"] != "single": bbox_individual = annotation["bbox"] original_bboxes.append(bbox_individual) @@ -379,17 +389,22 @@ def _extract_keypoints_and_bboxes( keypoints = safe_stack(keypoints, (0, num_joints, 3)) original_bboxes = safe_stack(original_bboxes, (0, 4)) - bboxes = _compute_crop_bounds(original_bboxes, image_shape) + bboxes = _compute_crop_bounds(original_bboxes, image_shape, remove_empty=False) + + # at least 1 visible joint to keep individuals + vis_mask = (keypoints[..., 2] > 0).any(axis=1) + keypoints = keypoints[vis_mask] + bboxes = bboxes[vis_mask] keys_to_merge = ["area", "category_id", "iscrowd", "individual_id"] anns_merged = {k: [] for k in keys_to_merge} if len(anns_to_merge) > 0: anns_merged = merge_list_of_dicts(anns_to_merge, keys_to_include=keys_to_merge) + anns_merged = {k: np.array(v)[vis_mask] for k, v in anns_merged.items()} if len(anns_merged["area"]) != len(keypoints): raise ValueError(f"Missing area values! {anns_merged}, {keypoints.shape}") - anns_merged = {k: np.array(v) for k, v in anns_merged.items()} return keypoints, unique_keypoints, bboxes, anns_merged @@ -410,30 +425,40 @@ def calc_area_from_keypoints(keypoints: np.ndarray) -> np.ndarray: Returns: np.ndarray: array containing the computed areas based on the keypoints """ - w = keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1) - h = keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1) - area = w * h - area[area < 1] = 1 - return area + w = np.maximum(keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1), 1) + h = np.maximum(keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1), 1) + return w * h -def _annotation_to_keypoints(annotation: dict) -> np.array: +def _annotation_to_keypoints(annotation: dict, h: int, w: int) -> np.array: """ Convert the coco annotations into array of keypoints returns the array of the keypoints' visibility. If keypoint is not visible, the value for (x,y) coordinates - is set to 0 + is set to 0. If the keypoints are outside of the image, they are also set to 0. Args: annotation: dictionary containing coco-like annotations with essential `keypoints` field + h: the image height + w: the image width + Returns: keypoints: np.array where the first two columns are x and y coordinates of the keypoints and the third column is the visibility of the keypoints """ keypoints = annotation["keypoints"].reshape(-1, 3) - visibility_mask = keypoints > -1 + visibility_mask = np.logical_and( + np.logical_and( + 0 < keypoints[..., 0], + keypoints[..., 0] < w, + ), + np.logical_and( + 0 < keypoints[..., 1], + keypoints[..., 1] < h, + ), + ) keypoints[~visibility_mask] = 0 - keypoints[:, -1] = visibility_mask.all(axis=1) + keypoints[:, 2] = 2 * visibility_mask return keypoints @@ -521,6 +546,12 @@ def _apply_transform( transformed = _set_invalid_keypoints_to_neg_one( transformed, keypoints, class_labels ) + + bboxes_out = np.zeros(bboxes.shape) + for bbox, bbox_id in zip(transformed["bboxes"], transformed["bbox_labels"]): + bboxes_out[bbox_id] = bbox + + transformed["bboxes"] = bboxes_out return transformed diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index aa7df60f76..8e503d0ccb 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -331,13 +331,29 @@ def _compute_epoch_metrics(self) -> dict[str, float]: Returns: A dictionary containing the different losses for the step """ + num_animals = max( + [len(kpts) for kpts in self._epoch_ground_truth["bodyparts"].values()] + ) poses = pair_predicted_individuals_with_gt( self._epoch_predictions["bodyparts"], self._epoch_ground_truth["bodyparts"] ) + + # pad predictions if there are any missing (needed for top-down models) + gt, pred = {}, {} + for path, img_gt in self._epoch_ground_truth["bodyparts"].items(): + for kpt_dict, kpts in [(gt, img_gt), (pred, poses[path])]: + if len(kpts) < num_animals: + padded_kpts = np.zeros((num_animals, *kpts.shape[1:])) + padded_kpts.fill(np.nan) + padded_kpts[:len(kpts)] = kpts + kpt_dict[path] = padded_kpts + else: + kpt_dict[path] = kpts + scores = get_scores( - poses=poses, - ground_truth=self._epoch_ground_truth["bodyparts"], + poses=pred, + ground_truth=gt, unique_bodypart_poses=self._epoch_predictions.get("unique_bodyparts"), unique_bodypart_gt=self._epoch_ground_truth.get("unique_bodyparts"), pcutoff=0.6, @@ -497,6 +513,10 @@ def _update_epoch_predictions( scale_factors = np.array([scale_x, scale_y, scale_x, scale_y]) offset = np.array(offset) + # remove bboxes that are not visible + img_bbox_mask = (img_bboxes[:, 2] > 0.0) & (img_bboxes[:, 3] > 0.0) + img_bboxes = img_bboxes[img_bbox_mask] + # rescale ground truth bounding boxes gt_rescaled = img_bboxes.cpu().numpy() * scale_factors gt_rescaled[..., :2] = gt_rescaled[..., :2] + offset diff --git a/docs/pytorch/pytorch_config.md b/docs/pytorch/pytorch_config.md index fc88fa8bd8..4c9db14f10 100644 --- a/docs/pytorch/pytorch_config.md +++ b/docs/pytorch/pytorch_config.md @@ -62,8 +62,15 @@ data: affine: p: 0.9 rotation: 30 - scaling: [ 0.5, 1.25 ] translation: 40 + collate: # rescales the images when putting them in a batch + type: ResizeFromDataSizeCollate + min_scale: 0.4 + max_scale: 1.0 + min_short_side: 128 + max_short_side: 1152 + multiple_of: 32 + to_square: false covering: true gaussian_noise: 12.75 hist_eq: true @@ -71,6 +78,42 @@ data: normalize_images: true # this should always be set to true ``` +One of the most important elements is the `collate` configuration. If all images in your +dataset have the same size, then it doesn't necessarily need to be added (but might +still be beneficial). But if you have images of different sizes, then you'll need to +define a way of "combining" these images into a single tensor of shape `(B, 3, H, W)`. +The default way to do this is to use the `ResizeFromDataSizeCollate` collate function +(other collate functions are defined in +`deeplabcut/pose_estimation_pytorch/data/collate.py`). For each batch to collate, this +implementation: +1. Selects the target width & height all images will be resized to by getting the size +of the first image in the batch, and multiplying it by a scale sampled uniformly at +random from `(min_scale, max_scale)`. +2. Resizes all images in the batch (while preserving their aspect ratio) such that they +are the smallest size such that the target size fits entirely in the image. +3. Crops each resulting image into the target size with a random crop. + +**Collate**: Defines how images are collated into batches. + +```yaml +collate: # rescales the images when putting them in a batch + type: ResizeFromDataSizeCollate # You can also use `ResizeFromListCollate` + max_shift: 10 # the maximum shift, in pixels, to add to the random crop (this means + # there can be a slight border around the image) + max_size: 1024 # the maximum size of the long edge of the image when resized. If the + # longest side will be greater than this value, resizes such that the longest side + # is this size, and the shortest side is smaller than the desired size. This is + # useful to keep some information from images with extreme aspect ratios. + min_scale: 0.4 # the minimum scale to resize the image with + max_scale: 1.0 # the maximum scale to resize the image with + min_short_side: 128 # the minimum size of the target short side + max_short_side: 1152 # the maximum size of the target short side + multiple_of: 32 # pads the target height, width such that they are multiples of 32 + to_square: false # instead of using the aspect ratio of the first image, only the + # short side of the first image will be used to sample a "side", and the images will + # be cropped in squares +``` + The following transformations are available for the `train` and `inference` keys. **Affine**: Applies an affine (rotation, translation, scaling) transformation to the diff --git a/examples/testscript_pytorch_single_animal.py b/examples/testscript_pytorch_single_animal.py index 89e986353f..95be5258b2 100644 --- a/examples/testscript_pytorch_single_animal.py +++ b/examples/testscript_pytorch_single_animal.py @@ -86,11 +86,11 @@ def main( if __name__ == "__main__": main( - synthetic_data=True, + synthetic_data=False, net_types=["resnet_50", "hrnet_w18", "hrnet_w32", "hrnet_w48"], batch_size=8, - epochs=3, - save_epochs=1, + epochs=20, + save_epochs=10, max_snapshots_to_keep=2, device="cpu", # "cpu", "cuda:0", "mps" logger={ From b09f5e8d18bfecf554a5b77ad95da64119ad3820 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 1 May 2024 11:38:58 +0200 Subject: [PATCH 080/293] fix missing import --- .../pose_estimation_tensorflow/core/evaluate_multianimal.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py index e845ad089d..16736b5e74 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py +++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py @@ -19,6 +19,7 @@ from tqdm import tqdm from deeplabcut.core import crossvalutils +from deeplabcut.core.crossvalutils import find_closest_neighbors from deeplabcut.pose_estimation_tensorflow.core.evaluate import ( make_results_file, keypoint_error, From 050c5243743126180e644d6f604c1e343ccff218 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 10 May 2024 13:36:18 +0200 Subject: [PATCH 081/293] niels/fix_torch_superanimal_config (#187) * fixed superanimal video inference * added demo notebook for transfer learning --- .../modelzoo/model_configs/hrnetw32.yaml | 145 ++++---- .../apis/analyze_videos.py | 2 +- .../models/__init__.py | 5 +- .../modelzoo/inference.py | 60 +++- .../pose_estimation_pytorch/modelzoo/utils.py | 32 +- .../pose_estimation_pytorch/runners/base.py | 2 +- .../pose_estimation_pytorch/runners/train.py | 2 +- .../JUPYTER/Demo_coco_transfer_learning.ipynb | 318 ++++++++++++++++++ 8 files changed, 471 insertions(+), 95 deletions(-) create mode 100644 examples/JUPYTER/Demo_coco_transfer_learning.ipynb diff --git a/deeplabcut/modelzoo/model_configs/hrnetw32.yaml b/deeplabcut/modelzoo/model_configs/hrnetw32.yaml index 153df79357..0938f129c2 100644 --- a/deeplabcut/modelzoo/model_configs/hrnetw32.yaml +++ b/deeplabcut/modelzoo/model_configs/hrnetw32.yaml @@ -1,55 +1,70 @@ corner2move2: move2corner: cfg_path: -colormode: RGB data: - covering: true - gaussian_noise: 12.75 - hist_eq: true - motion_blur: true - normalize_images: true - rotation: 30 - scale_jitter: - - 0.5 - - 1.25 - auto_padding: - pad_width_divisor: 32 - pad_height_divisor: 32 -data_detector: - hflip: true - normalize_images: true + colormode: RGB + inference: + auto_padding: + pad_width_divisor: 32 + pad_height_divisor: 32 + normalize_images: true + train: + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true + rotation: 30 + scale_jitter: + - 0.5 + - 1.25 + auto_padding: + pad_width_divisor: 32 + pad_height_divisor: 32 detector: + data: + colormode: RGB + inference: + normalize_images: true + train: + hflip: true + normalize_images: true model: type: FasterRCNN + variant: fasterrcnn_resnet50_fpn_v2 box_score_thresh: 0.6 - optimizer: - type: AdamW - params: - lr: 0.0001 - scheduler: - type: LRListScheduler - params: - milestones: - - 90 - lr_list: - - - 1e-05 + pretrained: false runner: - type: DetectorRunner - max_individuals: - batch_size: 1 - epochs: 500 - save_epochs: 100 - display_iters: 500 -device: cuda -display_iters: 50 -epochs: 200 + type: DetectorTrainingRunner + eval_interval: 1 + optimizer: + type: AdamW + params: + lr: 1e-4 + scheduler: + type: LRListScheduler + params: + milestones: [ 160 ] + lr_list: [ [ 1e-5 ] ] + snapshots: + max_snapshots: 5 + save_epochs: 50 + save_optimizer_state: false + train_settings: + batch_size: 1 + dataloader_workers: 0 + dataloader_pin_memory: true + display_iters: 500 + epochs: 250 +device: auto method: td model: backbone: type: HRNet model_name: hrnet_w32 - pretrained: true - only_high_res: true + pretrained: false + interpolate_branches: false + increased_channel_count: false backbone_output_channels: 32 heads: bodypart: @@ -60,7 +75,7 @@ model: locref_std: 7.2801 target_generator: type: HeatmapPlateauGenerator - num_heatmaps: # number of bodyparts + num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT generate_locref: false @@ -73,28 +88,32 @@ model: type: WeightedHuberCriterion weight: 0 heatmap_config: - channels: - - 32 - - 0 # number of bodyparts - kernel_size: - - 1 - strides: - - 1 -optimizer: - params: - lr: 0.0001 - type: AdamW -pose_config_path: + channels: [32, "num_bodyparts"] + kernel_size: [1] + strides: [1] runner: - type: PoseRunner -save_epochs: 50 -scheduler: - params: - lr_list: - - - 1e-05 - - - 1e-06 - milestones: - - 90 - - 120 - type: LRListScheduler -seed: 42 + type: PoseTrainingRunner + key_metric: "test.mAP" + key_metric_asc: true + eval_interval: 1 + optimizer: + type: AdamW + params: + lr: 0.0001 + scheduler: + type: LRListScheduler + params: + lr_list: [ [ 1e-5 ], [ 1e-6 ] ] + milestones: [ 160, 190 ] + snapshots: + max_snapshots: 5 + save_epochs: 25 + save_optimizer_state: false +train_settings: + batch_size: 1 + dataloader_workers: 0 + dataloader_pin_memory: true + display_iters: 500 + epochs: 200 + pretrained_weights: null + seed: 42 diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 582da5746e..dfac57ff7c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -377,7 +377,7 @@ def analyze_videos( def create_df_from_prediction( pred_bodyparts: np.ndarray, - pred_unique_bodyparts: np.ndarray, + pred_unique_bodyparts: np.ndarray | None, dlc_scorer: str, cfg: dict, output_path: str | Path, diff --git a/deeplabcut/pose_estimation_pytorch/models/__init__.py b/deeplabcut/pose_estimation_pytorch/models/__init__.py index 4b1bad5dea..6e28f8722c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/__init__.py @@ -9,7 +9,10 @@ # Licensed under GNU Lesser General Public License v3.0 # from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES -from deeplabcut.pose_estimation_pytorch.models.criterions import CRITERIONS +from deeplabcut.pose_estimation_pytorch.models.criterions import ( + CRITERIONS, + LOSS_AGGREGATORS, +) from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS from deeplabcut.pose_estimation_pytorch.models.model import PoseModel diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index e7198b225e..7d1d1f9bf3 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -9,11 +9,10 @@ # Licensed under GNU Lesser General Public License v3.0 # import os -import pickle -import time from pathlib import Path from typing import Optional, Union +import numpy as np import torch from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import ( @@ -28,7 +27,6 @@ select_device, ) from deeplabcut.pose_estimation_pytorch.task import Task -from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path, read_config from deeplabcut.utils.make_labeled_video import _create_labeled_video @@ -50,7 +48,6 @@ def _video_inference_superanimal( dest_folder: Optional[str] = None, ) -> dict: """ - Perform inference on a video using a superanimal model from the model zoo specified by `superanimal_name`. During inference, the video is analyzed using the specified model and the results are saved in the specified destination folder. The predictions are saved in the form of a .h5 file. The video with the predictions is saved @@ -97,6 +94,9 @@ def _video_inference_superanimal( config = {**project_config, **model_config} config = _update_config(config, max_individuals, device) + pose_model_path = _parse_model_snapshot(Path(pose_model_path), device) + detector_model_path = _parse_model_snapshot(Path(detector_model_path), device) + pose_runner, detector_runner = get_inference_runners( config, snapshot_path=pose_model_path, @@ -120,13 +120,16 @@ def _video_inference_superanimal( for video_path in video_paths: print(f"Processing video {video_path}") - prediction, video_metadata = video_inference( + predictions, video_metadata = video_inference( video_path, task=pose_task, pose_runner=pose_runner, detector_runner=detector_runner, return_video_metadata=True, ) + pred_bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions]) + pred_unique_bodyparts = None + bbox = (0, video_metadata["resolution"][0], 0, video_metadata["resolution"][1]) print(f"Saving results to {dest_folder}") config["uniquebodyparts"] = [] @@ -136,11 +139,12 @@ def _video_inference_superanimal( output_prefix = f"{Path(video_path).stem}_{dlc_scorer}" output_path = Path(dest_folder) df = create_df_from_prediction( - prediction, - dlc_scorer, - config, - output_path, - output_prefix, + pred_bodyparts=pred_bodyparts, + pred_unique_bodyparts=pred_unique_bodyparts, + dlc_scorer=dlc_scorer, + cfg=config, + output_path=output_path, + output_prefix=output_prefix, ) results[video_path] = df @@ -159,3 +163,39 @@ def _video_inference_superanimal( print(f"Video with predictions was saved as {output_path}") return results + + +def _parse_model_snapshot(base: Path, device: str, print_keys: bool = False) -> Path: + """FIXME: A new snapshot should be uploaded and used""" + def _map_model_keys(state_dict: dict) -> dict: + updated_dict = {} + for k, v in state_dict.items(): + if not ( + k.startswith("backbone.model.downsamp_modules.") + or k.startswith("backbone.model.final_layer") + or k.startswith("backbone.model.classifier") + ): + parts = k.split(".") + if parts[:4] == ["heads", "bodypart", "heatmap_head", "model"]: + parts[3] = "deconv_layers.0" + updated_dict[".".join(parts)] = v + return updated_dict + + parsed = base.with_stem(base.stem + "_parsed") + if not parsed.exists(): + snapshot = torch.load(base, map_location=device) + if print_keys: + print(5 * "-----\n") + print(base.stem + " keys") + for name, _ in snapshot["model_state_dict"].items(): + print(f" * {name}") + print() + + parsed_model_snapshot = { + "model": _map_model_keys(snapshot["model_state_dict"]), + "metadata": { + "epoch": snapshot["epoch"], + }, + } + torch.save(parsed_model_snapshot, parsed) + return parsed diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py index 97fd70f967..5c66d4903b 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py @@ -9,13 +9,14 @@ # Licensed under GNU Lesser General Public License v3.0 # import inspect -import json import os import subprocess import warnings import torch +import deeplabcut.pose_estimation_pytorch.config.utils as config_utils +from deeplabcut.pose_estimation_pytorch.config.make_pose_config import add_metadata from deeplabcut.utils import auxiliaryfunctions @@ -38,12 +39,15 @@ def _get_config_model_paths( dlc_root_path = auxiliaryfunctions.get_deeplabcut_path() modelzoo_path = os.path.join(dlc_root_path, "modelzoo") - model_config = auxiliaryfunctions.read_plainconfig( - os.path.join(modelzoo_path, "model_configs", f"{pose_model_type}.yaml") + model_cfg_path = os.path.join( + modelzoo_path, "model_configs", f"{pose_model_type}.yaml" ) + model_config = auxiliaryfunctions.read_plainconfig(model_cfg_path) project_config = auxiliaryfunctions.read_config( os.path.join(modelzoo_path, "project_configs", f"{project_name}.yaml") ) + + model_config = add_metadata(project_config, model_config, model_cfg_path) if weight_folder is None: weight_folder = os.path.join(modelzoo_path, "checkpoints") @@ -97,20 +101,12 @@ def raise_warning_if_called_directly(): def _update_config(config, max_individuals, device): - print(config) - num_bodyparts = len(config["bodyparts"]) - config["detector"]["runner"]["max_individuals"] = max_individuals - config["multianimalproject"] = max_individuals > 1 - config["individuals"] = ["animal"] - config["multianimalbodyparts"] = config["bodyparts"] - config["uniquebodyparts"] = [] + config = config_utils.replace_default_values( + config, + num_bodyparts=len(config["bodyparts"]), + num_individuals=max_individuals, + backbone_output_channels=config["model"]["backbone_output_channels"] + ) config["device"] = device - config["model"]["heads"]["bodypart"]["target_generator"][ - "num_heatmaps" - ] = num_bodyparts - config["model"]["heads"]["bodypart"]["heatmap_config"]["channels"][ - -1 - ] = num_bodyparts - config["individuals"] = ["single"] * max_individuals - + config_utils.pretty_print(config) return config diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index a3ce833b0b..b820319454 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -62,7 +62,7 @@ def load_snapshot( """ snapshot = torch.load(snapshot_path, map_location=device) model.load_state_dict(snapshot["model"]) - if optimizer is not None: + if optimizer is not None and "optimizer" in snapshot: optimizer.load_state_dict(snapshot["optimizer"]) return snapshot["metadata"]["epoch"] diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index 8e503d0ccb..869d1be74d 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -77,7 +77,7 @@ def __init__( self.starting_epoch = 0 self.current_epoch = 0 - if self.snapshot_path is not None and len(self.snapshot_path) > 0: + if self.snapshot_path is not None and self.snapshot_path != "": self.starting_epoch = self.load_snapshot( self.snapshot_path, self.device, diff --git a/examples/JUPYTER/Demo_coco_transfer_learning.ipynb b/examples/JUPYTER/Demo_coco_transfer_learning.ipynb new file mode 100644 index 0000000000..6204931474 --- /dev/null +++ b/examples/JUPYTER/Demo_coco_transfer_learning.ipynb @@ -0,0 +1,318 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "619fc646-bfb3-4dba-9117-10e0e9d4720c", + "metadata": {}, + "source": [ + "# Demo - Transfer Learning from a COCO dataset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79eb1572-ddf5-46de-959c-4cc1e010b19c", + "metadata": {}, + "outputs": [], + "source": [ + "import copy\n", + "from pathlib import Path\n", + "\n", + "import torch\n", + "import torch.nn as nn\n", + "from torch.utils.data import DataLoader\n", + "\n", + "import deeplabcut\n", + "import deeplabcut.pose_estimation_pytorch.utils as utils\n", + "import deeplabcut.pose_estimation_pytorch.config.utils as config_utils\n", + "\n", + "from deeplabcut.pose_estimation_pytorch.models import PoseModel\n", + "from deeplabcut.pose_estimation_pytorch import COCOLoader\n", + "from deeplabcut.pose_estimation_pytorch.data import build_transforms\n", + "from deeplabcut.pose_estimation_pytorch.data.collate import COLLATE_FUNCTIONS\n", + "from deeplabcut.pose_estimation_pytorch.modelzoo.inference import _parse_model_snapshot\n", + "from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (\n", + " _get_config_model_paths,\n", + " _update_config,\n", + ")\n", + "from deeplabcut.pose_estimation_pytorch.runners import build_training_runner\n", + "from deeplabcut.pose_estimation_pytorch.task import Task" + ] + }, + { + "cell_type": "markdown", + "id": "d7b3b8d5-6a85-4027-84fd-f42af3fff1f4", + "metadata": {}, + "source": [ + "## Data & Configuration " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ece96d6-846b-4dab-8391-4f8a272813b9", + "metadata": {}, + "outputs": [], + "source": [ + "experiment_path = Path(\"/Users/niels/Desktop/coco_transfer_experiments\") / \"experiment_1\"\n", + "\n", + "# create the experiment folder structure\n", + "train_dir = experiment_path / \"train\"\n", + "test_dir = experiment_path / \"test\"\n", + "experiment_path.mkdir(parents=True, exist_ok=True)\n", + "train_dir.mkdir(exist_ok=True)\n", + "test_dir.mkdir(exist_ok=True)\n", + "model_config_path = train_dir / \"pytorch_config.yaml\"\n", + "\n", + "# Path to the folder containing the COCO dataset\n", + "# Format:\n", + "# quadruped80k/\n", + "# annotations/\n", + "# images/\n", + "dataset_path = Path(\"/Users/niels/Documents/upamathis/dlc/benchmarks/modelzoo/quadruped80k\")\n", + "train_file = \"train.json\"\n", + "test_file = \"test.json\"\n", + "\n", + "project_name = \"superanimal_topviewmouse\"\n", + "model_name = \"hrnetw32\"\n", + "\n", + "max_individuals = 10 # only needed for detector\n", + "num_bodyparts = 17 # the number of bodyparts in the project to transfer learn to\n", + "\n", + "device = \"cpu\"" + ] + }, + { + "cell_type": "markdown", + "id": "03d0ba4f-0f5e-4437-af59-6897aadb4929", + "metadata": {}, + "source": [ + "## Transfer Learning" + ] + }, + { + "cell_type": "markdown", + "id": "88e870cd-72a9-42a1-a7c0-beeeabeda6d5", + "metadata": {}, + "source": [ + "### Creating the Experiment Configuration File" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "810ea751-9a2c-4f4e-9d62-f99e813711cf", + "metadata": {}, + "outputs": [], + "source": [ + "# Get paths to SuperAnimal configs and weights\n", + "model_cfg, project_cfg, pose_model_path, detector_model_path = _get_config_model_paths(\n", + " project_name, model_name\n", + ")\n", + "pose_model_path = _parse_model_snapshot(Path(pose_model_path), device)\n", + "detector_model_path = _parse_model_snapshot(Path(detector_model_path), device)\n", + "\n", + "# Update the configuration file to have the correct number of output joints\n", + "model_cfg = config_utils.replace_default_values(\n", + " model_cfg,\n", + " num_bodyparts=len(project_cfg[\"bodyparts\"]),\n", + " num_individuals=max_individuals,\n", + " backbone_output_channels=model_cfg[\"model\"][\"backbone_output_channels\"]\n", + ")\n", + "model_cfg[\"device\"] = device\n", + "\n", + "# print results\n", + "print(pose_model_path)\n", + "print(detector_model_path)\n", + "print(\"Model Config\")\n", + "print(\"------------\")\n", + "config_utils.pretty_print(model_cfg)\n", + "\n", + "# save config\n", + "print(\"------------\")\n", + "print(f\"Saving Config to {model_config_path}\")\n", + "config_utils.write_config(model_config_path, model_cfg, overwrite=True)" + ] + }, + { + "cell_type": "markdown", + "id": "d47348ec-8d3d-4f2a-9795-8c8890b69884", + "metadata": {}, + "source": [ + "### Loading the dataset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f0d3183-4397-410b-a5ba-3da290ad7cdd", + "metadata": {}, + "outputs": [], + "source": [ + "loader = COCOLoader(\n", + " project_root=dataset_path,\n", + " model_config_path=model_config_path,\n", + " train_json_filename=train_file,\n", + " test_json_filename=test_file,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "4c6b2068-6daf-443a-9e14-71cd3262ebbe", + "metadata": {}, + "source": [ + "### Training " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52613d5d-fed4-4b44-a861-56cb8e1f0fe6", + "metadata": {}, + "outputs": [], + "source": [ + "# You can update these values here - or directly in the model_config file, \n", + "# but before creating the COCOLoader\n", + "epochs = 4\n", + "save_epochs = 2\n", + "detector_epochs = None # if 0, will not train the detector\n", + "detector_save_epochs = None # if 0, will not train the detector\n", + "\n", + "updates = {\n", + " \"train_settings\": {},\n", + " \"detector\": {\"train_settings\": {}},\n", + "}\n", + "if epochs is not None:\n", + " updates[\"train_settings\"][\"epochs\"] = epochs\n", + "if save_epochs is not None:\n", + " updates[\"train_settings\"][\"save_epochs\"] = save_epochs\n", + "if detector_epochs is not None:\n", + " updates[\"detector\"][\"train_settings\"][\"epochs\"] = detector_epochs\n", + "if detector_save_epochs is not None:\n", + " updates[\"detector\"][\"train_settings\"][\"save_epochs\"] = detector_save_epochs\n", + "\n", + "loader.update_model_cfg(updates)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a336d155-272b-47d5-94f3-46136dcae5a8", + "metadata": {}, + "outputs": [], + "source": [ + "# Loads the pose model, builds a training runner - adapted from apis/train.py\n", + "pose_task = Task(loader.model_cfg[\"method\"])\n", + "model = PoseModel.build(loader.model_cfg[\"model\"])\n", + "runner = build_training_runner(\n", + " runner_config=loader.model_cfg[\"runner\"],\n", + " model_folder=loader.model_folder,\n", + " task=pose_task,\n", + " model=model,\n", + " device=device,\n", + " snapshot_path=None, # we don't use 'pose_model_path' here, as we only want to load the backbone weights\n", + " logger=loader.model_cfg.get(\"logger\", None),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e8dae8d-cff8-44cb-8a82-3ead748e7816", + "metadata": {}, + "outputs": [], + "source": [ + "def load_backbone_weights(snapshot_path: Path) -> dict:\n", + " snapshot = torch.load(snapshot_path, map_location=device)\n", + " state_dict = {\n", + " \".\".join(k.split(\".\")[1:]): v # remove 'backbone.' from the keys\n", + " for k, v in snapshot[\"model\"].items()\n", + " if k.startswith(\"backbone.\")\n", + " }\n", + " print(f\"Kept {len(state_dict)} weights\")\n", + " return state_dict\n", + "\n", + "\n", + "backbone_state_dict = load_backbone_weights(pose_model_path)\n", + "runner.model.backbone.load_state_dict(backbone_state_dict)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd5db7ba-79f2-480e-8bfe-66750ab38c2c", + "metadata": {}, + "outputs": [], + "source": [ + "# Loads the dataset, trains\n", + "transform = build_transforms(loader.model_cfg[\"data\"][\"train\"])\n", + "inf_transform = build_transforms(loader.model_cfg[\"data\"][\"inference\"])\n", + "\n", + "train_dataset = loader.create_dataset(transform=transform, mode=\"train\", task=pose_task)\n", + "valid_dataset = loader.create_dataset(transform=inf_transform, mode=\"test\", task=pose_task)\n", + "\n", + "collate_fn = None\n", + "if collate_fn_cfg := loader.model_cfg[\"data\"][\"train\"].get(\"collate\"):\n", + " collate_fn = COLLATE_FUNCTIONS.build(collate_fn_cfg)\n", + " print(f\"Using custom collate function: {collate_fn_cfg}\")\n", + "\n", + "batch_size = loader.model_cfg[\"train_settings\"][\"batch_size\"]\n", + "num_workers = loader.model_cfg[\"train_settings\"][\"dataloader_workers\"]\n", + "pin_memory = loader.model_cfg[\"train_settings\"][\"dataloader_pin_memory\"]\n", + "train_dataloader = DataLoader(\n", + " train_dataset,\n", + " batch_size=batch_size,\n", + " shuffle=True,\n", + " collate_fn=collate_fn,\n", + " num_workers=num_workers,\n", + " pin_memory=pin_memory,\n", + ")\n", + "valid_dataloader = DataLoader(\n", + " valid_dataset,\n", + " batch_size=1,\n", + " shuffle=False,\n", + " num_workers=num_workers,\n", + " pin_memory=pin_memory,\n", + ")\n", + "\n", + "# Train the model\n", + "runner.fit(\n", + " train_dataloader,\n", + " valid_dataloader,\n", + " epochs=loader.model_cfg[\"train_settings\"][\"epochs\"],\n", + " display_iters=loader.model_cfg[\"train_settings\"][\"display_iters\"],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ccee4ef3-f818-41de-b86f-69618a40a353", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From bc0770136d89bb8892795a38772d3f6b2e4f7952 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 15 May 2024 15:33:18 +0200 Subject: [PATCH 082/293] Fix top-down video analysis scorer (#192) updated code to get scorer for pytorch models, fixed bugs for video analysis --- benchmark/benchmark_train.py | 17 +++-- deeplabcut/create_project/new.py | 1 + .../apis/analyze_videos.py | 10 +-- .../apis/convert_detections_to_tracklets.py | 33 ++------- .../pose_estimation_pytorch/apis/evaluate.py | 14 ++-- .../pose_estimation_pytorch/apis/utils.py | 74 ++++++++++++++++++- deeplabcut/refine_training_dataset/stitch.py | 2 +- deeplabcut/utils/auxiliaryfunctions.py | 50 +++++++++---- deeplabcut/utils/make_labeled_video.py | 2 +- 9 files changed, 138 insertions(+), 65 deletions(-) diff --git a/benchmark/benchmark_train.py b/benchmark/benchmark_train.py index 372c0293db..4c90aa5239 100644 --- a/benchmark/benchmark_train.py +++ b/benchmark/benchmark_train.py @@ -98,6 +98,8 @@ class VideoAnalysisParameters: videos: list[str] videotype: str + snapshot_index: int = -1 + detector_snapshot_index: int = -1 output_folder: str = "" @@ -172,8 +174,10 @@ def run_dlc(parameters: RunParameters) -> None: videos=parameters.video_analysis_params.videos, videotype=parameters.video_analysis_params.videotype, trainingsetindex=parameters.shuffle.trainset_index, + shuffle=parameters.shuffle.index, destfolder=str(destfolder), - snapshot_index=5, + snapshot_index=parameters.video_analysis_params.snapshot_index, + detector_snapshot_index=parameters.video_analysis_params.detector_snapshot_index, device=parameters.device, modelprefix=parameters.shuffle.model_prefix, batchsize=parameters.train_params.batch_size, @@ -189,17 +193,19 @@ def run_dlc(parameters: RunParameters) -> None: ) api.convert_detections2tracklets( config=str(parameters.shuffle.project.config_path()), - videos=parameters.video_analysis_params.videos, + videos=[str(v) for v in parameters.video_analysis_params.videos], videotype=".mp4", + trainingsetindex=parameters.shuffle.trainset_index, + shuffle=parameters.shuffle.index, modelprefix=parameters.shuffle.model_prefix, destfolder=str(destfolder), track_method="box", ) deeplabcut.stitch_tracklets( str(parameters.shuffle.project.config_path()), - videos=parameters.video_analysis_params.videos, - shuffle=1, + videos=[str(v) for v in parameters.video_analysis_params.videos], trainingsetindex=parameters.shuffle.trainset_index, + shuffle=parameters.shuffle.index, destfolder=str(destfolder), modelprefix=parameters.shuffle.model_prefix, save_as_csv=True, @@ -213,7 +219,7 @@ def run_dlc(parameters: RunParameters) -> None: ) deeplabcut.create_labeled_video( config=str(parameters.shuffle.project.config_path()), - videos=parameters.video_analysis_params.videos, + videos=[str(v) for v in parameters.video_analysis_params.videos], videotype="mp4", trainingsetindex=parameters.shuffle.trainset_index, color_by="individual", # bodypart, individual @@ -221,7 +227,6 @@ def run_dlc(parameters: RunParameters) -> None: destfolder=str(destfolder), track_method="box", ) - return def main(runs: list[RunParameters]) -> None: diff --git a/deeplabcut/create_project/new.py b/deeplabcut/create_project/new.py index c6194e9882..7423155fa8 100644 --- a/deeplabcut/create_project/new.py +++ b/deeplabcut/create_project/new.py @@ -272,6 +272,7 @@ def create_new_project( cfg_file["TrainingFraction"] = [0.95] cfg_file["iteration"] = 0 cfg_file["snapshotindex"] = -1 + cfg_file["detector_snapshotindex"] = -1 cfg_file["x1"] = 0 cfg_file["x2"] = 640 cfg_file["y1"] = 277 diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index dfac57ff7c..dc7c0f9878 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -29,6 +29,7 @@ from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_model_snapshots, get_inference_runners, + get_scorer_name, get_scorer_uid, list_videos_in_folder, ) @@ -259,12 +260,11 @@ def analyze_videos( detector_path = detector_snapshot.path print(f" -> Using detector {detector_path}") - dlc_scorer, _ = auxiliaryfunctions.get_scorer_name( + dlc_scorer = get_scorer_name( cfg, shuffle, train_fraction, - trainingsiterations=get_scorer_uid(snapshot, detector_snapshot), - engine=Engine.PYTORCH, + snapshot_uid=get_scorer_uid(snapshot, detector_snapshot), modelprefix=modelprefix, ) pose_runner, detector_runner = get_inference_runners( @@ -361,7 +361,7 @@ def analyze_videos( trainingsetindex=trainingsetindex, overwrite=False, identity_only=identity_only, - destfolder=destfolder, + destfolder=str(destfolder), ) stitch_tracklets( config, @@ -369,7 +369,7 @@ def analyze_videos( videotype, shuffle, trainingsetindex, - destfolder=destfolder, + destfolder=str(destfolder), ) return results diff --git a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py index 83a8b2dbbb..f602aecc24 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py +++ b/deeplabcut/pose_estimation_pytorch/apis/convert_detections_to_tracklets.py @@ -22,13 +22,13 @@ import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions import deeplabcut.utils.auxfun_multianimal as auxfun_multianimal +from deeplabcut.core import trackingutils from deeplabcut.core.engine import Engine +from deeplabcut.core.inferenceutils import Assembly from deeplabcut.pose_estimation_pytorch.apis.utils import ( - get_model_snapshots, + get_scorer_name, list_videos_in_folder, ) -from deeplabcut.core import trackingutils -from deeplabcut.core.inferenceutils import Assembly def convert_detections2tracklets( @@ -92,33 +92,12 @@ def convert_detections2tracklets( # between trackers cannot be evaluated, resulting in empty tracklets. inference_cfg["boundingboxslack"] = max(inference_cfg["boundingboxslack"], 40) - # Check which snapshots are available and sort them by # iterations - snapshots = get_model_snapshots(model_dir / "train") - assert ( - len(snapshots) > 0 - ), f"No snapshots were found in the model directory {model_dir / 'train'}" - snapshot_index = cfg["snapshotindex"] - if snapshot_index == "all": - print( - "snapshotindex is set to 'all' in the config.yaml file. Running video " - "analysis with all snapshots is very costly! Use the function " - "'evaluate_network' to choose the best the snapshot. For now, changing " - "snapshot index to -1. To evaluate another snapshot, you can change the " - "value in the config file or call `analyze_videos` with your desired " - "snapshot index." - ) - snapshot_index = -1 - - snapshot = snapshots[snapshot_index] - print(f"Using snapshot {snapshot} for model {model_dir}") - dlc_cfg["init_weights"] = str(snapshot) - num_epochs = int(snapshot.stem.split("-")[-1]) - dlc_scorer, dlc_scorer_legacy = auxiliaryfunctions.get_scorer_name( + dlc_scorer = get_scorer_name( cfg, shuffle, train_fraction, - trainingsiterations=num_epochs, - engine=Engine.PYTORCH, + snapshot_index=None, + detector_index=None, modelprefix=modelprefix, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 7c70851372..1e65ee44e1 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -19,13 +19,13 @@ import pandas as pd from tqdm import tqdm -from deeplabcut.core.engine import Engine from deeplabcut.pose_estimation_pytorch import utils from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_predictions_dataframe, ensure_multianimal_df_format, get_model_snapshots, get_inference_runners, + get_scorer_name, get_scorer_uid, ) from deeplabcut.pose_estimation_pytorch.data import Loader, DLCLoader @@ -35,10 +35,7 @@ pair_predicted_individuals_with_gt, ) from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner -from deeplabcut.pose_estimation_pytorch.runners.snapshots import ( - Snapshot, - TorchSnapshotManager, -) +from deeplabcut.pose_estimation_pytorch.runners.snapshots import Snapshot from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.utils import auxiliaryfunctions from deeplabcut.utils.visualization import plot_evaluation_results @@ -400,12 +397,11 @@ def evaluate_network( print("Using GT bounding boxes to compute evaluation metrics") for snapshot in snapshots: - scorer, _ = auxiliaryfunctions.get_scorer_name( + scorer = get_scorer_name( cfg=cfg, shuffle=shuffle, - trainFraction=loader.train_fraction, - engine=Engine.PYTORCH, - trainingsiterations=get_scorer_uid(snapshot, detector_snapshot), + train_fraction=loader.train_fraction, + snapshot_uid=get_scorer_uid(snapshot, detector_snapshot), modelprefix=modelprefix, ) evaluate_snapshot( diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 978cfe3e4e..dfec11e678 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -18,6 +18,7 @@ import pandas as pd from deeplabcut.core.engine import Engine +from deeplabcut.pose_estimation_pytorch.config import read_config_as_dict from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.data.dlcloader import ( build_dlc_dataframe_columns, @@ -108,7 +109,15 @@ def get_model_snapshots( elif isinstance(index, str) and index.lower() == "all": snapshots = snapshot_manager.snapshots(include_best=True) elif isinstance(index, int): - snapshots = [snapshot_manager.snapshots(include_best=True)[index]] + all_snapshots = snapshot_manager.snapshots(include_best=True) + if len(all_snapshots) <= index: + names = [s.path.name for s in all_snapshots] + raise ValueError( + f"Found {len(all_snapshots)} snapshots in {model_folder} (with names " + f"{names}). Could not return snapshot with index {index}. " + ) + + snapshots = [all_snapshots[index]] else: raise ValueError(f"Invalid snapshotindex: {index}") @@ -132,6 +141,69 @@ def get_scorer_uid(snapshot: Snapshot, detector_snapshot: Snapshot | None) -> st return snapshot_id +def get_scorer_name( + cfg: dict, + shuffle: int, + train_fraction: float, + snapshot_index: int | None = None, + detector_index: int | None = None, + snapshot_uid: str | None = None, + modelprefix: str = "", +) -> str: + """Get the scorer name for a particular PyTorch DeepLabCut shuffle + + Args: + cfg: The project configuration. + shuffle: The index of the shuffle for which to get the scorer + train_fraction: The training fraction for the shuffle. + snapshot_index: The index of the snapshot used. If None, the value is loaded + from the project's config.yaml file. + detector_index: For top-down models, the index of the detector used. If None, + the value is loaded from the project's config.yaml file. + snapshot_uid: If the snapshot_uid is not None, this value will be used instead + of loading the snapshot and detector with given indices and calling + utils.get_scorer_uid. + modelprefix: The model prefix, if one was used. + + Returns: + the scorer name + """ + model_dir = auxiliaryfunctions.get_model_folder( + train_fraction, + shuffle, + cfg, + engine=Engine.PYTORCH, + modelprefix=modelprefix, + ) + train_dir = model_dir / "train" + model_cfg = read_config_as_dict(str(train_dir / Engine.PYTORCH.pose_cfg_name)) + net_type = model_cfg["net_type"] + pose_task = Task(model_cfg["method"]) + + if snapshot_uid is None: + if snapshot_index is None: + snapshot_index = auxiliaryfunctions.get_snapshot_index_for_scorer( + "snapshotindex", cfg["snapshotindex"] + ) + if detector_index is None: + detector_index = auxiliaryfunctions.get_snapshot_index_for_scorer( + "detector_snapshotindex", cfg["detector_snapshotindex"] + ) + + snapshot = get_model_snapshots(snapshot_index, train_dir, pose_task)[0] + detector_snapshot = None + if pose_task == Task.TOP_DOWN: + detector_snapshot = get_model_snapshots( + detector_index, train_dir, Task.DETECT + )[0] + + snapshot_uid = get_scorer_uid(snapshot, detector_snapshot) + + task, date = cfg["Task"], cfg["date"] + name = "".join([p.capitalize() for p in net_type.split("_")]) + return f"DLC_{name}_{task}{date}shuffle{shuffle}_{snapshot_uid}" + + def list_videos_in_folder( data_path: str | list[str], video_type: str | None ) -> list[Path]: diff --git a/deeplabcut/refine_training_dataset/stitch.py b/deeplabcut/refine_training_dataset/stitch.py index 9b5f4502a8..65f663db17 100644 --- a/deeplabcut/refine_training_dataset/stitch.py +++ b/deeplabcut/refine_training_dataset/stitch.py @@ -1135,7 +1135,7 @@ def stitch_tracklets( if n_tracks is None: n_tracks = len(animal_names) - DLCscorer, _ = deeplabcut.utils.auxiliaryfunctions.GetScorerName( + DLCscorer, _ = deeplabcut.utils.auxiliaryfunctions.get_scorer_name( cfg, shuffle, cfg["TrainingFraction"][trainingsetindex], diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 8cc6828aa6..f09e62a81d 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -82,6 +82,7 @@ def create_config_template(multianimal=False): default_augmenter: default_track_method: snapshotindex: +detector_snapshotindex: batch_size: \n # Cropping Parameters (for analysis and outlier frame detection) @@ -134,6 +135,7 @@ def create_config_template(multianimal=False): default_net_type: default_augmenter: snapshotindex: +detector_snapshotindex: batch_size: \n # Cropping Parameters (for analysis and outlier frame detection) @@ -668,28 +670,36 @@ def get_scorer_name( modelprefix=modelprefix, ) + if engine == Engine.PYTORCH: + from deeplabcut.pose_estimation_pytorch.apis.utils import get_scorer_name + snapshot_index = None + if isinstance(trainingsiterations, int): + snapshot_index = trainingsiterations + + dlc3_scorer = get_scorer_name( + cfg=cfg, + shuffle=shuffle, + train_fraction=trainFraction, + snapshot_index=snapshot_index, + detector_index=None, + modelprefix=modelprefix, + ) + return dlc3_scorer, dlc3_scorer + Task = cfg["Task"] date = cfg["date"] if trainingsiterations == "unknown": - snapshotindex = cfg["snapshotindex"] - if cfg["snapshotindex"] == "all": - print( - "Changing snapshotindext to the last one -- plotting, videomaking, etc. should not be performed for all indices. For more selectivity enter the ordinal number of the snapshot you want (ie. 4 for the fifth) in the config file." - ) - snapshotindex = -1 - + snapshotindex = get_snapshot_index_for_scorer( + "snapshotindex", cfg["snapshotindex"] + ) modelfolder = os.path.join( cfg["project_path"], str(get_model_folder(trainFraction, shuffle, cfg, engine=engine, modelprefix=modelprefix)), "train", ) Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(modelfolder) - if ("index" in fn) or ("snapshot" in fn and not "detector" in fn) - ] + [fn.split(".")[0] for fn in os.listdir(modelfolder) if "index" in fn] ) increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) Snapshots = Snapshots[increasing_indices] @@ -705,9 +715,7 @@ def get_scorer_name( ) ) # ABBREVIATE NETWORK NAMES -- esp. for mobilenet! - if engine == Engine.PYTORCH: - netname = "".join([p.capitalize() for p in dlc_cfg["net_type"].split("_")]) - elif "resnet" in dlc_cfg["net_type"]: + if "resnet" in dlc_cfg["net_type"]: if dlc_cfg.get("multi_stage", False): netname = "dlcrnetms5" else: @@ -938,6 +946,18 @@ def find_next_unlabeled_folder(config_path, verbose=False): return next_folder +def get_snapshot_index_for_scorer(name: str, index: int | str) -> int: + if index == "all": + print( + f"Changing {name} to the last one -- plotting, videomaking, etc. should " + "not be performed for all indices. For more selectivity enter the ordinal " + "number of the snapshot you want (ie. 4 for the fifth) in the config file." + ) + return -1 + + return index + + # aliases for backwards-compatibility. SaveData = save_data SaveMetadata = save_metadata diff --git a/deeplabcut/utils/make_labeled_video.py b/deeplabcut/utils/make_labeled_video.py index 26884fe1df..66e14c185c 100644 --- a/deeplabcut/utils/make_labeled_video.py +++ b/deeplabcut/utils/make_labeled_video.py @@ -569,7 +569,7 @@ def create_labeled_video( ) if init_weights == "": - DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName( + DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name( cfg, shuffle, trainFraction, modelprefix=modelprefix ) # automatically loads corresponding model (even training iteration based on snapshot index) else: From 0aac49093817c98aa4a259f3152ab1dc44514f1a Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Thu, 23 May 2024 16:41:48 +0200 Subject: [PATCH 083/293] Minor compatibility fixes (#194) * Fix NaN centroids with 0 likelihood * Minor compatibility fixes * Improved error message * Fix config dict key setting * Delete redundant neighbor search implementations --- deeplabcut/benchmark/metrics.py | 5 ++-- deeplabcut/core/crossvalutils.py | 4 +-- .../apis/analyze_videos.py | 2 +- .../pose_estimation_pytorch/apis/train.py | 8 +++++- .../models/predictors/utils.py | 26 +++---------------- .../core/evaluate_multianimal.py | 23 ++-------------- deeplabcut/refine_training_dataset/stitch.py | 2 +- deeplabcut/utils/auxiliaryfunctions.py | 4 +-- 8 files changed, 21 insertions(+), 53 deletions(-) diff --git a/deeplabcut/benchmark/metrics.py b/deeplabcut/benchmark/metrics.py index f0cb08efd4..626103d46a 100644 --- a/deeplabcut/benchmark/metrics.py +++ b/deeplabcut/benchmark/metrics.py @@ -29,8 +29,7 @@ import pandas as pd import deeplabcut.benchmark.utils -from deeplabcut.pose_estimation_tensorflow.core import evaluate_multianimal -from deeplabcut.core import inferenceutils +from deeplabcut.core import inferenceutils, crossvalutils from deeplabcut.utils.conversioncode import guarantee_multiindex_rows @@ -99,7 +98,7 @@ def calc_prediction_errors(preds, gt): if visible.size and xy_pred_.size: # Pick the predictions closest to ground truth, # rather than the ones the model has most confident in. - neighbors = evaluate_multianimal._find_closest_neighbors( + neighbors = crossvalutils.find_closest_neighbors( xy_gt_[visible], xy_pred_, k=3 ) found = neighbors != -1 diff --git a/deeplabcut/core/crossvalutils.py b/deeplabcut/core/crossvalutils.py index edda405e90..59ef1bd260 100644 --- a/deeplabcut/core/crossvalutils.py +++ b/deeplabcut/core/crossvalutils.py @@ -153,7 +153,7 @@ def _calc_within_between_pafs( inds = np.flatnonzero(np.all(~np.isnan(coord), axis=1)) inds_gt = np.flatnonzero(np.all(~np.isnan(coord_gt), axis=1)) if inds.size and inds_gt.size: - neighbors = _find_closest_neighbors(coord_gt[inds_gt], coord[inds], k=3) + neighbors = find_closest_neighbors(coord_gt[inds_gt], coord[inds], k=3) found = neighbors != -1 lookup[i] = dict(zip(inds_gt[found], inds[neighbors[found]])) @@ -313,7 +313,7 @@ def _benchmark_paf_graphs( hyp = np.concatenate(animals) hyp = hyp[~np.isnan(hyp).any(axis=1)] scores[i, 0] = max(0, (n_dets - hyp.shape[0]) / n_dets) - neighbors = _find_closest_neighbors(gt[:, :2], hyp[:, :2]) + neighbors = find_closest_neighbors(gt[:, :2], hyp[:, :2]) valid = neighbors != -1 id_gt = gt[valid, 2] id_hyp = hyp[neighbors[valid], -1] diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index dc7c0f9878..86eddb744e 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -226,7 +226,7 @@ def analyze_videos( pose_cfg = auxiliaryfunctions.read_plainconfig(pose_cfg_path) if snapshot_index is None: - snapshot_index = config["snapshotindex"] + snapshot_index = cfg["snapshotindex"] if snapshot_index == "all": logging.warning( "snapshotindex is set to 'all' (in the config.yaml file or as given to " diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 944f92b66b..16ca2c6555 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -64,7 +64,7 @@ def train( model = PoseModel.build(run_config["model"]) if max_snapshots_to_keep is not None: - run_config["snapshots"]["max_snapshots"] = max_snapshots_to_keep + run_config["runner"]["snapshots"]["max_snapshots"] = max_snapshots_to_keep logger = None if logger_config is not None: @@ -168,7 +168,13 @@ def train_network( trainset_index=trainingsetindex, modelprefix=modelprefix, ) + batch_size = kwargs.pop("batch_size", None) + epochs = kwargs.pop("epochs", None) loader.update_model_cfg(kwargs) + if batch_size is not None: + loader.model_cfg["train_settings"]["batch_size"] = batch_size + if epochs is not None: + loader.model_cfg["train_settings"]["epochs"] = epochs setup_file_logging(loader.model_folder / "train.txt") logging.info("Training with configuration:") diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py index bef18e3341..1aee88baa4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py @@ -17,37 +17,19 @@ import numpy as np import torch from numpy.typing import ArrayLike, NDArray -from scipy.spatial import cKDTree from torch.utils.data import DataLoader from tqdm import tqdm +from deeplabcut.core.crossvalutils import find_closest_neighbors from deeplabcut.pose_estimation_pytorch import Loader from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( get_scores, - align_predicted_individuals_to_gt, + pair_predicted_individuals_with_gt, ) from deeplabcut.pose_estimation_pytorch.models import PoseModel from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import Graph -def _find_closest_neighbors(query: NDArray, ref: NDArray, k: int = 3) -> NDArray: - n_preds = ref.shape[0] - tree = cKDTree(ref) - dist, inds = tree.query(query, k=k) - idx = np.argsort(dist[:, 0]) - neighbors = np.full(len(query), -1, dtype=int) - picked = set() - for i, ind in enumerate(inds[idx]): - for j in ind: - if j not in picked: - picked.add(j) - neighbors[idx[i]] = j - break - if len(picked) == n_preds: - break - return neighbors - - def _calc_separability( vals_left: ArrayLike, vals_right: ArrayLike, @@ -147,7 +129,7 @@ def compute_within_between_paf_costs( inds = np.flatnonzero(np.all(~np.isnan(coord_pred), axis=1)) inds_gt = np.flatnonzero(np.all(~np.isnan(coord_gt), axis=1)) if inds.size and inds_gt.size: - neighbors = _find_closest_neighbors( + neighbors = find_closest_neighbors( coord_gt[inds_gt], coord_pred[inds], k=3 ) found = neighbors != -1 @@ -202,7 +184,7 @@ def benchmark_paf_graphs( poses_.extend(preds["poses"]) poses_ = torch.stack(poses_).detach().cpu().numpy() poses_ = dict(zip(paths, poses_)) - poses_ = align_predicted_individuals_to_gt(poses_, poses_gt) + poses_ = pair_predicted_individuals_with_gt(poses_, poses_gt) poses.append(poses_) results.append(get_scores(poses_, poses_gt)) return results, poses, best_paf_edges diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py index 16736b5e74..c714da02a9 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py +++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py @@ -15,7 +15,6 @@ from pathlib import Path import numpy as np import pandas as pd -from scipy.spatial import cKDTree from tqdm import tqdm from deeplabcut.core import crossvalutils @@ -51,24 +50,6 @@ def _compute_stats(df): ).stack(level=1) -def _find_closest_neighbors(xy_true, xy_pred, k=5): - n_preds = xy_pred.shape[0] - tree = cKDTree(xy_pred) - dist, inds = tree.query(xy_true, k=k) - idx = np.argsort(dist[:, 0]) - neighbors = np.full(len(xy_true), -1, dtype=int) - picked = set() - for i, ind in enumerate(inds[idx]): - for j in ind: - if j not in picked: - picked.add(j) - neighbors[idx[i]] = j - break - if len(picked) == n_preds: - break - return neighbors - - def _calc_prediction_error(data): _ = data.pop("metadata", None) dists = [] @@ -76,7 +57,7 @@ def _calc_prediction_error(data): gt = np.concatenate(dict_["groundtruth"][1]) xy = np.concatenate(dict_["prediction"]["coordinates"][0]) p = np.concatenate(dict_["prediction"]["confidence"]) - neighbors = _find_closest_neighbors(gt, xy) + neighbors = find_closest_neighbors(gt, xy) found = neighbors != -1 gt2 = gt[found] xy2 = xy[neighbors[found]] @@ -409,7 +390,7 @@ def evaluate_multianimal_full( # Pick the predictions closest to ground truth, # rather than the ones the model has most confident in xy_gt_values = xy_gt.iloc[inds_gt].values - neighbors = _find_closest_neighbors( + neighbors = find_closest_neighbors( xy_gt_values, xy, k=3 ) found = neighbors != -1 diff --git a/deeplabcut/refine_training_dataset/stitch.py b/deeplabcut/refine_training_dataset/stitch.py index 65f663db17..ce48693cf0 100644 --- a/deeplabcut/refine_training_dataset/stitch.py +++ b/deeplabcut/refine_training_dataset/stitch.py @@ -123,7 +123,7 @@ def centroid(self): return self._centroid def _update_centroid(self): - like = self.data[..., 2:3] + like = self.data[..., 2:3] + 1e-10 # Avoid division by zero in very uncertain tracklets self._centroid = np.nansum(self.xy * like, axis=1) / np.nansum(like, axis=1) @property diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index f09e62a81d..fb9ea9c2f9 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -222,7 +222,7 @@ def read_config(configname): else: raise FileNotFoundError( - "Config file is not found. Please make sure that the file exists and/or that you passed the path of the config file correctly!" + f"Config file at {path} not found. Please make sure that the file exists and/or that you passed the path of the config file correctly!" ) return cfg @@ -436,7 +436,7 @@ def get_list_of_videos( videotype = auxfun_videos.SUPPORTED_VIDEOS # filter list of videos videos = [ - v + v for v in videos if os.path.isfile(v) and any(v.endswith(ext) for ext in videotype) From c804eb447f3d62535dff352bb397e7dd65542dff Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 23 May 2024 18:24:39 +0200 Subject: [PATCH 084/293] changes to defaults, bug fixes, albumentations CoarseDropout, top-down data augmentation, add tests (#190) * bug fix: incorrectly reading snapshot index from config in analyze videos * added files for backwards compatibility (e.g. when loading pickles) * update default top down augmentation * fixed tokenpose cfg * added tests for topdown config creation, fixed bug * add coarse dropout test, pin albu * switch default resnet50 to GN model * changed default resnet output stride to 16 * freeze BN stats but not weights as default * fixed tests * fix topdown aug --- .../config/backbones/hrnet_w18.yaml | 2 + .../config/backbones/hrnet_w32.yaml | 2 + .../config/backbones/hrnet_w48.yaml | 2 + .../config/backbones/resnet_101.yaml | 3 ++ .../config/backbones/resnet_50.yaml | 5 +- .../config/base/aug_top_down.yaml | 15 ++++++ .../config/make_pose_config.py | 3 ++ .../config/tokenpose/tokenpose_base.yaml | 30 ++++++++--- .../lib/crossvalutils.py | 12 +++++ .../lib/inferenceutils.py | 12 +++++ .../lib/trackingutils.py | 12 +++++ requirements.txt | 2 +- setup.py | 2 +- .../config/test_make_pose_config.py | 52 ++++++++++++++++++- .../data/test_transforms.py | 11 ++++ .../other/test_pose_model.py | 35 ++++++++++--- 16 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml create mode 100644 deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py create mode 100644 deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py create mode 100644 deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml index f95a8c8bc7..8021509eda 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml @@ -7,6 +7,8 @@ model: backbone: type: HRNet model_name: hrnet_w18 + freeze_bn_stats: True + freeze_bn_weights: False pretrained: true interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml index e72374bb92..1e753e6c2e 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml @@ -7,6 +7,8 @@ model: backbone: type: HRNet model_name: hrnet_w32 + freeze_bn_stats: True + freeze_bn_weights: False pretrained: true interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml index f87cc5eade..d7446eaea7 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml @@ -7,6 +7,8 @@ model: backbone: type: HRNet model_name: hrnet_w48 + freeze_bn_stats: True + freeze_bn_weights: False pretrained: true interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml index 560ba96941..8752add7bf 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml @@ -2,6 +2,9 @@ model: backbone: type: ResNet model_name: resnet101 + output_stride: 16 + freeze_bn_stats: True + freeze_bn_weights: False pretrained: true backbone_output_channels: 2048 runner: diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml index 580e6b577a..eb22dc56f0 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml @@ -1,7 +1,10 @@ model: backbone: type: ResNet - model_name: resnet50 + model_name: resnet50_gn + output_stride: 16 + freeze_bn_stats: True + freeze_bn_weights: False pretrained: true backbone_output_channels: 2048 runner: diff --git a/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml b/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml new file mode 100644 index 0000000000..233f6b8497 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml @@ -0,0 +1,15 @@ +colormode: RGB +inference: + normalize_images: true +train: + affine: + p: 0.5 + scaling: [1.0, 1.0] + rotation: 30 + translation: 0 + collate: null + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index e774d54936..4bc080bf82 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -110,6 +110,9 @@ def make_pytorch_pose_config( is_top_down = model_cfg.get("method", "BU").upper() == "TD" if is_top_down: + model_cfg["data"] = read_config_as_dict( + configs_dir / "base" / "aug_top_down.yaml" + ) model_cfg = add_detector(configs_dir, model_cfg, len(individuals)) # add the model to the config diff --git a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml b/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml index ee053a1060..bdfd5de7e8 100644 --- a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml @@ -2,17 +2,35 @@ # base TokenPose configuration, as defined in # https://github.com/leeyegy/TokenPose/blob/main/experiments/coco/tokenpose/tokenpose_b_256_192_patch43_dim192_depth12_heads8.yaml data: - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 -method: TD # Need to add a detector + colormode: RGB + inference: + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 + normalize_images: true + train: + affine: + p: 0.5 + rotation: 30 + translation: 0 + auto_padding: # Required for HRNet backbones + pad_width_divisor: 32 + pad_height_divisor: 32 + collate: null + covering: true + gaussian_noise: 12.75 + hist_eq: true + motion_blur: true + normalize_images: true +method: td # Need to add a detector model: backbone: type: HRNet model_name: hrnet_w32 pretrained: true - only_high_res: true # only uses high-resolution output - backbone_output_channels: 480 + interpolate_branches: false + increased_channel_count: false # changes backbone_output_channels to 128 when true + backbone_output_channels: 32 neck: type: Transformer feature_size: diff --git a/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py b/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py new file mode 100644 index 0000000000..50f31dddf6 --- /dev/null +++ b/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py @@ -0,0 +1,12 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Backwards compatibility""" +from deeplabcut.core.crossvalutils import * diff --git a/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py b/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py new file mode 100644 index 0000000000..889311441c --- /dev/null +++ b/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py @@ -0,0 +1,12 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Backwards compatibility""" +from deeplabcut.core.inferenceutils import * diff --git a/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py b/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py new file mode 100644 index 0000000000..f769fa7238 --- /dev/null +++ b/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py @@ -0,0 +1,12 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Backwards compatibility""" +from deeplabcut.core.trackingutils import * diff --git a/requirements.txt b/requirements.txt index 81ddb9bede..c75a49e968 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # novel for pytorch DLC: -albumentations +albumentations<=1.4.3 einops timm wandb diff --git a/setup.py b/setup.py index 52b3e97679..3f049bb1d8 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ def pytorch_config_paths() -> list[str]: long_description_content_type="text/markdown", url="https://github.com/DeepLabCut/DeepLabCut", install_requires=[ - "albumentations", + "albumentations<=1.4.3", "dlclibrary>=0.0.5", "einops", "dlclibrary>=0.0.6", diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py index 4497aa0102..f855f4a528 100644 --- a/tests/pose_estimation_pytorch/config/test_make_pose_config.py +++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py @@ -131,6 +131,50 @@ def test_backbone_plus_paf_config( assert id_head["target_generator"]["heatmap_mode"] == "INDIVIDUAL" +@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]]) +@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]]) +@pytest.mark.parametrize( + "net_type", ["resnet_50", "resnet_101", "hrnet_w18", "hrnet_w32", "hrnet_w48"] +) +def test_top_down_config( + individuals: list[str], + bodyparts: list[str], + net_type: str, +): + # Single animal projects can't have unique bodyparts + project_config = _make_project_config( + project_path="my/little/project", + multianimal=True, + identity=False, + individuals=individuals, + bodyparts=bodyparts, + unique_bodyparts=[], + ) + pytorch_pose_config = make_pytorch_pose_config( + project_config, + "pytorch_config.yaml", + net_type=net_type, + top_down=True, + ) + pretty_print(pytorch_pose_config) + + # check no collate function + collate = pytorch_pose_config["data"]["train"].get("collate") + print(f"Collate: {collate}") + assert not collate + + # check heads are there + assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() + bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"] + + for name, output_channels in [ + ("heatmap_config", len(bodyparts)), + ]: + print(name, bodypart_head[name]["channels"]) + assert name in bodypart_head + assert bodypart_head[name]["final_conv"]["out_channels"] == output_channels + + @pytest.mark.parametrize("multianimal", [True]) @pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]]) @pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]]) @@ -282,7 +326,7 @@ def test_make_tokenpose_config( if identity or len(unique_bodyparts) > 0: with pytest.raises(ValueError) as err_info: # Not yet implemented! - pytorch_pose_config = make_pytorch_pose_config( + _ = make_pytorch_pose_config( project_config, "pytorch_config.yaml", net_type=net_type, @@ -294,6 +338,12 @@ def test_make_tokenpose_config( net_type=net_type, ) pretty_print(pytorch_pose_config) + + # check no collate function + collate = pytorch_pose_config["data"]["train"].get("collate") + print(f"Collate: {collate}") + assert not collate + # check detector is there assert "detector" in pytorch_pose_config assert "data" in pytorch_pose_config["detector"] diff --git a/tests/pose_estimation_pytorch/data/test_transforms.py b/tests/pose_estimation_pytorch/data/test_transforms.py index 424ad72ddd..3569ed8f45 100644 --- a/tests/pose_estimation_pytorch/data/test_transforms.py +++ b/tests/pose_estimation_pytorch/data/test_transforms.py @@ -90,3 +90,14 @@ def test_dlc_resize_pad_bad_aspect_ratio_with_keypoints(data): transformed = transform(image=fake_image, keypoints=data["in_keypoints"]) assert transformed["image"].shape == data["out_shape"] assert transformed["keypoints"] == data["out_keypoints"] + + +def test_coarse_dropout(): + aug = transforms.CoarseDropout( + max_holes=10, + max_height=0.05, + min_height=0.01, + max_width=0.05, + min_width=0.01, + p=0.5, + ) diff --git a/tests/pose_estimation_pytorch/other/test_pose_model.py b/tests/pose_estimation_pytorch/other/test_pose_model.py index e5565e7d2e..f45428ca41 100644 --- a/tests/pose_estimation_pytorch/other/test_pose_model.py +++ b/tests/pose_estimation_pytorch/other/test_pose_model.py @@ -20,31 +20,52 @@ from deeplabcut.pose_estimation_pytorch.models.modules import AdaptBlock, BasicBlock backbones_dicts = [ - {"type": "HRNet", "model_name": "hrnet_w32", "output_channels": 480, "stride": 4}, - {"type": "HRNet", "model_name": "hrnet_w18", "output_channels": 270, "stride": 4}, - {"type": "HRNet", "model_name": "hrnet_w48", "output_channels": 720, "stride": 4}, + { + "type": "HRNet", + "model_name": "hrnet_w32", + "output_channels": 480, + "stride": 4, + "interpolate_branches": True, + }, + { + "type": "HRNet", + "model_name": "hrnet_w18", + "output_channels": 270, + "stride": 4, + "interpolate_branches": True, + }, + { + "type": "HRNet", + "model_name": "hrnet_w48", + "output_channels": 720, + "stride": 4, + "interpolate_branches": True, + }, { "type": "HRNet", "model_name": "hrnet_w32", "output_channels": 32, - "only_high_res": True, + "interpolate_branches": False, + "increased_channel_count": False, "stride": 4, }, { "type": "HRNet", "model_name": "hrnet_w18", "output_channels": 18, - "only_high_res": True, + "interpolate_branches": False, + "increased_channel_count": False, "stride": 4, }, { "type": "HRNet", "model_name": "hrnet_w48", "output_channels": 48, - "only_high_res": True, + "interpolate_branches": False, + "increased_channel_count": False, "stride": 4, }, - {"type": "ResNet", "model_name": "resnet50", "output_channels": 2048, "stride": 32}, + {"type": "ResNet", "model_name": "resnet50_gn", "output_channels": 2048, "stride": 32}, ] heads_dicts = [ From b59864a7c2a64bad735332d8ae24e6f49e818da7 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 29 May 2024 10:36:33 +0200 Subject: [PATCH 085/293] finetune SuperAnimal models (#193) * Loading SuperAnimal weights for model training * various bug fixes --- benchmark_superanimal/eval_zeroshot.py | 98 ++++++ benchmark_superanimal/train.py | 325 ++++++++++++++++++ deeplabcut/core/conversion_table.py | 78 +++++ deeplabcut/core/weight_init.py | 135 ++++++++ ...ple_individuals_trainingsetmanipulation.py | 39 ++- .../trainingsetmanipulation.py | 40 ++- deeplabcut/gui/components.py | 4 +- .../gui/tabs/create_training_dataset.py | 207 +++++++++-- deeplabcut/gui/tabs/train_network.py | 94 +++-- deeplabcut/gui/window.py | 53 ++- .../modelzoo/model_configs/hrnetw32.yaml | 22 +- .../superanimal_quadruped.yaml | 1 + .../superanimal_topviewmouse.yaml | 1 + deeplabcut/modelzoo/utils.py | 123 ++++++- deeplabcut/modelzoo/webapp/inference.py | 8 +- .../pose_estimation_pytorch/apis/train.py | 16 +- .../pose_estimation_pytorch/apis/utils.py | 13 +- .../config/backbones/hrnet_w18.yaml | 1 - .../config/backbones/hrnet_w32.yaml | 1 - .../config/backbones/hrnet_w48.yaml | 1 - .../config/backbones/resnet_101.yaml | 1 - .../config/backbones/resnet_50.yaml | 1 - .../config/base/base.yaml | 1 - .../config/make_pose_config.py | 8 + .../pose_estimation_pytorch/data/dataset.py | 22 +- .../data/transforms.py | 47 ++- .../pose_estimation_pytorch/data/utils.py | 37 +- .../models/backbones/hrnet.py | 2 +- .../models/backbones/resnet.py | 2 +- .../models/detectors/base.py | 38 +- .../models/detectors/fasterRCNN.py | 4 +- .../models/heads/base.py | 45 +++ .../models/heads/simple_head.py | 74 +++- .../pose_estimation_pytorch/models/model.py | 87 ++++- .../models/predictors/single_predictor.py | 12 +- .../modelzoo/config.py | 109 ++++++ .../modelzoo/inference.py | 50 +-- .../pose_estimation_pytorch/modelzoo/utils.py | 78 ++++- .../pose_estimation_pytorch/runners/train.py | 2 + deeplabcut/utils/auxiliaryfunctions.py | 8 +- .../JUPYTER/Demo_coco_transfer_learning.ipynb | 318 ----------------- .../openfield-Pranav-2018-10-30/config.yaml | 9 + .../modelzoo/test_modelzoo_utils.py | 4 +- 43 files changed, 1664 insertions(+), 555 deletions(-) create mode 100644 benchmark_superanimal/eval_zeroshot.py create mode 100644 benchmark_superanimal/train.py create mode 100644 deeplabcut/core/conversion_table.py create mode 100644 deeplabcut/core/weight_init.py create mode 100644 deeplabcut/pose_estimation_pytorch/modelzoo/config.py delete mode 100644 examples/JUPYTER/Demo_coco_transfer_learning.ipynb diff --git a/benchmark_superanimal/eval_zeroshot.py b/benchmark_superanimal/eval_zeroshot.py new file mode 100644 index 0000000000..e585682673 --- /dev/null +++ b/benchmark_superanimal/eval_zeroshot.py @@ -0,0 +1,98 @@ +"""SuperAnimal model zero-shot evaluation""" +from __future__ import annotations + +from pathlib import Path + +import torch + +import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.core.weight_init import WeightInitialization +from deeplabcut.generate_training_dataset import TrainingDatasetMetadata +from deeplabcut.pose_estimation_pytorch import DLCLoader +from deeplabcut.pose_estimation_pytorch.apis.evaluate import evaluate_snapshot +from deeplabcut.pose_estimation_pytorch.models import PoseModel +from deeplabcut.pose_estimation_pytorch.runners.snapshots import Snapshot + + +def main( + config_path: Path, + super_animal: str, + shuffle_index: int, + device: str, +): + metadata = TrainingDatasetMetadata.load(config_path, load_splits=True) + shuffles = [s for s in metadata.shuffles if s.index == shuffle_index] + if len(shuffles) != 1: + raise ValueError( + "Found multiple shuffles with different train indices but the same index " + f"({shuffles}). To run this benchmark, there should only be one such " + "shuffle." + ) + + shuffle = shuffles[0] + print(f"Training shuffle: {shuffle.name}") + print(f" index: {shuffle.index}") + print(f" train fraction: {shuffle.train_fraction}") + print(f" train indices: {shuffle.split.train_indices}") + print(f" test indices: {shuffle.split.test_indices}") + print() + + # edit config to have the desired training fraction + af.edit_config(str(config_path), {"TrainingFraction": [shuffle.train_fraction]}) + + # Load the config and create a data loader + cfg = af.read_config(str(config_path)) + loader = DLCLoader( + config=Path(cfg["project_path"]) / "config.yaml", + shuffle=shuffle.index, + trainset_index=0, + modelprefix="", + ) + loader.evaluation_folder.mkdir(exist_ok=True, parents=True) + loader.model_cfg["device"] = device + + # Build the pose model + model = PoseModel.build( + loader.model_cfg["model"], + weight_init=WeightInitialization.build( + cfg=cfg, + super_animal=super_animal, + with_decoder=True, + ) + ) + + # Save the zero-shot snapshot + state_dict = { + "model": model.state_dict(), + "metadata": { + "epoch": 0, + "metrics": {}, + "losses": {}, + }, + } + snapshot_path = loader.model_folder / "zero-shot.pt" + torch.save(state_dict, snapshot_path) + + # Evaluate the snapshot + evaluate_snapshot( + loader=loader, + cfg=cfg, + scorer=f"{super_animal}-zero-shot", + snapshot=Snapshot(best=False, epochs=0, path=snapshot_path), + transform=None, + plotting=True, + show_errors=True, + detector_snapshot=None, + ) + + +if __name__ == "__main__": + DATA = Path("/home/niels/datasets/superanimal") + CONFIG_PATH = DATA / "openfield-Pranav-2018-08-20" / "config.yaml" + SUPER_ANIMAL = "superanimal_topviewmouse" + main( + config_path=CONFIG_PATH, + super_animal=SUPER_ANIMAL, + shuffle_index=1001, + device="cuda", + ) diff --git a/benchmark_superanimal/train.py b/benchmark_superanimal/train.py new file mode 100644 index 0000000000..554c9347dc --- /dev/null +++ b/benchmark_superanimal/train.py @@ -0,0 +1,325 @@ +"""Fine-tuning SuperAnimal models""" +from __future__ import annotations + +import pickle +from pathlib import Path + +import deeplabcut +import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.core.engine import Engine +from deeplabcut.core.weight_init import WeightInitialization +from deeplabcut.generate_training_dataset import TrainingDatasetMetadata +from deeplabcut.modelzoo.utils import create_conversion_table +from deeplabcut.pose_estimation_pytorch import DLCLoader + + +def create_shuffles(config_path: Path, super_animal: str): + """ + Iteration 0: the three data splits given by Shaokai + Iteration 1: trained model on these data splits + Shuffle 1001: Split 1, 1% data + Shuffle 1005: Split 1, 5% data + Shuffle 1010: Split 1, 10% data + Shuffle 1050: Split 1, 50% data + Shuffle 1100: Split 1, 100% data + + Shuffle 2001: Split 2, 1% data + Shuffle 2005: Split 2, 5% data + Shuffle 2010: Split 2, 10% data + Shuffle 2050: Split 2, 50% data + Shuffle 2100: Split 2, 100% data + + Shuffle 3001: Split 3, 1% data + Shuffle 3005: Split 3, 5% data + Shuffle 3010: Split 3, 10% data + Shuffle 3050: Split 3, 50% data + Shuffle 3100: Split 3, 100% data + """ + project_path = config_path.parent + split_folder = ( + project_path / + "training-datasets" / + "iteration-0" / + "UnaugmentedDataSet_openfieldAug20" + ) + + data_splits = [1, 2, 3] + frac_train_data_used = [0.01, 0.05, 0.1, 0.5, 1] + + shuffles = [] + train_indices = [] + test_indices = [] + + for split in data_splits: + split_name = f"Documentation_data-openfield_95shuffle{split - 1}.pickle" + path_metadata = split_folder / split_name + with open(path_metadata, "rb") as f: + _, train_idx, test_idx, _ = pickle.load(f) + + num_train_images = len(train_idx) + for frac in frac_train_data_used: + shuffle_idx = (1000 * split) + int(100 * frac) + num_samples = int(frac * num_train_images) # as done by Shaokai + + shuffles.append(shuffle_idx) + train_indices.append(train_idx[:num_samples]) + test_indices.append(test_idx) + + cfg = af.read_config(config_path) + weight_init = WeightInitialization.build(cfg, super_animal, with_decoder=True) + deeplabcut.create_training_dataset( + str(config_path), + Shuffles=shuffles, + trainIndices=train_indices, + testIndices=test_indices, + userfeedback=False, + weight_init=weight_init, + engine=Engine.PYTORCH, + ) + + +def create_transfer_learning_shuffles( + config_path: Path, + net_type: str, + super_animal: str | None, +): + project_path = config_path.parent + split_folder = ( + project_path / + "training-datasets" / + "iteration-0" / + "UnaugmentedDataSet_openfieldAug20" + ) + + data_splits = [1, 2, 3] + frac_train_data_used = [0.01, 0.05, 0.1, 0.5, 1] + + weight_init = None + if super_animal is not None: + weight_init = WeightInitialization(dataset=super_animal, with_decoder=False) + + shuffles = [] + train_indices = [] + test_indices = [] + + for split in data_splits: + split_name = f"Documentation_data-openfield_95shuffle{split - 1}.pickle" + path_metadata = split_folder / split_name + with open(path_metadata, "rb") as f: + _, train_idx, test_idx, _ = pickle.load(f) + + num_train_images = len(train_idx) + for frac in frac_train_data_used: + if super_animal == "superanimal_topviewmouse": + shuffle_idx = 50_000 + (1000 * split) + int(100 * frac) + elif super_animal is None: + shuffle_idx = 90_000 + (1000 * split) + int(100 * frac) + else: + raise ValueError(f"Failed to generate shuffles for super_animal={super_animal}") + + num_samples = int(frac * num_train_images) # as done by Shaokai + shuffles.append(shuffle_idx) + train_indices.append(train_idx[:num_samples]) + test_indices.append(test_idx) + + deeplabcut.create_training_dataset( + str(config_path), + net_type=net_type, + Shuffles=shuffles, + trainIndices=train_indices, + testIndices=test_indices, + userfeedback=False, + weight_init=weight_init, + engine=Engine.PYTORCH, + ) + + +def update_cfg( + config_path: Path, + shuffle: int, + train_augmentations: dict, + optimizer: dict, + scheduler: dict, +) -> None: + loader = DLCLoader( + config=config_path, + shuffle=shuffle, + trainset_index=0, + modelprefix="", + ) + loader.model_cfg["data"]["train"] = None + loader.model_cfg["runner"]["optimizer"] = None + loader.model_cfg["runner"]["scheduler"] = None + loader.update_model_cfg({ + "data": {"train": train_augmentations}, + "runner": { + "optimizer": optimizer, + "scheduler": scheduler, + } + }) + + +def data_preparation( + config_path: Path, + super_animal: str, + run_build_conversion_table: bool, + run_create_shuffles: bool, +) -> None: + if run_build_conversion_table: + _ = create_conversion_table( + config=config_path, + super_animal=super_animal, + project_to_super_animal={ + "snout": "nose", + "leftear": "left_ear", + "rightear": "right_ear", + "tailbase": "tail_base", + }, + ) + + if run_create_shuffles: + create_shuffles(config_path, super_animal) + + +def main( + config_path: Path, + shuffle_index: int, + epochs: int, + train_augmentations: dict, + optimizer: dict, + scheduler: dict, + device: str | None = None, + batch_size: int = 32, + save_epochs: int = 20, + eval_interval: int = 5, +): + metadata = TrainingDatasetMetadata.load(config_path, load_splits=True) + shuffles = [s for s in metadata.shuffles if s.index == shuffle_index] + if len(shuffles) != 1: + raise ValueError( + "Found multiple shuffles with different train indices but the same index " + f"({shuffles}). To run this benchmark, there should only be one such " + "shuffle." + ) + + shuffle = shuffles[0] + print(f"Training shuffle: {shuffle.name}") + print(f" index: {shuffle.index}") + print(f" train fraction: {shuffle.train_fraction}") + print(f" train indices: {shuffle.split.train_indices}") + print(f" test indices: {shuffle.split.test_indices}") + print() + + # edit config to have the desired training fraction + af.edit_config(str(config_path), {"TrainingFraction": [shuffle.train_fraction]}) + + # information about shuffle + mode = shuffle.index // 10_000 + split = (shuffle.index - (10_000 * mode)) // 1000 + data_used = (shuffle.index - (10_000 * mode)) % 1000 + + project = "SuperAnimal-openfield-finetune-v2" + uid = "sa-finetune" + if mode == 5: + uid = "sa-transfer" + elif mode == 9: + uid = "in-transfer" + + # update the pose config to have the correct augmentation, optimizer and scheduler + update_cfg( + config_path=config_path, + shuffle=shuffle.index, + train_augmentations=train_augmentations, + optimizer=optimizer, + scheduler=scheduler, + ) + + # train the model + deeplabcut.train_network( + str(config_path), + shuffle=shuffle.index, + trainingsetindex=0, + device=device, + # edit the pytorch config + detector=dict(train_settings=dict(epochs=0)), + runner=dict( + eval_interval=eval_interval, + snapshots=dict( + max_snapshots=5, + save_epochs=save_epochs, + ), + ), + train_settings=dict(batch_size=batch_size, epochs=epochs), + logger=dict( + type="WandbLogger", + project_name=project, + run_name=f"openfield-{uid}-shuffle{shuffle.index}", + save_code=True, + tags=( + f"mode={uid}", + f"split={split}", + f"data_used={data_used}", + ) + ), + ) + + +if __name__ == "__main__": + DATA = Path("/home/niels/datasets/superanimal") + CONFIG_PATH = DATA / "openfield-Pranav-2018-08-20" / "config.yaml" + SUPER_ANIMAL = "superanimal_topviewmouse" + + FINETUNE_AUG = { + "affine": { + "p": 0.5, + "scaling": [1, 1], + "rotation": 90, + "translation": 0, + }, + "hflip": { + "p": 0.5, + "symmetries": [[1, 2]], + }, + "gaussian_noise": 12.75, + "normalize_images": True, + } + FINETUNE_OPTIM = { + "type": "AdamW", + "params": {"lr": 1e-05}, + } + FINETUNE_SCHEDULER = { + "type": "LRListScheduler", + "params": { + "lr_list": [[1e-06], [1e-07]], + "milestones": [450, 590], + }, + } + + PREP_DATA = False + PREP_TRANSFER_LEARNING_DATA = False + if PREP_DATA: + # ONLY RUN ONCE: prepare data (create shuffles, conversion table) + data_preparation( + config_path=CONFIG_PATH, + super_animal=SUPER_ANIMAL, + run_build_conversion_table=True, + run_create_shuffles=True, + ) + elif PREP_TRANSFER_LEARNING_DATA: + create_transfer_learning_shuffles( + CONFIG_PATH, "top_down_hrnet_w32", SUPER_ANIMAL + ) + create_transfer_learning_shuffles(CONFIG_PATH, "top_down_hrnet_w32", None) + else: + # train a shuffle + for idx in [51001, 52001, 53001, 51005, 52005, 53005]: + main( + config_path=CONFIG_PATH, + shuffle_index=idx, + epochs=600, + train_augmentations=FINETUNE_AUG, + optimizer=FINETUNE_OPTIM, + scheduler=FINETUNE_SCHEDULER, + batch_size=32, + device="cuda", + ) diff --git a/deeplabcut/core/conversion_table.py b/deeplabcut/core/conversion_table.py new file mode 100644 index 0000000000..4820a7dd7b --- /dev/null +++ b/deeplabcut/core/conversion_table.py @@ -0,0 +1,78 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Defines conversion tables mapping DeepLabCut project bodyparts to SA bodyparts""" +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass +class ConversionTable: + """Maps DLC project bodyparts to the corresponding SuperAnimal bodyparts + + The conversion table must satisfy the following conditions (checked by validate): + - All SuperAnimal bodyparts must be valid (defined for the SuperAnimal model) + - All project bodyparts must be valid (defined for the DLC project) + """ + super_animal: str + project_bodyparts: list[str] + super_animal_bodyparts: list[str] + table: dict[str, str] + + def __post_init__(self): + """Validates the table""" + self.validate() + + def to_array(self) -> np.ndarray: + """ + Returns: + An array mapping the indices of SuperAnimal bodyparts + + Raises: + ValueError: If the conversion table is misconfigured. + """ + self.validate() + sa_indices = {sa_bpt: i for i, sa_bpt in enumerate(self.super_animal_bodyparts)} + sa_bpt_ordering = [self.table[bpt] for bpt in self.converted_bodyparts()] + return np.array([sa_indices[sa_bpt] for sa_bpt in sa_bpt_ordering]) + + def converted_bodyparts(self) -> list[str]: + """Returns: The project bodyparts included in this ordered""" + return [bpt for bpt in self.project_bodyparts if bpt in self.table] + + def validate(self) -> None: + """ + Raises: + ValueError: If the conversion table is misconfigured. + """ + project_bpts = set(self.project_bodyparts) + sa_bpts = set(self.super_animal_bodyparts) + + mapped_sa = set(self.table.values()) + mapped_project = set(self.table.keys()) + + # check all mapped SuperAnimal bodyparts are in the config + if len(mapped_sa.difference(sa_bpts)) != 0: + extra_bodyparts = set(mapped_sa).difference(sa_bpts) + raise ValueError( + f"Some bodyparts in your mapping are not in the {self.super_animal} " + f"model: {extra_bodyparts}. Available bodyparts are {' '.join(sa_bpts)}" + ) + + # check all given bodyparts are in the project configuration + if len(mapped_project.difference(project_bpts)) != 0: + extra_bodyparts = mapped_project.difference(project_bpts) + raise ValueError( + "Some bodyparts in your mapping are not in your project configuration: " + f"{extra_bodyparts}. Defined bodyparts are {' '.join(project_bpts)}" + ) diff --git a/deeplabcut/core/weight_init.py b/deeplabcut/core/weight_init.py new file mode 100644 index 0000000000..e59775acbe --- /dev/null +++ b/deeplabcut/core/weight_init.py @@ -0,0 +1,135 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Classes to configure how to initialize model weights""" +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +import deeplabcut.modelzoo.utils as modelzoo_utils + + +@dataclass +class WeightInitialization: + """The dataset from which to initialize weights + + To build a WeightInitialization instance for a project using the conversion table + specified in the project configuration file, use: + + ``` + from pathlib import Path + from deeplabcut.utils.auxiliaryfunctions import read_config + + project_cfg = read_config("/path/to/my/project/config.yaml") + super_animal = "superanimal_quadruped" + weight_init = WeightInitialization.build( + cfg=project_cfg, + super_animal="superanimal_quadruped", + with_decoder=True, + ) + ``` + + Args: + dataset: The dataset on which the model weights were trained. Must be one of the + SuperAnimal weights. + with_decoder: Whether to load the decoder weights as well. + conversion_array: The mapping from SuperAnimal to project bodyparts. Required + when `with_decoder=True`. + + An array [7, 0, 1] means the project has 3 bodyparts, where the 1st bodypart + corresponds to the 8th bodypart in the pretrained model, the 2nd to the 1st + and the 3rd to the 2nd (as arrays are 0-indexed). + bodyparts: Optionally, the name of each bodypart entry in the conversion array. + """ + dataset: str + with_decoder: bool = False + conversion_array: np.ndarray | None = None + bodyparts: list[str] | None = None + + def __post_init__(self): + # check that the dataset exists; raises a ValueError if it doesn't + _ = modelzoo_utils.get_super_animal_project_cfg(self.dataset) + if self.with_decoder and self.conversion_array is None: + raise ValueError( + f"You must specify a conversion_array to initialize decoder weights " + f"(``with_decoder=True``)." + ) + + if self.bodyparts is not None and self.conversion_array is None: + raise ValueError( + f"Specifying bodyparts should only be done when `with_decoder=True` and" + f" the conversion array is specified." + ) + + if self.conversion_array is not None and self.bodyparts is not None: + if not len(self.conversion_array) == len(self.bodyparts): + raise ValueError( + f"There must be the same number of elements in the bodyparts list " + "and conv. array; found {self.bodyparts}, {self.conversion_array}" + ) + + def to_dict(self) -> dict: + """Returns: the weight initialization as a dict""" + data = { + "dataset": self.dataset, + "with_decoder": self.with_decoder, + } + if self.conversion_array is not None: + data["conversion_array"] = self.conversion_array.tolist() + + return data + + @staticmethod + def from_dict(data: dict) -> "WeightInitialization": + conversion_array = data.get("conversion_array") + if conversion_array is not None: + conversion_array = np.array(conversion_array, dtype=int) + + return WeightInitialization( + dataset=data["dataset"], + with_decoder=data["with_decoder"], + conversion_array=conversion_array, + ) + + @staticmethod + def build( + cfg: dict, + super_animal: str, + with_decoder: bool = False, + ) -> "WeightInitialization": + """Builds a WeightInitialization for a project + + Args: + cfg: The project's configuration. + super_animal: The SuperAnimal model with which to initialize weights. + with_decoder: Whether to load the decoder weights as well. If this is true, + a conversion table must be specified for the given SuperAnimal in the + project configuration file. See + ``deeplabcut.modelzoo.utils.create_conversion_table`` to create a + conversion table. + + Returns: + The built WeightInitialization. + """ + conversion_array = None + bodyparts = None + if with_decoder: + conversion_table = modelzoo_utils.get_conversion_table(cfg, super_animal) + conversion_array = conversion_table.to_array() + bodyparts = conversion_table.converted_bodyparts() + + return WeightInitialization( + dataset=super_animal, + with_decoder=with_decoder, + conversion_array=conversion_array, + bodyparts=bodyparts, + ) diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index c0147c2df8..73e7c6bb0a 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -23,6 +23,7 @@ import deeplabcut.compat as compat import deeplabcut.generate_training_dataset.metadata as metadata from deeplabcut.core.engine import Engine +from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.generate_training_dataset import ( merge_annotateddatasets, read_image_shape_fast, @@ -115,6 +116,7 @@ def create_multianimaltraining_dataset( n_edges_threshold=105, paf_graph_degree=6, userfeedback: bool = True, + weight_init: WeightInitialization | None = None, engine: Engine | None = None, ): """ @@ -180,6 +182,10 @@ def create_multianimaltraining_dataset( already exist). If you want to assure that previous splits etc. are not overwritten, set this to ``True`` and you will be asked for each split. + weight_init: WeightInitialisation, optional, default=None + PyTorch engine only. Specify how model weights should be initialized. The + default mode uses transfer learning from ImageNet weights. + engine: Engine, optional Whether to create a pose config for a Tensorflow or PyTorch model. Defaults to the value specified in the project configuration file. If no engine is specified @@ -345,6 +351,11 @@ def create_multianimaltraining_dataset( test_inds = test_inds[test_inds != -1] splits.append((trainFraction, Shuffles[shuffle], (train_inds, test_inds))) + top_down = False + if engine == Engine.PYTORCH and net_type.startswith("top_down_"): + top_down = True + net_type = net_type[len("top_down_"):] + for trainFraction, shuffle, (trainIndices, testIndices) in splits: #################################################### # Generating data structure with labeled information & frame metadata (for deep cut) @@ -525,19 +536,25 @@ def create_multianimaltraining_dataset( # Populate the pytorch config yaml file if engine == Engine.PYTORCH: from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config - - top_down = False - if net_type.startswith("top_down_"): - top_down = True - net_type = net_type[len("top_down_"):] + from deeplabcut.pose_estimation_pytorch.modelzoo.config import make_super_animal_finetune_config pose_cfg_path = path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml") - pytorch_cfg = make_pytorch_pose_config( - project_config=cfg, - pose_config_path=path_train_config, - net_type=net_type, - top_down=top_down, - ) + if weight_init is not None and weight_init.with_decoder: + pytorch_cfg = make_super_animal_finetune_config( + project_config=cfg, + pose_config_path=path_train_config, + net_type=net_type, + weight_init=weight_init, + ) + else: + pytorch_cfg = make_pytorch_pose_config( + project_config=cfg, + pose_config_path=path_train_config, + net_type=net_type, + top_down=top_down, + weight_init=weight_init, + ) + auxiliaryfunctions.write_plainconfig(pose_cfg_path, pytorch_cfg) print( diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index f08bcf091c..fb72665f58 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -28,6 +28,7 @@ import deeplabcut.compat as compat import deeplabcut.generate_training_dataset.metadata as metadata from deeplabcut.core.engine import Engine +from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.utils import ( auxiliaryfunctions, conversioncode, @@ -779,6 +780,7 @@ def create_training_dataset( augmenter_type=None, posecfg_template=None, superanimal_name="", + weight_init: WeightInitialization | None = None, engine: Engine | None = None, ): """Creates a training dataset. @@ -846,6 +848,10 @@ def create_training_dataset( superanimal_name: string, optional, default="" Specify the superanimal name is transfer learning with superanimal is desired. This makes sure the pose config template uses superanimal configs as template + weight_init: WeightInitialisation, optional, default=None + PyTorch engine only. Specify how model weights should be initialized. The + default mode uses transfer learning from ImageNet weights. + engine: Engine, optional Whether to create a pose config for a Tensorflow or PyTorch model. Defaults to the value specified in the project configuration file. If no engine is specified @@ -974,6 +980,12 @@ def create_training_dataset( else: raise ValueError("Invalid network type:", net_type) + top_down = False + if engine == Engine.PYTORCH: + if net_type.startswith("top_down_"): + top_down = True + net_type = net_type[len("top_down_"):] + augmenters = compat.get_available_aug_methods(engine) default_augmenter = augmenters[0] if augmenter_type is None: @@ -1210,19 +1222,25 @@ def create_training_dataset( # Populate the pytorch config yaml file if engine == Engine.PYTORCH: from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config - - top_down = False - if net_type.startswith("top_down_"): - top_down = True - net_type = net_type[len("top_down_"):] + from deeplabcut.pose_estimation_pytorch.modelzoo.config import make_super_animal_finetune_config pose_cfg_path = path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml") - pytorch_cfg = make_pytorch_pose_config( - project_config=cfg, - pose_config_path=path_train_config, - net_type=net_type, - top_down=top_down, - ) + if weight_init is not None and weight_init.with_decoder: + pytorch_cfg = make_super_animal_finetune_config( + project_config=cfg, + pose_config_path=path_train_config, + net_type=net_type, + weight_init=weight_init, + ) + else: + pytorch_cfg = make_pytorch_pose_config( + project_config=cfg, + pose_config_path=path_train_config, + net_type=net_type, + top_down=top_down, + weight_init=weight_init, + ) + auxiliaryfunctions.write_plainconfig(pose_cfg_path, pytorch_cfg) return splits diff --git a/deeplabcut/gui/components.py b/deeplabcut/gui/components.py index f164804876..170d43fd1e 100644 --- a/deeplabcut/gui/components.py +++ b/deeplabcut/gui/components.py @@ -54,7 +54,7 @@ def _create_grid_layout( alignment=None, spacing: int = 20, margins: tuple = None, -) -> QtWidgets.QGridLayout(): +) -> QtWidgets.QGridLayout: layout = QtWidgets.QGridLayout() layout.setAlignment(Qt.AlignLeft | Qt.AlignTop) layout.setSpacing(spacing) @@ -190,7 +190,7 @@ def __init__(self, root, parent): self.root = root self.parent = parent - self.setMaximum(100) + self.setMaximum(10_000) self.setValue(self.root.shuffle_value) self.valueChanged.connect(self.root.update_shuffle) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index d7c83eefb4..4ba4cee032 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -11,12 +11,13 @@ import os from PySide6 import QtWidgets -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QIcon import deeplabcut import deeplabcut.compat as compat from deeplabcut.core.engine import Engine +from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.generate_training_dataset import get_existing_shuffle_indices from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine from deeplabcut.gui.dlc_params import DLCParams @@ -77,34 +78,33 @@ def _generate_layout_attributes(self, layout): shuffle_label = QtWidgets.QLabel("Shuffle") self.shuffle = ShuffleSpinBox(root=self.root, parent=self) + # Dataset choices + self.weight_init_label = QtWidgets.QLabel("Weight Initialization") + self.weight_init_choice = QtWidgets.QComboBox() + self.update_weight_init_methods(self.root.engine) + self.root.engine_change.connect(self.update_weight_init_methods) + # Augmentation method augmentation_label = QtWidgets.QLabel("Augmentation method") - methods = compat.get_available_aug_methods(self.root.project_engine) self.aug_choice = QtWidgets.QComboBox() - self.aug_choice.addItems(methods) - self.aug_choice.setCurrentText(methods[0]) + self.update_aug_methods(self.root.engine) + self.root.engine_change.connect(self.update_aug_methods) self.aug_choice.currentTextChanged.connect(self.log_augmentation_choice) # Neural Network nnet_label = QtWidgets.QLabel("Network architecture") self.net_choice = QtWidgets.QComboBox() - - if self.root.project_engine == Engine.TF: - nets = DLCParams.NNETS.copy() - if not self.root.is_multianimal: - nets.remove("dlcrnet_ms5") - else: - # FIXME: Circular imports make it impossible to import this at the top - from deeplabcut.pose_estimation_pytorch import available_models - nets = available_models() - - self.net_choice.addItems(nets) - default_net_type = self.root.cfg.get("default_net_type", "resnet_50") - if default_net_type in nets: - self.net_choice.setCurrentIndex(nets.index(default_net_type)) + self.update_nets(self.root.engine) + self.root.engine_change.connect(self.update_nets) self.net_choice.currentTextChanged.connect(self.log_net_choice) - self.overwrite = QtWidgets.QCheckBox("Overwrite") + # Update Net types when selected weight init changes + self.weight_init_choice.currentTextChanged.connect( + lambda _: self.update_nets(None) + ) + + # Overwrite selection + self.overwrite = QtWidgets.QCheckBox("Overwrite if exists") self.overwrite.setChecked(False) self.overwrite.setToolTip( "When checked, creating a new shuffle with an index that already exists " @@ -117,11 +117,15 @@ def _generate_layout_attributes(self, layout): layout.addWidget(shuffle_label, 0, 0) layout.addWidget(self.shuffle, 0, 1) - layout.addWidget(nnet_label, 0, 2) - layout.addWidget(self.net_choice, 0, 3) - layout.addWidget(augmentation_label, 0, 4) - layout.addWidget(self.aug_choice, 0, 5) - layout.addWidget(self.overwrite, 1, 0) + layout.addWidget(self.weight_init_label, 0, 2) + layout.addWidget(self.weight_init_choice, 0, 3) + + layout.addWidget(nnet_label, 1, 0) + layout.addWidget(self.net_choice, 1, 1) + layout.addWidget(augmentation_label, 1, 2) + layout.addWidget(self.aug_choice, 1, 3) + + layout.addWidget(self.overwrite, 2, 0) def log_net_choice(self, net): self.root.logger.info(f"Network architecture set to {net.upper()}") @@ -156,15 +160,22 @@ def create_training_dataset(self): self.root.writer.write("Training dataset creation failed.") return + try: + weight_init = self.get_weight_init() + except ValueError as err: + print(f"The training dataset could not be created: {err}.") + return + if self.model_comparison: raise NotImplementedError # TODO: finish model_comparison - deeplabcut.create_training_model_comparison( - config_file, - num_shuffles=shuffle, - net_types=self.net_type, - augmenter_types=self.aug_type, - ) + # deeplabcut.create_training_model_comparison( + # config_file, + # num_shuffles=shuffle, + # net_types=self.net_type, + # augmenter_types=self.aug_type, + # ) + else: if self.root.is_multianimal: deeplabcut.create_multianimaltraining_dataset( @@ -173,6 +184,8 @@ def create_training_dataset(self): Shuffles=[self.shuffle.value()], net_type=self.net_choice.currentText(), userfeedback=not overwrite, + weight_init=weight_init, + engine=self.root.engine, ) else: deeplabcut.create_training_dataset( @@ -182,6 +195,8 @@ def create_training_dataset(self): net_type=self.net_choice.currentText(), augmenter_type=self.aug_choice.currentText(), userfeedback=not overwrite, + weight_init=weight_init, + engine=self.root.engine, ) # Check that training data files were indeed created. trainingsetfolder = get_training_set_folder(self.root.cfg) @@ -254,6 +269,107 @@ def _confirm_overwrite(self, shuffle: int, existing_indices: list[int]) -> bool: return True + @Slot(Engine) + def update_nets(self, engine: Engine | None) -> None: + if engine is None: + engine = self.root.engine + + if engine == Engine.TF: + nets = DLCParams.NNETS.copy() + if not self.root.is_multianimal: + nets.remove("dlcrnet_ms5") + else: + # FIXME: Circular imports make it impossible to import this at the top + from deeplabcut.pose_estimation_pytorch import available_models + nets = available_models() + net_filter = self.get_net_filter() + td_prefix = "top_down_" + if net_filter is not None: + nets = [ + n + for n in nets + if ( + n in net_filter or + (n.startswith(td_prefix) and n[len(td_prefix):] in net_filter) + ) + ] + + while self.net_choice.count() > 0: + self.net_choice.removeItem(0) + + self.net_choice.addItems(nets) + default_net_type = self.root.cfg.get("default_net_type", "resnet_50") + if default_net_type in nets: + self.net_choice.setCurrentIndex(nets.index(default_net_type)) + + @Slot(Engine) + def update_aug_methods(self, engine: Engine) -> None: + methods = compat.get_available_aug_methods(engine) + while self.aug_choice.count() > 0: + self.aug_choice.removeItem(0) + + self.aug_choice.addItems(methods) + self.aug_choice.setCurrentText(methods[0]) + + @Slot(Engine) + def update_weight_init_methods(self, engine: Engine) -> None: + if engine != Engine.PYTORCH: + self.weight_init_label.hide() + self.weight_init_choice.hide() + return + + while self.weight_init_choice.count() > 0: + self.weight_init_choice.removeItem(0) + + self.weight_init_label.show() + self.weight_init_choice.show() + self.weight_init_choice.addItems(list(_WEIGHT_INIT_OPTIONS.keys())) + + def get_net_filter(self) -> list[str] | None: + """Returns: the net type that can be used based on weight initialization""" + if self.root.engine != Engine.PYTORCH: + return None + + weight_init_choice = self.weight_init_choice.currentText() + return _WEIGHT_INIT_OPTIONS[weight_init_choice]["model_filter"] + + def get_weight_init(self) -> WeightInitialization | None: + """ + Raises: + ValueError if WeightInitialization should be defined but could not be + created (e.g. if there's no conversion table). + """ + if self.root.engine != Engine.PYTORCH: + return None + + weight_init_choice = self.weight_init_choice.currentText() + if "imagenet" in weight_init_choice.lower(): + return + + weight_init_data = _WEIGHT_INIT_OPTIONS[weight_init_choice] + super_animal = weight_init_data["super_animal"] + with_decoder = "fine-tuning" in weight_init_choice.lower() + try: + weight_init = WeightInitialization.build( + self.root.cfg, + super_animal=super_animal, + with_decoder=with_decoder, + ) + except ValueError as err: + QtWidgets.QMessageBox.critical( + self, + "Error", + ( + f"No Conversion table specified for {super_animal} in the project " + "configuration file. Please create a conversion table using the GUI" + ", with ``deeplabcut.modelzoo.utils.create_conversion_table``, or " + "by adding it to your project's configuration file manually." + ), + ) + raise err + + return weight_init + def _create_message_box(text, info_text): msg = QtWidgets.QMessageBox() @@ -283,3 +399,34 @@ def _create_confirmation_box(title, description): msg.setWindowIcon(QIcon(logo)) msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) return msg + + +_WEIGHT_INIT_OPTIONS = { # FIXME - Generate dynamically + "Transfer Learning - ImageNet": { + "model_filter": None, + }, + "Transfer Learning - SuperAnimal Quadruped": { + "model_filter": [ + "dekr_w32", + "hrnet_w32", + "resnet_50", + ], + "super_animal": "superanimal_quadruped", + }, + "Transfer Learning - SuperAnimal TopViewMouse": { + "model_filter": [ + "dekr_w32", + "hrnet_w32", + "resnet_50", + ], + "super_animal": "superanimal_topviewmouse", + }, + "Fine-tuning - SuperAnimal Quadruped": { + "model_filter": ["hrnet_w32"], # FIXME - Add ResNet + "super_animal": "superanimal_quadruped", + }, + "Fine-tuning - SuperAnimal TopViewMouse": { + "model_filter": ["hrnet_w32"], # FIXME - Add ResNet + "super_animal": "superanimal_topviewmouse", + }, +} diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py index 8ff132a527..b78b5727b2 100644 --- a/deeplabcut/gui/tabs/train_network.py +++ b/deeplabcut/gui/tabs/train_network.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from PySide6 import QtWidgets -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QIcon import deeplabcut.compat as compat @@ -36,17 +36,25 @@ class IntTrainAttribute: class TrainNetwork(DefaultTab): - def __init__(self, root, parent, h1_description, engine: Engine = Engine.TF): + def __init__(self, root, parent, h1_description): super(TrainNetwork, self).__init__(root, parent, h1_description) - self.train_attributes = get_train_attributes(engine=engine) + self.root.engine_change.connect(self._on_engine_change) + self._attribute_layouts: dict[Engine, QtWidgets.QWidget] = {} + self._shuffles: dict[Engine, ShuffleSpinBox] = {} + self._attribute_kwargs: dict[Engine, dict] = {} self._set_page() + @Slot(Engine) + def _on_engine_change(self, engine: Engine) -> None: + for e, layout in self._attribute_layouts.items(): + if e == engine: + layout.show() + else: + layout.hide() + def _set_page(self): self.main_layout.addWidget(_create_label_widget("Attributes", "font:bold")) - self.layout_attributes = _create_grid_layout(margins=(20, 0, 0, 0)) - self._generate_layout_attributes(self.layout_attributes) - self.main_layout.addLayout(self.layout_attributes) - + self._generate_layout_attributes() self.main_layout.addWidget(_create_label_widget("")) # dummy label self.edit_posecfg_btn = QtWidgets.QPushButton("Edit pose_cfg.yaml") @@ -77,27 +85,39 @@ def show_help_dialog(self): dialog.setLayout(layout) dialog.exec_() - def _generate_layout_attributes(self, layout): - # Shuffle - shuffle_label = QtWidgets.QLabel("Shuffle") - self.shuffle = ShuffleSpinBox(root=self.root, parent=self) - layout.addWidget(shuffle_label, 0, 0) - layout.addWidget(self.shuffle, 0, 1) - - # Other parameters - self.attribute_spin_boxes = {} - for i, attribute in enumerate(self.train_attributes): - label = QtWidgets.QLabel(attribute.label) - spin_box = QtWidgets.QSpinBox() - spin_box.setMinimum(attribute.min) - spin_box.setMaximum(attribute.max) - spin_box.setValue(attribute.default) - spin_box.valueChanged.connect( - lambda new_val: self.log_attribute_change(attribute, new_val) - ) - self.attribute_spin_boxes[attribute.fn_key] = spin_box - layout.addWidget(label, 0, 2 * (i + 1)) - layout.addWidget(spin_box, 0, 2 * (i + 1) + 1) + def _generate_layout_attributes(self) -> None: + for engine in Engine: + train_attributes = get_train_attributes(engine) + layout = _create_grid_layout(margins=(20, 0, 0, 0)) + + # Shuffle + shuffle_label = QtWidgets.QLabel("Shuffle") + self._shuffles[engine] = ShuffleSpinBox(root=self.root, parent=self) + layout.addWidget(shuffle_label, 0, 0) + layout.addWidget(self._shuffles[engine], 0, 1) + + # Other parameters + self._attribute_kwargs[engine] = {} + for i, attribute in enumerate(train_attributes): + label = QtWidgets.QLabel(attribute.label) + spin_box = QtWidgets.QSpinBox() + spin_box.setMinimum(attribute.min) + spin_box.setMaximum(attribute.max) + spin_box.setValue(attribute.default) + spin_box.valueChanged.connect( + lambda new_val: self.log_attribute_change(attribute, new_val) + ) + self._attribute_kwargs[engine][attribute.fn_key] = spin_box + layout.addWidget(label, 0, 2 * (i + 1)) + layout.addWidget(spin_box, 0, 2 * (i + 1) + 1) + + layout_widget = QtWidgets.QWidget() + layout_widget.setLayout(layout) + self._attribute_layouts[engine] = layout_widget + if engine != self.root.engine: + layout_widget.hide() + + self.main_layout.addWidget(layout_widget) def log_attribute_change(self, attribute: IntTrainAttribute, value: int) -> None: self.root.logger.info(f"{attribute.label} set to {value}") @@ -108,9 +128,9 @@ def open_posecfg_editor(self): def train_network(self): config = self.root.config - shuffle = int(self.shuffle.value()) + shuffle = int(self._shuffles[self.root.engine].value()) kwargs = dict(gputouse=None, autotune=False) - for k, spin_box in self.attribute_spin_boxes.items(): + for k, spin_box in self._attribute_kwargs[self.root.engine].items(): kwargs[k] = int(spin_box.value()) compat.train_network(config, shuffle, **kwargs) @@ -185,13 +205,13 @@ def get_train_attributes(engine: Engine) -> list[IntTrainAttribute]: min=1, max=1000, ), - # IntTrainAttribute( # FIXME: Implement - # label="Number of snapshots to keep", - # fn_key="max_snapshots_to_keep", - # default=5, - # min=1, - # max=100, - # ), + IntTrainAttribute( # FIXME: Implement + label="Number of snapshots to keep", + fn_key="max_snapshots_to_keep", + default=5, + min=1, + max=100, + ), ] raise NotImplementedError(f"Unknown engine: {engine}") diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 2b099baf81..89adaa3102 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -24,7 +24,15 @@ from deeplabcut.gui.tabs import * from deeplabcut.gui.widgets import StreamReceiver, StreamWriter from napari_deeplabcut import misc -from PySide6.QtWidgets import QMessageBox, QMenu, QWidget, QMainWindow +from PySide6.QtWidgets import ( + QMessageBox, + QMenu, + QWidget, + QMainWindow, + QComboBox, + QLabel, + QSizePolicy, +) from PySide6 import QtCore from PySide6.QtGui import QIcon, QAction from PySide6 import QtWidgets, QtGui @@ -68,6 +76,7 @@ class MainWindow(QMainWindow): config_loaded = QtCore.Signal() video_type_ = QtCore.Signal(str) video_files_ = QtCore.Signal(set) + engine_change = QtCore.Signal(Engine) def __init__(self, app): super(MainWindow, self).__init__() @@ -99,6 +108,8 @@ def __init__(self, app): self._toolbar = None self.create_toolbar() + self._engine = Engine.PYTORCH + # Thread-safe Stdout redirector self.writer = StreamWriter() sys.stdout = self.writer @@ -153,8 +164,16 @@ def cfg(self): return cfg @property - def project_engine(self) -> Engine: - return compat.get_project_engine(self.cfg) + def engine(self) -> Engine: + return self._engine + + @engine.setter + def engine(self, e: Engine) -> None: + if self._engine == e: + return + + self._engine = e + self.engine_change.emit(e) @property def project_folder(self) -> str: @@ -187,7 +206,6 @@ def pose_cfg_path(self) -> str: shuffle=int(self.shuffle_value), trainingsetindex=int(self.trainingset_index), modelprefix="", - engine=self.project_engine, )[0] ) except FileNotFoundError: @@ -396,6 +414,32 @@ def create_toolbar(self): self.toolbar.addAction(self.openAction) self.toolbar.addAction(self.helpAction) + size_policy = QSizePolicy() # QtWidgets.QSizePolicy.Policy.Expanding + size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding) + spacer = QLabel() + spacer.setSizePolicy(size_policy) + spacer.setStyleSheet("background: transparent;") + + engine_label = QLabel() + engine_label.autoFillBackground() + engine_label.setText("Engine") + engine_label.setStyleSheet("background: transparent;") + + engines = [engine for engine in Engine] + + def _update_engine(index: int) -> None: + self.logger.info(f"Changed engine to {engines[index]}") + self.engine = engines[index] + + change_engine_widget = QComboBox() + change_engine_widget.addItems([e.aliases[0] for e in engines]) + change_engine_widget.setFixedWidth(180) + change_engine_widget.currentIndexChanged.connect(_update_engine) + + self.toolbar.addWidget(spacer) + self.toolbar.addWidget(engine_label) + self.toolbar.addWidget(change_engine_widget) + def remove_action(self): self.toolbar.removeAction(self.newAction) self.toolbar.removeAction(self.openAction) @@ -497,7 +541,6 @@ def add_tabs(self): root=self, parent=None, h1_description="DeepLabCut - Train network", - engine=self.project_engine, ) self.evaluate_network = EvaluateNetwork( root=self, diff --git a/deeplabcut/modelzoo/model_configs/hrnetw32.yaml b/deeplabcut/modelzoo/model_configs/hrnetw32.yaml index 0938f129c2..2c05c0f1d8 100644 --- a/deeplabcut/modelzoo/model_configs/hrnetw32.yaml +++ b/deeplabcut/modelzoo/model_configs/hrnetw32.yaml @@ -9,15 +9,16 @@ data: pad_height_divisor: 32 normalize_images: true train: + affine: + p: 0.5 + scaling: [1.0, 1.0] + rotation: 30 + translation: 0 covering: true gaussian_noise: 12.75 hist_eq: true motion_blur: true normalize_images: true - rotation: 30 - scale_jitter: - - 0.5 - - 1.25 auto_padding: pad_width_divisor: 32 pad_height_divisor: 32 @@ -63,6 +64,8 @@ model: type: HRNet model_name: hrnet_w32 pretrained: false + freeze_bn_stats: True + freeze_bn_weights: False interpolate_branches: false increased_channel_count: false backbone_output_channels: 32 @@ -71,10 +74,12 @@ model: type: HeatmapHead predictor: type: HeatmapPredictor + apply_sigmoid: false + clip_scores: true location_refinement: false locref_std: 7.2801 target_generator: - type: HeatmapPlateauGenerator + type: HeatmapGaussianGenerator num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT @@ -82,11 +87,8 @@ model: locref_std: 7.2801 criterion: heatmap: - type: WeightedBCECriterion + type: WeightedMSECriterion weight: 1.0 - locref: - type: WeightedHuberCriterion - weight: 0 heatmap_config: channels: [32, "num_bodyparts"] kernel_size: [1] @@ -99,7 +101,7 @@ runner: optimizer: type: AdamW params: - lr: 0.0001 + lr: 1e-5 scheduler: type: LRListScheduler params: diff --git a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml index e24c0a8827..1688482e29 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml @@ -79,6 +79,7 @@ iteration: default_net_type: default_augmenter: snapshotindex: +detector_snapshotindex: batch_size: 1 diff --git a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml index d90b890bb6..5dd911002b 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml @@ -67,6 +67,7 @@ iteration: default_net_type: default_augmenter: snapshotindex: +detector_snapshotindex: batch_size: 1 diff --git a/deeplabcut/modelzoo/utils.py b/deeplabcut/modelzoo/utils.py index a6d64fdd90..6b8ffd0a68 100644 --- a/deeplabcut/modelzoo/utils.py +++ b/deeplabcut/modelzoo/utils.py @@ -8,16 +8,131 @@ # # Licensed under GNU Lesser General Public License v3.0 # -import json -import os +from __future__ import annotations +import os import warnings from glob import glob +from pathlib import Path + +from deeplabcut.core.conversion_table import ConversionTable +from deeplabcut.utils.auxiliaryfunctions import ( + get_bodyparts, + get_deeplabcut_path, + read_config, + write_config, +) + + +def dlc_modelzoo_path() -> Path: + """Returns: the path to the `modelzoo` folder in the DeepLabCut installation""" + dlc_root_path = Path(get_deeplabcut_path()) + return dlc_root_path / "modelzoo" + + +def get_super_animal_project_cfg(super_animal: str) -> dict: + """Gets the project configuration file for a SuperAnimal model + + Args: + super_animal: the name of the SuperAnimal model for which to load the project + configuration + + Returns: + the project configuration for the given SuperAnimal model + + Raises: + ValueError if no such SuperAnimal is found + """ + project_configs_dir = dlc_modelzoo_path() / "project_configs" + super_animal_projects = {p.stem: p for p in project_configs_dir.iterdir()} + if super_animal not in super_animal_projects: + raise ValueError( + f"No such SuperAnimal model: {super_animal}. Available SuperAnimal models " + f"are {', '.join(super_animal_projects.keys())}." + ) + + return read_config(str(super_animal_projects[super_animal])) + + +def create_conversion_table( + config: str | Path, + super_animal: str, + project_to_super_animal: dict[str, str], +) -> ConversionTable: + """ + Creates a conversion table mapping bodyparts defined for a DeepLabCut project + to bodyparts defined for a SuperAnimal model. This allows to fine-tune SuperAnimal + weights instead of transfer learning from ImageNet. The conversion table is directly + added to the project's configuration file. -from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path + Args: + config: The path to the project configuration for which the conversion table + should be created. + super_animal: The SuperAnimal model for the conversion table + project_to_super_animal: The conversion table mapping each project bodypart + to the corresponding SuperAnimal bodypart. + + Returns: + The conversion table that was added to the project config. + + Raises: + ValueError: If the conversion table is misconfigured (e.g., if there are + misnamed bodyparts in the table). See ConversionTable for more. + """ + cfg = read_config(str(config)) + sa_cfg = get_super_animal_project_cfg(super_animal) + conversion_table = ConversionTable( + super_animal=super_animal, + project_bodyparts=get_bodyparts(cfg), + super_animal_bodyparts=sa_cfg["bodyparts"], + table=project_to_super_animal, + ) + + conversion_tables = cfg.get("SuperAnimalConversionTables") + if conversion_tables is None: + conversion_tables = {} + + conversion_tables[super_animal] = conversion_table.table + cfg["SuperAnimalConversionTables"] = conversion_tables + write_config(str(config), cfg) + return conversion_table + + +def get_conversion_table(cfg: dict | str | Path, super_animal: str) -> ConversionTable: + """Gets the conversion table from a project to a SuperAnimal model + + Args: + cfg: The path to a project configuration file, or directly the project config. + super_animal: The SuperAnimal for which to get the configuration file. + + Returns: + A dictionary mapping {project_bodypart: super_animal_bodypart} + + Raises: + ValueError: If the conversion table is misconfigured (e.g., if there are + misnamed bodyparts in the table). See ConversionTable for more. + """ + if isinstance(cfg, (str, Path)): + cfg = read_config(str(cfg)) + + conversion_tables = cfg.get("SuperAnimalConversionTables", {}) + if super_animal not in conversion_tables: + raise ValueError( + f"No conversion table defined in the project config for {super_animal}." + "Call deeplabcut.modelzoo.create_conversion_table to create one." + ) + + sa_cfg = get_super_animal_project_cfg(super_animal) + conversion_table = ConversionTable( + super_animal=super_animal, + project_bodyparts=get_bodyparts(cfg), + super_animal_bodyparts=sa_cfg["bodyparts"], + table=conversion_tables[super_animal], + ) + return conversion_table -def parse_project_model_name(superanimal_name: str) -> str: +def parse_project_model_name(superanimal_name: str) -> tuple[str, str]: """Parses model zoo model names for SuperAnimal models Args: diff --git a/deeplabcut/modelzoo/webapp/inference.py b/deeplabcut/modelzoo/webapp/inference.py index d140d5347c..9452382a99 100644 --- a/deeplabcut/modelzoo/webapp/inference.py +++ b/deeplabcut/modelzoo/webapp/inference.py @@ -14,8 +14,8 @@ from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( - _get_config_model_paths, - _update_config, + get_config_model_paths, + update_config, ) @@ -81,11 +81,11 @@ def __init__( project_config, _, _, - ) = _get_config_model_paths(project_name, pose_model_type) + ) = get_config_model_paths(project_name, pose_model_type) self.max_individuals = max_individuals config = {**project_config, **model_config} - config = _update_config(config, max_individuals, device) + config = update_config(config, max_individuals, device) self._config = config diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 16ca2c6555..d3d8ac3806 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -15,11 +15,11 @@ import logging import albumentations as A -import numpy as np from torch.utils.data import DataLoader import deeplabcut.pose_estimation_pytorch.config as torch_config import deeplabcut.pose_estimation_pytorch.utils as utils +from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.pose_estimation_pytorch.data import build_transforms, DLCLoader, Loader from deeplabcut.pose_estimation_pytorch.data.collate import COLLATE_FUNCTIONS from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel @@ -58,10 +58,14 @@ def train( the model config max_snapshots_to_keep: the maximum number of snapshots to store for each model """ + weight_init = None + if weight_init_cfg := run_config["train_settings"].get("weight_init"): + weight_init = WeightInitialization.from_dict(weight_init_cfg) + if task == Task.DETECT: - model = DETECTORS.build(run_config["model"]) + model = DETECTORS.build(run_config["model"], weight_init=weight_init) else: - model = PoseModel.build(run_config["model"]) + model = PoseModel.build(run_config["model"], weight_init=weight_init) if max_snapshots_to_keep is not None: run_config["runner"]["snapshots"]["max_snapshots"] = max_snapshots_to_keep @@ -193,9 +197,13 @@ def train_network( if loader.model_cfg.get("logger"): logger_config = copy.deepcopy(loader.model_cfg["logger"]) logger_config["run_name"] += "-detector" + + detector_run_config = loader.model_cfg["detector"] + detector_run_config["device"] = loader.model_cfg["device"] + detector_run_config["train_settings"]["weight_init"] = loader.model_cfg["train_settings"].get("weight_init") train( loader=loader, - run_config=loader.model_cfg["detector"], + run_config=detector_run_config, task=Task.DETECT, device=device, logger_config=logger_config, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index dfec11e678..e41707343b 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -110,11 +110,16 @@ def get_model_snapshots( snapshots = snapshot_manager.snapshots(include_best=True) elif isinstance(index, int): all_snapshots = snapshot_manager.snapshots(include_best=True) - if len(all_snapshots) <= index: + if ( + len(all_snapshots) == 0 + or len(all_snapshots) <= index + or (index < 0 and len(all_snapshots) < -index) + ): names = [s.path.name for s in all_snapshots] raise ValueError( f"Found {len(all_snapshots)} snapshots in {model_folder} (with names " - f"{names}). Could not return snapshot with index {index}. " + f"{names}) with prefix {snapshot_manager.task.snapshot_prefix}. Could " + f"not return snapshot with index {index}." ) snapshots = [all_snapshots[index]] @@ -168,7 +173,7 @@ def get_scorer_name( Returns: the scorer name """ - model_dir = auxiliaryfunctions.get_model_folder( + model_dir = Path(cfg["project_path"]) / auxiliaryfunctions.get_model_folder( train_fraction, shuffle, cfg, @@ -390,7 +395,7 @@ def get_inference_runners( pose_runner = build_inference_runner( task=pose_task, - model=PoseModel.build(model_config["model"], no_pretrained_backbone=True), + model=PoseModel.build(model_config["model"]), device=device, snapshot_path=snapshot_path, preprocessor=pose_preprocessor, diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml index 8021509eda..180d28e2ea 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml @@ -9,7 +9,6 @@ model: model_name: hrnet_w18 freeze_bn_stats: True freeze_bn_weights: False - pretrained: true interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 18 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml index 1e753e6c2e..1a23155f41 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml @@ -9,7 +9,6 @@ model: model_name: hrnet_w32 freeze_bn_stats: True freeze_bn_weights: False - pretrained: true interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 32 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml index d7446eaea7..4d9eddd395 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml @@ -9,7 +9,6 @@ model: model_name: hrnet_w48 freeze_bn_stats: True freeze_bn_weights: False - pretrained: true interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 48 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml index 8752add7bf..cbaba267e9 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml @@ -5,7 +5,6 @@ model: output_stride: 16 freeze_bn_stats: True freeze_bn_weights: False - pretrained: true backbone_output_channels: 2048 runner: optimizer: diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml index eb22dc56f0..5c189c9e24 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml @@ -5,7 +5,6 @@ model: output_stride: 16 freeze_bn_stats: True freeze_bn_weights: False - pretrained: true backbone_output_channels: 2048 runner: optimizer: diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml index 9a49e7f57b..2d2c715693 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -46,5 +46,4 @@ train_settings: dataloader_pin_memory: true display_iters: 500 epochs: 200 - pretrained_weights: null seed: 42 diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index 4bc080bf82..631b1f2ca6 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -22,6 +22,7 @@ replace_default_values, update_config, ) +from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal @@ -30,6 +31,7 @@ def make_pytorch_pose_config( pose_config_path: str, net_type: str | None = None, top_down: bool = False, + weight_init: WeightInitialization | None = None, ) -> dict: """Creates a PyTorch pose configuration file for a DeepLabCut project @@ -57,6 +59,8 @@ def make_pytorch_pose_config( by associating a detector to the pose model. Required for multi-animal projects when net_type is a backbone (as a backbone + heatmap head can only predict pose for single individuals). + weight_init: Specify how model weights should be initialized. If None, ImageNet + pretrained weights from Timm will be loaded when training. Returns: the PyTorch pose configuration file @@ -118,6 +122,10 @@ def make_pytorch_pose_config( # add the model to the config pose_config = update_config(pose_config, model_cfg) + # set the dataset from which to load weights + if weight_init is not None: + pose_config["train_settings"]["weight_init"] = weight_init.to_dict() + # add a unique bodypart head if needed if len(unique_bpts) > 0: if is_top_down: diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 46ddbe1371..31fedb658c 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -144,6 +144,15 @@ def __getitem__(self, index: int) -> dict: bboxes, annotations_merged, ) = self.extract_keypoints_and_bboxes(anns, image.shape) + + transformed = self.apply_transform_all_keypoints( + image, keypoints, keypoints_unique, bboxes + ) + image = transformed["image"] + keypoints = transformed["keypoints"] + keypoints_unique = transformed["keypoints_unique"] + bboxes = transformed["bboxes"] + offsets = (0, 0) scales = (1, 1) if self.task == Task.TOP_DOWN: @@ -171,19 +180,13 @@ def __getitem__(self, index: int) -> dict: bboxes[..., 3] = bboxes[..., 3] / scales[1] bboxes = np.clip(bboxes, 0, self.parameters.cropped_image_size[0] - 1) - transformed = self.apply_transform_all_keypoints( - image, keypoints, keypoints_unique, bboxes - ) - keypoints = transformed["keypoints"] - bboxes = transformed["bboxes"] - if self.parameters.with_center_keypoints: keypoints = self.add_center_keypoints(keypoints) - item = self._prepare_final_data_dict( - transformed["image"], + return self._prepare_final_data_dict( + image, keypoints, - transformed["keypoints_unique"], + keypoints_unique, original_size, image_path, bboxes, @@ -192,7 +195,6 @@ def __getitem__(self, index: int) -> dict: offsets, scales, ) - return item def _prepare_final_data_dict( self, diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 367314ae39..4b85413842 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -45,15 +45,28 @@ def build_transforms(augmentations: dict) -> A.BaseCompose: if resize_aug := augmentations.get("resize", False): transforms += build_resize_transforms(resize_aug) - if augmentations.get("hflip"): - warnings.warn( - "Be careful! Do not train pose models with horizontal flips if you have" - " symmetric keypoints!" - ) + if hflip_cfg := augmentations.get("hflip"): hflip_proba = 0.5 - if isinstance(augmentations["hflip"], float): - hflip_proba = augmentations["hflip"] - transforms.append(A.HorizontalFlip(p=hflip_proba)) + symmetries = None + if isinstance(hflip_cfg, float): + hflip_proba = hflip_cfg + elif isinstance(hflip_cfg, dict): + if "p" in hflip_cfg: + hflip_proba = float(hflip_cfg["p"]) + + if "symmetries" in hflip_cfg: + symmetries = [] + for kpt_a, kpt_b in hflip_cfg["symmetries"]: + symmetries.append((int(kpt_a), int(kpt_b))) + + if symmetries is not None: + transforms.append(HFlip(symmetries=symmetries, p=hflip_proba)) + else: + warnings.warn( + "Be careful! Do not train pose models with horizontal flips if you have" + " symmetric keypoints!" + ) + transforms.append(A.HorizontalFlip(p=hflip_proba)) if (affine := augmentations.get("affine")) is not None: scaling = affine.get("scaling") @@ -196,6 +209,24 @@ def build_resize_transforms(resize_cfg: dict) -> list[A.BasicTransform]: return transforms +class HFlip(A.HorizontalFlip): + """Horizontal Flip which swaps symmetric keypoints""" + + def __init__(self, symmetries: list[tuple[int, int]], *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._symmetries = {} + for i, j in symmetries: + self._symmetries[i] = j + self._symmetries[j] = i + + def apply_to_keypoints(self, keypoints, **params): + swapped_keypoints = [ + keypoints[self._symmetries.get(kpt_idx, kpt_idx)] + for kpt_idx in range(len(keypoints)) + ] + return super().apply_to_keypoints(swapped_keypoints, **params) + + class KeypointAwareCrop(A.RandomCrop): """Random crop for an image around keypoints diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index f56e89d5e4..7bda145a3a 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -254,7 +254,11 @@ def _crop_image_keypoints( def _crop_and_pad_image_torch( - image: np.array, bbox: np.array, bbox_format: str, output_size: int + image: np.array, + bbox: np.array, + bbox_format: str, + output_size: int, + center: bool = True, ) -> tuple[np.array, tuple[int, int], tuple[int, int]]: """TODO: Reimplement this function with numpy and for non-square resize :) Only works for square cropped bounding boxes. Crops images around bounding boxes @@ -269,6 +273,7 @@ def _crop_and_pad_image_torch( bbox: (4,) the bounding box to crop around bbox_format: {"xyxy", "xywh", "cxcywh"} the format of the bounding box output_size: the size to resize the image to + center: Whether to center the crop if it needs to be padded Returns: cropped_image, (offset_x, offset_y), (scale_x, scale_y) @@ -294,22 +299,26 @@ def _crop_and_pad_image_torch( # Pad image if not square if not crop_h == crop_w: padded_cropped_image = torch.zeros((c, pad_size, pad_size), dtype=image.dtype) - # Try to center bbox in padding - w_start = 0 - if bbox[0] - (crop_size / 2) < 0: - # padding on the left - w_start = pad_size - crop_w - elif bbox[0] + (crop_size / 2) >= w: - # padding on the right + if center: + # center the bbox in padding + w_start = (pad_size - crop_w) // 2 + h_start = (pad_size - crop_h) // 2 + else: w_start = 0 + if bbox[0] - (crop_size / 2) < 0: + # padding on the left + w_start = pad_size - crop_w + elif bbox[0] + (crop_size / 2) >= w: + # padding on the right + w_start = 0 - h_start = 0 - if bbox[1] - (crop_size / 2) < 0: - # padding at the top - h_start = pad_size - crop_h - elif bbox[1] + (crop_size / 2) >= h: - # padding at the bottom h_start = 0 + if bbox[1] - (crop_size / 2) < 0: + # padding at the top + h_start = pad_size - crop_h + elif bbox[1] + (crop_size / 2) >= h: + # padding at the bottom + h_start = 0 h_end = h_start + crop_h w_end = w_start + crop_w diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index ae1e7902f9..cb15a977d5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -49,7 +49,7 @@ class HRNet(BaseBackbone): def __init__( self, model_name: str = "hrnet_w32", - pretrained: bool = True, + pretrained: bool = False, interpolate_branches: bool = False, increased_channel_count: bool = False, **kwargs, diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 642a3779fe..1c209184f0 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -33,7 +33,7 @@ def __init__( self, model_name: str = "resnet50", output_stride: int = 32, - pretrained: bool = True, + pretrained: bool = False, drop_path_rate: float = 0.0, drop_block_rate: float = 0.0, **kwargs, diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index 30abbf5713..302bad01ad 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -15,9 +15,42 @@ import torch import torch.nn as nn +import deeplabcut.pose_estimation_pytorch.modelzoo.utils as modelzoo_utils +from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg -DETECTORS = Registry("detectors", build_func=build_from_cfg) + +def _build_detector( + cfg: dict, weight_init: WeightInitialization | None = None, **kwargs, +) -> BaseDetector: + """Builds a detector using its configuration file + + Args: + cfg: The detector configuration. + weight_init: The weight initialization to use. + **kwargs: Other parameters given by the Registry. + + Returns: + the built detector + """ + if weight_init is not None: + cfg["pretrained"] = False + + detector: BaseDetector = build_from_cfg(cfg, **kwargs) + + if weight_init is not None: + _, _, _, snapshot_path = modelzoo_utils.get_config_model_paths( + project_name=weight_init.dataset, + pose_model_type="hrnetw32", # pose model does not matter here + detector_type="fasterrcnn", # TODO: include variant + ) + snapshot = torch.load(snapshot_path, map_location="cpu") + detector.load_state_dict(snapshot["model"]) + + return detector + + +DETECTORS = Registry("detectors", build_func=_build_detector) class BaseDetector(ABC, nn.Module): @@ -26,8 +59,9 @@ class BaseDetector(ABC, nn.Module): This is an abstract class defining the common structure and inference for detectors. """ - def __init__(self) -> None: + def __init__(self, pretrained: bool = False) -> None: super().__init__() + self._pretrained = pretrained @abstractmethod def forward( diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index 9a51b27292..3e2734384d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -64,10 +64,10 @@ def __init__( "https://pytorch.org/vision/stable/models.html#object-detection" ) - super().__init__() + super().__init__(pretrained=pretrained) model_fn = getattr(detection, variant) weights = None - if pretrained: + if self._pretrained: weights = "COCO_V1" # Load the model diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index 8e3c1b8ecc..e50cf546cf 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -107,3 +107,48 @@ def get_loss( } losses["total_loss"] = self.aggregator(losses) return losses + + +class WeightConversionMixin(ABC): + """A mixin for heads that can re-order and/or filter the output channels. + + This mixin is useful to convert SuperAnimal model weights such that they can be used + in downstream projects (either existing or new), where only a subset of keypoints + are available (and where they might be re-ordered). + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + @staticmethod + @abstractmethod + def convert_weights( + state_dict: dict[str, torch.Tensor], + module_prefix: str, + conversion: torch.Tensor, + ) -> dict[str, torch.Tensor]: + """Converts pre-trained weights to be fine-tuned on another dataset + + Args: + state_dict: the state dict for the pre-trained model + module_prefix: the prefix for weights in this head (e.g., 'heads.bodypart.') + conversion: the mapping of old indices to new indices + + Examples: + A SuperAnimal model was trained on the keypoints ["ear_left", "ear_right", + "eye_left", "eye_right", "nose"]. A down-stream project has the bodyparts + labeled ["nose", "eye_left", "eye_right"]. The SuperAnimal weights can be + converted (to be used with the downstream project) with the following code: + + `` + state_dict = torch.load( + snapshot_path, map_location=torch.device('cpu') + )["model"] + state_dict = HeadClass.convert_weights( + state_dict, + "heads.bodypart", + [4, 2, 3] + ) + `` + """ + pass diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index b8d768a921..88fb40e63f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -17,13 +17,17 @@ BaseCriterion, BaseLossAggregator, ) -from deeplabcut.pose_estimation_pytorch.models.heads.base import BaseHead, HEADS +from deeplabcut.pose_estimation_pytorch.models.heads.base import ( + BaseHead, + WeightConversionMixin, + HEADS, +) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator @HEADS.register_module -class HeatmapHead(BaseHead): +class HeatmapHead(WeightConversionMixin, BaseHead): """ Deconvolutional head to predict maps from the extracted features. This class implements a simple deconvolutional head to predict maps from the extracted features. @@ -50,6 +54,32 @@ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: outputs["locref"] = self.locref_head(x) return outputs + @staticmethod + def convert_weights( + state_dict: dict[str, torch.Tensor], + module_prefix: str, + conversion: torch.Tensor, + ) -> dict[str, torch.Tensor]: + """Converts pre-trained weights to be fine-tuned on another dataset + + Args: + state_dict: the state dict for the pre-trained model + module_prefix: the prefix for weights in this head (e.g., 'heads.bodypart.') + conversion: the mapping of old indices to new indices + """ + state_dict = DeconvModule.convert_weights( + state_dict, f"{module_prefix}heatmap_head.", conversion, + ) + + locref_conversion = torch.stack( + [2 * conversion, 2 * conversion + 1], + dim=1, + ).reshape(-1) + state_dict = DeconvModule.convert_weights( + state_dict, f"{module_prefix}locref_head.", locref_conversion, + ) + return state_dict + class DeconvModule(nn.Module): """ @@ -137,3 +167,43 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: x = self.deconv_layers(x) x = self.final_conv(x) return x + + @staticmethod + def convert_weights( + state_dict: dict[str, torch.Tensor], + module_prefix: str, + conversion: torch.Tensor, + ) -> dict[str, torch.Tensor]: + """Converts pre-trained weights to be fine-tuned on another dataset + + Args: + state_dict: the state dict for the pre-trained model + module_prefix: the prefix for weights in this head (e.g., 'heads.bodypart') + conversion: the mapping of old indices to new indices + """ + if f"{module_prefix}final_conv.weight" in state_dict: + # has final convolution + weight_key = f"{module_prefix}final_conv.weight" + bias_key = f"{module_prefix}final_conv.bias" + state_dict[weight_key] = state_dict[weight_key][conversion] + state_dict[bias_key] = state_dict[bias_key][conversion] + return state_dict + + # get the last deconv layer of the net + next_index = 0 + while f"{module_prefix}deconv_layers.{next_index}.weight" in state_dict: + next_index += 1 + last_index = next_index - 1 + + # if there are deconv layers for this module prefix (there might not be, + # e.g., when there are no location refinement layers in a heatmap head) + if last_index >= 0: + weight_key = f"{module_prefix}deconv_layers.{last_index}.weight" + bias_key = f"{module_prefix}deconv_layers.{last_index}.bias" + + # for ConvTranspose2d, the weight shape is (in_channels, out_channels, ...) + # while it's (out_channels, in_channels, ...) for Conv2d + state_dict[weight_key] = state_dict[weight_key][:, conversion] + state_dict[bias_key] = state_dict[bias_key][conversion] + + return state_dict diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 189a4ebf08..08f8e7a365 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -15,6 +15,7 @@ import torch import torch.nn as nn +import deeplabcut.pose_estimation_pytorch.modelzoo.utils as modelzoo_utils from deeplabcut.pose_estimation_pytorch.models.backbones import BaseBackbone, BACKBONES from deeplabcut.pose_estimation_pytorch.models.criterions import ( CRITERIONS, @@ -26,6 +27,7 @@ from deeplabcut.pose_estimation_pytorch.models.target_generators import ( TARGET_GENERATORS, ) +from deeplabcut.core.weight_init import WeightInitialization class PoseModel(nn.Module): @@ -48,7 +50,6 @@ def __init__( backbone: backbone network architecture. heads: the heads for the model neck: neck network architecture (default is None). Defaults to None. - stride: stride used in the model. Defaults to 8. """ super().__init__() self.cfg = cfg @@ -134,19 +135,22 @@ def get_predictions( } @staticmethod - def build(cfg: dict, no_pretrained_backbone: bool = False) -> "PoseModel": + def build( + cfg: dict, + weight_init: None | WeightInitialization = None, + ) -> "PoseModel": """ Args: - cfg: the configuration of the model to build - no_pretrained_backbone: does not load pretrained weights for the backbone, - even if the config asks for it (e.g., useful when loading a model for - inference, when fully trained weights will be loaded) + cfg: The configuration of the model to build. + weight_init: How model weights should be initialized. If None, ImageNet + pre-trained backbone weights are loaded from Timm. Returns: the built pose model """ - if no_pretrained_backbone and "pretrained" in cfg["backbone"]: - cfg["backbone"]["pretrained"] = False + if weight_init is None: # Transfer learning from ImageNet + cfg["backbone"]["pretrained"] = True + backbone = BACKBONES.build(dict(cfg["backbone"])) neck = None @@ -178,4 +182,69 @@ def build(cfg: dict, no_pretrained_backbone: bool = False) -> "PoseModel": head_cfg["predictor"] = PREDICTORS.build(head_cfg["predictor"]) heads[name] = HEADS.build(head_cfg) - return PoseModel(cfg=cfg, backbone=backbone, neck=neck, heads=heads) + model = PoseModel(cfg=cfg, backbone=backbone, neck=neck, heads=heads) + + if weight_init is not None: + print(f"Loading pretrained model weights: {weight_init}") + + # TODO: Should we specify the pose_model_type in WeightInitialization? + backbone_name = cfg["backbone"]["model_name"] + pose_model_type = modelzoo_utils.get_pose_model_type(backbone_name) + + # load pretrained weights + _, _, snapshot_path, _ = modelzoo_utils.get_config_model_paths( + project_name=weight_init.dataset, + pose_model_type=pose_model_type, + ) + snapshot = torch.load(snapshot_path, map_location="cpu") + state_dict = snapshot["model"] + + # load backbone state dict + model.backbone.load_state_dict(filter_state_dict(state_dict, "backbone")) + + # if there's a neck, load state dict + if model.neck is not None: + model.neck.load_state_dict(filter_state_dict(state_dict, "neck")) + + # load head state dicts + if weight_init.with_decoder: + heads_state_dict = filter_state_dict(state_dict, "heads") + conversion_tensor = torch.from_numpy(weight_init.conversion_array) + for name, head in model.heads.items(): + # requires WeightConversionMixin + head.load_state_dict( + head.convert_weights( + state_dict=filter_state_dict(heads_state_dict, name), + module_prefix="", + conversion=conversion_tensor, + ) + ) + + return model + + +def filter_state_dict(state_dict: dict, module: str) -> dict[str, torch.Tensor]: + """ + Filters keys in the state dict for a module to only keep a given prefix. Removes + the module from the keys (e.g. for module="backbone", "backbone.stage1.weight" will + be converted to "stage1.weight" so the state dict can be loaded into the backbone + directly). + + Args: + state_dict: the state dict + module: the module to keep, e.g. "backbone" + + Returns: + the filtered state dict, with the module removed from the keys + + Examples: + state_dict = {"backbone.conv.weight": t1, "head.conv.weight": t2} + filtered = filter_state_dict(state_dict, "backbone") + # filtered = {"conv.weight": t1} + model.backbone.load_state_dict(filtered) + """ + return { + ".".join(k.split(".")[1:]): v # remove 'backbone.' from the keys + for k, v in state_dict.items() + if k.startswith(module) + } diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index 77a20ae2c6..837336c8c8 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -36,17 +36,21 @@ class HeatmapPredictor(BasePredictor): def __init__( self, apply_sigmoid: bool = True, + clip_scores: bool = False, location_refinement: bool = True, locref_std: float = 7.2801, ): """ Args: apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + clip_scores: If a sigmoid is not applied, this can be used to clip scores + for predicted keypoints to values in [0, 1]. location_refinement : Enable location refinement. locref_std: Standard deviation for location refinement. """ super().__init__() self.apply_sigmoid = apply_sigmoid + self.clip_scores = clip_scores self.sigmoid = torch.nn.Sigmoid() self.location_refinement = location_refinement self.locref_std = locref_std @@ -88,9 +92,11 @@ def forward( ) locrefs = locrefs * self.locref_std - poses = self.get_pose_prediction( - heatmaps, locrefs, scale_factors - ) + poses = self.get_pose_prediction(heatmaps, locrefs, scale_factors) + + if self.clip_scores: + poses[..., 2] = torch.clip(poses[..., 2], min=0, max=1) + return {"poses": poses} def get_top_values( diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/config.py b/deeplabcut/pose_estimation_pytorch/modelzoo/config.py new file mode 100644 index 0000000000..7aa16ecd59 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/config.py @@ -0,0 +1,109 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Methods to create the configuration files to fine-tune SuperAnimal models""" +from __future__ import annotations + +import deeplabcut.pose_estimation_pytorch.config.utils as config_utils +import deeplabcut.pose_estimation_pytorch.modelzoo.utils as modelzoo_utils +import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.core.weight_init import WeightInitialization + + +def make_super_animal_finetune_config( + weight_init: WeightInitialization, + project_config: dict, + pose_config_path: str, + net_type: str | None = None, +) -> dict: + """ + Creates a PyTorch pose configuration file to finetune a SuperAnimal model on a + downstream project. + + Args: + weight_init: The weight initialization configuration. + project_config: The project configuration. + pose_config_path: The path where the pose configuration file will be saved + net_type: The type of neural net to finetune. + + Returns: + The generated pose configuration file. + """ + bodyparts = af.get_bodyparts(project_config) + if weight_init is not None and weight_init.with_decoder: + converted_bodyparts = bodyparts + if weight_init.bodyparts is not None: + assert len(weight_init.bodyparts) == len(weight_init.conversion_array) + converted_bodyparts = weight_init.bodyparts + elif len(bodyparts) != len(weight_init.conversion_array): + raise ValueError( + "You don't have the same number of bodyparts in your project config as " + f"number of entries your conversion array ({bodyparts} vs " + f"{weight_init.conversion_array}). If you're fine-tuning from " + "SuperAnimal on a subset of your bodyparts, you must specify which " + "ones in `WeightInitialization.bodyparts`. This should be done " + "automatically when creating the `weight_init` with " + "`WeightInitialization.build`." + ) + + # Load the exact pose configuration file for the model to fine-tune + return create_config_from_modelzoo( + net_type=net_type, + super_animal=weight_init.dataset, + converted_bodyparts=converted_bodyparts, + weight_init=weight_init, + project_config=project_config, + pose_config_path=pose_config_path, + ) + + +def create_config_from_modelzoo( + net_type: str, + super_animal: str, + converted_bodyparts: list[str], + weight_init: WeightInitialization, + project_config: dict, + pose_config_path: str, +) -> dict: + """Creates a model configuration file to fine-tune a SuperAnimal model + + Args: + net_type: The type of neural net to finetune. + super_animal: The SuperAnimal model to finetune. + converted_bodyparts: The project bodyparts that the model will learn. + weight_init: The weight initialization to use. + project_config: The project configuration. + pose_config_path: The path where the pose configuration file will be saved. + + Returns: + The generated pose configuration file. + """ + # load the SuperAnimal model config + pose_config, project_cfg, _, _ = modelzoo_utils.get_config_model_paths( + project_name=super_animal, + pose_model_type=modelzoo_utils.get_pose_model_type(net_type), + ) + pose_config["net_type"] = net_type + pose_config["metadata"] = { + "project_path": project_config["project_path"], + "pose_config_path": pose_config_path, + "bodyparts": converted_bodyparts, + "unique_bodyparts": [], + "individuals": project_config.get("individuals", ["animal"]), + "with_identity": False, + } + + pose_config["model"] = config_utils.replace_default_values( + pose_config["model"], num_bodyparts=len(converted_bodyparts) + ) + pose_config["train_settings"]["weight_init"] = weight_init.to_dict() + + # sort first-level keys to make it prettier + return dict(sorted(pose_config.items())) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index 7d1d1f9bf3..0f9a3d0b01 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -21,8 +21,8 @@ ) from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( - _get_config_model_paths, - _update_config, + get_config_model_paths, + update_config, raise_warning_if_called_directly, select_device, ) @@ -87,23 +87,19 @@ def _video_inference_superanimal( project_config, pose_model_path, detector_model_path, - ) = _get_config_model_paths(project_name, model_name) + ) = get_config_model_paths(project_name, model_name) if device is None: device = select_device() config = {**project_config, **model_config} - config = _update_config(config, max_individuals, device) - - pose_model_path = _parse_model_snapshot(Path(pose_model_path), device) - detector_model_path = _parse_model_snapshot(Path(detector_model_path), device) - + config = update_config(config, max_individuals, device) pose_runner, detector_runner = get_inference_runners( config, snapshot_path=pose_model_path, max_individuals=max_individuals, num_bodyparts=len(config["bodyparts"]), num_unique_bodyparts=0, - detector_path=detector_model_path + detector_path=detector_model_path, ) pose_task = Task(config.get("method", "BU")) results = {} @@ -163,39 +159,3 @@ def _video_inference_superanimal( print(f"Video with predictions was saved as {output_path}") return results - - -def _parse_model_snapshot(base: Path, device: str, print_keys: bool = False) -> Path: - """FIXME: A new snapshot should be uploaded and used""" - def _map_model_keys(state_dict: dict) -> dict: - updated_dict = {} - for k, v in state_dict.items(): - if not ( - k.startswith("backbone.model.downsamp_modules.") - or k.startswith("backbone.model.final_layer") - or k.startswith("backbone.model.classifier") - ): - parts = k.split(".") - if parts[:4] == ["heads", "bodypart", "heatmap_head", "model"]: - parts[3] = "deconv_layers.0" - updated_dict[".".join(parts)] = v - return updated_dict - - parsed = base.with_stem(base.stem + "_parsed") - if not parsed.exists(): - snapshot = torch.load(base, map_location=device) - if print_keys: - print(5 * "-----\n") - print(base.stem + " keys") - for name, _ in snapshot["model_state_dict"].items(): - print(f" * {name}") - print() - - parsed_model_snapshot = { - "model": _map_model_keys(snapshot["model_state_dict"]), - "metadata": { - "epoch": snapshot["epoch"], - }, - } - torch.save(parsed_model_snapshot, parsed) - return parsed diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py index 5c66d4903b..b3cf3e00ea 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py @@ -12,15 +12,17 @@ import os import subprocess import warnings +from pathlib import Path import torch +from dlclibrary import download_huggingface_model import deeplabcut.pose_estimation_pytorch.config.utils as config_utils from deeplabcut.pose_estimation_pytorch.config.make_pose_config import add_metadata from deeplabcut.utils import auxiliaryfunctions -def _get_config_model_paths( +def get_config_model_paths( project_name: str, pose_model_type: str, detector_type: str = "fasterrcnn", @@ -30,9 +32,10 @@ def _get_config_model_paths( Args: project_name: the name of the project - pose_model_name: the name of the pose model + pose_model_type: the name of the pose model detector_type: the type of the detector weight_folder: the folder containing the weights + Returns: the paths to the models and project configs """ @@ -51,12 +54,23 @@ def _get_config_model_paths( if weight_folder is None: weight_folder = os.path.join(modelzoo_path, "checkpoints") - pose_model_path = os.path.join( - weight_folder, f"{project_name}_{pose_model_type}.pth" - ) - detector_model_path = os.path.join( - weight_folder, f"{project_name}_{detector_type}.pt" - ) + # FIXME - DO NOT DOWNLOAD HERE + pose_model_name = f"{project_name}_{pose_model_type}.pth" + pose_model_path = os.path.join(weight_folder, pose_model_name) + detector_name = f"{project_name}_{detector_type}.pt" + detector_model_path = os.path.join(weight_folder, detector_name) + if not (Path(pose_model_path).exists() and Path(detector_model_path).exists()): + download_huggingface_model( + f"{project_name}_{pose_model_type}", + target_dir=str(weight_folder), + rename_mapping={ + "pose_model.pth": pose_model_name, "detector.pt": detector_name + } + ) + + # FIXME: Needed due to changes in code - remove when new snapshots are uploaded + pose_model_path = _parse_model_snapshot(Path(pose_model_path), device="cpu") + detector_model_path = _parse_model_snapshot(Path(detector_model_path), device="cpu") return ( model_config, @@ -100,7 +114,7 @@ def raise_warning_if_called_directly(): ) -def _update_config(config, max_individuals, device): +def update_config(config, max_individuals, device): config = config_utils.replace_default_values( config, num_bodyparts=len(config["bodyparts"]), @@ -110,3 +124,49 @@ def _update_config(config, max_individuals, device): config["device"] = device config_utils.pretty_print(config) return config + + +def _parse_model_snapshot(base: Path, device: str, print_keys: bool = False) -> Path: + """FIXME: A new snapshot should be uploaded and used""" + def _map_model_keys(state_dict: dict) -> dict: + updated_dict = {} + for k, v in state_dict.items(): + if not ( + k.startswith("backbone.model.downsamp_modules.") + or k.startswith("backbone.model.final_layer") + or k.startswith("backbone.model.classifier") + ): + parts = k.split(".") + if parts[:4] == ["heads", "bodypart", "heatmap_head", "model"]: + parts[3] = "deconv_layers.0" + updated_dict[".".join(parts)] = v + return updated_dict + + parsed = base.with_stem(base.stem + "_parsed") + if not parsed.exists(): + snapshot = torch.load(base, map_location=device) + if print_keys: + print(5 * "-----\n") + print(base.stem + " keys") + for name, _ in snapshot["model_state_dict"].items(): + print(f" * {name}") + print() + + parsed_model_snapshot = { + "model": _map_model_keys(snapshot["model_state_dict"]), + "metadata": { + "epoch": snapshot["epoch"], + }, + } + torch.save(parsed_model_snapshot, parsed) + return parsed + + +def get_pose_model_type(backbone: str) -> str: + """Temporary fix: pose_model_types for SuperAnimal models do not match net types""" + if backbone.startswith("resnet"): + return backbone + elif backbone.startswith("hrnet"): + return backbone.replace("_", "") + + raise ValueError(f"Unknown backbone for SuperAnimal Weights") diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index 869d1be74d..58fc30d270 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -496,6 +496,8 @@ def _compute_epoch_metrics(self) -> dict[str, float]: "Cannot compute bounding box metrics; pycocotools is not installed" ) + return {} + def _update_epoch_predictions( self, paths: torch.Tensor, diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index fb9ea9c2f9..2f648b12a3 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -32,7 +32,6 @@ from deeplabcut.core.engine import Engine from deeplabcut.core.trackingutils import TRACK_METHODS -from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine from deeplabcut.utils import auxfun_videos, auxfun_multianimal @@ -96,6 +95,9 @@ def create_config_template(multianimal=False): # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) corner2move2: move2corner: +\n +# Conversion tables to fine-tune SuperAnimal weights +SuperAnimalConversionTables: """ else: yaml_str = """\ @@ -149,6 +151,9 @@ def create_config_template(multianimal=False): # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) corner2move2: move2corner: +\n +# Conversion tables to fine-tune SuperAnimal weights +SuperAnimalConversionTables: """ ruamelFile = YAML() @@ -663,6 +668,7 @@ def get_scorer_name( Returns tuple of DLCscorer, DLCscorerlegacy (old naming convention) """ if engine is None: + from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine engine = get_shuffle_engine( cfg=cfg, trainingsetindex=cfg["TrainingFraction"].index(trainFraction), diff --git a/examples/JUPYTER/Demo_coco_transfer_learning.ipynb b/examples/JUPYTER/Demo_coco_transfer_learning.ipynb deleted file mode 100644 index 6204931474..0000000000 --- a/examples/JUPYTER/Demo_coco_transfer_learning.ipynb +++ /dev/null @@ -1,318 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "619fc646-bfb3-4dba-9117-10e0e9d4720c", - "metadata": {}, - "source": [ - "# Demo - Transfer Learning from a COCO dataset" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "79eb1572-ddf5-46de-959c-4cc1e010b19c", - "metadata": {}, - "outputs": [], - "source": [ - "import copy\n", - "from pathlib import Path\n", - "\n", - "import torch\n", - "import torch.nn as nn\n", - "from torch.utils.data import DataLoader\n", - "\n", - "import deeplabcut\n", - "import deeplabcut.pose_estimation_pytorch.utils as utils\n", - "import deeplabcut.pose_estimation_pytorch.config.utils as config_utils\n", - "\n", - "from deeplabcut.pose_estimation_pytorch.models import PoseModel\n", - "from deeplabcut.pose_estimation_pytorch import COCOLoader\n", - "from deeplabcut.pose_estimation_pytorch.data import build_transforms\n", - "from deeplabcut.pose_estimation_pytorch.data.collate import COLLATE_FUNCTIONS\n", - "from deeplabcut.pose_estimation_pytorch.modelzoo.inference import _parse_model_snapshot\n", - "from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (\n", - " _get_config_model_paths,\n", - " _update_config,\n", - ")\n", - "from deeplabcut.pose_estimation_pytorch.runners import build_training_runner\n", - "from deeplabcut.pose_estimation_pytorch.task import Task" - ] - }, - { - "cell_type": "markdown", - "id": "d7b3b8d5-6a85-4027-84fd-f42af3fff1f4", - "metadata": {}, - "source": [ - "## Data & Configuration " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4ece96d6-846b-4dab-8391-4f8a272813b9", - "metadata": {}, - "outputs": [], - "source": [ - "experiment_path = Path(\"/Users/niels/Desktop/coco_transfer_experiments\") / \"experiment_1\"\n", - "\n", - "# create the experiment folder structure\n", - "train_dir = experiment_path / \"train\"\n", - "test_dir = experiment_path / \"test\"\n", - "experiment_path.mkdir(parents=True, exist_ok=True)\n", - "train_dir.mkdir(exist_ok=True)\n", - "test_dir.mkdir(exist_ok=True)\n", - "model_config_path = train_dir / \"pytorch_config.yaml\"\n", - "\n", - "# Path to the folder containing the COCO dataset\n", - "# Format:\n", - "# quadruped80k/\n", - "# annotations/\n", - "# images/\n", - "dataset_path = Path(\"/Users/niels/Documents/upamathis/dlc/benchmarks/modelzoo/quadruped80k\")\n", - "train_file = \"train.json\"\n", - "test_file = \"test.json\"\n", - "\n", - "project_name = \"superanimal_topviewmouse\"\n", - "model_name = \"hrnetw32\"\n", - "\n", - "max_individuals = 10 # only needed for detector\n", - "num_bodyparts = 17 # the number of bodyparts in the project to transfer learn to\n", - "\n", - "device = \"cpu\"" - ] - }, - { - "cell_type": "markdown", - "id": "03d0ba4f-0f5e-4437-af59-6897aadb4929", - "metadata": {}, - "source": [ - "## Transfer Learning" - ] - }, - { - "cell_type": "markdown", - "id": "88e870cd-72a9-42a1-a7c0-beeeabeda6d5", - "metadata": {}, - "source": [ - "### Creating the Experiment Configuration File" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "810ea751-9a2c-4f4e-9d62-f99e813711cf", - "metadata": {}, - "outputs": [], - "source": [ - "# Get paths to SuperAnimal configs and weights\n", - "model_cfg, project_cfg, pose_model_path, detector_model_path = _get_config_model_paths(\n", - " project_name, model_name\n", - ")\n", - "pose_model_path = _parse_model_snapshot(Path(pose_model_path), device)\n", - "detector_model_path = _parse_model_snapshot(Path(detector_model_path), device)\n", - "\n", - "# Update the configuration file to have the correct number of output joints\n", - "model_cfg = config_utils.replace_default_values(\n", - " model_cfg,\n", - " num_bodyparts=len(project_cfg[\"bodyparts\"]),\n", - " num_individuals=max_individuals,\n", - " backbone_output_channels=model_cfg[\"model\"][\"backbone_output_channels\"]\n", - ")\n", - "model_cfg[\"device\"] = device\n", - "\n", - "# print results\n", - "print(pose_model_path)\n", - "print(detector_model_path)\n", - "print(\"Model Config\")\n", - "print(\"------------\")\n", - "config_utils.pretty_print(model_cfg)\n", - "\n", - "# save config\n", - "print(\"------------\")\n", - "print(f\"Saving Config to {model_config_path}\")\n", - "config_utils.write_config(model_config_path, model_cfg, overwrite=True)" - ] - }, - { - "cell_type": "markdown", - "id": "d47348ec-8d3d-4f2a-9795-8c8890b69884", - "metadata": {}, - "source": [ - "### Loading the dataset" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9f0d3183-4397-410b-a5ba-3da290ad7cdd", - "metadata": {}, - "outputs": [], - "source": [ - "loader = COCOLoader(\n", - " project_root=dataset_path,\n", - " model_config_path=model_config_path,\n", - " train_json_filename=train_file,\n", - " test_json_filename=test_file,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "4c6b2068-6daf-443a-9e14-71cd3262ebbe", - "metadata": {}, - "source": [ - "### Training " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "52613d5d-fed4-4b44-a861-56cb8e1f0fe6", - "metadata": {}, - "outputs": [], - "source": [ - "# You can update these values here - or directly in the model_config file, \n", - "# but before creating the COCOLoader\n", - "epochs = 4\n", - "save_epochs = 2\n", - "detector_epochs = None # if 0, will not train the detector\n", - "detector_save_epochs = None # if 0, will not train the detector\n", - "\n", - "updates = {\n", - " \"train_settings\": {},\n", - " \"detector\": {\"train_settings\": {}},\n", - "}\n", - "if epochs is not None:\n", - " updates[\"train_settings\"][\"epochs\"] = epochs\n", - "if save_epochs is not None:\n", - " updates[\"train_settings\"][\"save_epochs\"] = save_epochs\n", - "if detector_epochs is not None:\n", - " updates[\"detector\"][\"train_settings\"][\"epochs\"] = detector_epochs\n", - "if detector_save_epochs is not None:\n", - " updates[\"detector\"][\"train_settings\"][\"save_epochs\"] = detector_save_epochs\n", - "\n", - "loader.update_model_cfg(updates)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a336d155-272b-47d5-94f3-46136dcae5a8", - "metadata": {}, - "outputs": [], - "source": [ - "# Loads the pose model, builds a training runner - adapted from apis/train.py\n", - "pose_task = Task(loader.model_cfg[\"method\"])\n", - "model = PoseModel.build(loader.model_cfg[\"model\"])\n", - "runner = build_training_runner(\n", - " runner_config=loader.model_cfg[\"runner\"],\n", - " model_folder=loader.model_folder,\n", - " task=pose_task,\n", - " model=model,\n", - " device=device,\n", - " snapshot_path=None, # we don't use 'pose_model_path' here, as we only want to load the backbone weights\n", - " logger=loader.model_cfg.get(\"logger\", None),\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2e8dae8d-cff8-44cb-8a82-3ead748e7816", - "metadata": {}, - "outputs": [], - "source": [ - "def load_backbone_weights(snapshot_path: Path) -> dict:\n", - " snapshot = torch.load(snapshot_path, map_location=device)\n", - " state_dict = {\n", - " \".\".join(k.split(\".\")[1:]): v # remove 'backbone.' from the keys\n", - " for k, v in snapshot[\"model\"].items()\n", - " if k.startswith(\"backbone.\")\n", - " }\n", - " print(f\"Kept {len(state_dict)} weights\")\n", - " return state_dict\n", - "\n", - "\n", - "backbone_state_dict = load_backbone_weights(pose_model_path)\n", - "runner.model.backbone.load_state_dict(backbone_state_dict)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cd5db7ba-79f2-480e-8bfe-66750ab38c2c", - "metadata": {}, - "outputs": [], - "source": [ - "# Loads the dataset, trains\n", - "transform = build_transforms(loader.model_cfg[\"data\"][\"train\"])\n", - "inf_transform = build_transforms(loader.model_cfg[\"data\"][\"inference\"])\n", - "\n", - "train_dataset = loader.create_dataset(transform=transform, mode=\"train\", task=pose_task)\n", - "valid_dataset = loader.create_dataset(transform=inf_transform, mode=\"test\", task=pose_task)\n", - "\n", - "collate_fn = None\n", - "if collate_fn_cfg := loader.model_cfg[\"data\"][\"train\"].get(\"collate\"):\n", - " collate_fn = COLLATE_FUNCTIONS.build(collate_fn_cfg)\n", - " print(f\"Using custom collate function: {collate_fn_cfg}\")\n", - "\n", - "batch_size = loader.model_cfg[\"train_settings\"][\"batch_size\"]\n", - "num_workers = loader.model_cfg[\"train_settings\"][\"dataloader_workers\"]\n", - "pin_memory = loader.model_cfg[\"train_settings\"][\"dataloader_pin_memory\"]\n", - "train_dataloader = DataLoader(\n", - " train_dataset,\n", - " batch_size=batch_size,\n", - " shuffle=True,\n", - " collate_fn=collate_fn,\n", - " num_workers=num_workers,\n", - " pin_memory=pin_memory,\n", - ")\n", - "valid_dataloader = DataLoader(\n", - " valid_dataset,\n", - " batch_size=1,\n", - " shuffle=False,\n", - " num_workers=num_workers,\n", - " pin_memory=pin_memory,\n", - ")\n", - "\n", - "# Train the model\n", - "runner.fit(\n", - " train_dataloader,\n", - " valid_dataloader,\n", - " epochs=loader.model_cfg[\"train_settings\"][\"epochs\"],\n", - " display_iters=loader.model_cfg[\"train_settings\"][\"display_iters\"],\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ccee4ef3-f818-41de-b86f-69618a40a353", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.13" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/openfield-Pranav-2018-10-30/config.yaml b/examples/openfield-Pranav-2018-10-30/config.yaml index 0b5d8f1155..ddebb31ab1 100644 --- a/examples/openfield-Pranav-2018-10-30/config.yaml +++ b/examples/openfield-Pranav-2018-10-30/config.yaml @@ -64,3 +64,12 @@ corner2move2: - 50 - 50 move2corner: true + + +# Conversion tables to fine-tune SuperAnimal weights +SuperAnimalConversionTables: + superanimal_topviewmouse: + snout: nose + leftear: left_ear + rightear: right_ear + tailbase: tail_base diff --git a/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py b/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py index fa7ecdec96..7b3bd49d29 100644 --- a/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py +++ b/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py @@ -12,7 +12,7 @@ import pytest -from deeplabcut.pose_estimation_pytorch.modelzoo.utils import _get_config_model_paths +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import get_config_model_paths @pytest.mark.skip(reason="require-models") @@ -25,7 +25,7 @@ def test_get_config_model_paths(project_name): project_config, pose_model_path, detector_model_path, - ) = _get_config_model_paths( + ) = get_config_model_paths( project_name, "hrnetw32", detector_type="fasterrcnn", From 5b0f23ee960b0f0106404db3d3f263847bd888d0 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 31 May 2024 18:09:23 +0200 Subject: [PATCH 086/293] niels/fix_model_stride (#196) * bug fix when creating train dataset, updated code to use model stride correctly * stride can be float --- .../trainingsetmanipulation.py | 1 + .../config/tokenpose/tokenpose_base.yaml | 1 + .../models/backbones/base.py | 11 ++- .../models/backbones/hrnet.py | 4 +- .../models/backbones/resnet.py | 2 +- .../models/heads/base.py | 12 +++ .../models/heads/dekr.py | 3 +- .../models/heads/dlcrnet.py | 2 +- .../models/heads/simple_head.py | 26 ++++++- .../models/heads/transformer.py | 10 ++- .../pose_estimation_pytorch/models/model.py | 26 ++++--- .../models/necks/base.py | 13 +--- .../models/necks/transformer.py | 4 +- .../models/predictors/__init__.py | 6 -- .../models/predictors/base.py | 4 +- .../models/predictors/dekr_predictor.py | 8 +- .../models/predictors/identity_predictor.py | 71 ----------------- .../models/predictors/paf_predictor.py | 19 +++-- .../models/predictors/single_predictor.py | 8 +- .../models/predictors/top_down_prediction.py | 77 ------------------- .../models/predictors/utils.py | 4 +- .../models/target_generators/base.py | 15 ++-- .../models/target_generators/dekr_targets.py | 10 +-- .../target_generators/heatmap_targets.py | 9 +-- .../models/target_generators/pafs_targets.py | 7 +- .../runners/inference.py | 2 +- .../pose_estimation_pytorch/runners/logger.py | 4 +- .../pose_estimation_pytorch/runners/train.py | 8 +- 28 files changed, 128 insertions(+), 239 deletions(-) delete mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py delete mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index fb72665f58..1eed9c9e74 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -935,6 +935,7 @@ def create_training_dataset( net_type=net_type, trainIndices=trainIndices, testIndices=testIndices, + userfeedback=userfeedback, engine=engine, ) else: diff --git a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml b/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml index bdfd5de7e8..582fcce235 100644 --- a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml @@ -66,3 +66,4 @@ model: - 64 - 64 apply_init: true + head_stride: 1 diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py index bded12dfb4..1b16db86b3 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py @@ -8,6 +8,8 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + from abc import ABC, abstractmethod import torch @@ -22,12 +24,19 @@ class BaseBackbone(ABC, nn.Module): """Base Backbone class for pose estimation. Attributes: + stride: the stride for the backbone freeze_bn_weights: freeze weights of batch norm layers during training freeze_bn_stats: freeze stats of batch norm layers during training """ - def __init__(self, freeze_bn_weights: bool = True, freeze_bn_stats: bool = True): + def __init__( + self, + stride: int | float, + freeze_bn_weights: bool = True, + freeze_bn_stats: bool = True, + ): super().__init__() + self.stride = stride self.freeze_bn_weights = freeze_bn_weights self.freeze_bn_stats = freeze_bn_stats diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py index cb15a977d5..3180bb53e4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py @@ -30,6 +30,7 @@ class HRNet(BaseBackbone): The model outputs 4 branches, with strides 4, 8, 16 and 32. Args: + stride: The stride of the HRNet. Should always be 4, except for custom models. model_name: Any HRNet variant available through timm (e.g., 'hrnet_w32', 'hrnet_w48'). See timm for more options. pretrained: If True, loads the backbone with ImageNet pretrained weights from @@ -48,13 +49,14 @@ class HRNet(BaseBackbone): def __init__( self, + stride: int = 4, model_name: str = "hrnet_w32", pretrained: bool = False, interpolate_branches: bool = False, increased_channel_count: bool = False, **kwargs, ) -> None: - super().__init__(**kwargs) + super().__init__(stride=stride, **kwargs) self.model = _load_hrnet(model_name, pretrained, increased_channel_count) self.interpolate_branches = interpolate_branches diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py index 1c209184f0..5103ae64a9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py @@ -48,7 +48,7 @@ def __init__( drop_block_rate: Drop block rate kwargs: BaseBackbone kwargs """ - super().__init__(**kwargs) + super().__init__(stride=output_stride, **kwargs) self.model = timm.create_model( model_name, output_stride=output_stride, diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index e50cf546cf..438021dc1a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -30,6 +30,13 @@ class BaseHead(ABC, nn.Module): """A head for pose estimation models Attributes: + stride: The stride for the head (or neck + head pair), where positive values + indicate an increase in resolution while negative values a decrease. + Assuming that H and W are divisible by `stride`, this is the value such + that if a backbone outputs an encoding of shape (C, H, W), the head will + output heatmaps of shape: + (C, H * stride, W * stride) if stride > 0 + (C, -H/stride, -W/stride) if stride < 0 predictor: an object to generate predictions from the head outputs target_generator: a target generator which must output a target for each output key of this module (i.e. if forward returns a "heatmap" tensor and @@ -43,12 +50,17 @@ class BaseHead(ABC, nn.Module): def __init__( self, + stride: int | float, predictor: BasePredictor, target_generator: BaseGenerator, criterion: dict[str, BaseCriterion] | BaseCriterion, aggregator: BaseLossAggregator | None = None, ) -> None: super().__init__() + if stride == 0: + raise ValueError(f"Stride must not be 0. Found {stride}.") + + self.stride = stride self.predictor = predictor self.target_generator = target_generator self.criterion = criterion diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py index 481a415357..1e3527d5ea 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py @@ -45,8 +45,9 @@ def __init__( aggregator: BaseLossAggregator, heatmap_config: dict, offset_config: dict, + stride: int | float = 1, # the stride for the head - should always be 1 for DEKR ) -> None: - super().__init__(predictor, target_generator, criterion, aggregator) + super().__init__(stride, predictor, target_generator, criterion, aggregator) self.heatmap_head = DEKRHeatmap(**heatmap_config) self.offset_head = DEKROffset(**offset_config) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py index 33c4d22298..f1eb312945 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py @@ -28,7 +28,7 @@ @HEADS.register_module class DLCRNetHead(HeatmapHead): - """ """ + """A head for DLCRNet models using Part-Affinity Fields to predict individuals""" def __init__( self, diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 88fb40e63f..245f5a1642 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -42,11 +42,25 @@ def __init__( heatmap_config: dict, locref_config: dict | None = None, ) -> None: - super().__init__(predictor, target_generator, criterion, aggregator) - self.heatmap_head = DeconvModule(**heatmap_config) - self.locref_head = None + heatmap_head = DeconvModule(**heatmap_config) + locref_head = None if locref_config is not None: - self.locref_head = DeconvModule(**locref_config) + locref_head = DeconvModule(**locref_config) + + # check that the heatmap and locref modules have the same stride + if heatmap_head.stride != locref_head.stride: + raise ValueError( + f"Invalid model config: Your heatmap and locref need to have the " + f"same stride (found {heatmap_head.stride}, " + f"{locref_head.stride}). Please check your config (found " + f"heatmap_config={heatmap_config}, locref_config={locref_config}" + ) + + super().__init__( + heatmap_head.stride, predictor, target_generator, criterion, aggregator + ) + self.heatmap_head = heatmap_head + self.locref_head = locref_head def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: outputs = {"heatmap": self.heatmap_head(x)} @@ -111,12 +125,16 @@ def __init__( ) in_channels = channels[0] + head_stride = 1 self.deconv_layers = nn.Identity() if len(kernel_size) > 0: self.deconv_layers = nn.Sequential( *self._make_layers(in_channels, channels[1:], kernel_size, strides) ) + for s in strides: + head_stride *= s + self.stride = head_stride self.final_conv = nn.Identity() if final_conv: self.final_conv = nn.Conv2d( diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py index 75a79a6adf..0dd014fed5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py @@ -38,6 +38,7 @@ def __init__( apply_multi: bool, heatmap_size: tuple[int, int], apply_init: bool, + head_stride: int, ): """ Args: @@ -50,8 +51,15 @@ def __init__( heatmap_size: Tuple (height, width) representing the size of the output heatmaps. apply_init: If True, apply weight initialization to the module's layers. + head_stride: The stride for the head (or neck + head pair), where positive + values indicate an increase in resolution while negative values a + decrease. Assuming that H and W are divisible by head_stride, this is + the value such that if a backbone outputs an encoding of shape + (C, H, W), the head will output heatmaps of shape: + (C, H * head_stride, W * head_stride) if head_stride > 0 + (C, -H/head_stride, -W/head_stride) if head_stride < 0 """ - super().__init__(predictor, target_generator, criterion) + super().__init__(head_stride, predictor, target_generator, criterion) self.mlp_head = ( nn.Sequential( nn.LayerNorm(dim * 3), diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 08f8e7a365..f06e8b1676 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -57,6 +57,11 @@ def __init__( self.heads = nn.ModuleDict(heads) self.neck = neck + self._strides = { + name: _model_stride(self.backbone.stride, head.stride) + for name, head in heads.items() + } + def forward(self, x: torch.Tensor) -> dict[str, dict[str, torch.Tensor]]: """ Forward pass of the PoseModel. @@ -97,7 +102,6 @@ def get_loss( def get_target( self, - inputs: torch.Tensor, outputs: dict[str, dict[str, torch.Tensor]], labels: dict, ) -> dict[str, dict]: @@ -105,7 +109,6 @@ def get_target( Get targets for model training. Args: - inputs: the input images given to the model, of shape (b, c, w, h) outputs: output of each head group labels: dictionary of labels @@ -113,25 +116,22 @@ def get_target( targets: dict of the targets for each model head group """ return { - name: head.target_generator(inputs, outputs[name], labels) + name: head.target_generator(self._strides[name], outputs[name], labels) for name, head in self.heads.items() } - def get_predictions( - self, inputs: torch.Tensor, outputs: dict[str, dict[str, torch.Tensor]] - ) -> dict: + def get_predictions(self, outputs: dict[str, dict[str, torch.Tensor]]) -> dict: """Abstract method for the forward pass of the Predictor. Args: - inputs: the input images given to the model, of shape (b, c, w, h) outputs: outputs of the model heads Returns: A dictionary containing the predictions of each head group """ return { - head_name: head.predictor(inputs, outputs[head_name]) - for head_name, head in self.heads.items() + name: head.predictor(self._strides[name], outputs[name]) + for name, head in self.heads.items() } @staticmethod @@ -248,3 +248,11 @@ def filter_state_dict(state_dict: dict, module: str) -> dict[str, torch.Tensor]: for k, v in state_dict.items() if k.startswith(module) } + + +def _model_stride(backbone_stride: int | float, head_stride: int | float) -> float: + """Computes the model stride from a backbone and a head""" + if head_stride > 0: + return backbone_stride / head_stride + + return backbone_stride * -head_stride diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/base.py b/deeplabcut/pose_estimation_pytorch/models/necks/base.py index b57df5bab5..201456a5d4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/base.py @@ -18,20 +18,9 @@ class BaseNeck(ABC, torch.nn.Module): - """Base Neck class for pose estimation. - - This class defines the base Neck for pose estimation models. - - Attributes: - None - """ + """Base Neck class for pose estimation""" def __init__(self): - """Initialize the BaseNeck. - - Args: - None - """ super().__init__() @abstractmethod diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py index 0cb8e60958..7b34d49975 100644 --- a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py +++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py @@ -14,7 +14,7 @@ from einops import rearrange, repeat from timm.layers import trunc_normal_ -from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS +from deeplabcut.pose_estimation_pytorch.models.necks.base import BaseNeck, NECKS from deeplabcut.pose_estimation_pytorch.models.necks.layers import TransformerLayer from deeplabcut.pose_estimation_pytorch.models.necks.utils import ( make_sine_position_embedding, @@ -25,7 +25,7 @@ @NECKS.register_module -class Transformer(torch.nn.Module): +class Transformer(BaseNeck): """Transformer Neck for pose estimation. title={TokenPose: Learning Keypoint Tokens for Human Pose Estimation}, author={Yanjie Li and Shoukui Zhang and Zhicheng Wang and Sen Yang and Wankou Yang and Shu-Tao Xia and Erjin Zhou}, diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index 9c917d49fc..c3acf11f9b 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -15,15 +15,9 @@ from deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor import ( DEKRPredictor, ) -from deeplabcut.pose_estimation_pytorch.models.predictors.identity_predictor import ( - IdentityPredictor, -) from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import ( PartAffinityFieldPredictor, ) from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import ( HeatmapPredictor, ) -from deeplabcut.pose_estimation_pytorch.models.predictors.top_down_prediction import ( - TopDownPredictor, -) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py index 4a09092938..dc9b38aab6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py @@ -48,12 +48,12 @@ def __init__(self): @abstractmethod def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] + self, stride: float, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Abstract method for the forward pass of the Predictor. Args: - inputs: the input images given to the model, of shape (b, c, w, h) + stride: the stride of the model outputs: outputs of the model heads Returns: diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index 7d94f3cc11..eaedc2964c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -99,12 +99,12 @@ def __init__( self.max_absorb_distance = max_absorb_distance def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] + self, stride: float, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Forward pass of DEKRPredictor. Args: - inputs: the input images given to the model, of shape (b, c, w, h) + stride: the stride of the model outputs: outputs of the model heads (heatmap, locref) Returns: @@ -116,9 +116,7 @@ def forward( poses_with_scores = predictor.forward(outputs, scale_factors) """ heatmaps, offsets = outputs["heatmap"], outputs["offset"] - h_in, w_in = inputs.shape[2:] - h_out, w_out = heatmaps.shape[2:] - scale_factors = h_in / h_out, w_in / w_out + scale_factors = stride, stride if self.apply_sigmoid: heatmaps = F.sigmoid(heatmaps) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py deleted file mode 100644 index f76c72364b..0000000000 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py +++ /dev/null @@ -1,71 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# -"""Predictor to generate identity maps from head outputs""" -from __future__ import annotations - -import torch -import torch.nn as nn -import torchvision.transforms.functional as F - -from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( - BasePredictor, - PREDICTORS, -) - - -@PREDICTORS.register_module -class IdentityPredictor(BasePredictor): - """Predictor to generate identity maps from head outputs - - Attributes: - apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. - """ - - def __init__(self, apply_sigmoid: bool = True): - """ - Args: - apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. - """ - super().__init__() - self.apply_sigmoid = apply_sigmoid - self.sigmoid = nn.Sigmoid() - - def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] - ) -> dict[str, torch.Tensor]: - """Forward pass of IdentityPredictor. - - Swaps the dimensions so the heatmap are (batch_size, h, w, num_individuals), - optionally applies a sigmoid to the heatmaps, and rescales it to be the size - of the original image (so that the identity scores of keypoints can be computed) - - Args: - inputs: the input images given to the model, of shape (b, c, h, w) - outputs: output of the model identity head, of shape (b, num_individuals, w', h') - - Returns: - A dictionary containing a "heatmap" key with the identity heatmap tensor as - value. - """ - heatmaps = outputs["heatmap"] - h_in, w_in = inputs.shape[2:] - heatmaps = F.resize( - heatmaps, - size=[h_in, w_in], - interpolation=F.InterpolationMode.BILINEAR, - antialias=True, - ) - if self.apply_sigmoid: - heatmaps = self.sigmoid(heatmaps) - - # permute to have shape (batch_size, h, w, num_individuals) - heatmaps = heatmaps.permute((0, 2, 3, 1)) - return {"heatmap": heatmaps} diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index a28d1bf2dc..4428871f2c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -105,15 +105,16 @@ def __init__( ) def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] + self, stride: float, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Forward pass of PartAffinityFieldPredictor. Gets predictions from model output. Args: - output: Output tensors from previous layers. - output = heatmaps, locref, pafs - heatmaps: torch.Tensor([batch_size, num_joints, height, width]) - locref: torch.Tensor([batch_size, num_joints, height, width]) + stride: the stride of the model + outputs: Output tensors from previous layers. + output = heatmaps, locref, pafs + heatmaps: torch.Tensor([batch_size, num_joints, height, width]) + locref: torch.Tensor([batch_size, num_joints, height, width]) Returns: A dictionary containing a "poses" key with the output tensor as value. @@ -121,15 +122,13 @@ def forward( Example: >>> predictor = PartAffinityFieldPredictor(num_animals=3, location_refinement=True, locref_stdev=7.2801) >>> output = (torch.rand(32, 17, 64, 64), torch.rand(32, 34, 64, 64), torch.rand(32, 136, 64, 64)) - >>> scale_factors = (0.5, 0.5) - >>> poses = predictor.forward(output, scale_factors) + >>> stride = 8 + >>> poses = predictor.forward(stride, output) """ heatmaps = outputs["heatmap"] locrefs = outputs["locref"] pafs = outputs["paf"] - h_in, w_in = inputs.shape[2:] - h_out, w_out = heatmaps.shape[2:] - scale_factors = h_in / h_out, w_in / w_out + scale_factors = stride, stride batch_size, n_channels, height, width = heatmaps.shape heatmaps = self.sigmoid(heatmaps) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index 837336c8c8..c0174ae986 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -56,12 +56,12 @@ def __init__( self.locref_std = locref_std def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor] + self, stride: float, outputs: dict[str, torch.Tensor] ) -> dict[str, torch.Tensor]: """Forward pass of SinglePredictor. Gets predictions from model output. Args: - inputs: the input images given to the model, of shape (b, c, w, h) + stride: the stride of the model outputs: output of the model heads (heatmap, locref) Returns: @@ -74,9 +74,7 @@ def forward( >>> poses = predictor.forward(inputs, output) """ heatmaps = outputs["heatmap"] - h_in, w_in = inputs.shape[2:] - h_out, w_out = heatmaps.shape[2:] - scale_factors = h_in / h_out, w_in / w_out + scale_factors = stride, stride if self.apply_sigmoid: heatmaps = self.sigmoid(heatmaps) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py b/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py deleted file mode 100644 index fd1f523bc3..0000000000 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/top_down_prediction.py +++ /dev/null @@ -1,77 +0,0 @@ -# -# DeepLabCut Toolbox (deeplabcut.org) -# © A. & M.W. Mathis Labs -# https://github.com/DeepLabCut/DeepLabCut -# -# Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS -# -# Licensed under GNU Lesser General Public License v3.0 -# - -import torch - -from deeplabcut.pose_estimation_pytorch.models.predictors import ( - BasePredictor, - PREDICTORS, -) - - -@PREDICTORS.register_module -class TopDownPredictor(BasePredictor): - """Predictor for regressing keypoints in a Top Down fashion based on bbox predictions - and regressed keypoints in cropped images. - - TODO: Does not respect base class; should not be a predictor - - Args: - format_bbox: Format of the bounding box prediction, either 'xyxy' or 'coco'. Defaults to "xyxy". - """ - - def __init__(self, format_bbox: str = "xyxy"): - super().__init__() - self.format_bbox = format_bbox - - def _convert_bbox_to_coco(self, bboxes: torch.Tensor) -> torch.Tensor: - """Convert bboxes in the format (x1, y1, x2, y2) to coco format (x, y, w, h) - - Args: - bboxes: Bounding boxes of the shape (batch_size, max_num_animals, 4) - - Returns: - coco_bboxes, shape (batch_size, max_num_animals, 4) - """ - coco_bboxes = bboxes.clone() - coco_bboxes[:, :, 2] -= coco_bboxes[:, :, 0] - coco_bboxes[:, :, 3] -= coco_bboxes[:, :, 1] - - return coco_bboxes - - def forward(self, bboxes: torch.Tensor, keypoints_cropped: torch.Tensor) -> dict: - """Computes keypoints coordinates in the original image given predicted bbox and predicted - keypoints coordinates inside the bbox cropped image. - - Args: - bboxes: Bounding boxes of the shape (batch_size, max_num_animals, 4) - keypoints_cropped: Keypoints with the shape (batch_size, max_num_animals, num_joints, 3) - - Returns: - A dictionary containing a "poses" key with a keypoints tensor of the shape: - (batch_size, max_num_animals, num_joints, 3) as value. - """ - if self.format_bbox != "coco": - bboxes = self._convert_bbox_to_coco(bboxes) - - num_joints = keypoints_cropped.shape[2] - new_kpts = keypoints_cropped.clone() - - x_corners = (bboxes[:, :, 0]).unsqueeze(2).expand(-1, -1, num_joints) - y_corners = (bboxes[:, :, 1]).unsqueeze(2).expand(-1, -1, num_joints) - # TODO hardcoded 256 - scales_x = (bboxes[:, :, 2] / 256).unsqueeze(2).expand(-1, -1, num_joints) - scales_y = (bboxes[:, :, 3] / 256).unsqueeze(2).expand(-1, -1, num_joints) - - new_kpts[:, :, :, 0] = scales_x * new_kpts[:, :, :, 0] + x_corners - new_kpts[:, :, :, 1] = scales_y * new_kpts[:, :, :, 1] + y_corners - - return {"poses": new_kpts} diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py index 1aee88baa4..ba349ac713 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py @@ -111,7 +111,7 @@ def compute_within_between_paf_costs( with torch.no_grad(): for batch in tqdm(dataloader): inputs = batch["image"].to(device) - preds = model.get_predictions(inputs, model(inputs))["bodypart"] + preds = model.get_predictions(model(inputs))["bodypart"] for coords_gt, preds_ in zip( batch["annotations"]["keypoints"], preds["preds"] @@ -180,7 +180,7 @@ def benchmark_paf_graphs( paths.extend(batch["path"]) inputs = batch["image"].to(device) # FIXME We can do better than the repetition below - preds = model.get_predictions(inputs, model(inputs))["bodypart"] + preds = model.get_predictions(model(inputs))["bodypart"] poses_.extend(preds["poses"]) poses_ = torch.stack(poses_).detach().cpu().numpy() poses_ = dict(zip(paths, poses_)) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py index d54809402e..6842277331 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py @@ -40,13 +40,13 @@ def __init__(self, label_keypoint_key: str = "keypoints"): @abstractmethod def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict + self, stride: float, outputs: dict[str, torch.Tensor], labels: dict ) -> dict[str, dict[str, torch.Tensor]]: """Generates targets Args: - inputs: the input images given to the model, of shape (b, c, w, h) - outputs: output of each model head + stride: the stride of the model + outputs: output of a model head labels: the labels for the inputs (each tensor should have shape (b, ...)) Returns: @@ -75,13 +75,16 @@ def generators(self): return self._generators def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict + self, stride: int, outputs: dict[str, torch.Tensor], labels: dict ) -> dict[str, dict[str, torch.Tensor]]: dict_ = {} for gen in self.generators: - dict_.update(gen(inputs, outputs, labels)) + dict_.update(gen(stride, outputs, labels)) return dict_ def __repr__(self): generators_repr = ", ".join(repr(gen) for gen in self._generators) - return f"<{self.__class__.__name__}(generators=[{generators_repr}], label_keypoint_key='{self.label_keypoint_key}')>" + return ( + f"<{self.__class__.__name__}(generators=[{generators_repr}], " + f"label_keypoint_key='{self.label_keypoint_key}')>" + ) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index 0661e522a2..b8d2d68876 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -46,13 +46,13 @@ def __init__( self.bg_weight = bg_weight def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict + self, stride: float, outputs: dict[str, torch.Tensor], labels: dict ) -> dict[str, dict[str, torch.Tensor]]: """ Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. Args: - inputs: the input images given to the model, of shape (b, c, w, h) + stride: the stride of the model outputs: output of each model head labels: the labels for the inputs (each tensor should have shape (b, ...)) @@ -80,10 +80,8 @@ def forward( "offset": {"target": offset_map, "weights": offset_masks} } """ - batch_size, _, input_h, input_w = inputs.shape - output_h, output_w = outputs["heatmap"].shape[2:] - stride_y, stride_x = input_h / output_h, input_w / output_w - + stride_y, stride_x = stride, stride + batch_size, _, output_h, output_w = outputs["heatmap"].shape coords = labels[self.label_keypoint_key].cpu().numpy() area = labels["area"].cpu().numpy() diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py index 02c1be66b6..8c6a0e4dc5 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py @@ -90,14 +90,14 @@ def __init__( self.locref_scale = 1.0 / locref_std def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict + self, stride: float, outputs: dict[str, torch.Tensor], labels: dict ) -> dict[str, dict[str, torch.Tensor]]: """ Given the annotations and predictions of your keypoints, this function returns the targets, a dictionary containing the heatmaps, locref_maps and locref_masks. Args: - inputs: the input images given to the model, of shape (b, c, w, h) + stride: the stride of the model outputs: output of each model head labels: the labels for the inputs (each tensor should have shape (b, ...)) @@ -122,9 +122,8 @@ def forward( output: targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} """ - batch_size, _, input_h, input_w = inputs.shape - height, width = outputs["heatmap"].shape[2:] - stride_y, stride_x = input_h / height, input_w / width + stride_y, stride_x = stride, stride + batch_size, _, height, width = outputs["heatmap"].shape coords = labels[self.label_keypoint_key].cpu().numpy() if len(coords.shape) == 3: # for single animal: add individual dimension coords = coords.reshape((batch_size, 1, *coords.shape[1:])) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py index 073c4fddc5..12417ebf1a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py @@ -52,11 +52,10 @@ def __init__(self, graph: list[list[int, int]], width: float): self.num_limbs = len(graph) def forward( - self, inputs: torch.Tensor, outputs: dict[str, torch.Tensor], labels: dict + self, stride: float, outputs: dict[str, torch.Tensor], labels: dict ) -> dict[str, dict[str, torch.Tensor]]: - batch_size, _, input_h, input_w = inputs.shape - height, width = outputs["heatmap"].shape[2:] - stride_y, stride_x = input_h / height, input_w / width + stride_y, stride_x = stride, stride + batch_size, _, height, width = outputs["heatmap"].shape coords = labels[self.label_keypoint_key].cpu().numpy() partaffinityfield_map = np.zeros( diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py index 4452aad3b6..5aa9e07c45 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -141,7 +141,7 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] batch_inputs = inputs[i : i + batch_size] batch_inputs = batch_inputs.to(self.device) batch_outputs = self.model(batch_inputs) - raw_predictions = self.model.get_predictions(batch_inputs, batch_outputs) + raw_predictions = self.model.get_predictions(batch_outputs) for b in range(batch_size): image_predictions = {} diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index a26d3b439f..940c51170b 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -106,8 +106,10 @@ class ImageLoggerMixin(ABC): for i in range(epochs): for batch_inputs in train_loader: + batch_labels = batch_data["annotations"] + batch_inputs = batch_data["image"] batch_outputs = model(batch_inputs) - batch_targets = model.get_targets(batch_inputs, batch_outputs) + batch_targets = model.get_target(batch_outputs, batch_labels) loss = criterion(batch_targets, batch_outputs) loss.backwards() optim.step() diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index 58fc30d270..e3bfb259ea 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -287,7 +287,7 @@ def step( inputs = inputs.to(self.device) outputs = self.model(inputs) - target = self.model.get_target(inputs, outputs, batch["annotations"]) + target = self.model.get_target(outputs, batch["annotations"]) losses_dict = self.model.get_loss(outputs, target) if mode == "train": losses_dict["total_loss"].backward() @@ -299,7 +299,7 @@ def step( if mode == "eval": predictions = { name: {k: v.detach().cpu().numpy() for k, v in pred.items()} - for name, pred in self.model.get_predictions(inputs, outputs).items() + for name, pred in self.model.get_predictions(outputs).items() } ground_truth = batch["annotations"]["keypoints"] @@ -452,9 +452,7 @@ def step( images = batch["image"] images = images.to(self.device) - target = self.model.get_target( - batch["annotations"] - ) # (batch_size, channels, h, w) + target = self.model.get_target(batch["annotations"]) for item in target: # target is a list here for key in item: if item[key] is not None: From 3bf862a2530219a6bd31fcb5667730b43b8c45bc Mon Sep 17 00:00:00 2001 From: shaokai Date: Thu, 6 Jun 2024 14:07:05 +0200 Subject: [PATCH 087/293] Generalized data converter (#195) --- .../keypoint_space_conversion.py | 26 + .../conversion_table_quadruped.csv | 40 + .../conversion_table_topview.csv | 28 + .../generalized_data_converter/__init__.py | 11 + .../conversion_table/__init__.py | 11 + .../conversion_table/conversion_table.py | 153 ++++ .../datasets/__init__.py | 15 + .../datasets/base.py | 314 +++++++ .../datasets/base_dlc.py | 115 +++ .../datasets/coco.py | 87 ++ .../datasets/ma_dlc.py | 160 ++++ .../datasets/materialize.py | 779 ++++++++++++++++++ .../datasets/multi.py | 291 +++++++ .../datasets/single_dlc.py | 135 +++ .../datasets/utils.py | 40 + .../generalized_data_converter/utils.py | 326 ++++++++ 16 files changed, 2531 insertions(+) create mode 100644 benchmark_superanimal/keypoint_space_conversion.py create mode 100644 deeplabcut/modelzoo/conversion_tables/conversion_table_quadruped.csv create mode 100644 deeplabcut/modelzoo/conversion_tables/conversion_table_topview.csv create mode 100644 deeplabcut/modelzoo/generalized_data_converter/__init__.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/conversion_table/__init__.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/conversion_table/conversion_table.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/base.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/multi.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/utils.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/utils.py diff --git a/benchmark_superanimal/keypoint_space_conversion.py b/benchmark_superanimal/keypoint_space_conversion.py new file mode 100644 index 0000000000..bcbf4b6213 --- /dev/null +++ b/benchmark_superanimal/keypoint_space_conversion.py @@ -0,0 +1,26 @@ +"""Script to convert a dataset for its keypoint space to match the SuperAnimal space""" +from pathlib import Path + +from deeplabcut.modelzoo.generalized_data_converter.datasets import COCOPoseDataset +from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path + + +def main(): + src_proj_root = Path("/media/data/trimouse_coco_original_shuffle0") + conversion_table_path = ( + Path(get_deeplabcut_path()) + / "modelzoo" + / "conversion_tables" + / "conversion_table_topview.csv" + ) + dataset = COCOPoseDataset(str(src_proj_root), "trimouse") + dataset.project_with_conversion_table(conversion_table_path) + dataset.materialize( + src_proj_root.with_name("trimouse_coco_superanimal_shuffle0_shallow_copy"), + deepcopy=False, + framework="coco", + ) + + +if __name__ == "__main__": + main() diff --git a/deeplabcut/modelzoo/conversion_tables/conversion_table_quadruped.csv b/deeplabcut/modelzoo/conversion_tables/conversion_table_quadruped.csv new file mode 100644 index 0000000000..5060943b21 --- /dev/null +++ b/deeplabcut/modelzoo/conversion_tables/conversion_table_quadruped.csv @@ -0,0 +1,40 @@ +ap10k,animalpose,stanforddogs,cheetah,horse,webapp,MasterName +nose,nose,Nose,nose,Nose,nose,nose +,,,,,,upper_jaw +,,,,,,lower_jaw +,,,,,,mouth_end_right +,,,,,,mouth_end_left +right_eye,right_eye,R_Eye,r_eye,Eye,right_eye,right_eye +,right_ear,R_EarBase,,,right_ear,right_earbase +,,R_EarTip,,,,right_earend +,,,,,,right_antler_base +,,,,,,right_antler_end +left_eye,left_eye,L_Eye,l_eye,,left_eye,left_eye +,left_ear,L_EarBase,,,left_ear,left_earbase +,,L_EarTip,,,,left_earend +,,,,,,left_antler_base +,,,,,,left_antler_end +neck,,,neck_base,,,neck_base +,,,,,,neck_end +,throat,Throat,,,throat,throat_base +,,,,,,throat_end +,withers,Withers,,Wither,withers,back_base +,,,,,,back_end +,,,spine,,,back_middle +root_of_tail,tailbase,TailBase,tail_base,,tailset,tail_base +,,TailEnd,tail_tip,,,tail_end +left_shoulder,left_front_elbow,L_F_Elbow,l_shoulder,,left_front_elbow,front_left_thai +,left_front_knee,L_F_Knee,l_front_knee,,,front_left_knee +left_front_paw,left_front_paw,L_F_Paw,l_front_paw,Nearfrontfoot,left_front_paw,front_left_paw +right_shoulder,right_front_elbow,R_F_Elbow,r_shoulder,Elbow,right_front_elbow,front_right_thai +,right_front_knee,R_F_Knee,r_front_knee,,,front_right_knee +right_front_paw,right_front_paw,R_F_Paw,r_front_paw,Offfrontfoot,right_front_paw,front_right_paw +left_back_paw,left_back_paw,L_B_Paw,l_back_paw,Nearhindfoot,left_back_paw,back_left_paw +left_hip,left_back_elbow,L_B_Elbow,l_hip,,left_back_stifle,back_left_thai +right_hip,right_back_elbow,R_B_Elbow,r_hip,Stifle,right_back_stifle,back_right_thai +left_knee,left_back_knee,L_B_Knee,l_back_knee,,,back_left_knee +right_knee,right_back_knee,R_B_Knee,r_back_knee,,,back_right_knee +right_back_paw,right_back_paw,R_B_Paw,r_back_paw,Offhindfoot,right_back_paw,back_right_paw +,,,,,,belly_bottom +,,,,,,body_middle_right +,,,,,,body_middle_left \ No newline at end of file diff --git a/deeplabcut/modelzoo/conversion_tables/conversion_table_topview.csv b/deeplabcut/modelzoo/conversion_tables/conversion_table_topview.csv new file mode 100644 index 0000000000..b02098abee --- /dev/null +++ b/deeplabcut/modelzoo/conversion_tables/conversion_table_topview.csv @@ -0,0 +1,28 @@ +treadmill_ole,swimming_ole,openfield_ole,MackenzieMausHaus, ChanLab,Daniel3Mouse,dlc-openfield,EPM ,FST,LBD,OFT,Mostafizur,3CSI,BM,TwoWhiteMice,MasterName +head,head,head,nose,Nose,snout,snout,nose,nose,nose,nose,snout,nose,nose,Nose,nose +,,,leftearbase,Ear_left,leftear,leftear,earl,earl,earl,earl,leftear,earl,earl,Left_ear,left_ear +,,,rightearbase,Ear_right,rightear,rightear,earr,earr,earr,earr,rightear,earr,earr,Right_ear,right_ear +,,,lefteartip,,,,,,,,,,,,left_ear_tip +,,,righteartip,,,,,,,,,,,,right_ear_tip +,,,lefteye,,,,,,,,,,,,left_eye +,,,righteye,,,,,,,,,,,,right_eye +spine 1,spine 1,,spine1,,shoulder,,neck,neck,neck,neck,shoulder,neck,neck,,neck +,,,spine2,,spine1,,,,,,spine1,,,,mid_back +spine 2,spine 2,middle,spine3,Center,spine2,,bodycentre,bodycentre,bodycentre,bodycentre,spine2,bodycenter,bodycenter,Centroid,mouse_center +,,,spine4,,spine3,,,,,,spine3,,,,mid_backend +spine 3,spine 3,,spine5,,spine4,,,,,,spine4,,,,mid_backend2 +spine 4,spine 4,,spine6,,,,,,,,,,,,mid_backend3 +base ,base ,tailbase,tailbase,Tail_base,tailbase,tailbase,tailbase,tailbase,tailbase,tailbase,tailbase,tailbase,tailbase,Tail_base,tail_base +,,,tail1,,tail1,,,,,,tail1,,,,tail1 +tail 25,tail 25,,tail2,,tail2,,,,,,tail2,,,,tail2 +,,,tail3,,,,tailcentre,tailcentre,tailcentre,tailcentre,,tailcenter,tailcenter,,tail3 +tail 50 ,tail 50,,tail4,, ,,,,,,,,,,tail4 +tail 75,tail 75,,tail5,, ,,,,,,,,,,tail5 +,,,leftshoulder,, ,,,,,,,,,,left_shoulder +,,,leftside,, ,,bcl,bcl,bcl,bcl,,bcl,bcl,Left_lateral,left_midside +,,,lefthip,Lateral_left,,,hipl,hipl,hipl,hipl,,hipl,hipl,,left_hip +,,,rightshoulder,,,,,,,,,,,,right_shoulder +,,,rightside,,,,bcr,bcr,bcr,bcr,,bcr,bcr,Right_lateral,right_midside +,,,righthip,Lateral_right,,,hipr,hipr,hipr,hipr,,hipr,hipr,,right_hip +tail 100,tail 100,tailtip,,Tail_end,tailend,,tailtip,tailtip,tailtip,tailtip,tailend,tailtip,tailtip,Tail_end,tail_end +,,,,,,,headcentre,headcentre,headcentre,headcentre,,headcenter,headcenter,,head_midpoint diff --git a/deeplabcut/modelzoo/generalized_data_converter/__init__.py b/deeplabcut/modelzoo/generalized_data_converter/__init__.py new file mode 100644 index 0000000000..fb1e45d7ba --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/__init__.py @@ -0,0 +1,11 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from .utils import add_skeleton, customized_colormap, create_modelprefix diff --git a/deeplabcut/modelzoo/generalized_data_converter/conversion_table/__init__.py b/deeplabcut/modelzoo/generalized_data_converter/conversion_table/__init__.py new file mode 100644 index 0000000000..7a3cd50142 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/conversion_table/__init__.py @@ -0,0 +1,11 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from .conversion_table import get_conversion_table diff --git a/deeplabcut/modelzoo/generalized_data_converter/conversion_table/conversion_table.py b/deeplabcut/modelzoo/generalized_data_converter/conversion_table/conversion_table.py new file mode 100644 index 0000000000..a05d58d3a3 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/conversion_table/conversion_table.py @@ -0,0 +1,153 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import warnings + +import numpy as np +import pandas as pd + + +class ConversionTableFromDict: + def __init__(self, raw_table_dict): + self.table_dict = raw_table_dict["conversion_table"] + self.master_keypoints = raw_table_dict["master_keypoints"] + + def convert(self, kpt): + if kpt not in self.table_dict: + warnings.warn( + f"{kpt} is defined in src space but not appeared in the conversion table" + ) + return None + else: + return self.table_dict[kpt] + + +class ConversionTableFromCSV: + """ + Base class only reads the table + """ + + def __init__(self, src_keypoints, table_path): + self.table_path = table_path + + # sep removes leading and tailing white space + df = pd.read_csv(table_path, sep="\s*,\s*") + + df.dropna(inplace=True, how="all") + # drop the row is MasterName has nan in the row + df = df.dropna(subset=["MasterName"]) + + self.df = df + + self.src_keypoints = src_keypoints + + kpt_list = df.to_numpy() + + self.lookup_set = [] + + for i in range(len(kpt_list)): + kpts = np.array(kpt_list[i]) + # remove nan + + kpt_alias = set(kpts) + + for k in list(kpt_alias): + if type(k) != str: + kpt_alias.remove(k) + + self.lookup_set.append(kpt_alias) + + target_keypoints = df["MasterName"].values + + # target_keypoints = target_keypoints[~np.isnan(target_keypoints.values)] + + self.master_keypoints = target_keypoints + + # paired when they both exist + + # following assumes that either it's 1vs.1 from src to target + # or 1 vs. 0 + # it could be 1 vs. 2 in horse data + self.table = {} + for src_kpt in src_keypoints: + for target_kpt in target_keypoints: + + src_kpt_id = self._search(src_kpt) + target_kpt_id = self._search(target_kpt) + + if src_kpt_id == -1 or target_kpt_id == -1: + # if any one of them not exist in the set + # skip + continue + if src_kpt_id == target_kpt_id: + self.table[src_kpt] = target_kpt + + self.check_inclusion() + + def _search(self, key): + """ + return -1 if not found + return kpt id if found + + """ + # [TODO] if it can be mapped to two, I can randomly return one + for kpt_id in range(len(self.lookup_set)): + if key in self.lookup_set[kpt_id]: + return kpt_id + return -1 + + def check_inclusion(self): + """ + check if conversion table covers + every keypoint contained in src proj + + """ + count = 0 + print("src keypoints") + print(self.src_keypoints) + for kpt in self.src_keypoints: + index = self._search(kpt) + if index == -1: + pass + else: + count += 1 + print(f"{count}/{len(self.src_keypoints)} keypoints will be converted") + + def convert(self, kpt): + if kpt not in self.table: + warnings.warn( + f"{kpt} is defined in src space but not appeared in the conversion table" + ) + return None + else: + return self.table[kpt] + + def get_subset(self, labname=""): + + bodyparts = self.df[labname] + + super_bodyparts = self.df["MasterName"] + + ret = [] + + for bodypart in bodyparts: + if bodypart in self.table: + ret.append(self.table[bodypart]) + + return ret + + +def get_conversion_table(keypoints=None, table_path="", table_dict=None): + if table_path is not None and keypoints is not None: + return ConversionTableFromCSV(keypoints, table_path) + elif table_dict: + return ConversionTableFromDict(table_dict) + else: + raise NotImplementedError("not supported") diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py new file mode 100644 index 0000000000..0082f64d80 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py @@ -0,0 +1,15 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from .ma_dlc import MaDLCPoseDataset +from .multi import MultiSourceDataset +from .coco import COCOPoseDataset +from .materialize import mat_func_factory +from .single_dlc import SingleDLCPoseDataset diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py new file mode 100644 index 0000000000..7e058ccfa7 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py @@ -0,0 +1,314 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import copy +import os +import warnings + +import numpy as np + +from deeplabcut.modelzoo.generalized_data_converter.conversion_table import ( + get_conversion_table, +) +from deeplabcut.modelzoo.generalized_data_converter.datasets.materialize import ( + mat_func_factory, +) + + +def raw_2_imagename_with_id(image): + """ + raw image data has filename and id. + we modify the imagename such that itis composed of + both original imagename and image id + """ + + file_name = image["file_name"] + image_name = file_name.split(os.sep)[-1] + pre, suffix = image_name.split(".") + image_id = image["id"] + return f"{pre}_{image_id}.{suffix}" + + +def raw_2_imagename(image): + """ + Only getting the imagename part from the image object + """ + + file_name = image["file_name"] + image_name = file_name.split(os.sep)[-1] + return image_name + + +class BasePoseDataset: + """ + Dual representation of generic and raw data. For classes that inherits this class, + the raw data is kept but generic data is populated so you have dual representation. + """ + + def __init__(self): + # generic data is what all the manipulation is based on + self.generic_train_images = [] + self.generic_test_images = [] + self.generic_train_annotations = [] + self.generic_test_annotations = [] + # These maps are very important for later analysis, including max_individuals + # and trace back the original dataset etc. + self.imageid2anno = {} + self.dataset2images = {} + self.imageid2filename = {} + self.imageid2datasetname = {} + self.datasetname2imageids = {} + # meta keeps information for later analysis + self.meta = {} + # if conversion_table is None, dataset is not yet converted to super keypoints + self.conversion_table = None + + def _build_maps(self): + self.datasetname2imageids[self.meta["dataset_name"]] = set() + + total_annotations = ( + self.generic_train_annotations + self.generic_test_annotations + ) + for anno in total_annotations: + image_id = anno["image_id"] + if image_id not in self.imageid2anno: + self.imageid2anno[image_id] = [] + self.imageid2anno[image_id].append(anno) + + total_images = self.generic_train_images + self.generic_test_images + for image in total_images: + image_id = image["id"] + self.imageid2datasetname[image_id] = self.meta["dataset_name"] + file_name = image["file_name"] + self.imageid2filename[image_id] = file_name + self.datasetname2imageids[self.meta["dataset_name"]].add(image_id) + + # in DLC, even if you have more than one annotations in one image, it does not + # mean it's a multi animal project + max_num = 0 + for k in self.imageid2anno: + max_num = max(len(self.imageid2anno[k]), max_num) + + self.meta["max_individuals"] = max_num + self.meta["imageid2filename"] = self.imageid2filename + + def filter_by_pattern(self, pattern): + + keep_ids = [] + keep_train_images = [] + keep_test_images = [] + for img in self.generic_train_images + self.generic_test_images: + print(img["file_name"]) + if pattern in img["file_name"]: + + image_id = img["id"] + keep_ids.append(image_id) + + for image in self.generic_train_images: + if image["id"] in keep_ids: + keep_train_images.append(image["id"]) + + self.generic_train_images = keep_train_images + + for image in self.generic_test_images: + if image["id"] in keep_ids: + keep_test_images.append(image["id"]) + + self.generic_test_images = keep_test_images + + keep_train_annotations = [] + keep_test_annotations = [] + + for anno in self.generic_train_annotations: + if anno["image_id"] in keep_ids: + keep_train_annotations.append(anno) + + self.generic_train_annotations = keep_train_annotations + + for anno in self.generic_test_annotations: + if anno["image_id"] in keep_ids: + keep_test_annotations.append(anno) + + self.generic_test_annotations = keep_test_annotations + + def summary(self): + print(f'Summary of dataset {self.meta["dataset_name"]}') + print("-------------") + print(f'max num individuals is {self.meta["max_individuals"]}') + print(f"total keypoints : {len(self.meta['categories']['keypoints'])}") + print(f"total train images : {len(self.generic_train_images)}") + print(f"total test images : {len(self.generic_test_images)}") + print(f"total train annotations : {len(self.generic_train_annotations)}") + print(f"total test annotations : {len(self.generic_test_annotations)}") + print("-------------") + + def populate_generic(self): + raise NotImplementedError("Must implement this function") + + def materialize(self, proj_root, framework="coco", deepcopy=False): + mat_func = mat_func_factory(framework) + self.meta["mat_datasets"] = {self.meta["dataset_name"]: self} + self.meta["imageid2datasetname"] = self.imageid2datasetname + mat_func( + proj_root, + self.generic_train_images, + self.generic_test_images, + self.generic_train_annotations, + self.generic_test_annotations, + self.meta, + deepcopy=deepcopy, + ) + + def whether_anno_image_match(self, images, annotations): + """ + Every image id should be annotated at least once + There should not be any image that is not being annotated + There should not be any annotation for beyond the set of given images + """ + + image_ids = set([image["id"] for image in images]) + + annotation_image_ids = set([anno["image_id"] for anno in annotations]) + + if image_ids != annotation_image_ids: + print("images-annotations", image_ids - annotation_image_ids) + print("len(images-annotatinos)", len(image_ids - annotation_image_ids)) + print("annotations-images", annotation_image_ids - image_ids) + print("len(annotations-images)", len(annotation_image_ids - image_ids)) + warnings.warn("annotation and image ids do not match") + + def get_keypoints(self): + # TODO make sure it's always one element in a list + return self.meta["categories"]["keypoints"] + + def _proj(self, annotations, conversion_table): + + keypoints = self.get_keypoints() + + kpt2index = {kpt: kpt_id for kpt_id, kpt in enumerate(keypoints)} + + ret = [] + + master2src = {} + for kpt in keypoints: + conv_kpt = conversion_table.convert(kpt) + # sometimes a keypoint might not find its corresponding one from mastername + if conv_kpt is not None: + master2src[conv_kpt] = kpt + + master_keypoints = conversion_table.master_keypoints + + # need to change this in meta + + for anno in annotations: + try: + kpts = anno["keypoints"] + except: + print(anno) + + new_kpts = np.zeros(len(master_keypoints) * 3) + new_num_kpts = len(master_keypoints) + + for master_kpt_id, master_kpt_name in enumerate(master_keypoints): + # check whether the dataset has the corresponding keypoint + if master_kpt_name not in master2src: + new_kpts[master_kpt_id * 3 : master_kpt_id * 3 + 3] = -1 + continue + + src_kpt_name = master2src[master_kpt_name] + src_kpt_id = kpt2index[src_kpt_name] + new_kpts[master_kpt_id * 3 : master_kpt_id * 3 + 3] = kpts[ + src_kpt_id * 3 : src_kpt_id * 3 + 3 + ] + + # skipping empty frames after conversion + new_anno = copy.deepcopy(anno) + new_anno["keypoints"] = new_kpts + new_anno["num_keypoints"] = new_num_kpts + ret.append(new_anno) + + return ret + + def adjust_bbox_and_area(self): + """Called during conversion. + + This is to remove the impact of keypoints that are potentially environmental + keypoints to the bbox and area calculation. + """ + from .utils import calc_bboxes_from_keypoints + + for annotation in ( + self.generic_train_annotations + self.generic_test_annotations + ): + keypoints = annotation["keypoints"] + bbox_margin = 20 + + num_kpts = annotation["num_keypoints"] + + keypoints = np.array(keypoints).reshape((num_kpts, 3)) + + mask = keypoints[:, 0] > 0 + keypoints = keypoints[mask] + + if keypoints.shape[0] == 0: + continue + + xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( + [keypoints], + slack=bbox_margin, + clip=True, + )[0][:4] + + w = xmax - xmin + h = ymax - ymin + area = w * h + bbox = np.nan_to_num([xmin, ymin, w, h]) + + if "bbox" not in annotation: + annotation["bbox"] = bbox + if "area" not in annotation: + annotation["area"] = area + + def project_with_conversion_table(self, table_path="", table_dict=None): + """ + Replace the generic annotations with those that are in superset keypoint space + + """ + print(f'Converting {self.meta["dataset_name"]}') + + keypoints = self.get_keypoints() + + self.conversion_table = get_conversion_table( + keypoints=keypoints, table_path=table_path, table_dict=table_dict + ) + + self.generic_train_annotations = self._proj( + self.generic_train_annotations, self.conversion_table + ) + + self.generic_test_annotations = self._proj( + self.generic_test_annotations, self.conversion_table + ) + + # all category id fixed to 1. So that it does not conflict with the background + # category id + for anno in self.generic_train_annotations + self.generic_test_annotations: + anno["category_id"] = 1 + + for img in self.generic_train_images + self.generic_test_images: + img["source_dataset"] = self.meta["dataset_name"] + + self.adjust_bbox_and_area() + self.meta["categories"]["keypoints"] = self.conversion_table.master_keypoints + self.meta["categories"]["supercategory"] = "animal" + self.meta["categories"]["name"] = "superanimal" + + # category id fixed to be 1, to avoid to conflict with background category id + self.meta["categories"]["id"] = 1 diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py new file mode 100644 index 0000000000..0a20439643 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py @@ -0,0 +1,115 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import os +import pickle + +import numpy as np +import pandas as pd + +import deeplabcut +from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset + + +class BaseDLCPoseDataset(BasePoseDataset): + + def __init__(self, proj_root, dataset_name, shuffle=1, modelprefix=""): + super(BaseDLCPoseDataset, self).__init__() + + assert proj_root != None and dataset_name != None + + self.meta["dataset_name"] = dataset_name + self.meta["proj_root"] = proj_root + self.meta["shuffle"] = shuffle + self.meta["modelprefix"] = modelprefix + + self.proj_root = proj_root + + if modelprefix: + config_file = os.path.join(self.proj_root, modelprefix + "_config.yaml") + else: + config_file = os.path.join(self.proj_root, "config.yaml") + + cfg = deeplabcut.auxiliaryfunctions.read_config(config_file) + + task = cfg["Task"] + + scorer = cfg["scorer"] + + datasets_folder = os.path.join( + self.proj_root, + deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(cfg), + ) + + self.datasets_folder = datasets_folder + + trainingFraction = int(cfg["TrainingFraction"][0] * 100) + + path_dlc_collected = os.path.join(datasets_folder, f"CollectedData_{scorer}.h5") + + path_dlc_document = os.path.join( + datasets_folder, + f"Documentation_data-{task}_{trainingFraction}shuffle{shuffle}.pickle", + ) + + df = pd.read_hdf(path_dlc_collected) + + self.dlc_df = df + + with open(path_dlc_document, "rb") as f: + document_data = pickle.load(f) + + train_indices = document_data[1] + # index 2 is test indices + test_indices = document_data[2] + + train_images = df.index[train_indices] + test_images = df.index[test_indices] + + self.dlc_images = np.hstack([train_images, test_images]) + + df_train = df.loc[train_images] + + df_test = df.loc[test_images] + + self.coco_train = self._df2generic(df_train) + + offset = len(self.coco_train["images"]) + + self.coco_test = self._df2generic(df_test, image_id_offset=offset) + + self.populate_generic() + + def _df2generic(self, df, image_id_offset=0): + raise NotImplementedError() + + def populate_generic(self): + + self.generic_train_images = self.coco_train["images"] + self.generic_test_images = self.coco_test["images"] + self.generic_train_annotations = self.coco_train["annotations"] + self.generic_test_annotations = self.coco_test["annotations"] + + self.meta["categories"] = self.coco_test["categories"][0] + + # to build maps for later analysis + self._build_maps() + + print(f"Before checking trainset {self.meta['dataset_name']}") + + self.whether_anno_image_match( + self.generic_train_images, self.generic_train_annotations + ) + + print(f"Before checking testset {self.meta['dataset_name']}") + + self.whether_anno_image_match( + self.generic_test_images, self.generic_test_annotations + ) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py new file mode 100644 index 0000000000..2472f37e60 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py @@ -0,0 +1,87 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import copy +import json +import os + +from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset + + +class COCOPoseDataset(BasePoseDataset): + def __init__( + self, + proj_root, + dataset_name, + shuffle=None, + ): + + super(COCOPoseDataset, self).__init__() + + self.meta["dataset_name"] = dataset_name + self.meta["proj_root"] = proj_root + + self.proj_root = proj_root + self.annotations_by_category = {} + + self.train_json_obj = ( + self._load_json("train.json") + if shuffle is None + else self._load_json(f"train_shuffle{shuffle}.json") + ) + self.test_json_obj = ( + self._load_json("test.json") + if shuffle is None + else self._load_json(f"test_shuffle{shuffle}.json") + ) + + self.populate_generic() + + def _load_json(self, json_fn): + path = os.path.join(self.proj_root, "annotations", json_fn) + with open(path, "r") as f: + json_obj = json.load(f) + return json_obj + + def populate_generic(self): + + temp_train_images = copy.deepcopy(self.train_json_obj["images"]) + temp_test_images = copy.deepcopy(self.test_json_obj["images"]) + + for image in temp_train_images + temp_test_images: + image_path = image["file_name"] + # if os.sep not in image_path: + # assuming the file_name is mmpose style, i.e. only the image name is stored + # so we need to add back absolute path + + image["file_name"] = os.path.join(self.proj_root, "images", image_path) + + self.generic_train_images = temp_train_images + self.generic_test_images = temp_test_images + + self.generic_train_annotations = self.train_json_obj["annotations"] + + self.generic_test_annotations = self.test_json_obj["annotations"] + + self.meta["categories"] = self.test_json_obj["categories"][0] + + self._build_maps() + + print(f"Before checking trainset {self.meta['dataset_name']}") + + self.whether_anno_image_match( + self.generic_train_images, self.generic_train_annotations + ) + + print(f"Before checking testset {self.meta['dataset_name']}") + + self.whether_anno_image_match( + self.generic_test_images, self.generic_test_annotations + ) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py new file mode 100644 index 0000000000..a194f4ad05 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py @@ -0,0 +1,160 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import os + +import numpy as np +import pandas as pd + +from deeplabcut.modelzoo.generalized_data_converter.datasets.base_dlc import ( + BaseDLCPoseDataset, +) +from deeplabcut.modelzoo.generalized_data_converter.datasets.utils import ( + calc_bboxes_from_keypoints, + read_image_shape_fast, +) + + +class MaDLCPoseDataset(BaseDLCPoseDataset): + def __init__(self, proj_root, dataset_name, shuffle=1, modelprefix=""): + super(MaDLCPoseDataset, self).__init__( + proj_root, dataset_name, shuffle=shuffle, modelprefix=modelprefix + ) + + def _df2generic(self, df, image_id_offset=0): + + individuals = df.columns.get_level_values("individuals").unique().tolist() + + unique_bpts = [] + + if "single" in individuals: + unique_bpts.extend( + df.xs("single", level="individuals", axis=1) + .columns.get_level_values("bodyparts") + .unique() + ) + multi_bpts = ( + df.xs(individuals[0], level="individuals", axis=1) + .columns.get_level_values("bodyparts") + .unique() + .tolist() + ) + + coco_categories = [] + + # assuming all individuals have the same name and same category id + + individual = individuals[0] + + category = { + "name": individual, + "id": 0, + "supercategory": "animal", + } + + if individual == "single": + category["keypoints"] = unique_bpts + else: + category["keypoints"] = multi_bpts + + coco_categories.append(category) + + coco_images = [] + coco_annotations = [] + + annotation_id = 0 + image_id = -1 + for _, file_name in enumerate(df.index): + data = df.loc[file_name] + + # skipping all nan + if np.isnan(data.to_numpy()).all(): + continue + + image_id += 1 + + for individual_id, individual in enumerate(individuals): + category_id = 0 + try: + kpts = ( + data.xs(individual, level="individuals") + .to_numpy() + .reshape((-1, 2)) + ) + except: + # somehow there are duplicates. So only use the first occurence + data = data.iloc[0] + kpts = ( + data.xs(individual, level="individuals") + .to_numpy() + .reshape((-1, 2)) + ) + + keypoints = np.zeros((len(kpts), 3)) + + keypoints[:, :2] = kpts + + is_visible = ~pd.isnull(kpts).all(axis=1) + + keypoints[:, 2] = np.where(is_visible, 2, 0) + + num_keypoints = is_visible.sum() + + bbox_margin = 20 + + xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( + [keypoints], + slack=bbox_margin, + clip=True, + )[0][:4] + + w = xmax - xmin + h = ymax - ymin + area = w * h + bbox = np.nan_to_num([xmin, ymin, w, h]) + keypoints = np.nan_to_num(keypoints.flatten()) + + annotation_id += 1 + annotation = { + "image_id": image_id + image_id_offset, + "num_keypoints": num_keypoints, + "keypoints": keypoints, + "id": annotation_id, + "category_id": category_id, + "area": area, + "bbox": bbox, + "iscrowd": 0, + } + if np.sum(keypoints) != 0: + coco_annotations.append(annotation) + + # I think width and height are important + + if isinstance(file_name, tuple): + image_path = os.path.join(self.proj_root, *list(file_name)) + else: + image_path = os.path.join(self.proj_root, file_name) + + _, height, width = read_image_shape_fast(image_path) + + image = { + "file_name": image_path, + "width": width, + "height": height, + "id": image_id + image_id_offset, + } + coco_images.append(image) + + ret_obj = { + "images": coco_images, + "annotations": coco_annotations, + "categories": coco_categories, + } + return ret_obj diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py new file mode 100644 index 0000000000..9e64706516 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -0,0 +1,779 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import json +import os +import pickle +import shutil + +import numpy as np +import pandas as pd +import scipy.io as sio +import yaml + +import deeplabcut +from deeplabcut.generate_training_dataset.multiple_individuals_trainingsetmanipulation import ( + format_multianimal_training_data, +) +from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( + format_training_data as format_single_training_data, +) + + +def get_filename(filename): + if type(filename) == tuple: + filename = os.path.join(*filename) + return filename + + +def modify_train_test_cfg(config_path, shuffle=1, modelprefix=""): + # get train_cfg from main cfg + # use dlcr net + # use gradient masking + # set batch size as 8 + trainposeconfigfile, testposeconfigfile, snapshotfolder = ( + deeplabcut.return_train_network_path( + config_path, shuffle=shuffle, modelprefix=modelprefix, trainingsetindex=0 + ) + ) + + train_cfg = deeplabcut.auxiliaryfunctions.read_plainconfig(trainposeconfigfile) + train_cfg["multi_stage"] = True + train_cfg["batch_size"] = 8 + train_cfg["gradient_masking"] = True + + deeplabcut.auxiliaryfunctions.write_plainconfig(trainposeconfigfile, train_cfg) + + test_cfg = deeplabcut.auxiliaryfunctions.read_plainconfig(testposeconfigfile) + test_cfg["multi_stage"] = True + test_cfg["batch_size"] = 8 + test_cfg["gradient_masking"] = True + + deeplabcut.auxiliaryfunctions.write_plainconfig(testposeconfigfile, test_cfg) + + +class NpEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, np.integer): + return int(obj) + elif isinstance(obj, np.floating): + return float(obj) + elif isinstance(obj, np.ndarray): + return obj.tolist() + else: + return super(NpEncoder, self).default(obj) + + +class SingleDLC_config: + def __init__(self): + Task = "" # could be dataset name + project_path = "" + scorer = "" # random stuff + date = "" # random stuff + video_sets = "" # has to be used for labeled data + skeleton = "" # could be arbitrary + bodyparts = "" # either single or multi + start = 0 # not sure + stop = 1 # not sure + numframes2pick = 42 # does not matter + skeleton_color = "black" + pcutoff = 0.6 + dotsize = 8 + alphavalue = 0.7 + colormap = "rainbow" + TrainingFraction = "" # need to be filled correctly + iteration = 0 + default_net_type = "resnet_50" + default_augmenter = "imgaug" + snapshotindex = -1 + batch_size = 8 + cropping = False + croppedtraining = False + multianimalproject = False + uniquebodyparts = [] + x1 = 0 + x2 = 640 + y1 = 277 + y2 = 624 + corer2move2 = [50, 50] + move2corner = True + identity = False + self.cfg = { + k: v for k, v in vars().items() if "__" not in k and "self" not in k + } + + def create_cfg(self, proj_root, kwargs): + self.cfg.update(kwargs) + with open(os.path.join(proj_root, "config.yaml"), "w") as f: + yaml.dump(self.cfg, f) + + +class MaDLC_config: + def __init__(self): + """ + Plain text only for generating templates + Some variables can be configured by the user later + """ + + Task = "" # could be dataset name + project_path = "" + scorer = "" # random stuff + date = "" # random stuff + video_sets = "" # has to be used for labeled data + individuals = "" # number of individuals + multianimalbodyparts = "" # keypoints + skeleton = "" # could be arbitrary + bodyparts = "" # either single or multi + start = 0 # not sure + stop = 1 # not sure + numframes2pick = 42 # does not matter + skeleton_color = "black" + pcutoff = 0.6 + dotsize = 8 + alphavalue = 0.7 + colormap = "rainbow" + TrainingFraction = "" # need to be filled correctly + iteration = 0 + default_net_type = "resnet_50" + default_augmenter = "multi-animal-imgaug" + snapshotindex = -1 + batch_size = 8 + cropping = False + croppedtraining = True + multianimalproject = True + uniquebodyparts = [] + x1 = 0 + x2 = 640 + y1 = 277 + y2 = 624 + corer2move2 = [50, 50] + move2corner = True + identity = False + self.cfg = { + k: v for k, v in vars().items() if "__" not in k and "self" not in k + } + + def create_cfg(self, proj_root, kwargs): + self.cfg.update(kwargs) + with open(os.path.join(proj_root, "config.yaml"), "w") as f: + yaml.dump(self.cfg, f) + + +def _generic2madlc( + proj_root, + train_images, + test_images, + train_annotations, + test_annotations, + meta, + deepcopy=False, + full_image_path=True, +): + """ + For DLC, the complexity is that if we don't explicity call deeplabcut.create_traindataset(), the train and test split might just be arbitrarily messed up. So here we need to calculate train and test indices to + + Args: + proj_root where to materialize the data + + """ + + assert full_image_path, "DLC wants full image path" + + os.makedirs(os.path.join(proj_root, "labeled-data"), exist_ok=True) + + cfg_template = MaDLC_config() + + individuals = [f"individual{i}" for i in range(meta["max_individuals"])] + + bodyparts = meta["categories"]["keypoints"] + + scorer = "maDLC_scorer" + # this line is taken from dlc's multi animal dataset creation function + train_fraction = round( + len(train_images) * 1.0 / (len(train_images) + len(test_images)), 2 + ) + + # need to fake a video path + # let's use individual dataset names as fake video name + # merged_dataset_name = '_'.join(meta['mat_datasets']) + video_sets = { + f"{dataset_name}.mp4": {"crop": "0, 400, 0, 400"} + for dataset_name in meta["mat_datasets"] + } + + modify_dict = dict( + Task=meta["dataset_name"], + project_path=proj_root, + individuals=individuals, + scorer=scorer, + date="March30", + video_sets=video_sets, + bodyparts="MULTI!", + TrainingFraction=[train_fraction], + multianimalbodyparts=list(bodyparts), + ) + + cfg_template.create_cfg(proj_root, modify_dict) + # what's special in dlc or madlc creation is that we will need to + # use dlc's code for creating the project structure + # because you don't want to write your own. It's a lot of lines of code + # But at least we can focus on labeled-data + + imageid2datasetname = meta["imageid2datasetname"] + + for dataset_name in meta["mat_datasets"]: + os.makedirs( + os.path.join(proj_root, "labeled-data", dataset_name), exist_ok=True + ) + + # also, to make sure the split is right, we will have to pass the right indices + + columnindex = pd.MultiIndex.from_product( + [[scorer], individuals, bodyparts, ["x", "y"]], + names=["scorer", "individuals", "bodyparts", "coords"], + ) + + # it's important to put train first so the train_fraction parameter can work correctly + total_images = train_images + test_images + total_annotations = train_annotations + test_annotations + + # DLC uses relative dest as index into dataframe + imageid2relativedest = {} + count = 0 + for image in total_images: + image_id = image["id"] + file_name = image["file_name"] + image_name = file_name.split(os.sep)[-1] + pre, suffix = image_name.split(".") + dest_image_name = f"{pre}_{image_id}.{suffix}" + # the generic data has original pointers to images in the original foders + # Here, we have to change the image name and location of these to fit corresponding framework's convention + + dataset_name = imageid2datasetname[image_id] + + dest = os.path.join(proj_root, "labeled-data", dataset_name, dest_image_name) + if deepcopy: + shutil.copy(file_name, dest) + else: + try: + os.symlink(file_name, dest) + except Exception as e: + pass + + relative_dest = os.path.join("labeled-data", dataset_name, dest_image_name) + + imageid2relativedest[image_id] = relative_dest + + temp_count = 0 + for dataset_name, dataset in meta["mat_datasets"].items(): + + dataset_total_images = ( + dataset.generic_train_images + dataset.generic_test_images + ) + dataset_total_annotations = ( + dataset.generic_train_annotations + dataset.generic_test_annotations + ) + + dataset_index = [] + + for image in dataset_total_images: + image_id = image["id"] + relative_dest = imageid2relativedest[image_id] + dataset_index.append(relative_dest) + + raw_data = np.zeros((len(dataset_total_images), len(columnindex))) * np.nan + df = pd.DataFrame(raw_data, columns=columnindex, index=dataset_index) + # so we know where to put the next annotation if there are multiple individuals in that image + imageid2filledindividualcount = {} + + image_ids = [] + for anno in dataset_total_annotations: + keypoints = anno["keypoints"] + image_id = anno["image_id"] + image_ids.append(image_id) + if image_id not in imageid2filledindividualcount: + imageid2filledindividualcount[image_id] = 0 + else: + imageid2filledindividualcount[image_id] += 1 + individual_id = imageid2filledindividualcount[image_id] + + file_name = imageid2relativedest[image_id] + for kpt_id, kpt_name in enumerate(meta["categories"]["keypoints"]): + coord = keypoints[3 * kpt_id : 3 * kpt_id + 3] + # note dlc does not yet have visibility flag + # need to be careful here to assign right keypoints to right people + if coord[0] > 0 and coord[1] > 0: + # leave them to NaN if values are 0 + df.loc[file_name][ + scorer, f"individual{individual_id}", kpt_name, "x" + ] = coord[0] + df.loc[file_name][ + scorer, f"individual{individual_id}", kpt_name, "y" + ] = coord[1] + elif coord[2] == -1: + df.loc[file_name][ + scorer, f"individual{individual_id}", kpt_name, "x" + ] = -1 + df.loc[file_name][ + scorer, f"individual{individual_id}", kpt_name, "y" + ] = -1 + df.to_hdf( + os.path.join( + proj_root, "labeled-data", dataset_name, f"CollectedData_{scorer}.h5" + ), + key="df_with_missing", + mode="w", + ) + # paf_graph default as None. But I am not sure how to do better + deeplabcut.create_multianimaltraining_dataset( + os.path.join(proj_root, "config.yaml"), paf_graph=None + ) + + # dlc's merge_annotation messes up my indices, so I will need to overwrite the documentation file + # I could have done it in a more elegant way if I could modify part of DLC source code, but for backward compatibility reasons, overriding documentation is smarter + + config_path = os.path.join(proj_root, "config.yaml") + + cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + + train_folder = os.path.join( + proj_root, deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(cfg) + ) + + datafilename, metafilename = ( + deeplabcut.auxiliaryfunctions.GetDataandMetaDataFilenames( + train_folder, train_fraction, 1, cfg + ) + ) + + modify_train_test_cfg(config_path) + + dlc_df = pd.read_hdf(os.path.join(train_folder, f"CollectedData_{scorer}.h5")) + + # I strip off video info from the naming. For horse10, I need to get it back + parent_trace = {} + + def _filter(image): + file_name = image["file_name"] + + image_name = file_name.split(os.sep)[-1] + video_folder = file_name.split(os.sep)[-2] + pre, suffix = image_name.split(".") + image_id = image["id"] + ret = f"{pre}_{image_id}.{suffix}" + parent_trace[ret] = video_folder + return ret + + _filter_train_images = list(map(_filter, train_images)) + _filter_test_images = list(map(_filter, test_images)) + + with open(os.path.join(train_folder, "parent_trace.pickle"), "wb") as f: + pickle.dump(parent_trace, f) + + trainIndices = [ + idx + for idx, image in enumerate(dlc_df.index) + if get_filename(image).split(os.sep)[-1] in _filter_train_images + ] + testIndices = [ + idx + for idx, image in enumerate(dlc_df.index) + if get_filename(image).split(os.sep)[-1] in _filter_test_images + ] + + with open(metafilename, "rb") as f: + metafile = pickle.load(f) + + metafile[1] = trainIndices + metafile[2] = testIndices + + with open(metafilename, "wb") as f: + pickle.dump(metafile, f) + + # need to overwrite the data pickle file too + + nbodyparts = len(bodyparts) + + if "individuals" not in dlc_df.columns.names: + old_idx = dlc_df.columns.to_frame() + old_idx.insert(0, "individuals", "") + dlc_df.columns = pd.MultiIndex.from_frame(old_idx) + + data = format_multianimal_training_data(dlc_df, trainIndices, cfg["project_path"]) + + datafilename = datafilename.split(".mat")[0] + ".pickle" + + print(f"overwriting data file {datafilename}") + + with open(os.path.join(proj_root, datafilename), "wb") as f: + + pickle.dump(data, f, pickle.HIGHEST_PROTOCOL) + + +def _generic2sdlc( + proj_root, + train_images, + test_images, + train_annotations, + test_annotations, + meta, + deepcopy=False, + full_image_path=True, +): + + assert full_image_path, "DLC wants full image path" + + os.makedirs(os.path.join(proj_root, "labeled-data"), exist_ok=True) + + cfg_template = SingleDLC_config() + + bodyparts = meta["categories"]["keypoints"] + scorer = "singleDLC_scorer" + + train_fraction = round( + len(train_images) * 1.0 / (len(train_images) + len(test_images)), 2 + ) + + # need to fake a video path + # let's use individual dataset names as fake video name + + video_sets = { + f"{dataset_name}.mp4": {"crop": "0, 400, 0, 400"} + for dataset_name in meta["mat_datasets"].keys() + } + + modify_dict = dict( + Task=meta["dataset_name"], + project_path=proj_root, + scorer=scorer, + date="March30", + bodyparts=list(bodyparts), + video_sets=video_sets, + TrainingFraction=[train_fraction], + ) + + cfg_template.create_cfg(proj_root, modify_dict) + + imageid2datasetname = meta["imageid2datasetname"] + + for dataset_name in meta["mat_datasets"]: + os.makedirs( + os.path.join(proj_root, "labeled-data", dataset_name), exist_ok=True + ) + + columnindex = pd.MultiIndex.from_product( + [[scorer], bodyparts, ["x", "y"]], names=["scorer", "bodyparts", "coords"] + ) + + total_images = train_images + test_images + total_annotations = train_annotations + test_annotations + + # DLC uses relative dest as index + imageid2relativedest = {} + + for image in total_images: + imageid = image["id"] + filename = image["file_name"] + datasetname = imageid2datasetname[imageid] + count = 0 + for image in total_images: + image_id = image["id"] + file_name = image["file_name"] + + image_name = file_name.split(os.sep)[-1] + pre, suffix = image_name.split(".") + dest_image_name = f"{pre}_{image_id}.{suffix}" + # the generic data has original pointers to images in the original foders + # Here, we have to change the image name and location of these to fit corresponding framework's convention + + dataset_name = imageid2datasetname[image_id] + + dest = os.path.join(proj_root, "labeled-data", dataset_name, dest_image_name) + if deepcopy: + shutil.copy(file_name, dest) + else: + try: + os.symlink(file_name, dest) + except: + pass + + if dataset_name == "AwA-Pose": + count += 1 + + relative_dest = os.path.join("labeled-data", dataset_name, dest_image_name) + imageid2relativedest[image_id] = relative_dest + + # so we know where to put the next annotation if there are multiple individuals in that image + + for dataset_name, dataset in meta["mat_datasets"].items(): + + dataset_total_images = ( + dataset.generic_train_images + dataset.generic_test_images + ) + dataset_total_annotations = ( + dataset.generic_train_annotations + dataset.generic_test_annotations + ) + + dataset_index = [] + freq = {} + for image in dataset_total_images: + filename = image["file_name"] + + image_id = image["id"] + relative_dest = imageid2relativedest[image_id] + + dataset_index.append(relative_dest) + + raw_data = np.zeros((len(dataset_total_images), len(columnindex))) * np.nan + + dataset_index = dataset_index + + df = pd.DataFrame(raw_data, columns=columnindex, index=dataset_index) + + for idx, anno in enumerate(dataset_total_annotations): + keypoints = np.array(anno["keypoints"]) + image_id = anno["image_id"] + + file_name = imageid2relativedest[image_id] + + for kpt_id, kpt_name in enumerate(meta["categories"]["keypoints"]): + coord = keypoints[3 * kpt_id : 3 * kpt_id + 3] + # note dlc does not yet have visibility flag + # need to be careful here to assign right keypoints to right people + + if coord[0] > 0 and coord[1] > 0: + + df.loc[file_name][scorer, kpt_name, "x"] = coord[0] + df.loc[file_name][scorer, kpt_name, "y"] = coord[1] + elif coord[2] == -1: + # if -1, it's expaned keypoints by keypoint space projection + df.loc[file_name][scorer, kpt_name, "x"] = -1 + df.loc[file_name][scorer, kpt_name, "y"] = -1 + + df = df.dropna(how="all") + df.to_hdf( + os.path.join( + proj_root, "labeled-data", dataset_name, f"CollectedData_{scorer}.h5" + ), + key="df_with_missing", + mode="w", + ) + + deeplabcut.create_training_dataset( + os.path.join(proj_root, "config.yaml"), + ) + + # dlc's merge_annotation messes up my indices, so I will need to overwrite the documentation file + # I could have done it in a more elegant way if I could modify part of DLC source code, but for backward compatibility reasons, overriding documentation is smarter + + config_path = os.path.join(proj_root, "config.yaml") + cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + + train_folder = os.path.join( + proj_root, deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(cfg) + ) + + datafilename, metafilename = ( + deeplabcut.auxiliaryfunctions.GetDataandMetaDataFilenames( + train_folder, train_fraction, 1, cfg + ) + ) + + modify_train_test_cfg(config_path) + + dlc_df = pd.read_hdf(os.path.join(train_folder, f"CollectedData_{scorer}.h5")) + + parent_trace = {} + + def _filter(image): + file_name = image["file_name"] + image_name = file_name.split(os.sep)[-1] + video_folder = file_name.split(os.sep)[-2] + pre, suffix = image_name.split(".") + image_id = image["id"] + ret = f"{pre}_{image_id}.{suffix}" + + parent_trace[ret] = video_folder + + return ret + + _filter_train_images = list(map(_filter, train_images)) + _filter_test_images = list(map(_filter, test_images)) + + with open(os.path.join(train_folder, "parent_trace.pickle"), "wb") as f: + pickle.dump(parent_trace, f) + + trainIndices = [ + idx + for idx, image in enumerate(dlc_df.index) + if get_filename(image).split(os.sep)[-1] in _filter_train_images + ] + testIndices = [ + idx + for idx, image in enumerate(dlc_df.index) + if get_filename(image).split(os.sep)[-1] in _filter_test_images + ] + + with open(metafilename, "rb") as f: + metafile = pickle.load(f) + + metafile[1] = trainIndices + metafile[2] = testIndices + + with open(metafilename, "wb") as f: + pickle.dump(metafile, f) + + # need to overwrite the true data file too + nbodyparts = len(bodyparts) + + data, MatlabData = format_single_training_data( + dlc_df, trainIndices, nbodyparts, cfg["project_path"] + ) + + print(f"overwriting data file {datafilename}") + + sio.savemat( + os.path.join(cfg["project_path"], datafilename), {"dataset": MatlabData} + ) + + +def _generic2coco( + proj_root, + train_images, + test_images, + train_annotations, + test_annotations, + meta, + deepcopy=False, + full_image_path=True, +): + """ + Take generic data and create coco structure + My generic definition of coco structure: + images + ... + annotations + - train.json + - test.json + """ + + os.makedirs(os.path.join(proj_root, "images"), exist_ok=True) + os.makedirs(os.path.join(proj_root, "annotations"), exist_ok=True) + + # from new path to old_path + lookuptable = {} + + for annotation in train_annotations + test_annotations: + if "iscrowd" not in annotation: + annotation["iscrowd"] = 0 + + keypoints = annotation["keypoints"] + for kpt_id, kpt_name in enumerate(meta["categories"]["keypoints"]): + coord = keypoints[3 * kpt_id : 3 * kpt_id + 3] + if coord[0] < 0 or coord[1] < 0: + coord[2] = -1 + + broken_links = [] + # copying images via symbolic link + for image in train_images + test_images: + src = image["file_name"] + image_id = image["id"] + + if not os.path.exists(src): + print("problem comes from", image["source_dataset"]) + print(src) + broken_links.append(image_id) + continue + else: + pass + # print ('success comes from', image['source_dataset']) + # print (src) + + # in dlc, some images have same name but under different folder + # we used to use a parent folder to distinguish them, but it's only applicable to DLC + # so here it's easier to just append a id into the filename + + image_name = src.split(os.sep)[-1] + + if image_name.count(".") > 1: + sep = image_name.rfind(".") + pre, suffix = image_name[:sep], image_name[sep + 1 :] + else: + # this does not work for image file that looks like image9.5.jpg.. + pre, suffix = image_name.split(".") + + dest_image_name = f"{pre}_{image_id}.{suffix}" + + dest = os.path.join(proj_root, "images", dest_image_name) + + # now, we will also need to update the path in the config files + + if full_image_path: + image["file_name"] = dest + else: + image["file_name"] = os.path.join("images", dest_image_name) + + if deepcopy: + shutil.copy(src, dest) + else: + try: + os.symlink(src, dest) + except: + pass + + lookuptable[dest] = src + + train_annotations = [ + train_anno + for train_anno in train_annotations + if train_anno["image_id"] not in broken_links + ] + test_annotations = [ + test_anno + for test_anno in test_annotations + if test_anno["image_id"] not in broken_links + ] + + with open(os.path.join(proj_root, "annotations", "train.json"), "w") as f: + + train_json_obj = dict( + images=train_images, + annotations=train_annotations, + categories=[meta["categories"]], + ) + + json.dump(train_json_obj, f, indent=4, cls=NpEncoder) + + with open(os.path.join(proj_root, "annotations", "test.json"), "w") as f: + test_json_obj = dict( + images=test_images, + annotations=test_annotations, + categories=[meta["categories"]], + ) + + json.dump(test_json_obj, f, indent=4, cls=NpEncoder) + + return lookuptable + + +def mat_func_factory(framework): + assert framework in [ + "coco", + "sdlc", + "madlc", + ], f"Does not support framework {framework}" + if framework == "madlc": + mat_func = _generic2madlc + elif framework == "coco": + mat_func = _generic2coco + elif framework == "sdlc": + mat_func = _generic2sdlc + + return mat_func diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/multi.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/multi.py new file mode 100644 index 0000000000..1b24a7a2b7 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/multi.py @@ -0,0 +1,291 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import warnings + +from deeplabcut.modelzoo.generalized_data_converter.datasets.materialize import ( + mat_func_factory, +) + + +class MultiSourceDataset: + """ + Parameters: + iid_ood_split: {'iid' : ['dataset1', 'dataset2'], + 'ood' : ['dataset3', 'dataset4'] } + + + + """ + + def __init__(self, dataset_name, datasets, table_path): + self.datasets = datasets + # + self.name2genericdataset = {} + + # useful maps for analysis + self.imageid2filename = {} + self.imageid2datasetname = {} + self.datasetname2imageids = {} + # + self.dataset_name = dataset_name + + names = [] + for dataset in datasets: + + # Must project datasets to same keypoint space before merging + if table_path != None: + dataset.project_with_conversion_table(table_path) + name = dataset.meta["dataset_name"] + names.append(name) + self.name2genericdataset[name] = dataset + + self.meta = {} + self.meta["dataset_name"] = dataset_name + # after conversion, all datasets have same categories + self.meta["categories"] = dataset.meta["categories"] + + # map id from local scope to global + self._update_imgids() + + ( + self.train_images, + self.test_images, + self.train_annotations, + self.test_annotations, + ) = self._merge_datasets(self.name2genericdataset) + self.meta["name2genericdataset"] = self.name2genericdataset + + # only build maps after images are merged and ids are in global scope + self._build_maps() + + def summary(self): + print(f"Summary of dataset {self.dataset_name}") + print("Decomposition of multi source datasets:") + for dataset_name, dataset in self.name2genericdataset.items(): + n_images = len(dataset.generic_train_images) + len( + dataset.generic_test_images + ) + n_annotations = len(dataset.generic_train_annotations) + len( + dataset.generic_test_annotations + ) + print(f"{dataset_name} has {n_images} images, {n_annotations} annotations") + + print(f"total train images : {len(self.train_images)}") + print(f"total test images : {len(self.test_images)}") + + def _build_maps(self): + + # shared by both scenarios + + species_set = set() + for dataset_name, dataset in self.name2genericdataset.items(): + # I could of course do this during merge to save compute, but doing it here makes the logic cleaner to understand + total_images = dataset.generic_train_images + dataset.generic_test_images + + for image in total_images: + image_id = image["id"] + image_name = image["file_name"] + self.imageid2filename[image_id] = image_name + + self.imageid2datasetname[image_id] = dataset_name + + if dataset_name == "AwA-Pose": + species_set.add(image_name.split("/")[-1].split("_")[0]) + self.meta["imageid2datasetname"] = self.imageid2datasetname + + max_num = 0 + for dataset_name, dataset in self.name2genericdataset.items(): + max_num = max(max_num, dataset.meta["max_individuals"]) + self.meta["max_individuals"] = max_num + dataset_name = self.meta["dataset_name"] + print(f"Max individual in {dataset_name} is {max_num}") + + def whether_anno_image_match(self, images, annotations): + """ + Every image id should be annotated at least once + There should not be any image that is not being annotated + There should not be any annotation for beyond the set of given images + """ + + image_ids = set([image["id"] for image in images]) + + annotation_image_ids = set([anno["image_id"] for anno in annotations]) + + if image_ids != annotation_image_ids: + print("images-annotations", image_ids - annotation_image_ids) + print("annotations-images", annotation_image_ids - image_ids) + + warnings.warn("annotation and image ids do not match") + + # This is constrain is too hard + # assert len(annotation_image_ids - image_ids) == 0, "You can't have annotation on non-existed images" + + def _update_imgids(self): + """ + update image ids for both image and annotation + + If datasets are merged, their image id, annotation id will conflict because they are defined within their own local scope. Therefore, we will need to put these ids in the global scope + + """ + + from collections import defaultdict + + dataset_id_pool = defaultdict(set) + all_datasets = self.name2genericdataset.values() + + total_number_images = 0 + total_number_annotations = 0 + for dataset in all_datasets: + total_number_images += len(dataset.generic_train_images) + len( + dataset.generic_test_images + ) + total_number_annotations += len(dataset.generic_train_annotations) + len( + dataset.generic_test_annotations + ) + + global_image_id_pool = set(range(total_number_images)) + global_annotation_id_pool = set(range(total_number_annotations)) + + for dataset_name, dataset in self.name2genericdataset.items(): + + local_image_id_map = defaultdict(int) + local_anno_id_map = defaultdict(int) + + traintest_images = ( + dataset.generic_train_images + dataset.generic_test_images + ) + traintest_annotations = ( + dataset.generic_train_annotations + dataset.generic_test_annotations + ) + + for img in traintest_images: + + new_image_id = global_image_id_pool.pop() + local_image_id_map[img["id"]] = new_image_id + img["id"] = new_image_id + dataset_id_pool[dataset_name].add(img["id"]) + + for anno in traintest_annotations: + anno["image_id"] = local_image_id_map[anno["image_id"]] + new_anno_id = global_annotation_id_pool.pop() + local_anno_id_map[anno["id"]] = new_anno_id + anno["id"] = new_anno_id + + self.whether_anno_image_match(traintest_images, traintest_annotations) + + from functools import reduce + + count = 0 + for k, v in dataset_id_pool.items(): + count += len(v) + print("size of the summation", count) + union = reduce(set.union, dataset_id_pool.values()) + print("size of the union", len(union)) + + def _merge_datasets(self, name2dataset): + """ + Merged datasets into common list + + # only do this when iid/ood split is done + + """ + + merged_train_images = [] + merged_test_images = [] + merged_train_annotations = [] + merged_test_annotations = [] + + for dataset_name, dataset in name2dataset.items(): + + train_images = dataset.generic_train_images + test_images = dataset.generic_test_images + train_annotations = dataset.generic_train_annotations + test_annotations = dataset.generic_test_annotations + + merged_train_images.extend(train_images) + merged_test_images.extend(test_images) + merged_train_annotations.extend(train_annotations) + merged_test_annotations.extend(test_annotations) + + print("Checking merged dataset") + + merged_traintest_images = merged_train_images + merged_test_images + merged_traintest_annotations = ( + merged_train_annotations + merged_test_annotations + ) + + self.whether_anno_image_match( + merged_traintest_images, merged_traintest_annotations + ) + + return ( + merged_train_images, + merged_test_images, + merged_train_annotations, + merged_test_annotations, + ) + + def __eq__(self, other_dataset): + + if isinstance(other_dataset, BasePoseDataset): + + train_images1 = set(map(raw_2_imagename_with_id, self.train_images)) + train_images2 = set( + map(raw_2_imagename, other_dataset.generic_train_images) + ) + + test_images1 = set(map(raw_2_imagename_with_id, self.test_images)) + test_images2 = set(map(raw_2_imagename, other_dataset.generic_test_images)) + if train_images1 == train_images2 and test_images1 == test_images2: + print( + f'dataset {self.meta["dataset_name"]} and {other_dataset.meta["dataset_name"]} are equivalent' + ) + return True + else: + print( + f'dataset {self.meta["dataset_name"]} and {other_dataset.meta["dataset_name"]} are NOT equivalent' + ) + return False + + else: + return NotImplementedError("Not existed") + + def materialize( + self, + proj_root, + framework="coco", + train_all=False, + deepcopy=False, + full_image_path=True, + ): + + # can't be set to true at the same time. This will cause bugs + assert sum([train_all, full_image_path]) != 2 + + mat_func = mat_func_factory(framework) + + self.meta["mat_datasets"] = self.name2genericdataset + + if train_all: + # for pretrian phase, we can just train everything including the test part + self.train_images += self.test_images + self.train_annotations += self.test_annotations + + mat_func( + proj_root, + self.train_images, + self.test_images, + self.train_annotations, + self.test_annotations, + self.meta, + deepcopy=deepcopy, + full_image_path=full_image_path, + ) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc.py new file mode 100644 index 0000000000..8d7b419654 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc.py @@ -0,0 +1,135 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import os + +import numpy as np +import pandas as pd + +from deeplabcut.modelzoo.generalized_data_converter.datasets.base_dlc import ( + BaseDLCPoseDataset, +) +from deeplabcut.modelzoo.generalized_data_converter.datasets.utils import ( + calc_bboxes_from_keypoints, + read_image_shape_fast, +) + + +class SingleDLCPoseDataset(BaseDLCPoseDataset): + """ + The philosophy is to assume the dataset is already created so this class is not + responsible for creating training dataset + """ + + def __init__(self, proj_root, dataset_name, shuffle=1, modelprefix=""): + super(SingleDLCPoseDataset, self).__init__( + proj_root, dataset_name, shuffle=shuffle, modelprefix=modelprefix + ) + + # overriding max_individuals + self.meta["max_individuals"] = 1 + + def _df2generic(self, df, image_id_offset=0): + + bpts = df.columns.get_level_values("bodyparts").unique().tolist() + + coco_categories = [] + + # single animal only has individual0 + + category = { + "name": "individual0", + "id": 0, + "supercategory": "animal", + } + + category["keypoints"] = bpts + + coco_categories.append(category) + + coco_images = [] + coco_annotations = [] + + annotation_id = 0 + image_id = -1 + + for _, file_name in enumerate(df.index): + data = df.loc[file_name] + + # skipping all nan + + if np.isnan(data.to_numpy()).all(): + continue + + image_id += 1 + category_id = 0 + kpts = data.to_numpy().reshape(-1, 2) + keypoints = np.zeros((len(kpts), 3)) + + keypoints[:, :2] = kpts + + is_visible = ~pd.isnull(kpts).all(axis=1) + + keypoints[:, 2] = np.where(is_visible, 2, 0) + + num_keypoints = is_visible.sum() + + bbox_margin = 20 + + xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( + [keypoints], + slack=bbox_margin, + clip=True, + )[0][:4] + + w = xmax - xmin + h = ymax - ymin + area = w * h + bbox = np.nan_to_num([xmin, ymin, w, h]) + keypoints = np.nan_to_num(keypoints.flatten()) + + annotation_id += 1 + annotation = { + "image_id": image_id + image_id_offset, + "num_keypoints": num_keypoints, + "keypoints": keypoints, + "id": annotation_id, + "category_id": category_id, + "area": area, + "bbox": bbox, + "iscrowd": 0, + } + if np.sum(keypoints) != 0: + + coco_annotations.append(annotation) + + # I think width and height are important + + if isinstance(file_name, tuple): + image_path = os.path.join(self.proj_root, *list(file_name)) + else: + image_path = os.path.join(self.proj_root, file_name) + + _, height, width = read_image_shape_fast(image_path) + + image = { + "file_name": image_path, + "width": width, + "height": height, + "id": image_id + image_id_offset, + } + coco_images.append(image) + + ret_obj = { + "images": coco_images, + "annotations": coco_annotations, + "categories": coco_categories, + } + return ret_obj diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/utils.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/utils.py new file mode 100644 index 0000000000..d04e92201a --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/utils.py @@ -0,0 +1,40 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from functools import lru_cache + +import numpy as np +from PIL import Image + + +def calc_bboxes_from_keypoints(data, slack=0, offset=0, clip=False): + data = np.asarray(data) + if data.shape[-1] < 3: + raise ValueError("Data should be of shape (n_animals, n_bodyparts, 3)") + + if data.ndim != 3: + data = np.expand_dims(data, axis=0) + bboxes = np.full((data.shape[0], 5), np.nan) + bboxes[:, :2] = np.nanmin(data[..., :2], axis=1) - slack # X1, Y1 + bboxes[:, 2:4] = np.nanmax(data[..., :2], axis=1) + slack # X2, Y2 + bboxes[:, -1] = np.nanmean(data[..., 2]) # Average confidence + bboxes[:, [0, 2]] += offset + if clip: + coord = bboxes[:, :4] + coord[coord < 0] = 0 + return bboxes + + +@lru_cache(maxsize=None) +def read_image_shape_fast(path): + # Blazing fast and does not load the image into memory + with Image.open(path) as img: + width, height = img.size + return len(img.getbands()), height, width diff --git a/deeplabcut/modelzoo/generalized_data_converter/utils.py b/deeplabcut/modelzoo/generalized_data_converter/utils.py new file mode 100644 index 0000000000..490639088c --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/utils.py @@ -0,0 +1,326 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import glob +import os +import pickle +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +import deeplabcut +from deeplabcut.modelzoo.generalized_data_converter.datasets.materialize import ( + MaDLC_config, + SingleDLC_config, +) +from deeplabcut.pose_estimation_tensorflow.config import load_config + + +def threshold_kpts(config_path, h5path, threshold_mean=0.9, threshold_min=0.1): + + df = pd.read_hdf(h5path) + + scorer = df.columns.get_level_values("scorer").unique()[0] + try: + data = df[scorer]["individual0"] + except: + data = df[scorer] + + cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + + bodyparts = cfg["multianimalbodyparts"] + + thresholded_bpts = [] + + for bpt in bodyparts: + _mean = data[bpt]["likelihood"].mean() + _min = data[bpt]["likelihood"].min() + _var = data[bpt]["likelihood"].var() + if _mean > threshold_mean and _min > threshold_min: + thresholded_bpts.append(bpt) + print(bpt, "mean", _mean) + print(bpt, "min", _min) + print(bpt, "var", _var) + + print("thresholded kpts", thresholded_bpts) + return thresholded_bpts + ret = [] + print(ret) + return ret + + +def create_dummy_config_file_from_h5( + proj_root, reference_h5, taskname="dummytask", scorer="dummyscorer", date="March30" +): + """ + Assuming at least labeled-data folder is there + """ + + cfg_template = SingleDLC_config() + + df = pd.read_hdf(reference_h5) + + print(df) + + pattern = glob.glob(os.path.join(proj_root, "labeled-data", "*")) + + labeled_folders = [f.split("/")[-1] for f in pattern] + + video_sets = { + f"{folder}.mp4": {"crop": "0, 400, 0, 400"} for folder in labeled_folders + } + + # bodyparts = df[scorer]['bodyparts'] + + bodyparts = list(df.columns.get_level_values("bodyparts").unique()) + scorer = df.columns.get_level_values("scorer").unique()[0] + + modify_dict = dict( + Task=taskname, + project_path=proj_root, + scorer=scorer, + date=date, + video_sets=video_sets, + bodyparts=bodyparts, + TrainingFraction=[0.95], + ) + + cfg_template.create_cfg(proj_root, modify_dict) + + +def create_dummy_config_file_from_pickle( + proj_root, + reference_pickle, + video_path, + taskname="dummytask", + scorer="dummyscorer", + date="March30", +): + """ + Assuming at least labeled-data folder is there + """ + + cfg_template = SingleDLC_config() + + with open(reference_pickle, "rb") as f: + + pickle_obj = pickle.load(f) + + # bodyparts = pickle_obj['keypoint_names'] + bodyparts = [ + "tail", + "spine4", + "spine3", + "spine2", + "spine1", + "head", + "nose", + "right ear", + "left ear", + ] + + video_name = video_path.split("/")[-1] + + video_sets = {f"{video_path}": {"crop": "0, 400, 0, 400"}} + + modify_dict = dict( + Task=taskname, + project_path=proj_root, + scorer=scorer, + date=date, + video_sets=video_sets, + bodyparts=bodyparts, + TrainingFraction=[0.95], + ) + + cfg_template.create_cfg(".", modify_dict) + + +def create_video_h5_from_pickle(proj_root, cfg, reference_pickle, videopath): + + with open(reference_pickle, "rb") as f: + + pickle_obj = pickle.load(f) + + # bodyparts = pickle_obj['keypoint_names'] + + bodyparts = [ + "tail", + "spine4", + "spine3", + "spine2", + "spine1", + "head", + "nose", + "right ear", + "left ear", + ] + + video_name = videopath.split("/")[-1] + + video_key = f"{video_name}" # .replace('.top.ir.mp4', '') + + print("video_key", video_key) + + print(list(pickle_obj.keys())) + + detections = pickle_obj[video_key] + + nframes = len(detections) + + xyz_labs = ["x", "y", "likelihood"] + + scorer = cfg["scorer"] + + keypoint_names = cfg["bodyparts"] + + product = [[scorer], keypoint_names, xyz_labs] + + names = ["scorer", "bodyparts", "coords"] + columnindex = pd.MultiIndex.from_product(product, names=names) + imagenames = [f"frame{i}" for i in range(nframes)] + data = np.zeros((len(imagenames), len(columnindex))) * np.nan + df = pd.DataFrame(data, columns=columnindex, index=imagenames) + + for imagename, kpts in zip(imagenames, detections): + + for kpt_id, kpt_name in enumerate(keypoint_names): + + df.loc[imagename][scorer, kpt_name, "x"] = kpts[kpt_id, 0] + df.loc[imagename][scorer, kpt_name, "y"] = kpts[kpt_id, 1] + df.loc[imagename][scorer, kpt_name, "likelihood"] = kpts[kpt_id, 2] + + vname = Path(videopath).stem + DLCscorer = "" + + coords = [0, 400, 0, 400] + trainFraction = cfg["TrainingFraction"][0] + modelfolder = os.path.join( + cfg["project_path"], + str(deeplabcut.auxiliaryfunctions.get_model_folder(trainFraction, 0, cfg)), + ) + + path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml" + test_cfg = load_config(str(path_test_config)) + start = 0 + stop = 10 + fps = 10 + dictionary = { + "start": start, + "stop": stop, + "run_duration": stop - start, + "Scorer": DLCscorer, + "DLC-model-config file": test_cfg, + "fps": fps, + "batch_size": test_cfg["batch_size"], + "frame_dimensions": (400, 400), + "nframes": nframes, + "iteration (active-learning)": cfg["iteration"], + "cropping": cfg["cropping"], + "training set fraction": trainFraction, + "cropping_parameters": coords, + } + metadata = {"data": dictionary} + + dataname = os.path.join(proj_root, vname + DLCscorer + ".h5") + + metadata_path = dataname.split(".h5")[0] + "_meta.pickle" + + with open(metadata_path, "wb") as f: + pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL) + + df.to_hdf(dataname, "df_with_missing", format="table", mode="w") + + +def add_skeleton(config_path, pretrain_model_name): + + modelzoo_names = ["superquadruped", "supertopview"] + + assert pretrain_model_name in modelzoo_names + + super_quadruped = [ + ("left_eye", "right_eye"), + ("left_eye", "left_earbase"), + ("right_eye", "right_earbase"), + ("left_eye", "nose"), + ("right_eye", "nose"), + ("nose", "throat_base"), + ("throat_base", "back_base"), + ("tail_base", "back_base"), + ("throat_base", "front_left_thai"), + ("front_left_thai", "front_left_knee"), + ("front_left_knee", "front_left_paw"), + ("throat_base", "front_right_thai"), + ("front_right_thai", "front_right_knee"), + ("front_right_knee", "front_right_paw"), + ("tail_base", "back_left_thai"), + ("back_left_thai", "back_left_knee"), + ("back_left_knee", "back_left_paw"), + ("tail_base", "back_right_thai"), + ("back_right_thai", "back_right_knee"), + ("back_right_knee", "back_right_paw"), + ] + + skeleton_dict = {"superquadruped": super_quadruped, "supertopview": None} + + skeleton = skeleton_dict[pretrain_model_name] + + cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + cfg["skeleton"] = skeleton + print(f"overwriting skeleton for {config_path}") + deeplabcut.auxiliaryfunctions.write_config(config_path, cfg) + + +def customized_colormap(config_path): + # look for all symmetric keypoints + # make symmetric keypoints the same color + + cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + bodyparts = cfg["multianimalbodyparts"] + n_bodyparts = len(cfg["multianimalbodyparts"]) + + import matplotlib.pyplot as plt + + cmap = plt.cm.get_cmap("rainbow", n_bodyparts) + + colors = [cmap(i) for i in range(n_bodyparts)] + + visited = set() + for kpt_id in range(len(bodyparts)): + + bodypart = bodyparts[kpt_id] + if "left" in bodypart: + ref_color = colors[kpt_id] + temp = bodypart.replace("left", "right") + if temp in bodyparts: + temp_id = bodyparts.index(temp) + colors[temp_id] = ref_color + + def ret_function(i): + return colors[i] + + return ret_function + + +def create_modelprefix(modelprefix): + import shutil + + shutil.copytree( + "template-dlc-models", + os.path.join(modelprefix, "dlc-models"), + dirs_exist_ok=True, + ) + + +if __name__ == "__main__": + + customized_colormap("hei") From 11547986d1ef3400a74a2794c2807a43b01ff0cf Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 7 Jun 2024 14:01:02 +0200 Subject: [PATCH 088/293] prepare for 3.0.0rc1 (#203) * prepare for 3.0.0rc1 (bump version) * Update version.py * Update reinstall.sh * Update NOTICE.yml (pytorch license) --- NOTICE.yml | 6 ++++++ deeplabcut/version.py | 2 +- reinstall.sh | 2 +- setup.py | 4 ++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/NOTICE.yml b/NOTICE.yml index fbde253f06..ec8df871b2 100644 --- a/NOTICE.yml +++ b/NOTICE.yml @@ -17,6 +17,7 @@ # License for files adapted from DeeperCut by Eldar Insafutdinov # https://github.com/eldar/pose-tensorflow + # Applies to most files in deeplabcut.pose_estimation_tensorflow - header: | DeepLabCut Toolbox (deeplabcut.org) @@ -107,3 +108,8 @@ include: - deeplabcut/pose_tracking_pytorch/solver/scheduler_factory.py - deeplabcut/pose_tracking_pytorch/model/backones/vit_pytorch.py + +# PyTorch license + +- header: | + See https://github.com/pytorch/pytorch/blob/main/LICENSE diff --git a/deeplabcut/version.py b/deeplabcut/version.py index 6fd013311e..d75fb048bc 100644 --- a/deeplabcut/version.py +++ b/deeplabcut/version.py @@ -9,5 +9,5 @@ # Licensed under GNU Lesser General Public License v3.0 # -__version__ = "2.3.9" +__version__ = "3.0.0rc1" VERSION = __version__ diff --git a/reinstall.sh b/reinstall.sh index 3c73d45b83..81f17e5fb2 100755 --- a/reinstall.sh +++ b/reinstall.sh @@ -1,3 +1,3 @@ pip uninstall deeplabcut python3 setup.py sdist bdist_wheel -pip install dist/deeplabcut-2.3.9-py3-none-any.whl +pip install dist/deeplabcut-3.0.0rc1-py3-none-any.whl diff --git a/setup.py b/setup.py index 3f049bb1d8..334a243725 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -DeepLabCut2.0-2.3 Toolbox (deeplabcut.org) +DeepLabCut2.0-3.0 Toolbox (deeplabcut.org) © A. & M. Mathis Labs https://github.com/DeepLabCut/DeepLabCut Please see AUTHORS for contributors. @@ -32,7 +32,7 @@ def pytorch_config_paths() -> list[str]: setuptools.setup( name="deeplabcut", - version="2.3.9", + version="3.0.0rc1", author="A. & M.W. Mathis Labs", author_email="alexander@deeplabcut.org", description="Markerless pose-estimation of user-defined features with deep learning", From 17fc56bd6bbe4d712e01b05669f332206a646ccf Mon Sep 17 00:00:00 2001 From: shaokai Date: Fri, 7 Jun 2024 14:18:36 +0200 Subject: [PATCH 089/293] Shaokai/dev (#188) Co-authored-by: Niels Poulsen --- benchmark/coco/analyze_video.py | 104 +++ benchmark/coco/evaluate.py | 34 +- benchmark/coco/train.py | 66 +- .../memory_replay_example.py | 69 ++ benchmark_superanimal/video_adapt_example.py | 18 + deeplabcut/core/weight_init.py | 20 +- .../trainingsetmanipulation.py | 1 + .../gui/tabs/create_training_dataset.py | 81 ++- .../datasets/__init__.py | 2 + .../datasets/ma_dlc_dataframe.py | 280 ++++++++ .../datasets/single_dlc_dataframe.py | 243 +++++++ .../modelzoo/model_configs/hrnetw32.yaml | 20 +- .../superanimal_quadruped.yaml | 3 + .../superanimal_topviewmouse.yaml | 9 +- deeplabcut/modelzoo/utils.py | 8 + deeplabcut/modelzoo/video_inference.py | 156 +++- .../pose_estimation_pytorch/apis/__init__.py | 1 + .../apis/analyze_images.py | 368 ++++++++++ .../apis/analyze_videos.py | 13 +- .../pose_estimation_pytorch/apis/train.py | 37 +- .../pose_estimation_pytorch/apis/utils.py | 46 ++ .../config/base/detector.yaml | 5 +- .../pose_estimation_pytorch/data/__init__.py | 5 +- .../pose_estimation_pytorch/data/base.py | 12 +- .../data/cocoloader.py | 30 +- .../pose_estimation_pytorch/data/dataset.py | 23 +- .../pose_estimation_pytorch/data/dlcloader.py | 4 +- .../pose_estimation_pytorch/data/utils.py | 90 +-- .../metrics/scoring.py | 16 +- .../models/criterions/weighted.py | 31 +- .../models/detectors/base.py | 36 +- .../models/detectors/fasterRCNN.py | 9 +- .../models/heads/simple_head.py | 39 +- .../pose_estimation_pytorch/models/model.py | 13 +- .../models/predictors/single_predictor.py | 9 +- .../models/target_generators/dekr_targets.py | 10 +- .../target_generators/heatmap_targets.py | 44 +- .../models/target_generators/pafs_targets.py | 2 +- .../models/weight_init.py | 59 ++ .../modelzoo/config.py | 94 ++- .../modelzoo/inference.py | 62 +- .../modelzoo/memory_replay.py | 117 +++ .../modelzoo/train_from_coco.py | 100 +++ .../pose_estimation_pytorch/modelzoo/utils.py | 7 +- .../match_predictions_to_gt.py | 94 ++- .../pose_estimation_pytorch/runners/base.py | 6 +- .../runners/inference.py | 1 - .../pose_estimation_pytorch/runners/train.py | 28 +- deeplabcut/utils/pseudo_label.py | 671 ++++++++++++++++++ 49 files changed, 2933 insertions(+), 263 deletions(-) create mode 100644 benchmark/coco/analyze_video.py create mode 100644 benchmark_superanimal/memory_replay_example.py create mode 100644 benchmark_superanimal/video_adapt_example.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py create mode 100644 deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc_dataframe.py create mode 100644 deeplabcut/pose_estimation_pytorch/apis/analyze_images.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/weight_init.py create mode 100644 deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py create mode 100644 deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py create mode 100644 deeplabcut/utils/pseudo_label.py diff --git a/benchmark/coco/analyze_video.py b/benchmark/coco/analyze_video.py new file mode 100644 index 0000000000..4db7e7a71b --- /dev/null +++ b/benchmark/coco/analyze_video.py @@ -0,0 +1,104 @@ +"""Run video analysis""" + +from __future__ import annotations + +import argparse +import copy +from pathlib import Path + +import numpy as np +import torch + +from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import ( + create_df_from_prediction, + video_inference, + VideoIterator, +) +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners +from deeplabcut.pose_estimation_pytorch.config import read_config_as_dict +from deeplabcut.pose_estimation_pytorch.task import Task +from deeplabcut.utils.make_labeled_video import _create_labeled_video + + +def main( + video_path: str | Path, + model_config: str, + snapshot_path: str, + detector_path: str | None, + num_animals: int = 1, +): + video_path = Path(video_path) + model_cfg = read_config_as_dict(model_config) + pose_task = Task(model_cfg["method"]) + pose_runner, detector_runner = get_inference_runners( + model_config=model_cfg, + snapshot_path=snapshot_path, + max_individuals=num_animals, + num_bodyparts=len(model_cfg["metadata"]["bodyparts"]), + num_unique_bodyparts=len(model_cfg["metadata"]["unique_bodyparts"]), + with_identity=model_cfg["metadata"].get("with_identity", False), + transform=None, + detector_path=detector_path, + detector_transform=None, + ) + predictions, video_metadata = video_inference( + video_path, + task=pose_task, + pose_runner=pose_runner, + detector_runner=detector_runner, + with_identity=False, + return_video_metadata=True, + ) + + pred_bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions]) + pred_unique_bodyparts = None + bbox = (0, video_metadata["resolution"][0], 0, video_metadata["resolution"][1]) + + cfg = copy.deepcopy(model_cfg) + cfg["individuals"] = [f"individual_{i}" for i in range(num_animals)] + cfg["bodyparts"] = cfg["metadata"]["bodyparts"] + cfg["uniquebodyparts"] = [] + cfg["multianimalbodyparts"] = cfg["metadata"]["bodyparts"] + + dlc_scorer = "" + if detector_path is not None: + dlc_scorer += Path(detector_path).stem + dlc_scorer += Path(snapshot_path).stem + + output_prefix = f"{video_path.stem}_{dlc_scorer}" + output_path = video_path.parent + output_h5 = output_path / (output_prefix + ".h5") + _ = create_df_from_prediction( + pred_bodyparts=pred_bodyparts, + pred_unique_bodyparts=pred_unique_bodyparts, + dlc_scorer=dlc_scorer, + cfg=cfg, + output_path=output_path, + output_prefix=output_prefix, + ) + _create_labeled_video( + str(video_path), + str(output_h5), + pcutoff=0.6, + fps=video_metadata["fps"], + bbox=bbox, + output_path=str(output_path / f"{output_prefix}_labeled.mp4"), + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("video_path") + parser.add_argument("model_config_path") + parser.add_argument("snapshot_path") + parser.add_argument("--detector_path", default=None) + parser.add_argument("--device", default=None) + parser.add_argument("--num_animals", type=int, default=1) + args = parser.parse_args() + main( + video_path=args.video_path, + model_config=args.model_config_path, + snapshot_path=args.snapshot_path, + detector_path=args.detector_path, + num_animals=args.num_animals, + ) diff --git a/benchmark/coco/evaluate.py b/benchmark/coco/evaluate.py index 29e0019f37..93f98e1e38 100644 --- a/benchmark/coco/evaluate.py +++ b/benchmark/coco/evaluate.py @@ -16,8 +16,8 @@ def pycocotools_evaluation( kpt_oks_sigmas: list[int], - gt_path: str, - predictions_path: str, + ground_truth: dict, + predictions: list[dict], annotation_type: str, ) -> None: """Evaluation of models using Pycocotools @@ -27,8 +27,8 @@ def pycocotools_evaluation( Args: kpt_oks_sigmas: the OKS sigma for each keypoint - gt_path: the path to the ground truth annotations - predictions_path: the path to the predictions + ground_truth: the ground truth data, in COCO format + predictions: the predictions, in COCO format annotation_type: {"bbox", "keypoints"} the annotation type to evaluate """ print(80 * "-") @@ -37,10 +37,15 @@ def pycocotools_evaluation( from pycocotools.coco import COCO from pycocotools.cocoeval import COCOeval - coco_gt = COCO(gt_path) - coco_det = coco_gt.loadRes(predictions_path) - coco_eval = COCOeval(coco_gt, coco_det, annotation_type) - coco_eval.params.kpt_oks_sigmas = kpt_oks_sigmas + coco = COCO() + coco.dataset["annotations"] = ground_truth["annotations"] + coco.dataset["categories"] = ground_truth["categories"] + coco.dataset["images"] = ground_truth["images"] + coco.createIndex() + + coco_det = coco.loadRes(predictions) + coco_eval = COCOeval(coco, coco_det, iouType=annotation_type) + coco_eval.params.kpt_oks_sigmas = np.array(kpt_oks_sigmas) coco_eval.evaluate() coco_eval.accumulate() @@ -70,12 +75,11 @@ def main( test_json_filename=test_file, ) parameters = loader.get_dataset_parameters() - pytorch_config = loader.model_cfg if device is not None: - pytorch_config["device"] = device + loader.model_cfg["device"] = device pose_runner, detector_runner = get_inference_runners( - model_config=pytorch_config, + model_config=loader.model_cfg, snapshot_path=snapshot_path, max_individuals=parameters.max_num_animals, num_bodyparts=parameters.num_joints, @@ -90,7 +94,7 @@ def main( output_path.mkdir(exist_ok=True) for mode in ["train", "test"]: scores, predictions = evaluate( - pose_task=Task(pytorch_config.get("method", "bu")), + pose_task=Task(loader.model_cfg["method"]), pose_runner=pose_runner, loader=loader, mode=mode, @@ -108,13 +112,15 @@ def main( annotation_types = ["keypoints"] if detector_runner is not None: annotation_types.append("bbox") + + ground_truth = loader.load_data(mode=mode) for annotation_type in annotation_types: kpt_oks_sigmas = oks_sigma * np.ones(parameters.num_joints) pycocotools_evaluation( + ground_truth=ground_truth, + predictions=coco_predictions, kpt_oks_sigmas=kpt_oks_sigmas, annotation_type=annotation_type, - gt_path=str(Path(project_root) / "annotations" / train_file), - predictions_path=str(predictions_file), ) print(80 * "-") diff --git a/benchmark/coco/train.py b/benchmark/coco/train.py index 7c6dfded45..675aa43f22 100644 --- a/benchmark/coco/train.py +++ b/benchmark/coco/train.py @@ -11,7 +11,7 @@ from deeplabcut.pose_estimation_pytorch.apis.train import train from deeplabcut.pose_estimation_pytorch.runners.logger import setup_file_logging from deeplabcut.pose_estimation_pytorch.task import Task - +from collections import defaultdict def main( project_root: str, @@ -25,8 +25,11 @@ def main( detector_save_epochs: int | None, snapshot_path: str | None, detector_path: str | None, + batch_size: int = 64, + dataloader_workers: int = 12, + detector_batch_size: int = 64, + detector_dataloader_workers: int = 12, ): - model_folder = Path(model_config_path).parent.parent log_path = Path(model_config_path).parent / "log.txt" setup_file_logging(log_path) @@ -36,17 +39,38 @@ def main( train_json_filename=train_file, test_json_filename=test_file, ) + utils.fix_seeds(loader.model_cfg["train_settings"]["seed"]) - updates = {} - if epochs is not None: - updates["train_settings"]["epochs"] = epochs - if save_epochs is not None: - updates["train_settings"]["save_epochs"] = save_epochs - if detector_epochs is not None: - updates["detector"]["train_settings"]["epochs"] = detector_epochs - if detector_save_epochs is not None: - updates["detector"]["train_settings"]["save_epochs"] = detector_save_epochs + if epochs is None: + epochs = loader.model_cfg["train_settings"]["epochs"] + if save_epochs is None: + save_epochs = loader.model_cfg["runner"]["snapshots"]["save_epochs"] + + updates = dict( + runner=dict(snapshots=dict(save_epochs=save_epochs)), + train_settings=dict( + batch_size=batch_size, + dataloader_workers=dataloader_workers, + epochs=epochs, + ), + ) + + det_cfg = loader.model_cfg("detector") + if det_cfg is not None: + if detector_epochs is None: + detector_epochs = det_cfg["train_settings"]["epochs"] + if detector_save_epochs is None: + detector_save_epochs = det_cfg["runner"]["snapshots"]["save_epochs"] + updates_detector = dict( + runner=dict(snapshots=dict(save_epochs=detector_save_epochs)), + train_settings=dict( + batch_size=detector_batch_size, + dataloader_workers=detector_dataloader_workers, + ), + ) + updates["detector"] = updates_detector + loader.update_model_cfg(updates) pose_task = Task(loader.model_cfg["method"]) @@ -56,7 +80,8 @@ def main( logger_config = copy.deepcopy(loader.model_cfg["logger"]) logger_config["run_name"] += "-detector" - if detector_epochs > 0: + # skipping detector training if a detector_path is given + if args.detector_path is None and detector_epochs > 0: train( loader=loader, run_config=loader.model_cfg["detector"], @@ -66,14 +91,15 @@ def main( snapshot_path=detector_path, ) - train( - loader=loader, - run_config=loader.model_cfg, - task=pose_task, - device=device, - logger_config=loader.model_cfg.get("logger"), - snapshot_path=snapshot_path, - ) + if epochs > 0: + train( + loader=loader, + run_config=loader.model_cfg, + task=pose_task, + device=device, + logger_config=loader.model_cfg.get("logger"), + snapshot_path=snapshot_path, + ) if __name__ == "__main__": diff --git a/benchmark_superanimal/memory_replay_example.py b/benchmark_superanimal/memory_replay_example.py new file mode 100644 index 0000000000..e3b38baefe --- /dev/null +++ b/benchmark_superanimal/memory_replay_example.py @@ -0,0 +1,69 @@ +from pathlib import Path + +import deeplabcut +from deeplabcut.core.engine import Engine +from deeplabcut.core.weight_init import WeightInitialization +from deeplabcut.modelzoo.utils import ( + create_conversion_table, + read_conversion_table_from_csv, +) +from deeplabcut.pose_estimation_pytorch.modelzoo.config import ( + create_config_from_modelzoo, + write_pytorch_config_for_memory_replay, +) +from deeplabcut.utils.pseudo_label import keypoint_matching + +dlc_proj_root = Path("/mnt/md0/shaokai/daniel3mouse") +config_path = str(dlc_proj_root / "config.yaml") +superanimal_name = "superanimal_topviewmouse" +model_name = "hrnetw32" +shuffle = 0 +conversion_table_out_path = "conversion_table.csv" +max_individuals = 3 +device = "cuda" + + +# keypoint matching before create training dataset +# keypoint matching creates pseudo prediction and a conversion table + + +# should be fine to infer max individuals in keypoint matching +keypoint_matching( + config_path, + superanimal_name, + model_name, +) + +conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" + +table = create_conversion_table( + config=config_path, + super_animal=superanimal_name, + project_to_super_animal=read_conversion_table_from_csv(conversion_table_path), +) + +# make sure to merge this weight init with the example given by Niels below +weight_init = WeightInitialization( + dataset=superanimal_name, + conversion_array=table.to_array(), + with_decoder=True, + memory_replay=True, +) + + +deeplabcut.create_training_dataset( + config_path, + Shuffles=[shuffle], + net_type="top_down_hrnet_w32", + weight_init=weight_init, + engine=Engine.PYTORCH, + userfeedback=False, +) +""" +# check the max individual thing one more time +deeplabcut.train_network(config_path, + shuffle = shuffle, + superanimal_name = superanimal_name, + max_individuals = max_individuals, + device = device) +""" diff --git a/benchmark_superanimal/video_adapt_example.py b/benchmark_superanimal/video_adapt_example.py new file mode 100644 index 0000000000..8299bbb2e6 --- /dev/null +++ b/benchmark_superanimal/video_adapt_example.py @@ -0,0 +1,18 @@ +import deeplabcut.modelzoo.video_inference as modelzoo + + +def main(): + modelzoo.video_inference_superanimal( + videos=["/mnt/md0/shaokai/DLCdev/3mice_video1_short.mp4"], + superanimal_name="superanimal_topviewmouse_hrnetw32", + video_adapt=True, + max_individuals=3, + pseudo_threshold=0.1, + bbox_threshold=0.9, + detector_epochs=4, + pose_epochs=4, + ) + + +if __name__ == "__main__": + main() diff --git a/deeplabcut/core/weight_init.py b/deeplabcut/core/weight_init.py index e59775acbe..a879ae7e24 100644 --- a/deeplabcut/core/weight_init.py +++ b/deeplabcut/core/weight_init.py @@ -35,13 +35,16 @@ class WeightInitialization: cfg=project_cfg, super_animal="superanimal_quadruped", with_decoder=True, + memory_replay=True, ) ``` Args: dataset: The dataset on which the model weights were trained. Must be one of the SuperAnimal weights. - with_decoder: Whether to load the decoder weights as well. + `with_decoder`: Whether to load the decoder weights as well. + memory_replay: Only when ``with_decoder=True``. Whether to train the model with + memory replay, so that it predicts all SuperAnimal bodyparts. conversion_array: The mapping from SuperAnimal to project bodyparts. Required when `with_decoder=True`. @@ -52,12 +55,21 @@ class WeightInitialization: """ dataset: str with_decoder: bool = False + memory_replay: bool = False conversion_array: np.ndarray | None = None bodyparts: list[str] | None = None def __post_init__(self): # check that the dataset exists; raises a ValueError if it doesn't _ = modelzoo_utils.get_super_animal_project_cfg(self.dataset) + if self.memory_replay and not self.with_decoder: + raise ValueError( + "You cannot train a model with memory replay if you do not keep the " + "decoder layers (``with_decoder=True``), but you passed " + "`memory_replay=True` and `with_decoder=False`. Please change your " + "WeightInitialization parameters." + ) + if self.with_decoder and self.conversion_array is None: raise ValueError( f"You must specify a conversion_array to initialize decoder weights " @@ -82,6 +94,7 @@ def to_dict(self) -> dict: data = { "dataset": self.dataset, "with_decoder": self.with_decoder, + "memory_replay": self.memory_replay, } if self.conversion_array is not None: data["conversion_array"] = self.conversion_array.tolist() @@ -97,6 +110,7 @@ def from_dict(data: dict) -> "WeightInitialization": return WeightInitialization( dataset=data["dataset"], with_decoder=data["with_decoder"], + memory_replay=data["memory_replay"], conversion_array=conversion_array, ) @@ -105,6 +119,7 @@ def build( cfg: dict, super_animal: str, with_decoder: bool = False, + memory_replay: bool = False, ) -> "WeightInitialization": """Builds a WeightInitialization for a project @@ -116,6 +131,8 @@ def build( project configuration file. See ``deeplabcut.modelzoo.utils.create_conversion_table`` to create a conversion table. + memory_replay: Only when ``with_decoder=True``. Whether to train the model + with memory replay, so that it predicts all SuperAnimal bodyparts. Returns: The built WeightInitialization. @@ -130,6 +147,7 @@ def build( return WeightInitialization( dataset=super_animal, with_decoder=with_decoder, + memory_replay=memory_replay, conversion_array=conversion_array, bodyparts=bodyparts, ) diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 1eed9c9e74..8191593d29 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -937,6 +937,7 @@ def create_training_dataset( testIndices=testIndices, userfeedback=userfeedback, engine=engine, + weight_init=weight_init, ) else: scorer = cfg["scorer"] diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 4ba4cee032..f3ab225ca1 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -80,7 +80,7 @@ def _generate_layout_attributes(self, layout): # Dataset choices self.weight_init_label = QtWidgets.QLabel("Weight Initialization") - self.weight_init_choice = QtWidgets.QComboBox() + self.weight_init_selector = WeightInitializationSelector(self.root) self.update_weight_init_methods(self.root.engine) self.root.engine_change.connect(self.update_weight_init_methods) @@ -94,12 +94,13 @@ def _generate_layout_attributes(self, layout): # Neural Network nnet_label = QtWidgets.QLabel("Network architecture") self.net_choice = QtWidgets.QComboBox() + self.net_choice.setMinimumWidth(80) self.update_nets(self.root.engine) self.root.engine_change.connect(self.update_nets) self.net_choice.currentTextChanged.connect(self.log_net_choice) # Update Net types when selected weight init changes - self.weight_init_choice.currentTextChanged.connect( + self.weight_init_selector.weight_init_choice.currentTextChanged.connect( lambda _: self.update_nets(None) ) @@ -118,7 +119,7 @@ def _generate_layout_attributes(self, layout): layout.addWidget(shuffle_label, 0, 0) layout.addWidget(self.shuffle, 0, 1) layout.addWidget(self.weight_init_label, 0, 2) - layout.addWidget(self.weight_init_choice, 0, 3) + layout.addWidget(self.weight_init_selector, 0, 3) layout.addWidget(nnet_label, 1, 0) layout.addWidget(self.net_choice, 1, 1) @@ -161,7 +162,7 @@ def create_training_dataset(self): return try: - weight_init = self.get_weight_init() + weight_init = self.weight_init_selector.get_weight_init() except ValueError as err: print(f"The training dataset could not be created: {err}.") return @@ -315,23 +316,67 @@ def update_aug_methods(self, engine: Engine) -> None: def update_weight_init_methods(self, engine: Engine) -> None: if engine != Engine.PYTORCH: self.weight_init_label.hide() - self.weight_init_choice.hide() + self.weight_init_selector.hide() return - while self.weight_init_choice.count() > 0: - self.weight_init_choice.removeItem(0) - self.weight_init_label.show() - self.weight_init_choice.show() - self.weight_init_choice.addItems(list(_WEIGHT_INIT_OPTIONS.keys())) + self.weight_init_selector.update_choices(list(_WEIGHT_INIT_OPTIONS.keys())) + self.weight_init_selector.show() def get_net_filter(self) -> list[str] | None: """Returns: the net type that can be used based on weight initialization""" if self.root.engine != Engine.PYTORCH: return None + if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS: + return None + + return _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init]["model_filter"] + + +class WeightInitializationSelector(QtWidgets.QWidget): + """Widget to select weight initialization""" + + def __init__(self, root): + super().__init__() + self.root = root + + self.weight_init_choice = QtWidgets.QComboBox() + + self.memory_replay_label = QtWidgets.QLabel("With memory replay") + self.memory_replay_box = QtWidgets.QCheckBox() + self.memory_replay_label.hide() + self.memory_replay_box.hide() + + memory_replay_layout = QtWidgets.QHBoxLayout() + memory_replay_layout.addWidget(self.memory_replay_label) + memory_replay_layout.addWidget(self.memory_replay_box) + + layout = QtWidgets.QHBoxLayout() + layout.addWidget(self.weight_init_choice) + layout.addLayout(memory_replay_layout) + self.setLayout(layout) + + self.weight_init_choice.currentTextChanged.connect(self._choice_changed) + + @property + def weight_init(self) -> str: + return self.weight_init_choice.currentText() + + @property + def with_decoder(self) -> bool: weight_init_choice = self.weight_init_choice.currentText() - return _WEIGHT_INIT_OPTIONS[weight_init_choice]["model_filter"] + return "fine-tuning" in weight_init_choice.lower() + + @property + def memory_replay(self) -> bool: + return self.memory_replay_box.isChecked() + + def update_choices(self, choices: list[str]) -> None: + """Updates the WeightInitialization methods that can be selected""" + while self.weight_init_choice.count() > 0: + self.weight_init_choice.removeItem(0) + self.weight_init_choice.addItems(choices) def get_weight_init(self) -> WeightInitialization | None: """ @@ -348,12 +393,12 @@ def get_weight_init(self) -> WeightInitialization | None: weight_init_data = _WEIGHT_INIT_OPTIONS[weight_init_choice] super_animal = weight_init_data["super_animal"] - with_decoder = "fine-tuning" in weight_init_choice.lower() try: weight_init = WeightInitialization.build( self.root.cfg, super_animal=super_animal, - with_decoder=with_decoder, + with_decoder=self.with_decoder, + memory_replay=self.memory_replay, ) except ValueError as err: QtWidgets.QMessageBox.critical( @@ -370,6 +415,14 @@ def get_weight_init(self) -> WeightInitialization | None: return weight_init + def _choice_changed(self, state: str) -> None: + if "fine-tuning" in str(state).lower(): + self.memory_replay_label.show() + self.memory_replay_box.show() + else: + self.memory_replay_label.hide() + self.memory_replay_box.hide() + def _create_message_box(text, info_text): msg = QtWidgets.QMessageBox() @@ -409,7 +462,6 @@ def _create_confirmation_box(title, description): "model_filter": [ "dekr_w32", "hrnet_w32", - "resnet_50", ], "super_animal": "superanimal_quadruped", }, @@ -417,7 +469,6 @@ def _create_confirmation_box(title, description): "model_filter": [ "dekr_w32", "hrnet_w32", - "resnet_50", ], "super_animal": "superanimal_topviewmouse", }, diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py index 0082f64d80..47e42a1bd7 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py @@ -13,3 +13,5 @@ from .coco import COCOPoseDataset from .materialize import mat_func_factory from .single_dlc import SingleDLCPoseDataset +from .single_dlc_dataframe import SingleDLCDataFrame +from .ma_dlc_dataframe import MaDLCDataFrame diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py new file mode 100644 index 0000000000..45e0754b63 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py @@ -0,0 +1,280 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import os +from pathlib import Path + +import numpy as np +import pandas as pd + +from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( + parse_video_filenames, +) +from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset +from deeplabcut.modelzoo.generalized_data_converter.datasets.utils import ( + calc_bboxes_from_keypoints, + read_image_shape_fast, +) +from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, conversioncode + + +def merge_annotateddatasets(cfg): + """ + Merges all the h5 files for all labeled-datasets (from individual videos). + + This is a bit of a mess because of cross platform compatibility. + + Within platform comp. is straightforward. But if someone labels on windows and wants to train on a unix cluster or colab... + """ + AnnotationData = [] + data_path = Path(os.path.join(cfg["project_path"], "labeled-data")) + videos = cfg["video_sets"].keys() + video_filenames = parse_video_filenames(videos) + for filename in video_filenames: + file_path = os.path.join( + data_path / filename, f'CollectedData_{cfg["scorer"]}.h5' + ) + try: + data = pd.read_hdf(file_path) + conversioncode.guarantee_multiindex_rows(data) + if data.columns.levels[0][0] != cfg["scorer"]: + print( + f"{file_path} labeled by a different scorer. This data will not be utilized in training dataset creation. If you need to merge datasets across scorers, see https://github.com/DeepLabCut/DeepLabCut/wiki/Using-labeled-data-in-DeepLabCut-that-was-annotated-elsewhere-(or-merge-across-labelers)" + ) + continue + AnnotationData.append(data) + except FileNotFoundError: + print(file_path, " not found (perhaps not annotated).") + + if not len(AnnotationData): + print( + "Annotation data was not found by splitting video paths (from config['video_sets']). An alternative route is taken..." + ) + AnnotationData = conversioncode.merge_windowsannotationdataONlinuxsystem(cfg) + if not len(AnnotationData): + print("No data was found!") + return + + AnnotationData = pd.concat(AnnotationData).sort_index() + # When concatenating DataFrames with misaligned column labels, + # all sorts of reordering may happen (mainly depending on 'sort' and 'join') + # Ensure the 'bodyparts' level agrees with the order in the config file. + if cfg.get("multianimalproject", False): + ( + _, + uniquebodyparts, + multianimalbodyparts, + ) = auxfun_multianimal.extractindividualsandbodyparts(cfg) + bodyparts = multianimalbodyparts + uniquebodyparts + else: + bodyparts = cfg["bodyparts"] + AnnotationData = AnnotationData.reindex( + bodyparts, axis=1, level=AnnotationData.columns.names.index("bodyparts") + ) + + return AnnotationData + + +class MaDLCDataFrame(BasePoseDataset): + + def __init__(self, proj_root, dataset_name): + super(MaDLCDataFrame, self).__init__() + assert proj_root != None and dataset_name != None + self.proj_root = proj_root + self.dataset_name = dataset_name + self.meta["dataset_name"] = dataset_name + self.meta["proj_root"] = proj_root + config_path = Path(proj_root) / "config.yaml" + # read config + cfg = auxiliaryfunctions.read_config(config_path) + # get the train folder + + Data = merge_annotateddatasets( + cfg, + ) + + # now with this data, we construct necessary generic data + + self.dlc_df = Data + + images = self.dlc_df.index + + ratio = 0.9 + + df_train = self.dlc_df.iloc[: int(len(images) * ratio)] + df_test = self.dlc_df.iloc[int(len(images) * ratio) :] + + self.coco_train = self._df2generic(df_train) + + offset = len(self.coco_train["images"]) + + self.coco_test = self._df2generic(df_test, image_id_offset=offset) + + self.populate_generic() + + def populate_generic(self): + + self.generic_train_images = self.coco_train["images"] + self.generic_test_images = self.coco_test["images"] + self.generic_train_annotations = self.coco_train["annotations"] + self.generic_test_annotations = self.coco_test["annotations"] + + self.meta["categories"] = self.coco_test["categories"][0] + + # to build maps for later analysis + self._build_maps() + + print(f"Before checking trainset {self.meta['dataset_name']}") + + self.whether_anno_image_match( + self.generic_train_images, self.generic_train_annotations + ) + + print(f"Before checking testset {self.meta['dataset_name']}") + + self.whether_anno_image_match( + self.generic_test_images, self.generic_test_annotations + ) + + def _df2generic(self, df, image_id_offset=0): + + individuals = df.columns.get_level_values("individuals").unique().tolist() + + unique_bpts = [] + + if "single" in individuals: + unique_bpts.extend( + df.xs("single", level="individuals", axis=1) + .columns.get_level_values("bodyparts") + .unique() + ) + multi_bpts = ( + df.xs(individuals[0], level="individuals", axis=1) + .columns.get_level_values("bodyparts") + .unique() + .tolist() + ) + + coco_categories = [] + + # assuming all individuals have the same name and same category id + + individual = individuals[0] + + category = { + "name": individual, + "id": 0, + "supercategory": "animal", + } + + if individual == "single": + category["keypoints"] = unique_bpts + else: + category["keypoints"] = multi_bpts + + coco_categories.append(category) + + coco_images = [] + coco_annotations = [] + + annotation_id = 0 + image_id = -1 + for _, file_name in enumerate(df.index): + data = df.loc[file_name] + + # skipping all nan + if np.isnan(data.to_numpy()).all(): + continue + + image_id += 1 + + for individual_id, individual in enumerate(individuals): + category_id = 0 + try: + kpts = ( + data.xs(individual, level="individuals") + .to_numpy() + .reshape((-1, 2)) + ) + except: + # somehow there are duplicates. So only use the first occurence + data = data.iloc[0] + kpts = ( + data.xs(individual, level="individuals") + .to_numpy() + .reshape((-1, 2)) + ) + + keypoints = np.zeros((len(kpts), 3)) + + keypoints[:, :2] = kpts + + is_visible = ~pd.isnull(kpts).all(axis=1) + + keypoints[:, 2] = np.where(is_visible, 2, 0) + + num_keypoints = is_visible.sum() + + bbox_margin = 20 + + xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( + [keypoints], + slack=bbox_margin, + clip=True, + )[0][:4] + + w = xmax - xmin + h = ymax - ymin + area = w * h + bbox = np.nan_to_num([xmin, ymin, w, h]) + keypoints = np.nan_to_num(keypoints.flatten()) + + annotation_id += 1 + annotation = { + "image_id": image_id + image_id_offset, + "num_keypoints": num_keypoints, + "keypoints": keypoints, + "id": annotation_id, + "category_id": category_id, + "area": area, + "bbox": bbox, + "iscrowd": 0, + } + if np.sum(keypoints) != 0: + coco_annotations.append(annotation) + + # I think width and height are important + + if isinstance(file_name, tuple): + image_path = os.path.join(self.proj_root, *list(file_name)) + else: + image_path = os.path.join(self.proj_root, file_name) + + _, height, width = read_image_shape_fast(image_path) + + image = { + "file_name": image_path, + "width": width, + "height": height, + "id": image_id + image_id_offset, + } + coco_images.append(image) + + ret_obj = { + "images": coco_images, + "annotations": coco_annotations, + "categories": coco_categories, + } + return ret_obj + + +if __name__ == "__main__": + dataset = MaDLCDataFrame("/mnt/md0/shaokai/daniel3mouse", "3mouse") + dataset.summary() diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc_dataframe.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc_dataframe.py new file mode 100644 index 0000000000..e6e8fd5828 --- /dev/null +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc_dataframe.py @@ -0,0 +1,243 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +import os +from pathlib import Path + +import numpy as np +import pandas as pd + +from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( + parse_video_filenames, +) +from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset +from deeplabcut.modelzoo.generalized_data_converter.datasets.utils import ( + calc_bboxes_from_keypoints, + read_image_shape_fast, +) +from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, conversioncode + + +def merge_annotateddatasets(cfg): + """ + Merges all the h5 files for all labeled-datasets (from individual videos). + + This is a bit of a mess because of cross platform compatibility. + + Within platform comp. is straightforward. But if someone labels on windows and wants to train on a unix cluster or colab... + """ + AnnotationData = [] + data_path = Path(os.path.join(cfg["project_path"], "labeled-data")) + videos = cfg["video_sets"].keys() + video_filenames = parse_video_filenames(videos) + for filename in video_filenames: + file_path = os.path.join( + data_path / filename, f'CollectedData_{cfg["scorer"]}.h5' + ) + try: + data = pd.read_hdf(file_path) + conversioncode.guarantee_multiindex_rows(data) + if data.columns.levels[0][0] != cfg["scorer"]: + print( + f"{file_path} labeled by a different scorer. This data will not be utilized in training dataset creation. If you need to merge datasets across scorers, see https://github.com/DeepLabCut/DeepLabCut/wiki/Using-labeled-data-in-DeepLabCut-that-was-annotated-elsewhere-(or-merge-across-labelers)" + ) + continue + AnnotationData.append(data) + except FileNotFoundError: + print(file_path, " not found (perhaps not annotated).") + + if not len(AnnotationData): + print( + "Annotation data was not found by splitting video paths (from config['video_sets']). An alternative route is taken..." + ) + AnnotationData = conversioncode.merge_windowsannotationdataONlinuxsystem(cfg) + if not len(AnnotationData): + print("No data was found!") + return + + AnnotationData = pd.concat(AnnotationData).sort_index() + # When concatenating DataFrames with misaligned column labels, + # all sorts of reordering may happen (mainly depending on 'sort' and 'join') + # Ensure the 'bodyparts' level agrees with the order in the config file. + if cfg.get("multianimalproject", False): + ( + _, + uniquebodyparts, + multianimalbodyparts, + ) = auxfun_multianimal.extractindividualsandbodyparts(cfg) + bodyparts = multianimalbodyparts + uniquebodyparts + else: + bodyparts = cfg["bodyparts"] + AnnotationData = AnnotationData.reindex( + bodyparts, axis=1, level=AnnotationData.columns.names.index("bodyparts") + ) + + return AnnotationData + + +class SingleDLCDataFrame(BasePoseDataset): + + def __init__(self, proj_root, dataset_name): + super(SingleDLCDataFrame, self).__init__() + self.meta["max_individuals"] = 1 + assert proj_root != None and dataset_name != None + self.proj_root = proj_root + self.dataset_name = dataset_name + self.meta["dataset_name"] = dataset_name + self.meta["proj_root"] = proj_root + config_path = Path(proj_root) / "config.yaml" + # read config + cfg = auxiliaryfunctions.read_config(config_path) + # get the train folder + + Data = merge_annotateddatasets( + cfg, + ) + + # now with this data, we construct necessary generic data + + self.dlc_df = Data + + images = self.dlc_df.index + + ratio = 0.9 + + df_train = self.dlc_df.iloc[: int(len(images) * ratio)] + df_test = self.dlc_df.iloc[int(len(images) * ratio) :] + + self.coco_train = self._df2generic(df_train) + + offset = len(self.coco_train["images"]) + + self.coco_test = self._df2generic(df_test, image_id_offset=offset) + + self.populate_generic() + + def populate_generic(self): + + self.generic_train_images = self.coco_train["images"] + self.generic_test_images = self.coco_test["images"] + self.generic_train_annotations = self.coco_train["annotations"] + self.generic_test_annotations = self.coco_test["annotations"] + + self.meta["categories"] = self.coco_test["categories"][0] + + # to build maps for later analysis + self._build_maps() + + print(f"Before checking trainset {self.meta['dataset_name']}") + + self.whether_anno_image_match( + self.generic_train_images, self.generic_train_annotations + ) + + print(f"Before checking testset {self.meta['dataset_name']}") + + self.whether_anno_image_match( + self.generic_test_images, self.generic_test_annotations + ) + + def _df2generic(self, df, image_id_offset=0): + + bpts = df.columns.get_level_values("bodyparts").unique().tolist() + + coco_categories = [] + + # single animal only has individual0 + + category = { + "name": "individual0", + "id": 0, + "supercategory": "animal", + } + + category["keypoints"] = bpts + + coco_categories.append(category) + + coco_images = [] + coco_annotations = [] + + annotation_id = 0 + image_id = -1 + + for _, file_name in enumerate(df.index): + data = df.loc[file_name] + + # skipping all nan + + if np.isnan(data.to_numpy()).all(): + continue + + image_id += 1 + category_id = 0 + kpts = data.to_numpy().reshape(-1, 2) + keypoints = np.zeros((len(kpts), 3)) + + keypoints[:, :2] = kpts + + is_visible = ~pd.isnull(kpts).all(axis=1) + + keypoints[:, 2] = np.where(is_visible, 2, 0) + + num_keypoints = is_visible.sum() + + bbox_margin = 20 + + xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints( + [keypoints], + slack=bbox_margin, + clip=True, + )[0][:4] + + w = xmax - xmin + h = ymax - ymin + area = w * h + bbox = np.nan_to_num([xmin, ymin, w, h]) + keypoints = np.nan_to_num(keypoints.flatten()) + + annotation_id += 1 + annotation = { + "image_id": image_id + image_id_offset, + "num_keypoints": num_keypoints, + "keypoints": keypoints, + "id": annotation_id, + "category_id": category_id, + "area": area, + "bbox": bbox, + "iscrowd": 0, + } + if np.sum(keypoints) != 0: + + coco_annotations.append(annotation) + + # I think width and height are important + + if isinstance(file_name, tuple): + image_path = os.path.join(self.proj_root, *list(file_name)) + else: + image_path = os.path.join(self.proj_root, file_name) + + _, height, width = read_image_shape_fast(image_path) + + image = { + "file_name": image_path, + "width": width, + "height": height, + "id": image_id + image_id_offset, + } + coco_images.append(image) + + ret_obj = { + "images": coco_images, + "annotations": coco_annotations, + "categories": coco_categories, + } + return ret_obj diff --git a/deeplabcut/modelzoo/model_configs/hrnetw32.yaml b/deeplabcut/modelzoo/model_configs/hrnetw32.yaml index 2c05c0f1d8..ec72b06470 100644 --- a/deeplabcut/modelzoo/model_configs/hrnetw32.yaml +++ b/deeplabcut/modelzoo/model_configs/hrnetw32.yaml @@ -1,6 +1,3 @@ -corner2move2: -move2corner: -cfg_path: data: colormode: RGB inference: @@ -14,10 +11,7 @@ data: scaling: [1.0, 1.0] rotation: 30 translation: 0 - covering: true gaussian_noise: 12.75 - hist_eq: true - motion_blur: true normalize_images: true auto_padding: pad_width_divisor: 32 @@ -30,6 +24,7 @@ detector: train: hflip: true normalize_images: true + device: auto model: type: FasterRCNN variant: fasterrcnn_resnet50_fpn_v2 @@ -37,16 +32,16 @@ detector: pretrained: false runner: type: DetectorTrainingRunner - eval_interval: 1 + eval_interval: 50 optimizer: type: AdamW params: - lr: 1e-4 + lr: 1e-5 scheduler: type: LRListScheduler params: - milestones: [ 160 ] - lr_list: [ [ 1e-5 ] ] + milestones: [ 90 ] + lr_list: [ [ 1e-6 ] ] snapshots: max_snapshots: 5 save_epochs: 50 @@ -72,6 +67,7 @@ model: heads: bodypart: type: HeatmapHead + weight_init: "normal" predictor: type: HeatmapPredictor apply_sigmoid: false @@ -97,7 +93,7 @@ runner: type: PoseTrainingRunner key_metric: "test.mAP" key_metric_asc: true - eval_interval: 1 + eval_interval: 10 optimizer: type: AdamW params: @@ -105,7 +101,7 @@ runner: scheduler: type: LRListScheduler params: - lr_list: [ [ 1e-5 ], [ 1e-6 ] ] + lr_list: [ [ 1e-6 ], [ 1e-7 ] ] milestones: [ 160, 190 ] snapshots: max_snapshots: 5 diff --git a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml index 1688482e29..6f543aa9c5 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml @@ -95,3 +95,6 @@ y2: # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) corner2move2: move2corner: + +# Conversion tables to fine-tune SuperAnimal weights +SuperAnimalConversionTables: diff --git a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml index 5dd911002b..ac93a84e0c 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml @@ -7,7 +7,7 @@ identity: # Project path (change when moving around) -project_path: +project_path: /mnt/md0/shaokai/DLCdev/deeplabcut/modelzoo/project_configs # Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow) @@ -46,6 +46,9 @@ bodyparts: - head_midpoint +# Fraction of video to start/stop when extracting frames for labeling/refinement + + # Fraction of video to start/stop when extracting frames for labeling/refinement start: stop: @@ -83,3 +86,7 @@ y2: # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage) corner2move2: move2corner: + + +# Conversion tables to fine-tune SuperAnimal weights +SuperAnimalConversionTables: diff --git a/deeplabcut/modelzoo/utils.py b/deeplabcut/modelzoo/utils.py index 6b8ffd0a68..e18d77819f 100644 --- a/deeplabcut/modelzoo/utils.py +++ b/deeplabcut/modelzoo/utils.py @@ -22,6 +22,7 @@ read_config, write_config, ) +import pandas as pd def dlc_modelzoo_path() -> Path: @@ -131,6 +132,13 @@ def get_conversion_table(cfg: dict | str | Path, super_animal: str) -> Conversio ) return conversion_table +def read_conversion_table_from_csv(csv_path): + df = pd.read_csv(csv_path, skiprows=1, header=None) + df = df.dropna() + df[0] = df[0].str.replace(r'\s+', '', regex=True) + df[1] = df[1].str.replace(r'\s+', '', regex=True) + _map = dict(zip(df[0], df[1])) + return _map def parse_project_model_name(superanimal_name: str) -> tuple[str, str]: """Parses model zoo model names for SuperAnimal models diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index 2979b4cd50..f4af1cb0e3 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -11,13 +11,23 @@ import json import os -import warnings +from pathlib import Path from typing import Optional, Union from dlclibrary.dlcmodelzoo.modelzoo_download import download_huggingface_model +from ruamel.yaml import YAML from deeplabcut.modelzoo.utils import parse_project_model_name +from deeplabcut.pose_estimation_pytorch.modelzoo.train_from_coco import adaptation_train +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( + get_config_model_paths, + update_config, +) from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path +from deeplabcut.utils.pseudo_label import ( + dlc3predictions_2_annotation_from_video, + video_to_frames, +) def video_inference_superanimal( @@ -31,8 +41,14 @@ def video_inference_superanimal( pcutoff: float = 0.1, adapt_iterations: int = 1000, pseudo_threshold: float = 0.1, + bbox_threshold: float = 0.9, + detector_epochs: int = 4, + pose_epochs: int = 4, max_individuals: int = 10, + video_adapt_batch_size: int = 8, device: Optional[str] = None, + customized_pose_checkpoint: Optional[str] = None, + customized_detector_checkpoint: Optional[str] = None, ): """ This function performs inference on videos using a superanimal model. The model is downloaded from hugginface to the `modelzoo/checkpoints` folder. @@ -57,12 +73,24 @@ def video_inference_superanimal( adapt_iterations (int): Number of iterations for adaptation training. Empirically 1000 is sufficient. + bbox_threshold (float): The pseudo-label threshold for the confidence of the detector. The default is 0.9 + + detector_epochs (int): Used in the pytorch engine. The number of epochs for training the detector. The default is 4. + + pose_epochs (int): Used in the pytorch engine. The number of epochs for training the pose estimator. The default is 4. + pseudo_threshold (float): The pseudo-label threshold for the confidence of the prediction. The default is 0.1. max_individuals (int): The maximum number of individuals in the video. The default is 30. Used only for top down models. + video_adapt_batch_size (int): The batch size to use for video adaptation. + device (str): The device to use for inference. The default is None (CPU). Used only for pytorch models. + customized_pose_checkpoint (str): Used in the pytorch engine. If specified, replacing the default pose checkpoint. + + customized_detector_checkpoint (str): Used in the pytorch engine. If specified, replacing the default detector checkpoint. + Raises: NotImplementedError: If the model is not found in the modelzoo. Warning: If the superanimal_name will be deprecated in the future. @@ -88,7 +116,10 @@ def video_inference_superanimal( "pose_model.pth": pose_model_name, "detector.pt": detector_name, } - pose_model_path = os.path.join(weight_folder, pose_model_name) + if customized_pose_checkpoint is None: + pose_model_path = os.path.join(weight_folder, pose_model_name) + else: + pose_model_path = customized_pose_checkpoint detector_model_path = os.path.join(weight_folder, detector_name) if not ( os.path.exists(pose_model_path) and os.path.exists(detector_model_path) @@ -129,9 +160,124 @@ def video_inference_superanimal( ) if video_adapt: - warnings.warn(f"Video adaptation is not yet implemented for HRNet models.") + # the users can pass in many videos. For now, we only use one video for + # video adaptation. As reported in Ye et al. 2023, one video should be + # sufficient for video adaptation. + video_path = Path(videos[0]) + print(f"using {video_path} for video adaptation training") - _video_inference_superanimal( + # video inference to get pseudo label + _video_inference_superanimal( + [str(video_path)], + project_name, + model_name, + max_individuals, + pcutoff, + device, + dest_folder, + customized_pose_checkpoint=None, + customized_detector_checkpoint=None, + ) + + ( + model_config, + project_config, + pose_model_path, + detector_path, + ) = get_config_model_paths(project_name, model_name) + config = {**project_config, **model_config} + config = update_config(config, max_individuals, device) + + # we need config to fetch the correct keypoints to dlc3predictions_2_annotation_from_video + bodyparts = config["metadata"]["bodyparts"] + + # we prepare the pseudo dataset in the same folder of the target video + pseudo_dataset_folder = video_path.with_name(f"pseudo_{video_path.stem}") + pseudo_dataset_folder.mkdir(exist_ok=True) + model_folder = pseudo_dataset_folder / "checkpoints" + model_folder.mkdir(exist_ok=True) + + image_folder = pseudo_dataset_folder / "images" + if image_folder.exists(): + print(f"{image_folder} exists, skipping the frame extraction") + else: + image_folder.mkdir() + video_to_frames(video_path, pseudo_dataset_folder) + + anno_folder = pseudo_dataset_folder / "annotations" + if anno_folder.exists(): + print(f"{anno_folder} exists, skipping the annotation construction") + else: + anno_folder.mkdir() + + if dest_folder is None: + pseudo_anno_dir = video_path.parent + else: + pseudo_anno_dir = Path(dest_folder) + dlc_scorer = f"{project_name}_{model_name}" + pseudo_anno_name = f"{video_path.stem}_{dlc_scorer}_before_adapt.json" + with open(pseudo_anno_dir / pseudo_anno_name, "r") as f: + predictions = json.load(f) + + # make sure we tune parameters inside this function such as pseudo + # threshold etc. + dlc3predictions_2_annotation_from_video( + predictions, + pseudo_dataset_folder, + bodyparts, + superanimal_name, + pose_threshold=pseudo_threshold, + bbox_threshold=bbox_threshold, + ) + + # this is probably needed for video generation + individuals = [f"animal{i}" for i in range(max_individuals)] + config["metadata"]["individuals"] = individuals + + # the model config's parameters need to be updated for adaptation training + model_config_path = model_folder / "pytorch_config.yaml" + with open(model_config_path, "w") as f: + yaml = YAML() + yaml.dump(config, f) + + adapted_detector_checkpoint = ( + model_folder / f"snapshot-detector-{detector_epochs:03}.pt" + ) + adapted_pose_checkpoint = model_folder / f"snapshot-{pose_epochs:03}.pt" + + if ( + adapted_detector_checkpoint.exists() + and adapted_pose_checkpoint.exists() + ): + print( + f"Video adaptation already ran; pose ({adapted_pose_checkpoint}) " + f"and detector ({adapted_detector_checkpoint}) already exist. To " + "rerun video adaptation training, delete the checkpoints or select" + "a different number of adaptation epochs. Continuing with the" + "existing checkpoints.") + else: + adaptation_train( + project_root=pseudo_dataset_folder, + model_folder=model_folder, + train_file="train.json", + test_file="test.json", + model_config_path=model_config_path, + device=device, + epochs=pose_epochs, + save_epochs=1, + detector_epochs=detector_epochs, + detector_save_epochs=1, + snapshot_path=pose_model_path, + detector_path=detector_path, + batch_size=video_adapt_batch_size, + detector_batch_size=video_adapt_batch_size, + ) + + # Set the customized checkpoint paths + customized_pose_checkpoint = str(adapted_pose_checkpoint) + customized_detector_checkpoint = str(adapted_detector_checkpoint) + + return _video_inference_superanimal( videos, project_name, model_name, @@ -139,4 +285,6 @@ def video_inference_superanimal( pcutoff, device, dest_folder, + customized_pose_checkpoint=customized_pose_checkpoint, + customized_detector_checkpoint=customized_detector_checkpoint, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py index eb4b17e952..1347cfaa14 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py @@ -9,6 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # +from deeplabcut.pose_estimation_pytorch.apis.analyze_images import analyze_images from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import analyze_videos from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( convert_detections2tracklets, diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py new file mode 100644 index 0000000000..75a0b11a88 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py @@ -0,0 +1,368 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import glob +import json +import logging +import os +from collections import defaultdict +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +from tqdm import tqdm + +import deeplabcut.pose_estimation_pytorch.config.utils as config_utils +from deeplabcut.core.engine import Engine +from deeplabcut.pose_estimation_pytorch.apis.utils import ( + get_inference_runners, + get_model_snapshots, + get_scorer_name, + get_scorer_uid, + parse_snapshot_index_for_analysis, +) +from deeplabcut.pose_estimation_pytorch.task import Task +from deeplabcut.pose_estimation_pytorch.utils import resolve_device +from deeplabcut.utils import ( + auxfun_videos, + auxiliaryfunctions, +) + + +def analyze_images( + config: str | Path, + images: str | Path | list[str] | list[Path], + output_dir: str | Path, + shuffle: int = 1, + trainingsetindex: int = 0, + snapshot_index: int | None = None, + detector_snapshot_index: int | None = None, + modelprefix: str = "", + device: str | None = None, + max_individuals: int | None = None, + progress_bar: bool = True, +) -> dict[str, dict]: + """Runs analysis on images using a pose model. + + Args: + config: The project configuration file. + images: The image(s) to run inference on. Can be the path to an image, the path + to a directory containing images, or a list of image paths or directories + containing images. + output_dir: The directory where the predictions will be stored. + shuffle: The shuffle for which to run image analysis. + trainingsetindex: The trainingsetindex for which to run image analysis. + snapshot_index: The index of the snapshot to use. Loaded from the project + configuration file if None. + detector_snapshot_index: For top-down models only. The index of the detector + snapshot to use. Loaded from the project configuration file if None. + modelprefix: The model prefix used for the shuffle. + device: The device to use to run image analysis. + max_individuals: The maximum number of individuals to detect in each image. Set + to the number of individuals in the project if None. + progress_bar: Whether to display a progress bar when running inference. + + Returns: + A dictionary mapping each image filename to the different types of predictions + for it (e.g. "bodyparts", "unique_bodyparts", "bboxes", "bbox_scores") + """ + if not output_dir.is_dir(): + raise ValueError( + f"The `output_dir` must be a directory - please change: {output_dir}" + ) + + cfg = auxiliaryfunctions.read_config(config) + train_frac = cfg["TrainingFraction"][trainingsetindex] + model_folder = Path(cfg["project_path"]) / auxiliaryfunctions.get_model_folder( + train_frac, shuffle, cfg, engine=Engine.PYTORCH, modelprefix=modelprefix, + ) + train_folder = model_folder / "train" + + model_cfg_path = train_folder / Engine.PYTORCH.pose_cfg_name + model_cfg = config_utils.read_config_as_dict(model_cfg_path) + pose_task = Task(model_cfg["method"]) + + # get the snapshots to analyze images with + snapshot_index, detector_snapshot_index = parse_snapshot_index_for_analysis( + cfg, model_cfg, snapshot_index, detector_snapshot_index + ) + snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0] + detector_snapshot = None + if detector_snapshot_index is not None: + detector_snapshot = get_model_snapshots( + detector_snapshot_index, train_folder, Task.DETECT + )[0] + + predictions = analyze_image_folder( + model_cfg=model_cfg, + images=images, + snapshot_path=snapshot.path, + detector_path=None if detector_snapshot is None else detector_snapshot.path, + device=device, + max_individuals=max_individuals, + progress_bar=progress_bar, + ) + + if len(predictions) == 0: + logging.info(f"Found no images in {images}") + return {} + + # FIXME(niels): store as H5 + pred_json = {} + for image, pred in predictions.items(): + pred_json[image] = dict(bodyparts=pred["bodyparts"].tolist()) + for k in ("unique_bodyparts", "bboxes", "bbox_scores"): + if k in pred: + pred_json[image][k] = pred[k].tolist() + + scorer = get_scorer_name( + cfg, + shuffle=shuffle, + train_fraction=train_frac, + snapshot_uid=get_scorer_uid(snapshot, detector_snapshot), + modelprefix=modelprefix, + ) + output_path = output_dir / f"{scorer}_image_predictions.json" + logging.info(f"Saving predictions to {output_path}") + with open(output_path, "w") as f: + json.dump(pred_json, f) + + return predictions + + +def analyze_image_folder( + model_cfg: str | Path | dict, + images: str | Path | list[str] | list[Path], + snapshot_path: str | Path, + detector_path: str | Path | None = None, + device: str | None = None, + max_individuals: int | None = None, + progress_bar: bool = True, +) -> dict[str, dict[str, np.ndarray | np.ndarray]]: + """Runs pose inference on a folder of images + + Args: + model_cfg: The model config (or its path) used to analyze the images. + images: The images to analyze. Can either be a directory containing images, or + a list of paths of images. + snapshot_path: The path of the snapshot to use to analyze the images. + detector_path: The path of the detector snapshot to use to analyze the images, + if a top-down model was used. + device: The device to use to run image analysis. + max_individuals: The maximum number of individuals to detect in each image. Set + to the number of individuals in the project if None. + progress_bar: Whether to display a progress bar when running inference. + + Returns: + A dictionary mapping each image filename to the different types of predictions + for it (e.g. "bodyparts", "unique_bodyparts", "bboxes", "bbox_scores") + + Raises: + ValueError: if the pose model is a top-down model but no detector path is given + """ + if not isinstance(model_cfg, dict): + model_cfg = config_utils.read_config_as_dict(model_cfg) + + pose_task = Task(model_cfg["method"]) + if pose_task == Task.TOP_DOWN and detector_path is None: + raise ValueError( + "A detector path must be specified for image analysis using top-down models" + f" Please specify the `detector_path` parameter." + ) + + bodyparts = model_cfg["metadata"]["bodyparts"] + unique_bodyparts = model_cfg["metadata"]["unique_bodyparts"] + individuals = model_cfg["metadata"]["individuals"] + if max_individuals is None: + max_individuals = len(individuals) + + if device is None: + device = resolve_device(model_cfg) + + pose_runner, detector_runner = get_inference_runners( + model_config=model_cfg, + snapshot_path=snapshot_path, + max_individuals=max_individuals, + num_bodyparts=len(bodyparts), + num_unique_bodyparts=len(unique_bodyparts), + device=device, + with_identity=False, + transform=None, + detector_path=detector_path, + detector_transform=None, + ) + + image_paths = parse_images_and_image_folders(images) + if detector_runner is not None: + logging.info(f"Running object detection with {detector_path}") + + detector_image_paths = image_paths + if progress_bar: + detector_image_paths = tqdm(detector_image_paths) + bbox_predictions = detector_runner.inference(images=detector_image_paths) + image_paths = list(zip(image_paths, bbox_predictions)) + + logging.info(f"Running pose estimation with {detector_path}") + pose_image_paths = image_paths + if progress_bar: + pose_image_paths = tqdm(pose_image_paths) + predictions = pose_runner.inference(pose_image_paths) + + return { + image_path: image_predictions + for image_path, image_predictions in zip(image_paths, predictions) + } + + +def plot_images_coco( + model_cfg: str | Path | dict, + image_folder: str | Path, + snapshot_path: str | Path, + out_path: str = "test_images", + data_json_path: str = "", + detector_path: str | Path | None = None, + device: str | None = None, + max_individuals: int | None = None, +) -> list[dict]: + """ + Runs pose inference on a folder of images from a COCO dataset, and plots all + predicted keypoints and bounding boxes + + Args: + model_cfg: The model config (or its path) used to analyze the images. + image_folder: The path to the folder containing the images to analyze. + snapshot_path: The path of the snapshot to use to analyze the images. + out_path: The path of the folder where images should be output. + data_json_path: The path to the JSON file containing ground truth data. + detector_path: The path of the detector snapshot to use to analyze the images, + if a top-down model was used. + device: The device on which to run image inference + max_individuals: The maximum number of individuals to detect in an image. + + Returns: + A list of dictionaries containing predictions made on each image. + + Raises: + ValueError: if a top-down model configuration is given but detector_path is None + """ + with open(data_json_path, "r") as f: + obj = json.load(f) + + coco_images = obj["images"] + coco_annotations = obj["annotations"] + + image_name_to_id = {} + for image in coco_images: + # only works with relative path as a test image can be in a different folder + image_name = image["file_name"].split(os.sep)[-1] + image_name_to_id[image_name] = image["id"] + + image_id_to_annotations = defaultdict(list) + image_ids = list(image_name_to_id.values()) + for annotation in coco_annotations: + image_id = annotation["image_id"] + if annotation["image_id"] in image_ids: + image_id_to_annotations[image_id].append(annotation) + + # need to support more image types + images_in_folder = glob.glob(str(Path(image_folder) / "*.png")) + corresponded_images = [] + for image in images_in_folder: + image_path = image + image_name = image.split(os.sep)[-1] + if image_name in image_name_to_id: + corresponded_images.append(image_path) + + images = corresponded_images + + predictions = analyze_image_folder( + model_cfg=model_cfg, + images=images, + snapshot_path=snapshot_path, + detector_path=detector_path, + device=device, + max_individuals=max_individuals, + progress_bar=True, + ) + + os.makedirs(out_path, exist_ok=True) + + coco_format_predictions = [] + for image_path, prediction in predictions.items(): + image_name = image_path.split(os.sep)[-1] + coco_prediction = dict( + image_id=image_name_to_id[image_name], + gt_annotations=image_id_to_annotations[image_name_to_id[image_name]], + file_name=image_path, + bodyparts=prediction["bodyparts"], + ) + if "unique_bodyparts" in prediction: + coco_prediction["unique_bodyparts"] = prediction["unique_bodyparts"] + if "bboxes" in prediction: + coco_prediction["bboxes"] = prediction["bboxes"] + if "bbox_scores" in prediction: + coco_prediction["bbox_scores"] = prediction["bbox_scores"] + + coco_format_predictions.append(coco_prediction) + + frame = auxfun_videos.imread(str(image_path), mode="skimage") + fig, ax = plt.subplots() + ax.imshow(frame) + + # TODO: color of keypoints are all red. Need to change to a different colormap + for pose in prediction["bodyparts"]: + x, y, confidence = pose[:, 0], pose[:, 1], pose[:, 2] + mask = confidence > 0.0 + x = x[mask] + y = y[mask] + ax.scatter(x, y, color="red") + + bboxes = prediction["bboxes"] + for bbox in bboxes: + # Draw bounding boxes around detected objects + xmin, ymin, w, h = bbox + rect = plt.Rectangle( + (xmin, ymin), w, h, fill=False, edgecolor="blue", linewidth=2 + ) + + ax.add_patch(rect) + image_name = image_path.split("/")[-1] + fig.savefig(os.path.join(out_path, image_name)) + + return coco_format_predictions + + +def parse_images_and_image_folders( + images: str | Path | list[str] | list[Path], + image_suffixes: tuple[str] = (".png", ".jpg", ".jpeg"), +) -> list[str]: + """Parses image paths or directory paths into a single list of image paths. + + Args: + images: Paths of images or folders containing images. + image_suffixes: Suffixes used for images. + + Returns: + The images contained in the folders or directly the paths given as input + """ + if isinstance(images, (str, Path)): + path = Path(images) + if path.is_dir(): + return [str(img) for img in path.iterdir() if img.suffix in image_suffixes] + + return [str(path)] + + image_to_analyze = [] + for file in images: + image_to_analyze += parse_images_and_image_folders(file) + + return image_to_analyze diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 86eddb744e..2a2cc51792 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -44,9 +44,9 @@ class VideoIterator(VideoReader): """A class to iterate over videos, with possible added context""" def __init__( - self, video_path: str, context: list[dict[str, Any]] | None = None + self, video_path: str | Path, context: list[dict[str, Any]] | None = None ) -> None: - super().__init__(video_path) + super().__init__(str(video_path)) self._context = context self._index = 0 @@ -117,6 +117,7 @@ def video_inference( print("Running Detector") bbox_predictions = detector_runner.inference(images=tqdm(video)) + video.set_context(bbox_predictions) print("Running Pose Prediction") @@ -213,7 +214,11 @@ def analyze_videos( project_path = Path(cfg["project_path"]) train_fraction = cfg["TrainingFraction"][trainingsetindex] model_folder = project_path / auxiliaryfunctions.get_model_folder( - train_fraction, shuffle, cfg, modelprefix=modelprefix, engine=Engine.PYTORCH, + train_fraction, + shuffle, + cfg, + modelprefix=modelprefix, + engine=Engine.PYTORCH, ) train_folder = model_folder / "train" @@ -250,7 +255,7 @@ def analyze_videos( model_cfg["device"] = device print(f"Analyzing videos with {snapshot.path}") - detector_path, detector_snapshot = None, None + detector_path, detector_snapshot = None, None if pose_task == Task.TOP_DOWN: if detector_snapshot_index is None: detector_snapshot_index = -1 diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index d3d8ac3806..f450b285b9 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -13,6 +13,7 @@ import argparse import copy import logging +from pathlib import Path import albumentations as A from torch.utils.data import DataLoader @@ -20,16 +21,24 @@ import deeplabcut.pose_estimation_pytorch.config as torch_config import deeplabcut.pose_estimation_pytorch.utils as utils from deeplabcut.core.weight_init import WeightInitialization -from deeplabcut.pose_estimation_pytorch.data import build_transforms, DLCLoader, Loader +from deeplabcut.pose_estimation_pytorch.data import ( + build_transforms, + COCOLoader, + DLCLoader, + Loader, +) from deeplabcut.pose_estimation_pytorch.data.collate import COLLATE_FUNCTIONS from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel +from deeplabcut.pose_estimation_pytorch.modelzoo.memory_replay import ( + prepare_memory_replay, +) from deeplabcut.pose_estimation_pytorch.runners import build_training_runner -from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.pose_estimation_pytorch.runners.logger import ( - LOGGER, destroy_file_logging, + LOGGER, setup_file_logging, ) +from deeplabcut.pose_estimation_pytorch.task import Task def train( @@ -172,6 +181,24 @@ def train_network( trainset_index=trainingsetindex, modelprefix=modelprefix, ) + if weight_init_cfg := loader.model_cfg["train_settings"].get("weight_init"): + weight_init = WeightInitialization.from_dict(weight_init_cfg) + if weight_init.memory_replay: + raise ValueError(f"Memory replay is not yet implemented! Come back soon!") + # dlc_proj_root = Path(config).parent + # superanimal_model_config = prepare_memory_replay( + # dlc_proj_root, + # shuffle, + # superanimal_name, + # model_name, + # device, + # max_individuals=3, + # ) + # loader = COCOLoader( + # project_root=loader.model_folder / "memory-replay", + # model_config_path=loader.model_config_path, + # ) + batch_size = kwargs.pop("batch_size", None) epochs = kwargs.pop("epochs", None) loader.update_model_cfg(kwargs) @@ -200,7 +227,9 @@ def train_network( detector_run_config = loader.model_cfg["detector"] detector_run_config["device"] = loader.model_cfg["device"] - detector_run_config["train_settings"]["weight_init"] = loader.model_cfg["train_settings"].get("weight_init") + detector_run_config["train_settings"]["weight_init"] = loader.model_cfg[ + "train_settings" + ].get("weight_init") train( loader=loader, run_config=detector_run_config, diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index e41707343b..4eca4d8f41 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import logging from pathlib import Path from typing import Callable @@ -47,6 +48,51 @@ from deeplabcut.utils import auxiliaryfunctions, auxfun_videos +def parse_snapshot_index_for_analysis( + cfg: dict, + model_cfg: dict, + snapshot_index: int | str | None, + detector_snapshot_index: int | str | None +) -> tuple[int, int | None]: + """Gets the index of the snapshots to use for data analysis (e.g. video analysis) + + Args: + cfg: The project configuration. + model_cfg: The model configuration. + snapshot_index: The index of the snapshot to use, if one was given by the user. + detector_snapshot_index: The index of the detector snapshot to use, if one + was given by the user. + + Returns: + snapshot_index: the snapshot index to use for analysis + detector_snapshot_index: the detector index to use for analysis, or None if no + detector should be used + """ + if snapshot_index is None: + snapshot_index = cfg["snapshotindex"] + if snapshot_index == "all": + logging.warning( + "snapshotindex is set to 'all' (in the config.yaml file or as given to " + "`analyze_...`). Running data analysis with all snapshots is very " + "costly! Use the function 'evaluate_network' to choose the best the " + "snapshot. For now, changing snapshot index to -1. To evaluate another " + "snapshot, you can change the value in the config file or call " + "`analyze_videos` or `analyze_images` with your desired snapshot index." + ) + snapshot_index = -1 + + pose_task = Task(model_cfg["method"]) + if pose_task == Task.TOP_DOWN: + if detector_snapshot_index is None: + detector_snapshot_index = cfg.get("detector_snapshotindex", -1) + if detector_snapshot_index is None or detector_snapshot_index == "all": + detector_snapshot_index = -1 + else: + detector_snapshot_index = None + + return snapshot_index, detector_snapshot_index + + def return_train_network_path( config: str, shuffle: int = 1, diff --git a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml index db5f475ad7..21698fe251 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml @@ -19,10 +19,13 @@ detector: to_square: false hflip: true normalize_images: true + device: auto model: type: FasterRCNN - variant: fasterrcnn_mobilenet_v3_large_fpn + freeze_bn_stats: false + freeze_bn_weights: false pretrained: true + variant: fasterrcnn_mobilenet_v3_large_fpn runner: type: DetectorTrainingRunner eval_interval: 1 diff --git a/deeplabcut/pose_estimation_pytorch/data/__init__.py b/deeplabcut/pose_estimation_pytorch/data/__init__.py index 7ded1f256c..9d3875347f 100644 --- a/deeplabcut/pose_estimation_pytorch/data/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/data/__init__.py @@ -11,5 +11,8 @@ from deeplabcut.pose_estimation_pytorch.data.base import Loader from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader -from deeplabcut.pose_estimation_pytorch.data.dataset import Dataset +from deeplabcut.pose_estimation_pytorch.data.dataset import ( + PoseDatasetParameters, + PoseDataset, +) from deeplabcut.pose_estimation_pytorch.data.transforms import build_transforms diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index af193b7cc1..24eac763ac 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -132,9 +132,7 @@ def ground_truth_keypoints( annotations[i]["individual"]: annotations[i]["keypoints"] for i in img_to_ann_map[image["id"]] } - gt_array = np.empty((len(individuals), num_bodyparts, 3)) - gt_array.fill(np.nan) - + gt_array = np.zeros((len(individuals), num_bodyparts, 3)) # Keep the shape of the ground truth for idv_idx, idv in enumerate(individuals): if idv in individual_keypoints: @@ -168,9 +166,11 @@ def ground_truth_bboxes(self, mode: str = "train") -> dict[str, np.ndarray]: image_path = image["file_name"] img_shape = image["height"], image["width"], 3 bboxes = [annotations[i]["bbox"] for i in img_to_ann_map[image["id"]]] - ground_truth_dict[image_path] = _compute_crop_bounds( - np.stack(bboxes, axis=0), img_shape - ) + if len(bboxes) == 0: + bboxes = np.zeros((0, 4)) + else: + bboxes = _compute_crop_bounds(np.stack(bboxes, axis=0), img_shape) + ground_truth_dict[image_path] = bboxes return ground_truth_dict diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py index 37debc0b75..2fd91d374b 100644 --- a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py @@ -243,11 +243,28 @@ def load_data(self, mode: str = "train") -> dict: data = COCOLoader.validate_categories(data) data = COCOLoader.validate_images(self.project_root, data) + annotations_per_image = {} for annotation in data["annotations"]: annotation["keypoints"] = np.array(annotation["keypoints"], dtype=float) annotation["bbox"] = np.array(annotation["bbox"], dtype=float) - annotation["individual"] = "unknown" + # set individual index + image_id = annotation["image_id"] + individual_idx = annotations_per_image.get(image_id, 0) + annotation["individual"] = f"individual{individual_idx}" + annotations_per_image[image_id] = individual_idx + 1 + + filter_annotations = [] + for annotation in data['annotations']: + keypoints = annotation['keypoints'] + bbox = annotation['bbox'] + if np.all(keypoints <= 0) or len(bbox) == 0: + continue + filter_annotations.append(annotation) + + data["annotations"] = filter_annotations + + # FIXME: why estimating bbox when there are already bbox? annotations_with_bbox = self._compute_bboxes( data["images"], data["annotations"], @@ -271,8 +288,17 @@ def get_project_parameters(train_json: dict) -> tuple[int, list[str]]: """ # TODO: Check that there's a single category bodyparts = train_json["categories"][0]["keypoints"] + img_to_annotations = map_id_to_annotations(train_json["annotations"]) - num_individuals = max(*[len(a_ids) for a_ids in img_to_annotations.values()]) + if len(img_to_annotations) == 0: + raise ValueError(f"No images found in the dataset: {train_json}!") + elif len(img_to_annotations) == 1: + num_individuals = len(list(img_to_annotations.values())[0]) + else: + num_individuals = max( + *[len(a_ids) for a_ids in img_to_annotations.values()] + ) + return num_individuals, bodyparts def predictions_to_coco( diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index 31fedb658c..de70ce375d 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -73,8 +73,10 @@ class PoseDataset(Dataset): task: Task = Task.BOTTOM_UP def __post_init__(self): + self.image_path_id_map = map_image_path_to_id(self.images) self.annotation_idx_map = map_id_to_annotations(self.annotations) + self.img_id_to_index = { img["id"]: index for index, img in enumerate(self.images) } @@ -102,11 +104,14 @@ def _get_raw_item(self, index: int) -> tuple[str, list[dict], int]: Otherwise, it returns the image path and a list of annotations for all instances in the image. """ img = self.images[index] + anns = [self.annotations[idx] for idx in self.annotation_idx_map[img["id"]]] + return img["file_name"], anns, img["id"] def _get_raw_item_crop(self, index: int) -> tuple[str, list[dict], int]: ann = self.annotations[index] + img = self.images[self.img_id_to_index[ann["image_id"]]] return img["file_name"], [ann], img["id"] @@ -137,6 +142,7 @@ def __getitem__(self, index: int) -> dict: } """ image_path, anns, image_id = self._get_data_based_on_task(index) + image, original_size = self._load_image(image_path) ( keypoints, @@ -145,6 +151,8 @@ def __getitem__(self, index: int) -> dict: annotations_merged, ) = self.extract_keypoints_and_bboxes(anns, image.shape) + # this is applying data augmentations before the cropping + # though normalization should be applied after the cropping transformed = self.apply_transform_all_keypoints( image, keypoints, keypoints_unique, bboxes ) @@ -152,10 +160,11 @@ def __getitem__(self, index: int) -> dict: keypoints = transformed["keypoints"] keypoints_unique = transformed["keypoints_unique"] bboxes = transformed["bboxes"] - offsets = (0, 0) scales = (1, 1) + if self.task == Task.TOP_DOWN: + if self.parameters.cropped_image_size is None: raise ValueError( "You must specify a cropped image size for top-down models" @@ -168,6 +177,7 @@ def __getitem__(self, index: int) -> dict: bboxes = bboxes.astype(int) # TODO: The following code should be replaced by a numpy version + image, offsets, scales = _crop_and_pad_image_torch( image, bboxes[0], "xywh", self.parameters.cropped_image_size[0] ) @@ -235,12 +245,15 @@ def _prepare_final_annotation_dict( bbox_widths = np.maximum(1, bboxes[..., 2]) bbox_heights = np.maximum(1, bboxes[..., 3]) area = bbox_widths * bbox_heights - if 'individual_id' not in anns: - anns['individual_id'] = -np.ones(len(anns['category_id']), dtype=int) + if "individual_id" not in anns: + anns["individual_id"] = -np.ones(len(anns["category_id"]), dtype=int) + # we use ..., :3 to pass the visibility flag along return { - "keypoints": pad_to_length(keypoints[..., :2], num_animals, -1).astype(np.single), - "keypoints_unique": keypoints_unique[..., :2].astype(np.single), + "keypoints": pad_to_length(keypoints[..., :3], num_animals, -1).astype( + np.single + ), + "keypoints_unique": keypoints_unique[..., :3].astype(np.single), "with_center_keypoints": self.parameters.with_center_keypoints, "area": pad_to_length(area, num_animals, 0).astype(np.single), "boxes": pad_to_length(bboxes, num_animals, 0).astype(np.single), diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index d663f0449b..eca6f87723 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -201,7 +201,9 @@ def load_ground_truth( splits = self.load_split(self._project_config, trainset_index, shuffle) dfs = self.split_data(df, splits) dfs["full"] = df - return _validate_dataframes(dfs, df_train), image_sizes + + dfs = _validate_dataframes(dfs, df_train) + return dfs, image_sizes @staticmethod def load_split( diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 7bda145a3a..b401847931 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -49,6 +49,11 @@ def bbox_from_keypoints( the bounding boxes for the keypoints, of shape (..., 4) in the xywh format """ squeeze = False + + # we do not estimate bbox on keypoints that have 0 or -1 flag + mask = keypoints[..., -1] > 0 + keypoints = keypoints[mask] + if len(keypoints.shape) == 2: squeeze = True keypoints = np.expand_dims(keypoints, axis=0) @@ -453,22 +458,10 @@ def _annotation_to_keypoints(annotation: dict, h: int, w: int) -> np.array: Returns: keypoints: np.array where the first two columns are x and y coordinates of the - keypoints and the third column is the visibility of the keypoints - """ - keypoints = annotation["keypoints"].reshape(-1, 3) - visibility_mask = np.logical_and( - np.logical_and( - 0 < keypoints[..., 0], - keypoints[..., 0] < w, - ), - np.logical_and( - 0 < keypoints[..., 1], - keypoints[..., 1] < h, - ), - ) - keypoints[~visibility_mask] = 0 - keypoints[:, 2] = 2 * visibility_mask - return keypoints + + """ + # we don't mess up visibility flags here + return annotation["keypoints"].reshape(-1, 3) def apply_transform( @@ -495,19 +488,23 @@ def apply_transform( """ if transform: - defined_keypoint_mask = _check_keypoints_within_bounds(keypoints, image.shape) + oob_mask = _out_of_bounds_keypoints(keypoints, image.shape) transformed = _apply_transform( transform, image, keypoints, bboxes, class_labels ) + transformed["keypoints"] = np.array(transformed["keypoints"]) - transformed["keypoints"][~defined_keypoint_mask] = -1 - shape_transformed = transformed["image"].shape + # out-of-bound keypoints have visibility flag 0. But we don't touch coordinates + if np.sum(oob_mask) > 0: + transformed["keypoints"][oob_mask][..., -1] = 0 + + out_shape = transformed["image"].shape if len(transformed["keypoints"]) > 0: - mask_valid = _check_keypoints_within_bounds( - transformed["keypoints"], shape_transformed - ) - transformed["keypoints"][~mask_valid] = -1 + oob_mask = _out_of_bounds_keypoints(transformed["keypoints"], out_shape) + # out-of-bound keypoints have visibility flag 0. Don't touch coordinates + if np.sum(oob_mask) > 0: + transformed["keypoints"][oob_mask][..., -1] = 0 # TODO: Check that the transformed bboxes are still within the image if len(transformed["bboxes"]) > 0: @@ -517,8 +514,9 @@ def apply_transform( else: transformed = {"keypoints": keypoints, "image": image} - np.nan_to_num(transformed["keypoints"], copy=False, nan=-1) + # do we ever need to do this if we had check_keypoints_within_bounds above? + # np.nan_to_num(transformed["keypoints"], copy=False, nan=-1) return transformed @@ -552,9 +550,11 @@ def _apply_transform( bboxes=bboxes, bbox_labels=np.arange(len(bboxes)), ) - transformed = _set_invalid_keypoints_to_neg_one( - transformed, keypoints, class_labels - ) + + # why are we repeatedly doing this? + # transformed = _set_invalid_keypoints_to_neg_one( + # transformed, keypoints, class_labels + # ) bboxes_out = np.zeros(bboxes.shape) for bbox, bbox_id in zip(transformed["bboxes"], transformed["bbox_labels"]): @@ -589,25 +589,25 @@ def _set_invalid_keypoints_to_neg_one( return transformed -def _check_keypoints_within_bounds(keypoints: np.ndarray, shape: tuple) -> np.ndarray: - """ - Check if each keypoint in an array of keypoints is within given bounds. - Parameters: - - keypoints (np.ndarray): A (N, 2) shaped array where N is the number of keypoints - and each keypoint is represented as [x, y] or [width, height]. - - shape (tuple): A tuple representing the shape or bounds as (height, width). +def _out_of_bounds_keypoints(keypoints: np.ndarray, shape: tuple) -> np.ndarray: + """Computes which visible keypoints are outside an image + + Args: + keypoints: A (N, 3) shaped array where N is the number of keypoints and each + keypoint is represented as (x, y, visibility). + shape: A tuple representing the shape or bounds as (height, width). + Returns: - - np.ndarray: A boolean array of shape (N,) where each element corresponds to whether - the respective keypoint is within the bounds. `True` indicates the keypoint - is within bounds, while `False` indicates it's outside. - Example: - >>> are_keypoints_within_bounds(np.array([[5, 5], [15, 15]]), (10, 10)) - array([ True, False]) - """ - return np.all( - (keypoints[..., :2] > 0) - & (keypoints[..., :2] < np.array([shape[1], shape[0]])), - axis=1, + A boolean array of shape (N,) where each element corresponds to whether + the respective keypoint is visible (visibility > 0) and outside the image + bounds. This mask can be used to set the visibility bit to 0 for keypoints that + were kicked off an image due to augmentation. + """ + return (keypoints[..., 2] > 0) & ( + (keypoints[..., 0] < 0) + | (keypoints[..., 0] > shape[1]) + | (keypoints[..., 1] < 0) + | (keypoints[..., 1] > shape[0]) ) diff --git a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py index f91fd13819..0eab2f14ee 100644 --- a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py @@ -31,6 +31,7 @@ def get_scores( unique_bodypart_poses: dict[str, np.ndarray] | None = None, unique_bodypart_gt: dict[str, np.ndarray] | None = None, pcutoff: float = -1, + bbox_margin: int = 0, ) -> dict[str, float]: """Computes for the different scores given the ground truth and the predictions. @@ -51,6 +52,8 @@ def get_scores( pcutoff: the pcutoff used to use unique_bodypart_poses: the predicted poses for unique bodyparts unique_bodypart_gt: the ground truth for unique bodyparts + bbox_margin: the margin used to create bounding boxes from keypoints to compute + keypoint mAP. Returns: a dictionary of scores containign the following keys @@ -110,8 +113,8 @@ def get_scores( pred_poses[pred_poses == -1] = np.nan rmse, rmse_pcutoff = compute_rmse(pred_poses, gt_poses, pcutoff=pcutoff) - oks = compute_oks(poses, ground_truth, pcutoff=None) - oks_pcutoff = compute_oks(poses, ground_truth, pcutoff=pcutoff) + oks = compute_oks(poses, ground_truth, margin=bbox_margin, pcutoff=None) + oks_pcutoff = compute_oks(poses, ground_truth, margin=bbox_margin, pcutoff=pcutoff) return { "rmse": rmse, @@ -379,9 +382,7 @@ def pair_predicted_individuals_with_gt( """ matched_poses = {} for image, pose in predictions.items(): - gt_pose = mask_invisible(ground_truth[image], mask_value=-1) - gt_pose = np.nan_to_num(gt_pose, nan=-1) - match_individuals = rmse_match_prediction_to_gt(pose, gt_pose) + match_individuals = rmse_match_prediction_to_gt(pose, ground_truth[image]) matched_poses[image] = pose[match_individuals] return matched_poses @@ -403,7 +404,6 @@ def mask_invisible( as invisible replaced with the mask value """ keypoints = keypoints.copy() - visibility = keypoints[..., 2] == 0 - keypoints[visibility, 0] = mask_value - keypoints[visibility, 1] = mask_value + not_visible = keypoints[..., 2] <= 0 + keypoints[not_visible, :2] = mask_value return keypoints[..., :2] diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py index 57186c87ce..eb46e8ade8 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import numpy as np import torch import torch.nn as nn import torch.nn.functional as F @@ -45,11 +46,14 @@ def forward( Returns: the weighted loss """ + # shape of loss: (batch_size, n_kpts, heatmap_size, heatmap_size) loss = self.criterion(output, target) + loss *= weights n_elems = utils.count_nonzero_elems(loss, weights) if n_elems == 0: return torch.tensor(0.0, device=output.device) - return torch.sum(loss * weights) / n_elems + + return torch.mean(loss) @CRITERIONS.register_module @@ -66,6 +70,31 @@ class WeightedMSECriterion(WeightedCriterion): def __init__(self) -> None: super().__init__(nn.MSELoss(reduction="none")) + def forward( + self, + output: torch.Tensor, + target: torch.Tensor, + weights: torch.Tensor | float = 1.0, + **kwargs, + ) -> torch.Tensor: + """ + Args: + output: predicted tensor + target: target tensor + weights: weights for each element in the loss calculation. If a float, + weights all elements by that value. Defaults to 1. + + Returns: + the weighted loss + """ + # shape of loss: (batch_size, n_kpts, h, w) + loss = self.criterion(output, target) + loss *= weights + n_elems = utils.count_nonzero_elems(loss, weights) + if n_elems == 0: + return torch.tensor(0.0, device=output.device) + return torch.mean(loss) + @CRITERIONS.register_module class WeightedHuberCriterion(WeightedCriterion): diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index 302bad01ad..17f0531e57 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -59,8 +59,15 @@ class BaseDetector(ABC, nn.Module): This is an abstract class defining the common structure and inference for detectors. """ - def __init__(self, pretrained: bool = False) -> None: + def __init__( + self, + freeze_bn_stats: bool = False, + freeze_bn_weights: bool = False, + pretrained: bool = False, + ) -> None: super().__init__() + self.freeze_bn_stats = freeze_bn_stats + self.freeze_bn_weights = freeze_bn_weights self._pretrained = pretrained @abstractmethod @@ -92,3 +99,30 @@ def get_target(self, labels: dict) -> list[dict]: list of dictionaries, each representing target information for a single annotation. """ pass + + def freeze_batch_norm_layers(self) -> None: + """Freezes batch norm layers + + Running mean + var are always given to F.batch_norm, except when the layer is + in `train` mode and track_running_stats is False, see + https://pytorch.org/docs/stable/_modules/torch/nn/modules/batchnorm.html + So to 'freeze' the running stats, the only way is to set the layer to "eval" + mode. + """ + for module in self.modules(): + if isinstance(module, nn.modules.batchnorm._BatchNorm): + if self.freeze_bn_weights: + module.weight.requires_grad = False + module.bias.requires_grad = False + if self.freeze_bn_stats: + module.eval() + + def train(self, mode: bool = True) -> None: + """Sets the module in training or evaluation mode. + + Args: + mode: whether to set training mode (True) or evaluation mode (False) + """ + super().train(mode) + if self.freeze_bn_weights or self.freeze_bn_stats: + self.freeze_batch_norm_layers() diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index 3e2734384d..02ccdf3f79 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -12,6 +12,7 @@ import torch import torchvision.models.detection as detection +from torch import nn from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( DETECTORS, @@ -54,6 +55,8 @@ class FasterRCNN(BaseDetector): def __init__( self, + freeze_bn_stats: bool = False, + freeze_bn_weights: bool = False, variant: str = "fasterrcnn_mobilenet_v3_large_fpn", pretrained: bool = True, box_score_thresh: float = 0.01, @@ -64,7 +67,11 @@ def __init__( "https://pytorch.org/vision/stable/models.html#object-detection" ) - super().__init__(pretrained=pretrained) + super().__init__( + freeze_bn_stats=freeze_bn_stats, + freeze_bn_weights=freeze_bn_weights, + pretrained=pretrained, + ) model_fn = getattr(detection, variant) weights = None if self._pretrained: diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 245f5a1642..1997f17ae2 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -24,13 +24,36 @@ ) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator +from deeplabcut.pose_estimation_pytorch.models.weight_init import ( + BaseWeightInitializer, + WEIGHT_INIT, +) @HEADS.register_module class HeatmapHead(WeightConversionMixin, BaseHead): - """ - Deconvolutional head to predict maps from the extracted features. - This class implements a simple deconvolutional head to predict maps from the extracted features. + """Deconvolutional head to predict maps from the extracted features. + + This class implements a simple deconvolutional head to predict maps from the + extracted features. + + Args: + predictor: The predictor used to transform heatmaps into keypoints. + target_generator: The module to generate target heatmaps from keypoints. + criterion: The loss criterion(s) for the head. + aggregator: The loss aggregator to use, if multiple criterions are used. + heatmap_config: The configuration for the heatmap outputs of the head. + locref_config: The configuration for the location refinement outputs (None if + no location refinement should be used). + weight_init: The way to initialize weights for the head. If None, default + PyTorch initialization is used. Otherwise, a BaseWeightInitializer can be + given (or a configuration for a BaseWeightInitializer). To initialize + the weights with a normal distribution, you could pass + ``weight_init="normal"`` (which initializes weights using a Normal + distribution 0.001 and biases with 0), or you could pass ``weight_init={ + type="normal", std=0.01}`` to change the standard deviation used. All + BaseWeightInitializers are defined in deeplabcut/pose_estimation_pytorch/ + models/weight_init.py. """ def __init__( @@ -41,6 +64,7 @@ def __init__( aggregator: BaseLossAggregator | None, heatmap_config: dict, locref_config: dict | None = None, + weight_init: str | dict | BaseWeightInitializer | None = None, ) -> None: heatmap_head = DeconvModule(**heatmap_config) locref_head = None @@ -62,6 +86,15 @@ def __init__( self.heatmap_head = heatmap_head self.locref_head = locref_head + if weight_init is not None: + if isinstance(weight_init, (str, dict)): + weight_init = WEIGHT_INIT.build(weight_init) + elif not isinstance(weight_init, BaseWeightInitializer): + raise ValueError( + f"Could not parse ``weight_init`` parameter: {weight_init}." + ) + weight_init.init_weights(self) + def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: outputs = {"heatmap": self.heatmap_head(x)} if self.locref_head is not None: diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index f06e8b1676..2444260422 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -208,17 +208,20 @@ def build( # load head state dicts if weight_init.with_decoder: - heads_state_dict = filter_state_dict(state_dict, "heads") + all_head_state_dicts = filter_state_dict(state_dict, "heads") conversion_tensor = torch.from_numpy(weight_init.conversion_array) for name, head in model.heads.items(): + head_state_dict = filter_state_dict(all_head_state_dicts, name) + # requires WeightConversionMixin - head.load_state_dict( - head.convert_weights( - state_dict=filter_state_dict(heads_state_dict, name), + if not weight_init.memory_replay: + head_state_dict = head.convert_weights( + state_dict=head_state_dict, module_prefix="", conversion=conversion_tensor, ) - ) + + head.load_state_dict(head_state_dict) return model diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index c0174ae986..5027f02074 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -115,11 +115,9 @@ def get_top_values( """ batchsize, ny, nx, num_joints = heatmap.shape heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints) - heatmap_top = torch.argmax(heatmap_flat, dim=1) - - Y, X = heatmap_top // nx, heatmap_top % nx - return Y, X + y, x = heatmap_top // nx, heatmap_top % nx + return y, x def get_pose_prediction( self, heatmap: torch.Tensor, locref: torch.Tensor | None, scale_factors @@ -142,6 +140,7 @@ def get_pose_prediction( >>> poses = predictor.get_pose_prediction(heatmap, locref, scale_factors) """ Y, X = self.get_top_values(heatmap) + batch_size, num_joints = X.shape DZ = torch.zeros((batch_size, 1, num_joints, 3)).to(X.device) @@ -155,11 +154,9 @@ def get_pose_prediction( X = X * scale_factors[1] + 0.5 * scale_factors[1] + DZ[:, :, :, 0] Y = Y * scale_factors[0] + 0.5 * scale_factors[0] + DZ[:, :, :, 1] - # P = DZ[:, :, 2] pose = torch.empty((batch_size, 1, num_joints, 3)) pose[:, :, :, 0] = X pose[:, :, :, 1] = Y pose[:, :, :, 2] = DZ[:, :, :, 2] - return pose diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py index b8d2d68876..5a21e49185 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py @@ -112,12 +112,18 @@ def forward( ct_x_sm = (ct_x - stride_x / 2) / stride_x ct_y_sm = (ct_y - stride_y / 2) / stride_y for idx, pt in enumerate(p): + if pt[-1] == -1: + # full gradient masking + heatmap_weights[b, idx] = 0.0 + continue + elif pt[-1] <= 0: + continue + if idx == idx_center: sigma = ct_sgm else: sigma = sgm - if np.any(pt <= 0.0): - continue + x, y = pt[0], pt[1] x_sm, y_sm = ( (x - stride_x / 2) / stride_x, diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py index 8c6a0e4dc5..6ee40f0cd2 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py @@ -144,6 +144,9 @@ def forward( map_size = batch_size, height, width heatmap = np.zeros((*map_size, self.num_heatmaps), dtype=np.float32) + # coords shape: (batch_size, n_keypoints, 1, 2) + weights = np.ones((batch_size, coords.shape[1], height, width)) + locref_map, locref_mask = None, None if self.generate_locref: locref_map = np.zeros((*map_size, self.num_heatmaps * 2), dtype=np.float32) @@ -153,28 +156,38 @@ def forward( grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2 grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2 + # heatmap (batch_size, height, width, num_kpts) + # coords (batch_size, num_kpts, num_individuals, 3) for b in range(batch_size): for heatmap_idx, group_keypoints in enumerate(coords[b]): for keypoint in group_keypoints: - keypoint = keypoint.copy()[::-1] - if np.any(keypoint <= 0.0): - continue - - self.update( - heatmap=heatmap[b, :, :, heatmap_idx], - grid=grid, - keypoint=keypoint, - locref_map=self.get_locref(locref_map, b, heatmap_idx), - locref_mask=self.get_locref(locref_mask, b, heatmap_idx), - ) - + # FIXME: Gradient masking weights should be parameters + if keypoint[-1] == 0: + # full gradient masking + weights[b, heatmap_idx] = 0.0 + elif keypoint[-1] == -1: + # full gradient masking + weights[b, heatmap_idx] = 0.0 + elif keypoint[-1] > 0: + # keypoint visible + self.update( + heatmap=heatmap[b, :, :, heatmap_idx], + grid=grid, + keypoint=keypoint[..., :2], + locref_map=self.get_locref(locref_map, b, heatmap_idx), + locref_mask=self.get_locref(locref_mask, b, heatmap_idx), + ) + + hm_device = outputs["heatmap"].device heatmap = heatmap.transpose((0, 3, 1, 2)) target = { "heatmap": { - "target": torch.tensor(heatmap, device=outputs["heatmap"].device) + "target": torch.tensor(heatmap, device=hm_device), + "weights": torch.tensor(weights.astype(float), device=hm_device) } } + # we don't handle masking for locref if self.generate_locref: locref_map = locref_map.transpose((0, 3, 1, 2)) locref_mask = locref_mask.transpose((0, 3, 1, 2)) @@ -244,6 +257,9 @@ def update( locref_mask: np.ndarray | None, ) -> None: """Updates the heatmap (and locref if defined) with gaussian values""" + # revert keypoints to follow image convention: from x,y to y,x + keypoint = keypoint.copy()[::-1] + dist = np.linalg.norm(grid - keypoint, axis=2) ** 2 heatmap_j = np.exp(-dist / (2 * self.std ** 2)) heatmap[:, :] = np.maximum(heatmap, heatmap_j) @@ -271,6 +287,8 @@ def update( locref_mask: np.ndarray | None, ) -> None: """Updates the heatmap (and locref if defined) with plateau values""" + # revert keypoints to follow image convention: from x,y to y,x + keypoint = keypoint.copy()[::-1] dist = np.sum((grid - keypoint) ** 2, axis=2) mask = dist <= self.dist_thresh_sq heatmap[mask] = 1 diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py index 12417ebf1a..9610a56c54 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py @@ -68,7 +68,7 @@ def forward( for b in range(batch_size): for _, kpts_animal in enumerate(coords[b]): - visible = set(np.flatnonzero(np.any(kpts_animal > 0.0, axis=1))) + visible = set(np.flatnonzero(kpts_animal[..., -1])) for l, (bp1, bp2) in enumerate(self.graph): if not (bp1 in visible and bp2 in visible): continue diff --git a/deeplabcut/pose_estimation_pytorch/models/weight_init.py b/deeplabcut/pose_estimation_pytorch/models/weight_init.py new file mode 100644 index 0000000000..32a82b9484 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/weight_init.py @@ -0,0 +1,59 @@ +"""Ways to initialize weights for PyTorch modules""" +from __future__ import annotations + +from abc import ABC, abstractmethod + +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry + + +def _build_weight_init(cfg: str | dict, **kwargs) -> BaseWeightInitializer: + """Builds a BaseWeightInitializer using its config or the name of the initializer + + Args: + cfg: Either the name of the initializer (e.g. 'normal') or the config + **kwargs: Other parameters given by the Registry. + + Returns: + the built BaseWeightInitializer + """ + if isinstance(cfg, str): + cfg = {"type": cfg.title().replace("_", "")} + return build_from_cfg(cfg, **kwargs) + + +WEIGHT_INIT = Registry("weight_init", build_func=_build_weight_init) + + +class BaseWeightInitializer(ABC): + """Class to used to initialize model weights""" + + @abstractmethod + def init_weights(self, model: nn.Module) -> None: + """Initializes weights for a model. + + Args: + model: The model for which to initialize weights + """ + + +@WEIGHT_INIT.register_module +class Normal(BaseWeightInitializer): + """Class to used to initialize model weights using a normal distribution + + Weights are initialized with a normal distribution, and biases are initialized to 0. + + Attributes: + std: the standard deviation to use to initialize weights + """ + + def __init__(self, std: float = 0.001): + self.std = std + + def init_weights(self, model: nn.Module) -> None: + for name, module in model.named_parameters(): + if 'bias' in name: + nn.init.constant_(module, 0) + else: + nn.init.normal_(module, std=self.std) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/config.py b/deeplabcut/pose_estimation_pytorch/modelzoo/config.py index 7aa16ecd59..47389bbb25 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/config.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/config.py @@ -15,7 +15,10 @@ import deeplabcut.pose_estimation_pytorch.modelzoo.utils as modelzoo_utils import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.core.weight_init import WeightInitialization - +from pathlib import Path +from deeplabcut.core.engine import Engine +from ruamel.yaml import YAML +import os def make_super_animal_finetune_config( weight_init: WeightInitialization, @@ -35,34 +38,46 @@ def make_super_animal_finetune_config( Returns: The generated pose configuration file. + + Raises: + ValueError: If `weight_init.with_decoder = False`. This method only creates + configs to fine-tune SuperAnimal models. Call `make_pytorch_pose_config` + to create configuration files for transfer learning. """ bodyparts = af.get_bodyparts(project_config) - if weight_init is not None and weight_init.with_decoder: - converted_bodyparts = bodyparts - if weight_init.bodyparts is not None: - assert len(weight_init.bodyparts) == len(weight_init.conversion_array) - converted_bodyparts = weight_init.bodyparts - elif len(bodyparts) != len(weight_init.conversion_array): - raise ValueError( - "You don't have the same number of bodyparts in your project config as " - f"number of entries your conversion array ({bodyparts} vs " - f"{weight_init.conversion_array}). If you're fine-tuning from " - "SuperAnimal on a subset of your bodyparts, you must specify which " - "ones in `WeightInitialization.bodyparts`. This should be done " - "automatically when creating the `weight_init` with " - "`WeightInitialization.build`." - ) - - # Load the exact pose configuration file for the model to fine-tune - return create_config_from_modelzoo( - net_type=net_type, - super_animal=weight_init.dataset, - converted_bodyparts=converted_bodyparts, - weight_init=weight_init, - project_config=project_config, - pose_config_path=pose_config_path, + if not weight_init.with_decoder: + raise ValueError( + "Can call ``make_super_animal_finetune_config`` when `with_decoder=True`, " + f" but you had {weight_init}. Please set `with_decoder=True` to fine-tune " + "a model or call `make_pytorch_pose_config` to create a transfer learning " + "pose configuration file." + ) + + converted_bodyparts = bodyparts + if weight_init.bodyparts is not None: + assert len(weight_init.bodyparts) == len(weight_init.conversion_array) + converted_bodyparts = weight_init.bodyparts + elif len(bodyparts) != len(weight_init.conversion_array): + raise ValueError( + "You don't have the same number of bodyparts in your project config as " + f"number of entries your conversion array ({bodyparts} vs " + f"{weight_init.conversion_array}). If you're fine-tuning from " + "SuperAnimal on a subset of your bodyparts, you must specify which " + "ones in `WeightInitialization.bodyparts`. This should be done " + "automatically when creating the `weight_init` with " + "`WeightInitialization.build`." ) + # Load the exact pose configuration file for the model to fine-tune + return create_config_from_modelzoo( + net_type=net_type, + super_animal=weight_init.dataset, + converted_bodyparts=converted_bodyparts, + weight_init=weight_init, + project_config=project_config, + pose_config_path=pose_config_path, + ) + def create_config_from_modelzoo( net_type: str, @@ -90,6 +105,11 @@ def create_config_from_modelzoo( project_name=super_animal, pose_model_type=modelzoo_utils.get_pose_model_type(net_type), ) + + # use SuperAnimal bodyparts + if weight_init.memory_replay: + converted_bodyparts = project_cfg["bodyparts"] + pose_config["net_type"] = net_type pose_config["metadata"] = { "project_path": project_config["project_path"], @@ -107,3 +127,27 @@ def create_config_from_modelzoo( # sort first-level keys to make it prettier return dict(sorted(pose_config.items())) + +def write_pytorch_config_for_memory_replay(config_path, + shuffle, + pytorch_config): + + cfg = af.read_config(config_path) + + trainIndex = 0 + + dlc_proj_root = Path(config_path).parent + + model_folder = dlc_proj_root / af.get_model_folder( + cfg['TrainingFraction'][trainIndex], shuffle, cfg, engine=Engine.PYTORCH) + + + os.makedirs(model_folder / 'train', exist_ok = True) + + out_path = model_folder / 'train' / 'pytorch_config.yaml' + + + + with open(str(out_path), 'w') as f: + yaml = YAML() + yaml.dump(pytorch_config, f) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index 0f9a3d0b01..e6276e5d10 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -8,12 +8,12 @@ # # Licensed under GNU Lesser General Public License v3.0 # +import json import os from pathlib import Path from typing import Optional, Union import numpy as np -import torch from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import ( create_df_from_prediction, @@ -22,14 +22,23 @@ from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( get_config_model_paths, - update_config, raise_warning_if_called_directly, select_device, + update_config, ) from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.utils.make_labeled_video import _create_labeled_video +class NumpyEncoder(json.JSONEncoder): + """Special json encoder for numpy types""" + + def default(self, obj): + if isinstance(obj, np.ndarray): + return obj.tolist() # Convert ndarray to list + return json.JSONEncoder.default(self, obj) + + def construct_bodypart_names(max_individuals, bodyparts): multianimalbodyparts = [] for i in range(max_individuals): @@ -46,6 +55,8 @@ def _video_inference_superanimal( pcutoff: float, device: Optional[str] = None, dest_folder: Optional[str] = None, + customized_pose_checkpoint: Optional[str] = None, + customized_detector_checkpoint: Optional[str] = None, ) -> dict: """ Perform inference on a video using a superanimal model from the model zoo specified by `superanimal_name`. @@ -74,6 +85,9 @@ def _video_inference_superanimal( dest_folder: Destination folder for the results. If not specified, the results are saved in the same folder as the video. Defaults to None. + customized_pose_checkpoint: A customized checkpoint to replace the default superanimal pose checkpoint + customized_detector_checkpoint: A customized checkpoint to replace the default superanimal detector checkpoint + Returns: results: Dictionary with the result pd.DataFrame for each video @@ -86,20 +100,29 @@ def _video_inference_superanimal( model_config, project_config, pose_model_path, - detector_model_path, + detector_path, ) = get_config_model_paths(project_name, model_name) + + if customized_pose_checkpoint is not None: + pose_model_path = customized_pose_checkpoint + if customized_detector_checkpoint is not None: + detector_path = customized_detector_checkpoint + if device is None: device = select_device() config = {**project_config, **model_config} config = update_config(config, max_individuals, device) + individuals = [f"animal{i}" for i in range(max_individuals)] + config["individuals"] = individuals + pose_runner, detector_runner = get_inference_runners( config, snapshot_path=pose_model_path, max_individuals=max_individuals, num_bodyparts=len(config["bodyparts"]), num_unique_bodyparts=0, - detector_path=detector_model_path, + detector_path=detector_path, ) pose_task = Task(config.get("method", "BU")) results = {} @@ -116,6 +139,22 @@ def _video_inference_superanimal( for video_path in video_paths: print(f"Processing video {video_path}") + dlc_scorer = f"{project_name}_{model_name}" + + output_prefix = f"{Path(video_path).stem}_{dlc_scorer}" + output_path = Path(dest_folder) + output_h5 = Path(output_path) / f"{output_prefix}.h5" + + # if there are no customized checkpoints passed, it's before adaptation + if ( + customized_pose_checkpoint is None + and customized_detector_checkpoint is None + ): + output_json = str(output_h5).replace(".h5", "_before_adapt.json") + else: + output_json = str(output_h5).replace(".h5", "_after_adapt.json") + # also output json file so it's easier for video adaptation to handle + predictions, video_metadata = video_inference( video_path, task=pose_task, @@ -131,9 +170,6 @@ def _video_inference_superanimal( config["uniquebodyparts"] = [] config["multianimalbodyparts"] = config["bodyparts"] - dlc_scorer = f"{project_name}_{model_name}" - output_prefix = f"{Path(video_path).stem}_{dlc_scorer}" - output_path = Path(dest_folder) df = create_df_from_prediction( pred_bodyparts=pred_bodyparts, pred_unique_bodyparts=pred_unique_bodyparts, @@ -145,8 +181,16 @@ def _video_inference_superanimal( results[video_path] = df - output_h5 = Path(output_path) / f"{output_prefix}.h5" - output_video = output_path / f"{output_prefix}_labeled.mp4" + with open(output_json, "w") as f: + json.dump(predictions, f, cls=NumpyEncoder) + + if ( + customized_pose_checkpoint is not None + and customized_detector_checkpoint is not None + ): + output_video = output_path / f"{output_prefix}_labeled_after_adapt.mp4" + else: + output_video = output_path / f"{output_prefix}_labeled.mp4" _create_labeled_video( video_path, output_h5, diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py new file mode 100644 index 0000000000..d3ea6798be --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py @@ -0,0 +1,117 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +from pathlib import Path + +import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.core.weight_init import WeightInitialization +from deeplabcut.modelzoo.generalized_data_converter.datasets import ( + COCOPoseDataset, + MaDLCPoseDataset, + MultiSourceDataset, + SingleDLCPoseDataset, +) +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( + get_config_model_paths, + update_config, +) + + +def prepare_memory_replay( + dlc_proj_root: str | Path, + shuffle: int, + superanimal_name: str, + model_name: str, + device: str, + max_individuals=3, + trainingsetindex: int = 0, +): + """TODO: Documentation""" + ( + superanimal_model_config, + project_config, + pose_model_path, + detector_path, + ) = get_config_model_paths(superanimal_name, model_name) + + # in order to fill the num_bodyparts stuff + + if "individuals" in cfg: + temp_dataset = MaDLCPoseDataset(str(dlc_proj_root), "temp_dataset") + else: + temp_dataset = SingleDLCPoseDataset(str(dlc_proj_root), "temp_dataset") + + superanimal_model_config = {**project_config, **superanimal_model_config} + superanimal_model_config = update_config( + superanimal_model_config, max_individuals, device + ) + + dlc_proj_root = Path(dlc_proj_root) + config_path = dlc_proj_root / "config.yaml" + + cfg = af.read_config(config_path) + + trainIndex = 0 + + model_folder = dlc_proj_root / af.get_model_folder( + cfg["TrainingFraction"][trainIndex], shuffle, cfg, engine=Engine.PYTORCH + ) + + memory_replay_folder = model_folder / "memory_replay" + + temp_dataset.materialize(memory_replay_folder, framework="coco") + + original_model_config = af.read_config( + str(model_folder / "train" / "pytorch_config.yaml") + ) + + weight_init_cfg = original_model_config["train_settings"].get("weight_init") + if weight_init_cfg is None: + raise ValueError( + "You can only train models with memory replay when you are fine-tuning a " + "SuperAnimal model. Please look at the documentation to see how to create " + "a training dataset to fine-tune one of the SuperAnimal models." + ) + + weight_init = WeightInitialization.from_dict(weight_init_cfg) + if not weight_init.with_decoder: + raise ValueError( + "You can only train models with memory replay when you are fine-tuning a " + "SuperAnimal model. Please look at the documentation to see how to create " + "a training dataset to fine-tune one of the SuperAnimal models. Ensure " + "that a conversion table is specified for your project and that you select" + "``with_decoder=True`` for your ``WeightInitialization``." + ) + + # keypoint matching removed here + + dataset = COCOPoseDataset(memory_replay_folder, "memory_replay_dataset") + + conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" + + dataset.project_with_conversion_table(str(conversion_table_path)) + dataset.materialize(memory_replay_folder, deepcopy=False, framework="coco") + + """ + # but we can copy all training related parameters from the original model config + pose_epochs = original_model_config["train_settings"].get("epochs", 10) + save_epochs = original_model_config["runner"]["snapshots"].get("save_epochs", 10) + batch_size = original_model_config["train_settings"].get("batch_size", 16) + + detector_cfg = original_model_config.get("detector", {}) + detector_train_settings = detector_cfg.get("train_settings", {}) + detector_epochs = detector_train_settings.get("epochs", 1) + detector_batch_size = detector_train_settings.get("batch_size", 4) + detector_save_epochs = ( + detector_cfg.get("runner", {}).get("snapshots", {}).get("save_epochs", 1) + ) + """ diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py b/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py new file mode 100644 index 0000000000..534dadb83d --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py @@ -0,0 +1,100 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""File to train a model on a COCO dataset""" + +from __future__ import annotations + +import copy +from pathlib import Path + +from deeplabcut.pose_estimation_pytorch import COCOLoader, utils +from deeplabcut.pose_estimation_pytorch.apis.train import train +from deeplabcut.pose_estimation_pytorch.runners.logger import setup_file_logging +from deeplabcut.pose_estimation_pytorch.task import Task + + +def adaptation_train( + project_root: str | Path, + model_folder: str | Path, + train_file: str, + test_file: str, + model_config_path: str | Path, + device: str | None, + epochs: int | None, + save_epochs: int | None, + detector_epochs: int | None, + detector_save_epochs: int | None, + snapshot_path: str | None, + detector_path: str | None, + batch_size: int = 8, + detector_batch_size: int = 8, + eval_interval: int = 1, +): + setup_file_logging(Path(model_folder) / "log.txt") + loader = COCOLoader( + project_root=project_root, + model_config_path=model_config_path, + train_json_filename=train_file, + test_json_filename=test_file, + ) + + utils.fix_seeds(loader.model_cfg["train_settings"]["seed"]) + + updates = dict( + detector=dict( + model=dict(freeze_bn_stats=True), + runner=dict(snapshots=dict(max_snapshots=5, save_epochs=1)), + train_settings=dict(batch_size=detector_batch_size, epochs=4), + ), + model=dict(backbone=dict(freeze_bn_stats=True)), + runner=dict(snapshots=dict(max_snapshots=5, save_epochs=1)), + train_settings=dict(batch_size=batch_size, epochs=4, dataloader_workers=12), + ) + + if epochs is not None: + updates["train_settings"]["epochs"] = epochs + if save_epochs is not None: + updates["runner"]["snapshots"]["save_epochs"] = save_epochs + if detector_epochs is not None: + updates["detector"]["train_settings"]["epochs"] = detector_epochs + if detector_save_epochs is not None: + updates["detector"]["runner"]["snapshots"]["save_epochs"] = detector_save_epochs + + if eval_interval is not None: + updates["runner"]["eval_interval"] = eval_interval + + loader.update_model_cfg(updates) + + pose_task = Task(loader.model_cfg["method"]) + if pose_task == Task.TOP_DOWN: + logger_config = None + if loader.model_cfg.get("logger"): + logger_config = copy.deepcopy(loader.model_cfg["logger"]) + logger_config["run_name"] += "-detector" + + if loader.model_cfg["detector"]["train_settings"]["epochs"] > 0: + train( + loader=loader, + run_config=loader.model_cfg["detector"], + task=Task.DETECT, + device=device, + logger_config=logger_config, + snapshot_path=detector_path, + ) + + train( + loader=loader, + run_config=loader.model_cfg, + task=pose_task, + device=device, + logger_config=loader.model_cfg.get("logger"), + snapshot_path=snapshot_path, + ) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py index b3cf3e00ea..4ab1d449ca 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py @@ -16,7 +16,6 @@ import torch from dlclibrary import download_huggingface_model - import deeplabcut.pose_estimation_pytorch.config.utils as config_utils from deeplabcut.pose_estimation_pytorch.config.make_pose_config import add_metadata from deeplabcut.utils import auxiliaryfunctions @@ -154,9 +153,7 @@ def _map_model_keys(state_dict: dict) -> dict: parsed_model_snapshot = { "model": _map_model_keys(snapshot["model_state_dict"]), - "metadata": { - "epoch": snapshot["epoch"], - }, + "metadata": {"epoch": 0}, } torch.save(parsed_model_snapshot, parsed) return parsed @@ -170,3 +167,5 @@ def get_pose_model_type(backbone: str) -> str: return backbone.replace("_", "") raise ValueError(f"Unknown backbone for SuperAnimal Weights") + + diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 930d32653e..9fad9d80a2 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -20,53 +20,77 @@ def rmse_match_prediction_to_gt( pred_kpts: np.ndarray, gt_kpts: np.ndarray ) -> np.ndarray: - """Summary: - Hungarian algorithm predicted individuals to ground truth ones, using root mean squared error (rmse). The function provides a way to - match predicted individuals to ground truth individuals based on the rmse distance between their corresponding - keypoints. This algorithm is used to find the optimal matching, taking into account the potential missing animal. + """ + Hungarian algorithm predicted individuals to ground truth ones, using root mean + squared error (rmse). The function provides a way to match predicted individuals to + ground truth individuals based on the rmse distance between their corresponding + keypoints. This algorithm is used to find the optimal matching, taking into account + the potential missing animal. Raises: ValueError: if `gt_kpts.shape != pred_kpts.shape` Args: - pred_kpts: predicted keypoints for each animal. The shape of the array is (num_animals, num_keypoints, 3): - num_animals: number of animals - num_keypoints: number of keypoints - 3: (x,y,score) coordinates of each keypoint - gt_kpts: ground truth keypoints for each animal. The shape of the array is (num_animals, num_keypoints, 2): - num_animals: number of animals - num_keypoints: number of keypoints - 2: (x,y) coordinates of each keypoint + pred_kpts: shape (num_individuals, num_keypoints, 3), ground truth keypoints for + an image, where the 3 values are (x,y,score) for each keypoint + gt_kpts: shape (num_individuals, num_keypoints, 3), ground truth keypoints for + an image, where the 3 values are (x,y,visibility) for each keypoint Returns: - col_ind (np.array): array of the individuals indices for prediction + col_ind: array of the individuals indices for prediction """ - num_animals, num_keypoints, _ = pred_kpts.shape - if num_keypoints + 1 == gt_kpts.shape[1]: - gt_kpts_without_ctr = gt_kpts[:, :-1, :].copy() - elif num_keypoints == gt_kpts.shape[1]: - gt_kpts_without_ctr = gt_kpts.copy() + num_pred, num_keypoints, _ = pred_kpts.shape + num_idv, num_keypoints_gt, _ = gt_kpts.shape + if num_keypoints + 1 == num_keypoints_gt: + gt_kpts = gt_kpts[:, :-1, :].copy() + elif num_keypoints == num_keypoints_gt: + gt_kpts = gt_kpts.copy() else: raise ValueError("Shape mismatch between ground truth and predictions") - # Computation of the number of annotated animals in the ground truth - num_animals_gt = num_animals - for animal_index in range(num_animals): - if (gt_kpts_without_ctr[animal_index] < 0).all(): - num_animals_gt -= 1 - - distance_matrix = np.zeros((num_animals_gt, num_animals)) - for g in range(num_animals_gt): - for p in range(num_animals): - distance_matrix[g, p] = np.nansum( - (gt_kpts_without_ctr[g] - pred_kpts[p, :, :2]) ** 2 - ) - - _, col_ind = linear_sum_assignment(distance_matrix) - # if animals are missing in the frame, the predictions corresponding to nothing are not shuffled - col_ind = extend_col_ind(col_ind, num_animals) + if num_pred != num_idv: + raise ValueError( + "Must have the same number of GT and predicted individuals, found " + f"pred_kpts={pred_kpts.shape} and gt_kpts={gt_kpts.shape}" + ) + + valid_gt = np.any(gt_kpts[..., 2] > 0, axis=1) + valid_gt_indices = np.nonzero(valid_gt)[0] + if len(valid_gt_indices) == 0: + return np.arange(num_idv) + + valid_pred = np.any(pred_kpts[..., 2] > 0, axis=1) + valid_pred_indices = np.nonzero(valid_pred)[0] + if len(valid_pred_indices) == 0: + return np.arange(num_idv) + + distance_matrix = np.full((len(valid_gt_indices), len(valid_pred_indices)), np.inf) + for g in valid_gt_indices: + gt_idv = gt_kpts[g] + mask = gt_idv[:, 2] > 0 + for p in valid_pred_indices: + pred_idv = pred_kpts[p] + d = (gt_idv[mask, :2] - pred_idv[mask, :2]) ** 2 + distance_matrix[g, p] = np.nanmean(d) + + _, col_ind = linear_sum_assignment(distance_matrix) # len == len(valid_gt_indices) + + gt_idx_to_pred_idx = { + valid_gt_indices[valid_gt_index]: valid_pred_indices[valid_pred_index] + for valid_gt_index, valid_pred_index in enumerate(col_ind) + } + matched_pred = {valid_pred_indices[i] for i in col_ind} + unmatched_pred = [i for i in range(num_idv) if i not in matched_pred] + next_unmatched = 0 + col_ind = [] + for gt_index in range(num_idv): + if gt_index in gt_idx_to_pred_idx: + col_ind.append(gt_idx_to_pred_idx[gt_index]) + else: + col_ind.append(unmatched_pred[next_unmatched]) + next_unmatched += 1 - return col_ind + return np.array(col_ind) def oks_match_prediction_to_gt( diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index b820319454..30c799d506 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -61,8 +61,8 @@ def load_snapshot( the number of epochs the model was trained for """ snapshot = torch.load(snapshot_path, map_location=device) - model.load_state_dict(snapshot["model"]) - if optimizer is not None and "optimizer" in snapshot: + model.load_state_dict(snapshot['model']) + if optimizer is not None and 'optimizer' in snapshot: optimizer.load_state_dict(snapshot["optimizer"]) - return snapshot["metadata"]["epoch"] + return snapshot.get("metadata", {}).get("epoch", 0) diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py index 5aa9e07c45..32cad872ef 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -186,7 +186,6 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] batch_inputs = inputs[i : i + batch_size] batch_inputs = batch_inputs.to(self.device) _, raw_predictions = self.model(batch_inputs) - for b, item in enumerate(raw_predictions): # take the top-k bounding boxes as individuals batch_predictions.append( diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index e3bfb259ea..3ac73ee037 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -146,6 +146,10 @@ def fit( if isinstance(self.logger, ImageLoggerMixin): self.logger.select_images_to_log(train_loader, valid_loader) + # continuing to train a model: either total epochs or extra epochs + if self.starting_epoch > epochs: + epochs = self.starting_epoch + epochs + for e in range(self.starting_epoch + 1, epochs + 1): self.current_epoch = e self._metadata["epoch"] = e @@ -218,7 +222,7 @@ def _epoch( self._metadata["metrics"] = perf_metrics self._epoch_predictions = {} self._epoch_ground_truth = {} - if len(perf_metrics) > 0: + if perf_metrics is not None and len(perf_metrics) > 0: logging.info(f"Epoch {self.current_epoch} performance:") for name, score in perf_metrics.items(): logging.info(f"{name + ':': <20}{score:.3f}") @@ -284,9 +288,8 @@ def step( self.optimizer.zero_grad() inputs = batch["image"] - inputs = inputs.to(self.device) + inputs = inputs.to(self.device).float() outputs = self.model(inputs) - target = self.model.get_target(outputs, batch["annotations"]) losses_dict = self.model.get_loss(outputs, target) if mode == "train": @@ -344,8 +347,7 @@ def _compute_epoch_metrics(self) -> dict[str, float]: for path, img_gt in self._epoch_ground_truth["bodyparts"].items(): for kpt_dict, kpts in [(gt, img_gt), (pred, poses[path])]: if len(kpts) < num_animals: - padded_kpts = np.zeros((num_animals, *kpts.shape[1:])) - padded_kpts.fill(np.nan) + padded_kpts = -np.ones((num_animals, *kpts.shape[1:])) padded_kpts[:len(kpts)] = kpts kpt_dict[path] = padded_kpts else: @@ -380,11 +382,19 @@ def _update_epoch_predictions( for path, gt, pred, scale, offset in zip( paths, gt_keypoints, pred_keypoints, scales, offsets, ): + + # ground_truth now should already have visibility flag ground_truth = gt.detach().cpu().numpy() - vis = 2 * np.all(ground_truth >= 0, axis=-1) - gt_with_vis = np.zeros((*ground_truth.shape[:-1], 3)) - gt_with_vis[..., :2] = ground_truth - gt_with_vis[..., 2] = vis + gt_with_vis = ground_truth + + # FIXME: convert (-1, -2) to 0. Else error is incorrectly calculated + for batch_id in range(len(ground_truth)): + # keypoints (num_kpts, 3) + keypoints = ground_truth[batch_id] + for kpts in keypoints: + vis = kpts[-1] + if vis < 0: + kpts[-1] = 0 # rescale to the full image for TD or CTD gt_with_vis[..., :2] = (gt_with_vis[..., :2] * scale) + offset diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py new file mode 100644 index 0000000000..d94ed82947 --- /dev/null +++ b/deeplabcut/utils/pseudo_label.py @@ -0,0 +1,671 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +import glob +import json +import os +from collections import defaultdict +from pathlib import Path + +import cv2 +import matplotlib.pyplot as plt +import numpy as np +from scipy.optimize import linear_sum_assignment +from scipy.spatial import distance +from scipy.spatial.distance import cdist + +import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.core.engine import Engine +from deeplabcut.modelzoo.generalized_data_converter.datasets import ( + COCOPoseDataset, + MaDLCDataFrame, + MaDLCPoseDataset, + SingleDLCDataFrame, + SingleDLCPoseDataset, +) +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( + get_config_model_paths, + select_device, + update_config, +) + + +def calculate_iou(box1, box2): + # Unpack the coordinates + x1_1, y1_1, x2_1, y2_1 = box1 + x1_2, y1_2, x2_2, y2_2 = box2 + + # Calculate the coordinates of the intersection rectangle + inter_x1 = max(x1_1, x1_2) + inter_y1 = max(y1_1, y1_2) + inter_x2 = min(x2_1, x2_2) + inter_y2 = min(y2_1, y2_2) + + # Calculate the width and height of the intersection rectangle + inter_width = max(0, inter_x2 - inter_x1) + inter_height = max(0, inter_y2 - inter_y1) + + # Calculate the area of the intersection rectangle + inter_area = inter_width * inter_height + + # Calculate the area of each bounding box + area_1 = (x2_1 - x1_1) * (y2_1 - y1_1) + area_2 = (x2_2 - x1_2) * (y2_2 - y1_2) + + # Calculate the area of the union of the two bounding boxes + union_area = area_1 + area_2 - inter_area + + # Calculate the IoU + iou = inter_area / union_area + + return iou + + +def video_to_frames(input_video, output_folder): + # Create the output folder if it doesn't exist + video = cv2.VideoCapture(str(input_video)) + # Get the frames per second (fps) of the video + fps = int(video.get(cv2.CAP_PROP_FPS)) + # Initialize a frame counter + frame_count = 0 + while True: + # Read a frame from the video + ret, frame = video.read() + # Break the loop if we have reached the end of the video + if not ret: + break + # Save the frame as an image file. + frame_str = str(frame_count).zfill(5) + frame_file = os.path.join(output_folder, "images", f"frame_{frame_str}.png") + cv2.imwrite(frame_file, frame) + # Increment the frame counter + frame_count += 1 + # Release the video object and close the window (if open) + video.release() + # cv2.destroyAllWindows() + + +def plot_cost_matrix( + matrix, gt_keypoint_names, pred_keypoint_names, conversion_plot_out_path +): + + matrix /= np.max(matrix) + fig, ax = plt.subplots() + heatmap = ax.pcolor(matrix, cmap=plt.cm.Blues, vmin=0, vmax=1) + ax.set_xticks(np.arange(matrix.shape[1]) + 0.5, minor=False) + ax.set_yticks(np.arange(matrix.shape[0]) + 0.5, minor=False) + ax.set_xlim(0, int(matrix.shape[1])) + ax.set_ylim(0, int(matrix.shape[0])) + ax.set_yticklabels(pred_keypoint_names, minor=False) + ax.set_xticklabels(gt_keypoint_names, minor=False) + ax.set_title("cost matrix") + plt.xticks(rotation=90) + fig = plt.gcf() + fig.tight_layout() + + plt.savefig(conversion_plot_out_path, dpi=300) + + +def keypoint_matching( + config_path, superanimal_name, model_name, device=None, train_file="train.json" +): + + cfg = af.read_config(config_path) + + trainIndex = 0 + + dlc_proj_root = str(Path(config_path).parent) + + if "individuals" in cfg: + temp_dataset = MaDLCDataFrame(dlc_proj_root, "temp_dataset") + max_individuals = len(cfg["individuals"]) + else: + temp_dataset = SingleDLCDataFrame(dlc_proj_root, "temp_dataset") + max_individuals = 1 + + memory_replay_folder = Path(dlc_proj_root) / "memory_replay" + + temp_dataset.materialize(str(memory_replay_folder), framework="coco") + + # inferencing the train set + ( + model_config, + project_config, + pose_model_path, + detector_path, + ) = get_config_model_paths(superanimal_name, model_name) + + if device is None: + device = select_device() + + config = {**project_config, **model_config} + config = update_config(config, max_individuals, device) + + individuals = [f"animal{i}" for i in range(max_individuals)] + config["individuals"] = individuals + num_bodyparts = len(config["bodyparts"]) + train_file_path = os.path.join(memory_replay_folder, "annotations", train_file) + + pose_runner, detector_runner = get_inference_runners( + config, + snapshot_path=pose_model_path, + max_individuals=max_individuals, + num_bodyparts=len(model_config["metadata"]["bodyparts"]), + num_unique_bodyparts=0, + detector_path=detector_path, + ) + + with open(train_file_path, "r") as f: + train_obj = json.load(f) + + images = train_obj["images"] + annotations = train_obj["annotations"] + categories = train_obj["categories"] + imagename2id = {} + imageid2name = {} + imagename2gt = defaultdict(list) + + for image in images: + # this only works with relative path as the testing image can be at a different folder + imagename = image["file_name"].split(os.sep)[-1] + imagename2id[imagename] = image["id"] + imageid2name[image["id"]] = imagename + + imagename2bbox = defaultdict(list) + + for anno in annotations: + imagename = imageid2name[anno["image_id"]] + imagename2gt[imagename].append(anno) + imagename2bbox[imagename].append(anno["bbox"]) + + imageid2annotations = defaultdict(list) + + imageids = list(imagename2id.values()) + for annotation in annotations: + image_id = annotation["image_id"] + if annotation["image_id"] in imageids: + imageid2annotations[image_id].append(annotation) + + # need to support more image types + image_extensions = ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.tiff"] + images_in_folder = [] + for ext in image_extensions: + images_in_folder.extend( + glob.glob(os.path.join(memory_replay_folder, "images", ext)) + ) + + corresponded_images = [] + + for image in images_in_folder: + image_path = image + imagename = image.split(os.sep)[-1] + if imagename in imagename2id: + corresponded_images.append(image_path) + + images = corresponded_images + + bbox_predictions = detector_runner.inference(images=images) + + bbox_gts = [ + {"bboxes": np.array(imagename2bbox[image.split(os.sep)[-1]])} + for image in images + ] + + pose_inputs = list(zip(images, bbox_gts)) + + # pose inference should return meta data for pseudo labeling + predictions = pose_runner.inference(pose_inputs) + + assert len(images) == len(predictions) + + imagename2prediction = {} + + for image_path, prediction in zip(images, predictions): + imagename = image_path.split(os.sep)[-1] + imagename2prediction[imagename] = prediction + + def xywh2xyxy(bbox): + temp_bbox = np.copy(bbox) + temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:] + return temp_bbox + + def optimal_match(gts_list, preds_list): + arranged_preds_list = [] + num_gts = len(gts_list) + num_preds = len(preds_list) + cost_matrix = np.zeros((num_gts, num_preds)) + + for i in range(num_gts): + for j in range(num_preds): + cost_matrix[i, j] = distance.euclidean( + gts_list[i][..., :2].flatten(), preds_list[j][..., :2].flatten() + ) + row_ind, col_ind = linear_sum_assignment(cost_matrix) + + return col_ind + + pred_keypoint_names = config["bodyparts"] + num_pred_keypoints = len(pred_keypoint_names) + gt_keypoint_names = categories[0]["keypoints"] + num_gt_keypoints = len(gt_keypoint_names) + + match_matrix = np.zeros((num_pred_keypoints, num_gt_keypoints)) + + match_dict = defaultdict(lambda: defaultdict(int)) + + for imagename, gts in imagename2gt.items(): + bbox_gts = [np.array(gt["bbox"]) for gt in gts] + bbox_gts = [xywh2xyxy(e) for e in bbox_gts] + prediction = imagename2prediction[imagename] + bbox_preds = [xywh2xyxy(pred) for pred in prediction["bboxes"]] + optimal_pred_indices = optimal_match(bbox_gts, bbox_preds) + + for idx in range(len(bbox_gts)): + if idx == len(optimal_pred_indices): + break + + optimal_index = optimal_pred_indices[idx] + matched_gt = np.array(gts[idx]["keypoints"]) + matched_pred = prediction["bodyparts"][optimal_index] + bbox_gt = bbox_gts[idx] + bbox_pred = bbox_preds[idx] + matched_gt = matched_gt.reshape(num_gt_keypoints, -1) + matched_pred = matched_pred.reshape(num_pred_keypoints, -1) + + gt_kpt_ids = np.arange(matched_gt.shape[0]) + pred_kpt_ids = np.arange(matched_pred.shape[0]) + pair_distance = cdist(matched_pred, matched_gt) + row_ind, column_ind = linear_sum_assignment(pair_distance) + original_gt_matched_indices = matched_gt[column_ind] + for row, column in zip(row_ind, column_ind): + pred_kpt_name = pred_keypoint_names[row] + anno_kpt_name = gt_keypoint_names[column] + match_matrix[row][column] += 1 + match_dict[pred_kpt_name][anno_kpt_name] += 1 + + row_ind, column_ind = linear_sum_assignment(match_matrix * -1) + keypoint_mapping_list = [] + + conversion_matrix_out_path = os.path.join( + memory_replay_folder, "confusion_matrix.png" + ) + + plot_cost_matrix( + match_matrix, gt_keypoint_names, pred_keypoint_names, conversion_matrix_out_path + ) + + for row, column in zip(row_ind, column_ind): + pred_kpt_name = pred_keypoint_names[row] + anno_kpt_name = gt_keypoint_names[column] + count = match_dict[pred_kpt_name][anno_kpt_name] + keypoint_mapping_list.append((pred_kpt_name, anno_kpt_name, count)) + + keypoint_mapping_list = sorted( + keypoint_mapping_list, key=lambda x: x[2], reverse=True + ) + + names = [e[:2] for e in keypoint_mapping_list] + conversion_table = {} + for pred, anno in names: + conversion_table[pred] = anno + + conversion_table_out_path = os.path.join( + memory_replay_folder, "conversion_table.csv" + ) + with open(conversion_table_out_path, "w") as f: + out = "gt, MasterName\n" + for name in pred_keypoint_names: + target = name + source = conversion_table.get(target, "") + out += f"{source}, {target}\n" + f.write(out) + + +# this is reading from a coco project +def prepare_memory_replay_dataset( + source_dataset_folder, + superanimal_name, + model_name, + max_individuals=1, + train_file="train.json", + test_file="test.json", + pose_threshold=0.0, + bbox_threshold=0.0, + device=None, + pose_model_path="", + detector_path="", + customized_pose_checkpoint=None, +): + """ + Need to first run inference on the source project train file + """ + + ( + model_config, + project_config, + pose_model_path, + detector_path, + ) = get_config_model_paths(superanimal_name, model_name) + + if customized_pose_checkpoint is not None: + pose_model_path = customized_pose_checkpoint + + if device is None: + device = select_device() + + config = {**project_config, **model_config} + config = update_config(config, max_individuals, device) + individuals = [f"animal{i}" for i in range(max_individuals)] + config["individuals"] = individuals + num_bodyparts = len(config["bodyparts"]) + train_file_path = os.path.join(source_dataset_folder, "annotations", train_file) + + pose_runner, detector_runner = get_inference_runners( + model_config, + snapshot_path=pose_model_path, + max_individuals=max_individuals, + num_bodyparts=len(model_config["metadata"]["bodyparts"]), + num_unique_bodyparts=0, + detector_path=detector_path, + ) + + import json + + with open(train_file_path, "r") as f: + train_obj = json.load(f) + + images = train_obj["images"] + annotations = train_obj["annotations"] + categories = train_obj["categories"] + imagename2id = {} + imageid2name = {} + imagename2gt = defaultdict(list) + + for image in images: + # this only works with relative path as the testing image can be at a different folder + imagename = image["file_name"].split(os.sep)[-1] + imagename2id[imagename] = image["id"] + imageid2name[image["id"]] = imagename + + imagename2bbox = defaultdict(list) + for anno in annotations: + imagename = imageid2name[anno["image_id"]] + imagename2gt[imagename].append(anno) + imagename2bbox[imagename].append(anno["bbox"]) + + imageid2annotations = defaultdict(list) + + imageids = list(imagename2id.values()) + for annotation in annotations: + image_id = annotation["image_id"] + if annotation["image_id"] in imageids: + imageid2annotations[image_id].append(annotation) + + # need to support more image types + images_in_folder = glob.glob(os.path.join(source_dataset_folder, "images", "*.png")) + + corresponded_images = [] + for image in images_in_folder: + image_path = image + imagename = image.split(os.sep)[-1] + if imagename in imagename2id: + corresponded_images.append(image_path) + + images = corresponded_images + + bbox_predictions = detector_runner.inference(images=images) + + bbox_gts = [ + {"bboxes": np.array(imagename2bbox[image.split(os.sep)[-1]])} + for image in images + ] + + pose_inputs = list(zip(images, bbox_gts)) + + # pose inference should return meta data for pseudo labeling + predictions = pose_runner.inference(pose_inputs) + + assert len(images) == len(predictions) + + imagename2prediction = {} + + for image_path, prediction in zip(images, predictions): + imagename = image_path.split(os.sep)[-1] + imagename2prediction[imagename] = prediction + + def xywh2xyxy(bbox): + temp_bbox = np.copy(bbox) + temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:] + return temp_bbox + + def optimal_match(gts_list, preds_list): + arranged_preds_list = [] + num_gts = len(gts_list) + num_preds = len(preds_list) + cost_matrix = np.zeros((num_gts, num_preds)) + + for i in range(num_gts): + for j in range(num_preds): + cost_matrix[i, j] = distance.euclidean( + gts_list[i][..., :2].flatten(), preds_list[j][..., :2].flatten() + ) + row_ind, col_ind = linear_sum_assignment(cost_matrix) + + return col_ind + + for imagename, gts in imagename2gt.items(): + bbox_gts = [np.array(gt["bbox"]) for gt in gts] + bbox_gts = [xywh2xyxy(e) for e in bbox_gts] + prediction = imagename2prediction[imagename] + bbox_preds = [xywh2xyxy(pred) for pred in prediction["bboxes"]] + optimal_pred_indices = optimal_match(bbox_gts, bbox_preds) + + for idx in range(len(bbox_gts)): + if idx == len(optimal_pred_indices): + break + + optimal_index = optimal_pred_indices[idx] + matched_gt = np.array(gts[idx]["keypoints"]) + matched_pred = prediction["bodyparts"][optimal_index] + bbox_gt = bbox_gts[idx] + bbox_pred = bbox_preds[idx] + + # maybe check iou of two bbox + iou = calculate_iou(bbox_gt, bbox_pred) + if iou < 0.7: + matched_gt = np.ones_like(matched_gt) * -1 + gts[idx]["keypoints"] = list(matched_gt.flatten()) + else: + matched_gt = matched_gt.reshape(num_bodyparts, -1) + matched_pred = matched_pred.reshape(num_bodyparts, -1) + mask = matched_gt == -1 + matched_gt[mask] = matched_pred[mask] + # after the mixing, we don't care about confidence anymore + + for kpt_idx in range(len(matched_gt)): + if matched_gt[kpt_idx][2] < 0.7 and matched_gt[kpt_idx][2] > 0: + matched_gt[kpt_idx][2] = 0 + elif matched_gt[kpt_idx][2] > 0: + matched_gt[kpt_idx][2] = 2 + + gts[idx]["keypoints"] = list(matched_gt.flatten()) + + # memory replay path + memory_replay_train_file_path = os.path.join( + source_dataset_folder, "annotations", "memory_replay_train.json" + ) + with open(memory_replay_train_file_path, "w") as f: + json.dump(train_obj, f, indent=4) + + +# this is to generate a coco project as an intermediate data +def dlc3predictions_2_annotation_from_video( + predictions, + dest_proj_folder, + bodyparts, + superanimal_name, + pose_threshold=0.0, + bbox_threshold=0.0, +): + """ + For video adaptation, we also need to create a coco project + dlc3 predictions: + + list of dictionary + [{ + bodyparts:[] # (n_individuals, n_kpts, 3) + bboxes: [] # (n_individuals, 4) -> x,y,w,h + }] + + coco result is a list of dictionary + # i might get a minimal version that works with my script + + category_id: + image_id: [] + image_path: [] + keypoints: [] + score: [] + bbox: [] + + """ + + print("pose threshold", pose_threshold) + print("bbox threshold", bbox_threshold) + + category_id = 1 # the default for superanimal. But it might be changed + + images = [] + annotations = [] + categories = [] + annotation_id = 0 + image_folder = os.path.join(dest_proj_folder, "images") + + print("image folder", image_folder) + # video_to_frames function by default outputs png or jpg + image_paths = sorted(glob.glob(os.path.join(image_folder, "*.png"))) + + # because inference api does not return image path. I am assuming the predictions come in an oder from the video + assert len(image_paths) == len( + predictions + ), f"number of images must be equal to number of predictions. image_paths: {len(image_paths)} , predictions: {len(predictions)}" + new_predictions = [] + + num_kpts = len(bodyparts) + + # superquadruped + if num_kpts == 39 and superanimal_name is not None: + categories = [ + { + "name": "superquadruped", + "id": 1, + "supercategory": "animal", + "keypoints": bodyparts, + } + ] + # supertopviewmouse + elif num_kpts == 27 and superanimal_name is not None: + categories = [ + { + "name": "supertopviewmouse", + "id": 1, + "supercategory": "animal", + "keypoints": bodyparts, + } + ] + + else: + raise ValueError("not supporting non superanimal model video adaptation yet") + + assert len(predictions) == len(image_paths) + + imageid2annotations = defaultdict(list) + for image_id, (prediction, image_path) in enumerate(zip(predictions, image_paths)): + + image_obj = cv2.imread(image_path) + + height, width, channels = image_obj.shape + + imagename = image_path.split(os.sep)[-1] + image = { + "id": image_id, + "file_name": imagename, + "width": width, + "height": height, + } + + # iterate through individuals if there are many + + assert ( + len(prediction["bodyparts"]) + == len(prediction["bboxes"]) + == len(prediction["bbox_scores"]) + ) + for pose, bbox, bbox_score in zip( + prediction["bodyparts"], prediction["bboxes"], prediction["bbox_scores"] + ): + if ( + np.all(np.array(pose) <= 0) + or len(bbox) == 0 + or bbox_score < bbox_threshold + ): + continue + imageid2annotations[image_id].append(pose) + pose = np.array(pose) + bbox = np.array(bbox) + + mask = pose[:, -1] < pose_threshold + + pose[mask] = 0 + + # by default all visible + pose[:, -1] = 2 + bbox_confidence = bbox[-1] + + keypoints = list(pose.reshape(-1)) + keypoints = [float(num) for num in keypoints] + # bbox here is x,y,w,h from dlc3 + bbox = [float(num) for num in bbox][:4] + + anno = { + "category_id": int(category_id), + "keypoints": keypoints, + "num_keypoints": len(keypoints) // 3, + "image_id": int(image_id), + "bbox": bbox, + "area": float(bbox[-2] * bbox[-3]), + "iscrowd": 0, + "id": int(annotation_id), + } + + annotation_id += 1 + annotations.append(anno) + # this is to prevent images that do not have annotations + if len(imageid2annotations[image_id]) > 0: + images.append(image) + + train_obj = {"images": images, "annotations": annotations, "categories": categories} + + test_annotations = [] + + # just use the first 10 image annotations for test + test_obj = { + "images": images[:10], + "annotations": annotations[:10], + "categories": categories, + } + + # there is no 'test' split of video adaptation. This is essentially train.json + with open(os.path.join(dest_proj_folder, "annotations", "test.json"), "w") as f: + json.dump(test_obj, f, indent=4) + + with open(os.path.join(dest_proj_folder, "annotations", "train.json"), "w") as f: + json.dump(train_obj, f, indent=4) From d00be9a6184f5b818cfcfcceb3be08906f5b9ac5 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:24:20 +0200 Subject: [PATCH 090/293] niels/create_training_dataset_improvements (#198) --- deeplabcut/__init__.py | 1 + .../trainingsetmanipulation.py | 132 +++++++++++++++++- deeplabcut/gui/displays/__init__.py | 12 ++ .../gui/displays/shuffle_metadata_viewer.py | 63 +++++++++ .../gui/tabs/create_training_dataset.py | 101 +++++++++++++- 5 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 deeplabcut/gui/displays/__init__.py create mode 100644 deeplabcut/gui/displays/shuffle_metadata_viewer.py diff --git a/deeplabcut/__init__.py b/deeplabcut/__init__.py index 7f11baa379..9609654b14 100644 --- a/deeplabcut/__init__.py +++ b/deeplabcut/__init__.py @@ -45,6 +45,7 @@ mergeandsplit, ) from deeplabcut.generate_training_dataset import ( + create_training_dataset_from_existing_split, create_training_model_comparison, create_multianimaltraining_dataset, ) diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 8191593d29..a24939bfce 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -1250,7 +1250,11 @@ def create_training_dataset( def get_largestshuffle_index(config): """Returns the largest shuffle for all dlc-models in the current iteration.""" - return get_existing_shuffle_indices(config)[-1] + shuffle_indices = get_existing_shuffle_indices(config) + if len(shuffle_indices) > 0: + return shuffle_indices[-1] + + return None def get_existing_shuffle_indices( @@ -1511,3 +1515,129 @@ def create_training_model_comparison( logger.info(log_info) return shuffle_list + + +def create_training_dataset_from_existing_split( + config: str, + from_shuffle: int, + from_trainsetindex: int = 0, + num_shuffles: int = 1, + shuffles: list[int] | None = None, + userfeedback: bool = True, + net_type: str | None = None, + augmenter_type: str | None = None, + posecfg_template: dict | None = None, + superanimal_name: str = "", + weight_init: WeightInitialization | None = None, + engine: Engine | None = None, +) -> None | list[int]: + """ + Labels from all the extracted frames are merged into a single .h5 file. + Only the videos included in the config file are used to create this dataset. + + Args: + config: Full path of the ``config.yaml`` file as a string. + + from_shuffle: The index of the shuffle from which to copy the train/test split. + + from_trainsetindex: The trainset index of the shuffle from which to use the data + split. Default is 0. + + num_shuffles: Number of shuffles of training dataset to create, used if + ``shuffles`` is None. + + shuffles: If defined, ``num_shuffles`` is ignored and a shuffle is created for + each index given in the list. + + userfeedback: If ``False``, all requested train/test splits are created (no + matter if they already exist). If you want to assure that previous splits + etc. are not overwritten, set this to ``True`` and you will be asked for + each existing split if you want to overwrite it. + + net_type: The type of network to create the shuffle for. Currently supported + options for engine=Engine.TF are: + * ``resnet_50`` + * ``resnet_101`` + * ``resnet_152`` + * ``mobilenet_v2_1.0`` + * ``mobilenet_v2_0.75`` + * ``mobilenet_v2_0.5`` + * ``mobilenet_v2_0.35`` + * ``efficientnet-b0`` + * ``efficientnet-b1`` + * ``efficientnet-b2`` + * ``efficientnet-b3`` + * ``efficientnet-b4`` + * ``efficientnet-b5`` + * ``efficientnet-b6`` + Currently supported options for engine=Engine.TF can be obtained by calling + ``deeplabcut.pose_estimation_pytorch.available_models()``. + + augmenter_type: Type of augmenter. Currently supported augmenters for + engine=Engine.TF are + * ``default`` + * ``scalecrop`` + * ``imgaug`` + * ``tensorpack`` + * ``deterministic`` + The only supported augmenter for Engine.PYTORCH is ``albumentations``. + + posecfg_template: Only for Engine.TF. Path to a ``pose_cfg.yaml`` file to use as + a template for generating the new one for the current iteration. Useful if + you would like to start with the same parameters a previous training + iteration. None uses the default ``pose_cfg.yaml``. + + superanimal_name: Specify the superanimal name is transfer learning with + superanimal is desired. This makes sure the pose config template uses + superanimal configs as template. + + weight_init: Only for Engine.PYTORCH. Specify how model weights should be + initialized. The default mode uses transfer learning from ImageNet weights. + + engine: Whether to create a pose config for a Tensorflow or PyTorch model. + Defaults to the value specified in the project configuration file. If no + engine is specified for the project, defaults to + ``deeplabcut.compat.DEFAULT_ENGINE``. + + Returns: + If training dataset was successfully created, a list of tuples is returned. + The first two elements in each tuple represent the training fraction and the + shuffle value. The last two elements in each tuple are arrays of integers + representing the training and test indices. + + Returns None if training dataset could not be created. + + Raises: + ValueError: If the shuffle from which to copy the data split doesn't exist. + """ + cfg = auxiliaryfunctions.read_config(config) + trainset_meta_path = metadata.TrainingDatasetMetadata.path(cfg) + if not trainset_meta_path.exists(): + meta = metadata.TrainingDatasetMetadata.create(cfg) + meta.save() + else: + meta = metadata.TrainingDatasetMetadata.load(cfg, load_splits=False) + + shuffle = meta.get(trainset_index=from_trainsetindex, index=from_shuffle) + shuffle = shuffle.load_split(cfg, trainset_path=trainset_meta_path.parent) + + num_copies = num_shuffles + if shuffles is not None: + num_copies = len(shuffles) + + train_indices = [shuffle.split.train_indices for _ in range(num_copies)] + test_indices = [shuffle.split.test_indices for _ in range(num_copies)] + return create_training_dataset( + config=config, + num_shuffles=num_shuffles, + Shuffles=shuffles, + userfeedback=userfeedback, + trainIndices=train_indices, + testIndices=test_indices, + net_type=net_type, + augmenter_type=augmenter_type, + posecfg_template=posecfg_template, + superanimal_name=superanimal_name, + weight_init=weight_init, + engine=engine, + ) diff --git a/deeplabcut/gui/displays/__init__.py b/deeplabcut/gui/displays/__init__.py new file mode 100644 index 0000000000..f511e6184a --- /dev/null +++ b/deeplabcut/gui/displays/__init__.py @@ -0,0 +1,12 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + + diff --git a/deeplabcut/gui/displays/shuffle_metadata_viewer.py b/deeplabcut/gui/displays/shuffle_metadata_viewer.py new file mode 100644 index 0000000000..b18aef85b4 --- /dev/null +++ b/deeplabcut/gui/displays/shuffle_metadata_viewer.py @@ -0,0 +1,63 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Widget to display existing shuffles""" +from __future__ import annotations + +from PySide6 import QtWidgets +from PySide6.QtCore import Qt + +import deeplabcut.generate_training_dataset.metadata as metadata + + +class ShuffleMetadataViewer(QtWidgets.QDialog): + """Viewer for shuffle metadata""" + + def __init__(self, root: QtWidgets.QMainWindow, parent: QtWidgets.QWidget): + super().__init__(parent) + self.root = root + self.parent = parent + self.file_content = _load_metadata(self.root.cfg) + + self.setWindowTitle("Existing Shuffles: Metadata") + self.setMinimumWidth(400) + self.setMinimumHeight(400) + + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + + inner_layout = QtWidgets.QVBoxLayout() + inner_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop) + inner_layout.setSpacing(0) + inner_layout.setContentsMargins(0, 0, 0, 0) + + for line in self.file_content: + + inner_layout.addWidget(QtWidgets.QLabel(line)) + + inner = QtWidgets.QFrame(scroll) + inner.setLayout(inner_layout) + scroll.setWidget(inner) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(scroll) + self.setLayout(layout) + + +def _load_metadata(cfg: dict) -> list[str]: + metadata_path = metadata.TrainingDatasetMetadata.path(cfg) + if not metadata_path.exists(): + trainset_meta = metadata.TrainingDatasetMetadata.create(cfg) + trainset_meta.save() + + with open(metadata_path, "r") as file: + raw_metadata = file.read() + + return raw_metadata.split("\n") diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index f3ab225ca1..65db307984 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -8,6 +8,8 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + import os from PySide6 import QtWidgets @@ -20,6 +22,7 @@ from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.generate_training_dataset import get_existing_shuffle_indices from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine +from deeplabcut.gui.displays.shuffle_metadata_viewer import ShuffleMetadataViewer from deeplabcut.gui.dlc_params import DLCParams from deeplabcut.gui.components import ( DefaultTab, @@ -50,6 +53,10 @@ def __init__(self, root, parent, h1_description): self.main_layout.addWidget(self.ok_button, alignment=Qt.AlignRight) + self.view_shuffles_button = QtWidgets.QPushButton("View Existing Shuffles") + self.view_shuffles_button.clicked.connect(self.view_shuffles) + self.main_layout.addWidget(self.view_shuffles_button, alignment=Qt.AlignLeft) + self.help_button = QtWidgets.QPushButton("Help") self.help_button.clicked.connect(self.show_help_dialog) self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) @@ -116,6 +123,9 @@ def _generate_layout_attributes(self, layout): lambda s: self.root.logger.info(f"Overwrite: {s}") ) + # Use same data split as another shuffle + self.data_split_selection = DataSplitSelector(self.root, self) + layout.addWidget(shuffle_label, 0, 0) layout.addWidget(self.shuffle, 0, 1) layout.addWidget(self.weight_init_label, 0, 2) @@ -127,6 +137,7 @@ def _generate_layout_attributes(self, layout): layout.addWidget(self.aug_choice, 1, 3) layout.addWidget(self.overwrite, 2, 0) + layout.addWidget(self.data_split_selection, 3, 0) def log_net_choice(self, net): self.root.logger.info(f"Network architecture set to {net.upper()}") @@ -176,9 +187,27 @@ def create_training_dataset(self): # net_types=self.net_type, # augmenter_types=self.aug_type, # ) - else: - if self.root.is_multianimal: + if self.data_split_selection.selected: + try: + deeplabcut.create_training_dataset_from_existing_split( + self.root.config, + from_shuffle=self.data_split_selection.from_shuffle, + shuffles=[self.shuffle.value()], + net_type=self.net_choice.currentText(), + userfeedback=not overwrite, + weight_init=weight_init, + engine=self.root.engine, + ) + except ValueError as err: + msg = _create_message_box( + f"The training dataset could not be created.", + str(err), + ) + msg.exec_() + return + + elif self.root.is_multianimal: deeplabcut.create_multianimaltraining_dataset( self.root.config, shuffle, @@ -199,6 +228,7 @@ def create_training_dataset(self): weight_init=weight_init, engine=self.root.engine, ) + # Check that training data files were indeed created. trainingsetfolder = get_training_set_folder(self.root.cfg) filenames = list( @@ -333,6 +363,10 @@ def get_net_filter(self) -> list[str] | None: return _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init]["model_filter"] + def view_shuffles(self) -> None: + viewer = ShuffleMetadataViewer(root=self.root, parent=self) + viewer.show() + class WeightInitializationSelector(QtWidgets.QWidget): """Widget to select weight initialization""" @@ -424,6 +458,69 @@ def _choice_changed(self, state: str) -> None: self.memory_replay_box.hide() +class DataSplitSelector(QtWidgets.QWidget): + """Allows users to create training sets with the same train/test split as another""" + + def __init__(self, root: QtWidgets.QMainWindow, parent: QtWidgets.QWidget): + super().__init__() + self.root = root + self.parent = parent + + self.setToolTip( + "This allows you to create a shuffle where the data split is the same as " + "one of your existing shuffles (the images on which the model is " + "trained/tested are the same)." + ) + + layout = QtWidgets.QVBoxLayout() + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + box_layout = QtWidgets.QHBoxLayout() + box_layout.setSpacing(0) + box_layout.setContentsMargins(0, 0, 0, 0) + + selector_layout = QtWidgets.QHBoxLayout() + selector_layout.setSpacing(0) + selector_layout.setContentsMargins(0, 0, 0, 0) + + self.shuffle_label = QtWidgets.QLabel("From shuffle:") + self.shuffle_label.hide() + self.shuffle_selector = QtWidgets.QSpinBox() + self.shuffle_selector.setMaximum(10_000) + self.shuffle_selector.setValue(0) + self.shuffle_selector.hide() + + self.box = QtWidgets.QCheckBox(parent=self) + self.box.stateChanged.connect(self._checkbox_status_changed) + self.box_label = QtWidgets.QLabel("Use an existing data split") + + box_layout.addWidget(self.box) + box_layout.addWidget(self.box_label) + selector_layout.addWidget(self.shuffle_label) + selector_layout.addWidget(self.shuffle_selector) + layout.addLayout(box_layout) + layout.addLayout(selector_layout) + self.setLayout(layout) + + @property + def selected(self) -> bool: + return self.box.isChecked() + + @property + def from_shuffle(self) -> int: + """The shuffle from which to copy the data split""" + return self.shuffle_selector.value() + + def _checkbox_status_changed(self, state: int) -> None: + if Qt.CheckState(state) == Qt.Checked: + self.shuffle_selector.show() + self.shuffle_label.show() + else: + self.shuffle_selector.hide() + self.shuffle_label.hide() + + def _create_message_box(text, info_text): msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Information) From 3037e4035f2b88ca41fe0acbcae2e6c78fd3cff7 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:26:27 +0200 Subject: [PATCH 091/293] niels/learning_stats_logger (#201) * added CSV logger * fixed imports --- deeplabcut/compat.py | 3 + .../pose_estimation_pytorch/apis/train.py | 21 ++++-- .../pose_estimation_pytorch/config/utils.py | 2 +- .../pose_estimation_pytorch/runners/logger.py | 69 +++++++++++++++++++ .../pose_estimation_pytorch/runners/train.py | 25 ++++--- 5 files changed, 104 insertions(+), 16 deletions(-) diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index 3afe8fc7fe..412e8ae7ff 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -102,6 +102,9 @@ def train_network( elif engine == Engine.PYTORCH: from deeplabcut.pose_estimation_pytorch.apis import train_network _update_device(gputouse, torch_kwargs) + if "display_iters" not in torch_kwargs: + torch_kwargs["display_iters"] = displayiters + return train_network( config, shuffle=shuffle, diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index f450b285b9..cf1ae394b9 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -155,6 +155,10 @@ def train_network( device: str | None = None, snapshot_path: str | None = None, detector_path: str | None = None, + batch_size: int | None = None, + epochs: int | None = None, + save_epochs: int | None = None, + display_iters: int | None = None, max_snapshots_to_keep: int | None = None, **kwargs, ) -> None: @@ -168,9 +172,14 @@ def train_network( to train the network (and where snapshots will be saved). By default, they are assumed to exist in the project folder. device: the torch device to train on (such as "cpu", "cuda", "mps") - snapshot_path: if resuming training, used to specify the snapshot from which to resume + snapshot_path: if resuming training, the snapshot from which to resume detector_path: if resuming training of a top-down model, used to specify the detector snapshot from which to resume + batch_size: overrides the batch size to train with + epochs: overrides the maximum number of epochs to train the model for + save_epochs: overrides the number of epochs between each snapshot save + display_iters: overrides the number of iterations between each log of the loss + within an epoch max_snapshots_to_keep: the maximum number of snapshots to save for each model **kwargs : could be any entry of the pytorch_config dictionary. Examples are to see the full list see the pytorch_cfg.yaml file in your project folder @@ -181,6 +190,7 @@ def train_network( trainset_index=trainingsetindex, modelprefix=modelprefix, ) + if weight_init_cfg := loader.model_cfg["train_settings"].get("weight_init"): weight_init = WeightInitialization.from_dict(weight_init_cfg) if weight_init.memory_replay: @@ -199,13 +209,16 @@ def train_network( # model_config_path=loader.model_config_path, # ) - batch_size = kwargs.pop("batch_size", None) - epochs = kwargs.pop("epochs", None) - loader.update_model_cfg(kwargs) if batch_size is not None: loader.model_cfg["train_settings"]["batch_size"] = batch_size if epochs is not None: loader.model_cfg["train_settings"]["epochs"] = epochs + if save_epochs is not None: + loader.model_cfg["runner"]["snapshots"]["save_epochs"] = save_epochs + if display_iters is not None: + loader.model_cfg["train_settings"]["display_iters"] = display_iters + + loader.update_model_cfg(kwargs) setup_file_logging(loader.model_folder / "train.txt") logging.info("Training with configuration:") diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py index 562021be21..2898963205 100644 --- a/deeplabcut/pose_estimation_pytorch/config/utils.py +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -227,7 +227,7 @@ def pretty_print( for k, v in config.items(): if isinstance(v, dict): print_fn(f"{indent * ' '}{k}:") - pretty_print(v, indent + 2) + pretty_print(v, indent + 2, print_fn=print_fn) else: print_fn(f"{indent * ' '}{k}: {v}") diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index 940c51170b..2d70fa7bc9 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import csv import logging from abc import ABC, abstractmethod from pathlib import Path @@ -360,3 +361,71 @@ def log_config(self, config: dict = None) -> None: """ self.run.config.update(config) + + +@LOGGER.register_module +class CSVLogger(BaseLogger): + """Logger saving stats and metrics to a CSV file""" + + def __init__(self, train_folder: Path) -> None: + """Initialize the WandbLogger class. + + Args: + train_folder: The path of the folder containing training files. + """ + super().__init__() + self.train_folder = train_folder + self.log_file = train_folder / "learning_stats.csv" + + self._steps: list[int] = [] + self._metric_store: list[dict] = [] + self._logged_metrics: set[str] = set() + + def log(self, metrics: dict[str, Any], step: Optional[int] = None) -> None: + """Logs metrics from runs + + Args: + metrics: the metrics to log + step: The global step in processing. Defaults to None. + """ + if step is None: + if len(self._steps) == 0: + step = 0 + else: + step = self._steps[-1] + 1 + + self._logged_metrics = self._logged_metrics.union(metrics.keys()) + if len(self._steps) > 0 and step == self._steps[-1]: + self._metric_store[-1].update(metrics) + else: + self._steps.append(step) + self._metric_store.append(metrics) + + self.save() + + def save(self): + """Saves the metrics to the file system""" + logs = self._prepare_logs() + with open(self.log_file, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerows(logs) + + def log_config(self, config: dict = None) -> None: + """Does not do anything as the config should already be saved + + Args: + config: Experiment config file. + """ + pass + + def _prepare_logs(self) -> list[list]: + """Prepares the data to log as a list of strings""" + if len(self._metric_store) == 0: + return [] + + metrics = list(sorted(self._logged_metrics)) + logs = [["step"] + metrics] + for step, step_metrics in zip(self._steps, self._metric_store): + logs.append([step] + [step_metrics.get(m) for m in metrics]) + + return logs diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index 3ac73ee037..1ae383b02a 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -31,6 +31,7 @@ from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner from deeplabcut.pose_estimation_pytorch.runners.logger import ( BaseLogger, + CSVLogger, ImageLoggerMixin, ) from deeplabcut.pose_estimation_pytorch.runners.schedulers import build_scheduler @@ -73,6 +74,7 @@ def __init__( self.scheduler = scheduler self.snapshot_manager = snapshot_manager self.history: dict[str, list] = dict(train_loss=[], eval_loss=[]) + self.csv_logger = CSVLogger(train_folder=snapshot_manager.model_folder) self.logger = logger self.starting_epoch = 0 self.current_epoch = 0 @@ -230,19 +232,20 @@ def _epoch( epoch_loss = np.mean(epoch_loss).item() self.history[f"{mode}_loss"].append(epoch_loss) - if self.logger: - metrics_to_log = {} - if perf_metrics: - for name, score in perf_metrics.items(): - if not isinstance(score, (int, float)): - score = 0.0 - metrics_to_log[name] = score + metrics_to_log = {} + if perf_metrics: + for name, score in perf_metrics.items(): + if not isinstance(score, (int, float)): + score = 0.0 + metrics_to_log[name] = score - for key in loss_metrics: - name, val = f"{mode}.{key}", np.nanmean(loss_metrics[key]).item() - self._metadata["losses"][name] = val - metrics_to_log[f"losses/{name}"] = val + for key in loss_metrics: + name, val = f"{mode}.{key}", np.nanmean(loss_metrics[key]).item() + self._metadata["losses"][name] = val + metrics_to_log[f"losses/{name}"] = val + self.csv_logger.log(metrics_to_log, step=self.current_epoch) + if self.logger: self.logger.log(metrics_to_log, step=self.current_epoch) return epoch_loss From b99efedb770ecfa74f22ce5f8879d5de7b3ae803 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Fri, 7 Jun 2024 14:46:01 +0200 Subject: [PATCH 092/293] bug fix: multiple engine selectors in toolbar --- deeplabcut/gui/window.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 89adaa3102..ca3078a6f3 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -95,6 +95,8 @@ def __init__(self, app): self.videotype = "mp4" self.files = set() + self._engine = Engine.PYTORCH + self.default_set() self._generate_welcome_page() @@ -108,8 +110,6 @@ def __init__(self, app): self._toolbar = None self.create_toolbar() - self._engine = Engine.PYTORCH - # Thread-safe Stdout redirector self.writer = StreamWriter() sys.stdout = self.writer @@ -410,6 +410,7 @@ def update_menu_bar(self): self.file_menu.removeAction(self.openAction) def create_toolbar(self): + self.toolbar.clear() self.toolbar.addAction(self.newAction) self.toolbar.addAction(self.openAction) self.toolbar.addAction(self.helpAction) @@ -435,6 +436,7 @@ def _update_engine(index: int) -> None: change_engine_widget.addItems([e.aliases[0] for e in engines]) change_engine_widget.setFixedWidth(180) change_engine_widget.currentIndexChanged.connect(_update_engine) + change_engine_widget.setCurrentIndex(engines.index(self.engine)) self.toolbar.addWidget(spacer) self.toolbar.addWidget(engine_label) From 9c807b2303ac6fa9ab8f8e9c4a6c5dc4151d57b1 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:52:16 +0200 Subject: [PATCH 093/293] Fix evaluation of ID prediction accuracy (#182) * Fix matching w/ single body parts * Minor fixes --- .../metrics/scoring.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py index 0eab2f14ee..38c3c197d4 100644 --- a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py @@ -290,11 +290,16 @@ def _match_identity_preds_to_gt( ) -> tuple[np.ndarray, list]: with open(full_pickle_path, "rb") as f: data = pickle.load(f) - cfg = read_config(config_path) - all_ids = cfg["individuals"] metadata = data.pop("metadata") + cfg = read_config(config_path) + all_ids = cfg["individuals"].copy() + all_bpts = cfg["multianimalbodyparts"] * len(all_ids) + n_multibodyparts = len(all_bpts) + if cfg["uniquebodyparts"]: + all_ids += ["single"] + all_bpts += cfg["uniquebodyparts"] + all_bpts = np.asarray(all_bpts) joints = metadata["all_joints_names"] - all_bpts = np.asarray(len(all_ids) * joints + cfg["uniquebodyparts"]) ids = np.full((len(data), len(all_bpts), 2), np.nan) for i, dict_ in enumerate(data.values()): id_gt, _, df_gt = dict_["groundtruth"] @@ -319,18 +324,18 @@ def _match_identity_preds_to_gt( ids[i, inds[inds_gt[found]], 1] = np.argmax( id_[neighbors[found]], axis=1 ) + ids = ids[:, :n_multibodyparts].reshape((len(data), len(cfg["individuals"]), -1, 2)) return ids, list(data) def compute_id_accuracy(ids: np.ndarray, mask_test: np.ndarray) -> np.ndarray: - ids2 = ids.reshape((ids.shape[0], 2, -1, 2)) - nbpts = ids2.shape[2] + nbpts = ids.shape[2] # ids shape is (n_images, n_individuals, n_bodyparts, 2) accu = np.empty((nbpts, 2)) for i in range(nbpts): - temp = ids2[:, :, i].reshape((-1, 2)) + temp = ids[:, :, i].reshape((-1, 2)) valid = np.isfinite(temp).all(axis=1) y_true, y_pred = temp[valid].T - mask = np.repeat(mask_test, 2)[valid] + mask = np.repeat(mask_test, ids.shape[1])[valid] ac_train = accuracy_score(y_true[~mask], y_pred[~mask]) ac_test = accuracy_score(y_true[mask], y_pred[mask]) accu[i] = ac_train, ac_test From 2254c519d32d9660668680589be34b08dcd02802 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 7 Jun 2024 17:36:05 +0200 Subject: [PATCH 094/293] Update codespell.yml --- .github/workflows/codespell.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 583406cbb0..762fe700c9 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,4 +18,4 @@ jobs: - name: Codespell uses: codespell-project/actions-codespell@v1 with: - ignore_words_list: bu,BU,td,TD,ctd,CTD + ignore_words_list: bu,BU,td,TD,ctd,CTD, Wither From 81bf6eebd0439d1049d4b5c89b2ecbf9c1daf775 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 7 Jun 2024 17:40:23 +0200 Subject: [PATCH 095/293] Update ma_dlc.py --- .../modelzoo/generalized_data_converter/datasets/ma_dlc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py index a194f4ad05..4a13c14f37 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py @@ -89,7 +89,7 @@ def _df2generic(self, df, image_id_offset=0): .reshape((-1, 2)) ) except: - # somehow there are duplicates. So only use the first occurence + # somehow there are duplicates. So only use the first occurrence data = data.iloc[0] kpts = ( data.xs(individual, level="individuals") From 6757f7c8b52fc90bbec96d2021229040ac937053 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 7 Jun 2024 17:40:44 +0200 Subject: [PATCH 096/293] Update ma_dlc_dataframe.py --- .../generalized_data_converter/datasets/ma_dlc_dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py index 45e0754b63..0278d39a54 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py @@ -204,7 +204,7 @@ def _df2generic(self, df, image_id_offset=0): .reshape((-1, 2)) ) except: - # somehow there are duplicates. So only use the first occurence + # somehow there are duplicates. So only use the first occurrence data = data.iloc[0] kpts = ( data.xs(individual, level="individuals") From 058d0bbc79738e1e6b72dd754396d2c129f9f4d2 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 7 Jun 2024 17:43:19 +0200 Subject: [PATCH 097/293] Update materialize.py --- .../generalized_data_converter/datasets/materialize.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py index 9e64706516..9ed2af0425 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -177,7 +177,7 @@ def _generic2madlc( full_image_path=True, ): """ - For DLC, the complexity is that if we don't explicity call deeplabcut.create_traindataset(), the train and test split might just be arbitrarily messed up. So here we need to calculate train and test indices to + Within DeepLabCut, if we don't explicitly call deeplabcut.create_traindataset(), the train and test split might just be arbitrarily messed up. So here we need to calculate train and test indices to Args: proj_root where to materialize the data @@ -253,7 +253,7 @@ def _generic2madlc( image_name = file_name.split(os.sep)[-1] pre, suffix = image_name.split(".") dest_image_name = f"{pre}_{image_id}.{suffix}" - # the generic data has original pointers to images in the original foders + # the generic data has original pointers to images in the original folders # Here, we have to change the image name and location of these to fit corresponding framework's convention dataset_name = imageid2datasetname[image_id] @@ -490,7 +490,7 @@ def _generic2sdlc( image_name = file_name.split(os.sep)[-1] pre, suffix = image_name.split(".") dest_image_name = f"{pre}_{image_id}.{suffix}" - # the generic data has original pointers to images in the original foders + # the generic data has original pointers to images in the original folders # Here, we have to change the image name and location of these to fit corresponding framework's convention dataset_name = imageid2datasetname[image_id] @@ -553,7 +553,7 @@ def _generic2sdlc( df.loc[file_name][scorer, kpt_name, "x"] = coord[0] df.loc[file_name][scorer, kpt_name, "y"] = coord[1] elif coord[2] == -1: - # if -1, it's expaned keypoints by keypoint space projection + # if -1, it's expanded keypoints by keypoint space projection df.loc[file_name][scorer, kpt_name, "x"] = -1 df.loc[file_name][scorer, kpt_name, "y"] = -1 From 0211e136d1378c3aec97fc55f0de5a3997a3e349 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 7 Jun 2024 17:48:28 +0200 Subject: [PATCH 098/293] Update materialize.py --- .../modelzoo/generalized_data_converter/datasets/materialize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py index 9ed2af0425..d01eb914ec 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -553,7 +553,7 @@ def _generic2sdlc( df.loc[file_name][scorer, kpt_name, "x"] = coord[0] df.loc[file_name][scorer, kpt_name, "y"] = coord[1] elif coord[2] == -1: - # if -1, it's expanded keypoints by keypoint space projection + # if -1, this visibility flag means a given keypoint was not annotated in the original dataset df.loc[file_name][scorer, kpt_name, "x"] = -1 df.loc[file_name][scorer, kpt_name, "y"] = -1 From 3d1477707a0874d2199d4f8236f75fcd11b89092 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 7 Jun 2024 18:35:24 +0200 Subject: [PATCH 099/293] icons for GUI engine --- deeplabcut/gui/media/dlc-pt.png | Bin 0 -> 30005 bytes deeplabcut/gui/media/dlc-tf.png | Bin 0 -> 30173 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 deeplabcut/gui/media/dlc-pt.png create mode 100644 deeplabcut/gui/media/dlc-tf.png diff --git a/deeplabcut/gui/media/dlc-pt.png b/deeplabcut/gui/media/dlc-pt.png new file mode 100644 index 0000000000000000000000000000000000000000..d0ac99c187d4a8c1f0c7774d4bcbe71be16375f7 GIT binary patch literal 30005 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 IM6N<$g4JQdb^rhX literal 0 HcmV?d00001 diff --git a/deeplabcut/gui/media/dlc-tf.png b/deeplabcut/gui/media/dlc-tf.png new file mode 100644 index 0000000000000000000000000000000000000000..79d06f0528e43136dcf2c7f372f067366bc99260 GIT binary patch literal 30173 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 L6{%_ovylG-w^64h literal 0 HcmV?d00001 From 183f6f336478c32100c805c296aed5c2dcb6270a Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 7 Jun 2024 18:52:15 +0200 Subject: [PATCH 100/293] niels - bug fixes (#210) * fixed vis flag setting for OOB keypoints * fixed warnings when loading weights when evaluating and detector device * fixed train_network docs * added train network docstring * fixed all tests locally, added docstrings to availabled compat methods * updated docstrings --- deeplabcut/compat.py | 736 ++++++++++++++++++ deeplabcut/gui/tabs/train_network.py | 2 +- .../pose_estimation_pytorch/apis/train.py | 19 +- .../pose_estimation_pytorch/data/utils.py | 8 +- .../models/detectors/base.py | 10 +- .../models/detectors/fasterRCNN.py | 1 - .../pose_estimation_pytorch/models/model.py | 8 +- .../target_generators/test_heatmap_targets.py | 10 +- .../target_generators/test_plateau_targets.py | 4 +- .../other/test_dataset.py | 4 +- .../other/test_gaussian_targets.py | 4 +- .../other/test_heatmap_plateau_targets.py | 4 +- .../other/test_match_predictions_to_gt.py | 6 +- .../other/test_paf_targets.py | 3 +- .../other/test_pose_model.py | 1 + .../other/test_seq_targets.py | 4 +- 16 files changed, 789 insertions(+), 35 deletions(-) diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index 412e8ae7ff..f1b2f36276 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -70,9 +70,125 @@ def train_network( autotune: bool = False, keepdeconvweights: bool = True, modelprefix: str = "", + superanimal_name: str = "", + superanimal_transfer_learning: bool = False, engine: Engine | None = None, **torch_kwargs, ): + """ + Trains the network with the labels in the training dataset. + + Parameters + ---------- + config : string + Full path of the config.yaml file as a string. + + shuffle: int, optional, default=1 + Integer value specifying the shuffle index to select for training. + + trainingsetindex: int, optional, default=0 + Integer specifying which TrainingsetFraction to use. + Note that TrainingFraction is a list in config.yaml. + + max_snapshots_to_keep: int or None + Sets how many snapshots are kept, i.e. states of the trained network. Every + saving iteration many times a snapshot is stored, however only the last + ``max_snapshots_to_keep`` many are kept! If you change this to None, then all + are kept. + See: https://github.com/DeepLabCut/DeepLabCut/issues/8#issuecomment-387404835 + + displayiters: optional, default=None + This variable is actually set in ``pose_config.yaml``. However, you can + overwrite it with this hack. Don't use this regularly, just if you are too lazy + to dig out the ``pose_config.yaml`` file for the corresponding project. If + ``None``, the value from there is used, otherwise it is overwritten! + + saveiters: optional, default=None + This variable is actually set in ``pose_config.yaml``. However, you can + overwrite it with this hack. Don't use this regularly, just if you are too lazy + to dig out the ``pose_config.yaml`` file for the corresponding project. + If ``None``, the value from there is used, otherwise it is overwritten! + + maxiters: optional, default=None + This variable is actually set in ``pose_config.yaml``. However, you can + overwrite it with this hack. Don't use this regularly, just if you are too lazy + to dig out the ``pose_config.yaml`` file for the corresponding project. + If ``None``, the value from there is used, otherwise it is overwritten! + + allow_growth: bool, optional, default=True. + For some smaller GPUs the memory issues happen. If ``True``, the memory + allocator does not pre-allocate the entire specified GPU memory region, instead + starting small and growing as needed. + See issue: https://forum.image.sc/t/how-to-stop-running-out-of-vram/30551/2 + + gputouse: optional, default=None + Natural number indicating the number of your GPU (see number in nvidia-smi). + If you do not have a GPU put None. + See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries + + autotune: bool, optional, default=False + Property of TensorFlow, somehow faster if ``False`` + (as Eldar found out, see https://github.com/tensorflow/tensorflow/issues/13317). + + keepdeconvweights: bool, optional, default=True + Also restores the weights of the deconvolution layers (and the backbone) when + training from a snapshot. Note that if you change the number of bodyparts, you + need to set this to false for re-training. + + modelprefix: str, optional, default="" + Directory containing the deeplabcut models to use when evaluating the network. + By default, the models are assumed to exist in the project folder. + + superanimal_name: str, optional, default ="" + Specified if transfer learning with superanimal is desired + + superanimal_transfer_learning: bool, optional, default = False. + If set true, the training is transfer learning (new decoding layer). If set + false, and superanimal_name is True, then the training is fine-tuning (reusing + the decoding layer) + + engine: Engine, optional, default = None. + The default behavior loads the engine for the shuffle from the metadata. You can + overwrite this by passing the engine as an argument, but this should generally + not be done. + + torch_kwargs: + You can add any keyword arguments for the deeplabcut.pose_estimation_pytorch + train_network method here. These arguments are passed to the downstream method. + Some of the parameters that can be passed are "epochs" (maximum number of + epochs to train the network for), "save_epochs" (the number of epochs between + each snapshot saved), "batch_size" (the batch size to use while training). + + Returns + ------- + None + + Examples + -------- + To train the network for first shuffle of the training dataset + + >>> deeplabcut.train_network('/analysis/project/reaching-task/config.yaml') + + To train the network for second shuffle of the training dataset + + >>> deeplabcut.train_network( + '/analysis/project/reaching-task/config.yaml', + shuffle=2, + keepdeconvweights=True, + ) + + To train the network for shuffle created with a PyTorch engine, while overriding the + number of epochs, batch size and other parameters. + + >>> deeplabcut.train_network( + '/analysis/project/reaching-task/config.yaml', + shuffle=1, + batch_size=8, + epochs=100, + save_epochs=10, + display_iters=50, + ) + """ if engine is None: engine = get_shuffle_engine( _load_config(config), @@ -97,6 +213,8 @@ def train_network( gputouse=gputouse, autotune=autotune, keepdeconvweights=keepdeconvweights, + superanimal_name=superanimal_name, + superanimal_transfer_learning=superanimal_transfer_learning, modelprefix=modelprefix, ) elif engine == Engine.PYTORCH: @@ -124,6 +242,33 @@ def return_train_network_path( modelprefix: str = "", engine: Engine | None = None, ) -> tuple[Path, Path, Path]: + """ + Returns the training and test pose config file names as well as the folder where the + snapshot is + + Parameters + ---------- + config : string + Full path of the config.yaml file as a string. + + shuffle: int + Integer value specifying the shuffle index to select for training. + + trainingsetindex: int, optional + Integer specifying which TrainingsetFraction to use. By default the first (note + that TrainingFraction is a list in config.yaml). + + modelprefix: str, optional + Directory containing the deeplabcut models to use when evaluating the network. + By default, the models are assumed to exist in the project folder. + + engine: Engine, optional, default = None. + The default behavior loads the engine for the shuffle from the metadata. You can + overwrite this by passing the engine as an argument, but this should generally + not be done. + + Returns the triple: trainposeconfigfile, testposeconfigfile, snapshotfolder + """ if engine is None: engine = get_shuffle_engine( _load_config(config), @@ -166,6 +311,105 @@ def evaluate_network( engine: Engine | None = None, **torch_kwargs, ): + """Evaluates the network. + + Evaluates the network based on the saved models at different stages of the training + network. The evaluation results are stored in the .h5 and .csv file under the + subdirectory 'evaluation_results'. Change the snapshotindex parameter in the config + file to 'all' in order to evaluate all the saved models. + + Parameters + ---------- + config : string + Full path of the config.yaml file. + + Shuffles: list, optional, default=[1] + List of integers specifying the shuffle indices of the training dataset. + + trainingsetindex: int or str, optional, default=0 + Integer specifying which "TrainingsetFraction" to use. + Note that "TrainingFraction" is a list in config.yaml. This variable can also + be set to "all". + + plotting: bool or str, optional, default=False + Plots the predictions on the train and test images. + If provided it must be either ``True``, ``False``, ``"bodypart"``, or + ``"individual"``. Setting to ``True`` defaults as ``"bodypart"`` for + multi-animal projects. + + show_errors: bool, optional, default=True + Display train and test errors. + + comparisonbodyparts: str or list, optional, default="all" + The average error will be computed for those body parts only. + The provided list has to be a subset of the defined body parts. + + gputouse: int or None, optional, default=None + Indicates the GPU to use (see number in ``nvidia-smi``). If you do not have a + GPU put `None``. + See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries + + rescale: bool, optional, default=False + Evaluate the model at the ``'global_scale'`` variable (as set in the + ``pose_config.yaml`` file for a particular project). I.e. every image will be + resized according to that scale and prediction will be compared to the resized + ground truth. The error will be reported in pixels at rescaled to the + *original* size. I.e. For a [200,200] pixel image evaluated at + ``global_scale=.5``, the predictions are calculated on [100,100] pixel images, + compared to 1/2*ground truth and this error is then multiplied by 2!. + The evaluation images are also shown for the original size! + + modelprefix: str, optional, default="" + Directory containing the deeplabcut models to use when evaluating the network. + By default, the models are assumed to exist in the project folder. + + per_keypoint_evaluation: bool, default=False + Compute the train and test RMSE for each keypoint, and save the results to + a {model_name}-keypoint-results.csv in the evalution-results folder + + engine: Engine, optional, default = None. + The default behavior loads the engine for the shuffle from the metadata. You can + overwrite this by passing the engine as an argument, but this should generally + not be done. + + torch_kwargs: + You can add any keyword arguments for the deeplabcut.pose_estimation_pytorch + evaluate_network function here. These arguments are passed to the downstream + function. Available parameters are `snapshotindex`, which overrides the + `snapshotindex` parameter in the project configuration file. For top-down models + the `detector_snapshot_index` parameter can override the index of the detector + to use for evaluation in the project configuration file. + + Returns + ------- + None + + Examples + -------- + If you do not want to plot and evaluate with shuffle set to 1. + + >>> deeplabcut.evaluate_network( + '/analysis/project/reaching-task/config.yaml', Shuffles=[1], + ) + + If you want to plot and evaluate with shuffle set to 0 and 1. + + >>> deeplabcut.evaluate_network( + '/analysis/project/reaching-task/config.yaml', + Shuffles=[0, 1], + plotting=True, + ) + + If you want to plot assemblies for a maDLC project + + >>> deeplabcut.evaluate_network( + '/analysis/project/reaching-task/config.yaml', + Shuffles=[1], + plotting="individual", + ) + + Note: This defaults to standard plotting for single-animal projects. + """ if engine is None: cfg = _load_config(config) engines = set() @@ -231,6 +475,49 @@ def return_evaluate_network_data( returnjustfns: bool = True, engine: Engine | None = None, ): + """ + Returns the results for (previously evaluated) network. deeplabcut.evaluate_network(..) + Returns list of (per model): [trainingsiterations,trainfraction,shuffle,trainerror,testerror,pcutoff,trainerrorpcutoff,testerrorpcutoff,Snapshots[snapindex],scale,net_type] + + This function is only implemented for tensorflow models/shuffles, and will throw + an error if called with a PyTorch shuffle. + + If fulldata=True, also returns (the complete annotation and prediction array) + Returns list of: (DataMachine, Data, data, trainIndices, testIndices, trainFraction, DLCscorer,comparisonbodyparts, cfg, Snapshots[snapindex]) + ---------- + config : string + Full path of the config.yaml file as a string. + + shuffle: integer + integers specifying shuffle index of the training dataset. The default is 0. + + trainingsetindex: int, optional + Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). This + variable can also be set to "all". + + comparisonbodyparts: list of bodyparts, Default is "all". + The average error will be computed for those body parts only (Has to be a subset of the body parts). + + rescale: bool, default False + Evaluate the model at the 'global_scale' variable (as set in the test/pose_config.yaml file for a particular project). I.e. every + image will be resized according to that scale and prediction will be compared to the resized ground truth. The error will be reported + in pixels at rescaled to the *original* size. I.e. For a [200,200] pixel image evaluated at global_scale=.5, the predictions are calculated + on [100,100] pixel images, compared to 1/2*ground truth and this error is then multiplied by 2!. The evaluation images are also shown for the + original size! + + engine: Engine, optional, default = None. + The default behavior loads the engine for the shuffle from the metadata. You can + overwrite this by passing the engine as an argument, but this should generally + not be done. + + Examples + -------- + If you do not want to plot + >>> deeplabcut._evaluate_network_data('/analysis/project/reaching-task/config.yaml', shuffle=[1]) + -------- + If you want to plot + >>> deeplabcut.evaluate_network('/analysis/project/reaching-task/config.yaml',shuffle=[1],plotting=True) + """ if engine is None: engine = get_shuffle_engine( _load_config(config), @@ -283,6 +570,202 @@ def analyze_videos( engine: Engine | None = None, **torch_kwargs, ): + """Makes prediction based on a trained network. + + The index of the trained network is specified by parameters in the config file + (in particular the variable 'snapshotindex'). + + The labels are stored as MultiIndex Pandas Array, which contains the name of + the network, body part name, (x, y) label position in pixels, and the + likelihood for each frame per body part. These arrays are stored in an + efficient Hierarchical Data Format (HDF) in the same directory where the video + is stored. However, if the flag save_as_csv is set to True, the data can also + be exported in comma-separated values format (.csv), which in turn can be + imported in many programs, such as MATLAB, R, Prism, etc. + + Parameters + ---------- + config: str + Full path of the config.yaml file. + + videos: list[str] + A list of strings containing the full paths to videos for analysis or a path to + the directory, where all the videos with same extension are stored. + + videotype: str, optional, default="" + Checks for the extension of the video in case the input to the video is a + directory. Only videos with this extension are analyzed. If left unspecified, + videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept. + + shuffle: int, optional, default=1 + An integer specifying the shuffle index of the training dataset used for + training the network. + + trainingsetindex: int, optional, default=0 + Integer specifying which TrainingsetFraction to use. + By default the first (note that TrainingFraction is a list in config.yaml). + + gputouse: int or None, optional, default=None + Indicates the GPU to use (see number in ``nvidia-smi``). If you do not have a + GPU put ``None``. + See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries + + save_as_csv: bool, optional, default=False + Saves the predictions in a .csv file. + + in_random_order: bool, optional (default=True) + Whether or not to analyze videos in a random order. + This is only relevant when specifying a video directory in `videos`. + + destfolder: string or None, optional, default=None + Specifies the destination folder for analysis data. If ``None``, the path of + the video is used. Note that for subsequent analysis this folder also needs to + be passed. + + batchsize: int or None, optional, default=None + Change batch size for inference; if given overwrites value in ``pose_cfg.yaml``. + + cropping: list or None, optional, default=None + List of cropping coordinates as [x1, x2, y1, y2]. + Note that the same cropping parameters will then be used for all videos. + If different video crops are desired, run ``analyze_videos`` on individual + videos with the corresponding cropping coordinates. + + TFGPUinference: bool, optional, default=True + Perform inference on GPU with TensorFlow code. Introduced in "Pretraining + boosts out-of-domain robustness for pose estimation" by Alexander Mathis, + Mert Yüksekgönül, Byron Rogers, Matthias Bethge, Mackenzie W. Mathis. + Source: https://arxiv.org/abs/1909.11229 + + dynamic: tuple(bool, float, int) triple containing (state, detectiontreshold, margin) + If the state is true, then dynamic cropping will be performed. That means that + if an object is detected (i.e. any body part > detectiontreshold), then object + boundaries are computed according to the smallest/largest x position and + smallest/largest y position of all body parts. This window is expanded by the + margin and from then on only the posture within this crop is analyzed (until the + object is lost, i.e. >> deeplabcut.analyze_videos( + 'C:\\myproject\\reaching-task\\config.yaml', + ['C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi'], + ) + + Analyzing a single video on Linux/MacOS + + >>> deeplabcut.analyze_videos( + '/analysis/project/reaching-task/config.yaml', + ['/analysis/project/videos/reachingvideo1.avi'], + ) + + Analyze all videos of type ``avi`` in a folder + + >>> deeplabcut.analyze_videos( + '/analysis/project/reaching-task/config.yaml', + ['/analysis/project/videos'], + videotype='.avi', + ) + + Analyze multiple videos + + >>> deeplabcut.analyze_videos( + '/analysis/project/reaching-task/config.yaml', + [ + '/analysis/project/videos/reachingvideo1.avi', + '/analysis/project/videos/reachingvideo2.avi', + ], + ) + + Analyze multiple videos with ``shuffle=2`` + + >>> deeplabcut.analyze_videos( + '/analysis/project/reaching-task/config.yaml', + [ + '/analysis/project/videos/reachingvideo1.avi', + '/analysis/project/videos/reachingvideo2.avi', + ], + shuffle=2, + ) + + Analyze multiple videos with ``shuffle=2``, save results as an additional csv file + + >>> deeplabcut.analyze_videos( + '/analysis/project/reaching-task/config.yaml', + [ + '/analysis/project/videos/reachingvideo1.avi', + '/analysis/project/videos/reachingvideo2.avi', + ], + shuffle=2, + save_as_csv=True, + ) + """ if engine is None: engine = get_shuffle_engine( _load_config(config), @@ -409,6 +892,50 @@ def analyze_time_lapse_frames( modelprefix: str = "", engine: Engine | None = None, ): + """ + Analyzed all images (of type = frametype) in a folder and stores the output in one file. + + You can crop the frames (before analysis), by changing 'cropping'=True and setting 'x1','x2','y1','y2' in the config file. + + Output: The labels are stored as MultiIndex Pandas Array, which contains the name of the network, body part name, (x, y) label position \n + in pixels, and the likelihood for each frame per body part. These arrays are stored in an efficient Hierarchical Data Format (HDF) \n + in the same directory, where the video is stored. However, if the flag save_as_csv is set to True, the data can also be exported in \n + comma-separated values format (.csv), which in turn can be imported in many programs, such as MATLAB, R, Prism, etc. + + This function is only implemented for tensorflow models/shuffles, and will throw + an error if called with a PyTorch shuffle. + + Parameters + ---------- + config : string + Full path of the config.yaml file as a string. + + directory: string + Full path to directory containing the frames that shall be analyzed + + frametype: string, optional + Checks for the file extension of the frames. Only images with this extension are analyzed. The default is ``.png`` + + shuffle: int, optional + An integer specifying the shuffle index of the training dataset used for training the network. The default is 1. + + trainingsetindex: int, optional + Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). + + gputouse: int, optional. Natural number indicating the number of your GPU (see number in nvidia-smi). If you do not have a GPU put None. + See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries + + save_as_csv: bool, optional + Saves the predictions in a .csv file. The default is ``False``; if provided it must be either ``True`` or ``False`` + + Examples + -------- + If you want to analyze all frames in /analysis/project/timelapseexperiment1 + >>> deeplabcut.analyze_videos('/analysis/project/reaching-task/config.yaml','/analysis/project/timelapseexperiment1') + -------- + + Note: for test purposes one can extract all frames from a video with ffmeg, e.g. ffmpeg -i testvideo.avi thumb%04d.png + """ if engine is None: engine = get_shuffle_engine( _load_config(config), @@ -451,6 +978,79 @@ def convert_detections2tracklets( track_method: str = "", engine: Engine | None = None, ): + """ + This should be called at the end of deeplabcut.analyze_videos for multianimal projects! + + Parameters + ---------- + config : string + Full path of the config.yaml file as a string. + + videos : list + A list of strings containing the full paths to videos for analysis or a path to the directory, where all the videos with same extension are stored. + + videotype: string, optional + Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed. + If left unspecified, videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept. + + shuffle: int, optional + An integer specifying the shuffle index of the training dataset used for training the network. The default is 1. + + trainingsetindex: int, optional + Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). + + overwrite: bool, optional. + Overwrite tracks file i.e. recompute tracks from full detections and overwrite. + + destfolder: string, optional + Specifies the destination folder for analysis data (default is the path of the video). Note that for subsequent analysis this + folder also needs to be passed. + + ignore_bodyparts: optional + List of body part names that should be ignored during tracking (advanced). + By default, all the body parts are used. + + inferencecfg: Default is None. + Configuration file for inference (assembly of individuals). Ideally + should be obtained from cross validation (during evaluation). By default + the parameters are loaded from inference_cfg.yaml, but these get_level_values + can be overwritten. + + calibrate: bool, optional (default=False) + If True, use training data to calibrate the animal assembly procedure. + This improves its robustness to wrong body part links, + but requires very little missing data. + + window_size: int, optional (default=0) + Recurrent connections in the past `window_size` frames are + prioritized during assembly. By default, no temporal coherence cost + is added, and assembly is driven mainly by part affinity costs. + + identity_only: bool, optional (default=False) + If True and animal identity was learned by the model, + assembly and tracking rely exclusively on identity prediction. + + track_method: string, optional + Specifies the tracker used to generate the pose estimation data. + For multiple animals, must be either 'box', 'skeleton', or 'ellipse' + and will be taken from the config.yaml file if none is given. + + engine: Engine, optional, default = None. + The default behavior loads the engine for the shuffle from the metadata. You can + overwrite this by passing the engine as an argument, but this should generally + not be done. + + Examples + -------- + If you want to convert detections to tracklets: + >>> deeplabcut.convert_detections2tracklets('/analysis/project/reaching-task/config.yaml',[]'/analysis/project/video1.mp4'], videotype='.mp4') + + If you want to convert detections to tracklets based on box_tracker: + >>> deeplabcut.convert_detections2tracklets('/analysis/project/reaching-task/config.yaml',[]'/analysis/project/video1.mp4'], videotype='.mp4',track_method='box') + + -------- + + """ if engine is None: engine = get_shuffle_engine( _load_config(config), @@ -516,6 +1116,43 @@ def extract_maps( modelprefix: str = "", engine: Engine | None = None, ): + """ + Extracts the scoremap, locref, partaffinityfields (if available). + + This function is only implemented for tensorflow models/shuffles, and will throw + an error if called with a PyTorch shuffle. + + Returns a dictionary indexed by: trainingsetfraction, snapshotindex, and imageindex + for those keys, each item contains: (image,scmap,locref,paf,bpt names,partaffinity graph, imagename, True/False if this image was in trainingset) + ---------- + config : string + Full path of the config.yaml file as a string. + + shuffle: integer + integers specifying shuffle index of the training dataset. The default is 0. + + trainingsetindex: int, optional + Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). This + variable can also be set to "all". + + rescale: bool, default False + Evaluate the model at the 'global_scale' variable (as set in the test/pose_config.yaml file for a particular project). I.e. every + image will be resized according to that scale and prediction will be compared to the resized ground truth. The error will be reported + in pixels at rescaled to the *original* size. I.e. For a [200,200] pixel image evaluated at global_scale=.5, the predictions are calculated + on [100,100] pixel images, compared to 1/2*ground truth and this error is then multiplied by 2!. The evaluation images are also shown for the + original size! + + engine: Engine, optional, default = None. + The default behavior loads the engine for the shuffle from the metadata. You can + overwrite this by passing the engine as an argument, but this should generally + not be done. + + Examples + -------- + If you want to extract the data for image 0 and 103 (of the training set) for model trained with shuffle 0. + >>> deeplabcut.extract_maps(configfile,0,Indices=[0,103]) + + """ if engine is None: engine = get_shuffle_engine( _load_config(config), @@ -594,6 +1231,52 @@ def extract_save_all_maps( dest_folder: str = None, engine: Engine | None = None, ): + """ + Extracts the scoremap, location refinement field and part affinity field prediction of the model. The maps + will be rescaled to the size of the input image and stored in the corresponding model folder in /evaluation-results. + + This function is only implemented for tensorflow models/shuffles, and will throw + an error if called with a PyTorch shuffle. + + ---------- + config : string + Full path of the config.yaml file as a string. + + shuffle: integer + integers specifying shuffle index of the training dataset. The default is 1. + + trainingsetindex: int, optional + Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). This + variable can also be set to "all". + + comparisonbodyparts: list of bodyparts, Default is "all". + The average error will be computed for those body parts only (Has to be a subset of the body parts). + + extract_paf : bool + Extract part affinity fields by default. + Note that turning it off will make the function much faster. + + all_paf_in_one : bool + By default, all part affinity fields are displayed on a single frame. + If false, individual fields are shown on separate frames. + + Indices: default None + For which images shall the scmap/locref and paf be computed? Give a list of images + + nplots_per_row: int, optional (default=None) + Number of plots per row in grid plots. By default, calculated to approximate a squared grid of plots + + engine: Engine, optional, default = None. + The default behavior loads the engine for the shuffle from the metadata. You can + overwrite this by passing the engine as an argument, but this should generally + not be done. + + Examples + -------- + Calculated maps for images 0, 1 and 33. + >>> deeplabcut.extract_save_all_maps('/analysis/project/reaching-task/config.yaml', shuffle=1,Indices=[0,1,33]) + + """ if engine is None: engine = get_shuffle_engine( _load_config(config), @@ -634,6 +1317,59 @@ def export_model( modelprefix: str = "", engine: Engine | None = None, ): + """Export DeepLabCut models for the model zoo or for live inference. + + Saves the pose configuration, snapshot files, and frozen TF graph of the model to + directory named exported-models within the project directory + + This function is only implemented for tensorflow models/shuffles, and will throw + an error if called with a PyTorch shuffle. + + Parameters + ----------- + + cfg_path : string + path to the DLC Project config.yaml file + + shuffle : int, optional + the shuffle of the model to export. default = 1 + + trainingsetindex : int, optional + the index of the training fraction for the model you wish to export. default = 1 + + snapshotindex : int, optional + the snapshot index for the weights you wish to export. If None, + uses the snapshotindex as defined in 'config.yaml'. Default = None + + iteration : int, optional + The model iteration (active learning loop) you wish to export. If None, + the iteration listed in the config file is used. + + TFGPUinference : bool, optional + use the tensorflow inference model? Default = True + For inference using DeepLabCut-live, it is recommended to set TFGPIinference=False + + overwrite : bool, optional + if the model you wish to export has already been exported, whether to overwrite. default = False + + make_tar : bool, optional + Do you want to compress the exported directory to a tar file? Default = True + This is necessary to export to the model zoo, but not for live inference. + + wipepaths : bool, optional + Removes the actual path of your project and the init_weights from pose_cfg. + + engine: Engine, optional, default = None. + The default behavior loads the engine for the shuffle from the metadata. You can + overwrite this by passing the engine as an argument, but this should generally + not be done. + + Example: + -------- + Export the first stored snapshot for model trained with shuffle 3: + >>> deeplabcut.export_model('/analysis/project/reaching-task/config.yaml',shuffle=3, snapshotindex=-1) + -------- + """ if engine is None: engine = get_shuffle_engine( _load_config(cfg_path), diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py index b78b5727b2..0d6b5fd334 100644 --- a/deeplabcut/gui/tabs/train_network.py +++ b/deeplabcut/gui/tabs/train_network.py @@ -75,7 +75,7 @@ def _set_page(self): def show_help_dialog(self): dialog = QtWidgets.QDialog(self) layout = QtWidgets.QVBoxLayout() - label = QtWidgets.QLabel(deeplabcut.train_network.__doc__, self) + label = QtWidgets.QLabel(compat.train_network.__doc__, self) scroll = QtWidgets.QScrollArea() scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index cf1ae394b9..ccf8445f43 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -45,7 +45,7 @@ def train( loader: Loader, run_config: dict, task: Task, - device: str = "cpu", + device: str | None = "cpu", logger_config: dict | None = None, snapshot_path: str | None = None, transform: A.BaseCompose | None = None, @@ -68,13 +68,23 @@ def train( max_snapshots_to_keep: the maximum number of snapshots to store for each model """ weight_init = None + pretrained = True if weight_init_cfg := run_config["train_settings"].get("weight_init"): weight_init = WeightInitialization.from_dict(weight_init_cfg) + pretrained = False if task == Task.DETECT: - model = DETECTORS.build(run_config["model"], weight_init=weight_init) + model = DETECTORS.build( + run_config["model"], + weight_init=weight_init, + pretrained=pretrained, + ) else: - model = PoseModel.build(run_config["model"], weight_init=weight_init) + model = PoseModel.build( + run_config["model"], + weight_init=weight_init, + pretrained_backbone=pretrained, + ) if max_snapshots_to_keep is not None: run_config["runner"]["snapshots"]["max_snapshots"] = max_snapshots_to_keep @@ -87,6 +97,9 @@ def train( if device is None: device = utils.resolve_device(run_config) + if device == "mps" and task == Task.DETECT: + device = "cpu" # FIXME: Cannot train detectors on MPS + model.to(device) # Move model before giving its parameters to the optimizer runner = build_training_runner( runner_config=run_config["runner"], diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index b401847931..273d6a1a8d 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -497,14 +497,14 @@ def apply_transform( # out-of-bound keypoints have visibility flag 0. But we don't touch coordinates if np.sum(oob_mask) > 0: - transformed["keypoints"][oob_mask][..., -1] = 0 + transformed["keypoints"][oob_mask, 2] = 0.0 out_shape = transformed["image"].shape if len(transformed["keypoints"]) > 0: oob_mask = _out_of_bounds_keypoints(transformed["keypoints"], out_shape) # out-of-bound keypoints have visibility flag 0. Don't touch coordinates if np.sum(oob_mask) > 0: - transformed["keypoints"][oob_mask][..., -1] = 0 + transformed["keypoints"][oob_mask, -1] = 0.0 # TODO: Check that the transformed bboxes are still within the image if len(transformed["bboxes"]) > 0: @@ -604,7 +604,9 @@ def _out_of_bounds_keypoints(keypoints: np.ndarray, shape: tuple) -> np.ndarray: were kicked off an image due to augmentation. """ return (keypoints[..., 2] > 0) & ( - (keypoints[..., 0] < 0) + np.isnan(keypoints[..., 0]) + | np.isnan(keypoints[..., 1]) + | (keypoints[..., 0] < 0) | (keypoints[..., 0] > shape[1]) | (keypoints[..., 1] < 0) | (keypoints[..., 1] > shape[0]) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index 17f0531e57..bf7d6db670 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -21,21 +21,23 @@ def _build_detector( - cfg: dict, weight_init: WeightInitialization | None = None, **kwargs, + cfg: dict, + weight_init: WeightInitialization | None = None, + pretrained: bool = False, + **kwargs, ) -> BaseDetector: """Builds a detector using its configuration file Args: cfg: The detector configuration. weight_init: The weight initialization to use. + pretrained: Whether COCO pretrained weights should be loaded for the detector **kwargs: Other parameters given by the Registry. Returns: the built detector """ - if weight_init is not None: - cfg["pretrained"] = False - + cfg["pretrained"] = pretrained detector: BaseDetector = build_from_cfg(cfg, **kwargs) if weight_init is not None: diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index 02ccdf3f79..4b9cc634da 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -12,7 +12,6 @@ import torch import torchvision.models.detection as detection -from torch import nn from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( DETECTORS, diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 2444260422..75092712f9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -138,19 +138,21 @@ def get_predictions(self, outputs: dict[str, dict[str, torch.Tensor]]) -> dict: def build( cfg: dict, weight_init: None | WeightInitialization = None, + pretrained_backbone: bool = False, ) -> "PoseModel": """ Args: cfg: The configuration of the model to build. weight_init: How model weights should be initialized. If None, ImageNet pre-trained backbone weights are loaded from Timm. + pretrained_backbone: Whether to load an ImageNet-pretrained weights for + the backbone. This should only be set to True when building a model + which will be trained on a transfer learning task. Returns: the built pose model """ - if weight_init is None: # Transfer learning from ImageNet - cfg["backbone"]["pretrained"] = True - + cfg["backbone"]["pretrained"] = pretrained_backbone backbone = BACKBONES.build(dict(cfg["backbone"])) neck = None diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py index 3c5fa85a93..d6641f265e 100644 --- a/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py +++ b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py @@ -69,13 +69,13 @@ def test_gaussian_heatmap_generation_single_keypoint(data): heatmap_mode=HeatmapGaussianGenerator.Mode.KEYPOINT, generate_locref=False, ) - inputs = torch.zeros((1, 3, *data["in_shape"])) + stride = data["in_shape"][0] / data["out_shape"][0] outputs = torch.zeros((1, data["num_heatmaps"], *data["out_shape"])) ann_shape = (1, len(data["centers"]), data["num_heatmaps"], 2) annotations = { "keypoints": torch.tensor(data["centers"]).reshape(ann_shape) # x, y } - targets = generator(inputs, {"heatmap": outputs}, annotations) + targets = generator(stride, {"heatmap": outputs}, annotations) print("Targets") print(targets["heatmap"]["target"]) @@ -101,8 +101,8 @@ def test_random_gaussian_target_generation( ) } # batch size, num animals, num keypoints, 2 for x,y - # generate input images (batch_size, 3, h, w) - inputs = torch.zeros((batch_size, 3, *image_size)) + # model stride 1 + stride = 1 # generate predictions predicted_heatmaps = { @@ -116,7 +116,7 @@ def test_random_gaussian_target_generation( heatmap_mode=HeatmapGaussianGenerator.Mode.KEYPOINT, generate_locref=False, ) - targets = generator(inputs, predicted_heatmaps, annotations) + targets = generator(stride, predicted_heatmaps, annotations) target_heatmap = targets["heatmap"]["target"].reshape( batch_size, num_keypoints, image_size[0] * image_size[1] ) diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py index 4b68b943ae..4aa7133a4d 100644 --- a/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py +++ b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py @@ -71,13 +71,13 @@ def test_plateau_heatmap_generation_single_keypoint(data): heatmap_mode=HeatmapGenerator.Mode.KEYPOINT, generate_locref=False, ) - inputs = torch.zeros((1, 3, *data["in_shape"])) + stride = data["in_shape"][0] / data["out_shape"][0] outputs = torch.zeros((1, data["num_heatmaps"], *data["out_shape"])) ann_shape = (1, len(data["centers"]), data["num_heatmaps"], 2) annotations = { "keypoints": torch.tensor(data["centers"]).reshape(ann_shape) # x, y } - targets = generator(inputs, {"heatmap": outputs}, annotations) + targets = generator(stride, {"heatmap": outputs}, annotations) print("Targets") print(targets["heatmap"]["target"]) diff --git a/tests/pose_estimation_pytorch/other/test_dataset.py b/tests/pose_estimation_pytorch/other/test_dataset.py index e19b407515..0bbcdbb1a0 100644 --- a/tests/pose_estimation_pytorch/other/test_dataset.py +++ b/tests/pose_estimation_pytorch/other/test_dataset.py @@ -120,7 +120,7 @@ def test_iter_all_dataset_no_transform(batch_size): b, _, h, w = item["image"].shape kpts, bboxes = anno["keypoints"], anno["boxes"] assert ( - kpts.shape == (batch_size, max_num_animals, num_keypoints, 2) + kpts.shape == (batch_size, max_num_animals, num_keypoints, 3) or is_last_batch ), "keypoints have the wrong shape" assert ( @@ -184,7 +184,7 @@ def test_iter_all_augmented_dataset(batch_size, x_size, y_size, exaggeration): b, _, h, w = item["image"].shape assert (h == y_size) and (w == x_size) assert ( - kpts.shape == (batch_size, max_num_animals, num_keypoints, 2) + kpts.shape == (batch_size, max_num_animals, num_keypoints, 3) or is_last_batch ), "keypoints have the wrong shape" assert ( diff --git a/tests/pose_estimation_pytorch/other/test_gaussian_targets.py b/tests/pose_estimation_pytorch/other/test_gaussian_targets.py index 443e23ebff..c1b98b0b23 100644 --- a/tests/pose_estimation_pytorch/other/test_gaussian_targets.py +++ b/tests/pose_estimation_pytorch/other/test_gaussian_targets.py @@ -28,7 +28,7 @@ def test_gaussian_target_generation( ) } # batch size, num animals, num keypoints, 2 for x,y # generate predictions - inputs = torch.rand((batch_size, 3, *image_size[:2])) + stride = 1 prediction = { "heatmap": torch.rand((batch_size, num_keypoints, *image_size[:2])), "locref": torch.rand((batch_size, 2 * num_keypoints, *image_size[:2])), @@ -41,7 +41,7 @@ def test_gaussian_target_generation( locref_std=5.0, ) output = torch.tensor( - output(inputs, prediction, labels)["heatmap"]["target"].reshape( + output(stride, prediction, labels)["heatmap"]["target"].reshape( batch_size, num_keypoints, image_size[0] * image_size[1] ) ) diff --git a/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py b/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py index 7d2e1e462e..2508d90860 100644 --- a/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py +++ b/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py @@ -56,7 +56,7 @@ def get_target( 1, min(image_size), (batch_size, num_animals, num_joints, 2) ) } # 2 for x,y coords - inputs = torch.rand((batch_size, 3, image_size[0], image_size[1])) + stride = 1 prediction = { "heatmap": torch.rand((batch_size, num_joints, image_size[0], image_size[1])), "locref": torch.rand((batch_size, 2 * num_joints, image_size[0], image_size[1])), @@ -68,7 +68,7 @@ def get_target( generate_locref=True, ) - targets_output = generator(inputs, prediction, labels) + targets_output = generator(stride, prediction, labels) return targets_output, labels diff --git a/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py b/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py index 9e8ec57df4..943e5c9882 100644 --- a/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py +++ b/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py @@ -26,7 +26,8 @@ def animals_and_keypoints_invalid(): ground truth keypoints (gt_kpts), of shape num_animals, num_keypoints, (x,y) individual names (indv_names) """ - gt_kpts = np.random.rand(6, 6, 2) # num animals, num keypoints, (x,y) + gt_kpts = 2 * np.ones((6, 6, 3)) # num animals, num keypoints, (x,y,vis) + gt_kpts[:, :, :2] = np.random.rand(6, 6, 2) pred_kpts = np.random.rand(6, 8, 3) # num animals, num keypoints, (x,y,score) indv_names = ["indv1", "indv2"] return pred_kpts, gt_kpts, indv_names @@ -43,7 +44,8 @@ def animals_and_keypoints(): ground truth keypoints (gt_kpts), of shape num_animals, num_keypoints, (x,y) individual names (indv_names) """ - gt_kpts = np.random.rand(6, 6, 2) # num animals, num keypoints, (x,y) + gt_kpts = 2 * np.ones((6, 6, 3)) # num animals, num keypoints, (x,y,vis) + gt_kpts[:, :, :2] = np.random.rand(6, 6, 2) # adding score value because the shape of pred_kpts should be (6,6,3) score = np.full((gt_kpts.shape[0], gt_kpts.shape[1], 1), 0.5) diff --git a/tests/pose_estimation_pytorch/other/test_paf_targets.py b/tests/pose_estimation_pytorch/other/test_paf_targets.py index 4db48311ad..f01fc3275a 100644 --- a/tests/pose_estimation_pytorch/other/test_paf_targets.py +++ b/tests/pose_estimation_pytorch/other/test_paf_targets.py @@ -27,13 +27,12 @@ def test_paf_target_generation( ) } # 2 for x,y coords graph = [(i, j) for i in range(num_keypoints) for j in range(i + 1, num_keypoints)] - inputs = torch.rand((batch_size, 3, image_size[0], image_size[1])) prediction = { "heatmap": torch.rand((batch_size, num_keypoints, image_size[0], image_size[1])), "paf": torch.rand((batch_size, len(graph) * 2, image_size[0], image_size[1])), } generator = pafs_targets.PartAffinityFieldGenerator(graph=graph, width=20) - targets_output = generator(inputs, prediction, labels) + targets_output = generator(1, prediction, labels) assert targets_output["paf"]["target"].shape == ( batch_size, len(graph) * 2, diff --git a/tests/pose_estimation_pytorch/other/test_pose_model.py b/tests/pose_estimation_pytorch/other/test_pose_model.py index f45428ca41..977cbbb88e 100644 --- a/tests/pose_estimation_pytorch/other/test_pose_model.py +++ b/tests/pose_estimation_pytorch/other/test_pose_model.py @@ -131,6 +131,7 @@ "total_stride": 1, "input_channels": -1, "output_channels": -1, + "head_stride": 1, }, { "type": "DEKRHead", diff --git a/tests/pose_estimation_pytorch/other/test_seq_targets.py b/tests/pose_estimation_pytorch/other/test_seq_targets.py index 8273bffd21..82f931f520 100644 --- a/tests/pose_estimation_pytorch/other/test_seq_targets.py +++ b/tests/pose_estimation_pytorch/other/test_seq_targets.py @@ -44,14 +44,12 @@ def test_sequential_generator(): 1, min(image_size), (batch_size, num_animals, num_keypoints, 2) ) } - prediction = [torch.rand((batch_size, num_keypoints, image_size[0], image_size[1]))] - inputs = torch.rand(batch_size, 3, *image_size) head_outputs = { "heatmap": torch.rand(batch_size, num_keypoints, 32, 32), "locref": torch.rand(batch_size, num_keypoints * 2, 32, 32), "paf": torch.rand(batch_size, num_limbs * 2, 32, 32), } - out = gen(inputs=inputs, outputs=head_outputs, labels=annotations) + out = gen(stride=1, outputs=head_outputs, labels=annotations) assert all(s in out for s in list(head_outputs)) for k, v in head_outputs.items(): assert out[k]["target"].shape == v.shape From 02621dd5b8afe8d72e1b952de430da1866f41275 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Fri, 7 Jun 2024 22:57:54 +0200 Subject: [PATCH 101/293] Automatically open napari to view labeled images (#214) * Minor fix * Auto open napari after image creation * Fix double quotes --- deeplabcut/gui/tabs/evaluate_network.py | 7 ++++++- deeplabcut/gui/tabs/label_frames.py | 3 +++ deeplabcut/pose_estimation_tensorflow/core/evaluate.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/deeplabcut/gui/tabs/evaluate_network.py b/deeplabcut/gui/tabs/evaluate_network.py index b94590a33e..633552903b 100644 --- a/deeplabcut/gui/tabs/evaluate_network.py +++ b/deeplabcut/gui/tabs/evaluate_network.py @@ -14,6 +14,7 @@ FigureCanvasQTAgg as FigureCanvas, ) from matplotlib.figure import Figure +from pathlib import Path from PySide6 import QtWidgets from PySide6.QtCore import Qt @@ -27,7 +28,7 @@ _create_label_widget, _create_vertical_layout, ) -from deeplabcut.gui.widgets import ConfigEditor +from deeplabcut.gui.widgets import ConfigEditor, launch_napari class GridCanvas(QtWidgets.QDialog): @@ -202,3 +203,7 @@ def evaluate_network(self): show_errors=True, comparisonbodyparts=bodyparts_to_use, ) + + if plotting: + labeled_images = list(Path(config).parent / "evaluation-results").rglob("**/Labeled*/*.png") + _ = launch_napari(labeled_images) \ No newline at end of file diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py index 507f61a00b..24d81e912e 100644 --- a/deeplabcut/gui/tabs/label_frames.py +++ b/deeplabcut/gui/tabs/label_frames.py @@ -9,6 +9,7 @@ # Licensed under GNU Lesser General Public License v3.0 # import os +from pathlib import Path from PySide6 import QtWidgets from PySide6.QtCore import Qt from deeplabcut.generate_training_dataset import check_labels @@ -60,3 +61,5 @@ def label_frames(self): def check_labels(self): check_labels(self.root.config, visualizeindividuals=self.root.is_multianimal) + labeled_images = (Path(self.root.config).parent / "labeled-data").rglob("*_labeled/*.png") + _ = launch_napari(labeled_images) diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate.py index c7214df642..8d24ab0f8d 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/evaluate.py +++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate.py @@ -856,7 +856,7 @@ def evaluate_network( cfg, shuffle, trainFraction, - trainingsiterations, + trainingsiterations=trainingsiterations, modelprefix=modelprefix, ) print( From c3e7cf6b9d345ae01ce227a12a0dbdf6066e4111 Mon Sep 17 00:00:00 2001 From: shaokai Date: Sat, 8 Jun 2024 14:04:59 +0200 Subject: [PATCH 102/293] Shaokai/memory replay (#204) * resolved conflicts * Fixed bugs and memory replay should work * niels - memory replay bug fixes (#207) * removed breaking imports * linting and cleaning * Fixed the bug that the pseudo prediction was not used in the memory replay * Added comments --------- Co-authored-by: Niels Poulsen Co-authored-by: n-poulsen <45132115+n-poulsen@users.noreply.github.com> --- .../memory_replay_example.py | 18 +- .../trainingsetmanipulation.py | 4 +- .../datasets/base_dlc.py | 7 +- .../datasets/materialize.py | 35 +-- .../generalized_data_converter/utils.py | 18 +- .../pose_estimation_pytorch/apis/train.py | 37 +-- .../pose_estimation_pytorch/data/dlcloader.py | 4 +- .../modelzoo/memory_replay.py | 228 +++++++++++++++-- deeplabcut/utils/pseudo_label.py | 232 +++--------------- 9 files changed, 305 insertions(+), 278 deletions(-) diff --git a/benchmark_superanimal/memory_replay_example.py b/benchmark_superanimal/memory_replay_example.py index e3b38baefe..2db0a01759 100644 --- a/benchmark_superanimal/memory_replay_example.py +++ b/benchmark_superanimal/memory_replay_example.py @@ -18,22 +18,19 @@ superanimal_name = "superanimal_topviewmouse" model_name = "hrnetw32" shuffle = 0 -conversion_table_out_path = "conversion_table.csv" max_individuals = 3 device = "cuda" - # keypoint matching before create training dataset # keypoint matching creates pseudo prediction and a conversion table - -# should be fine to infer max individuals in keypoint matching keypoint_matching( config_path, superanimal_name, model_name, ) +# keypoint matching creates a memory_replay folder in the root. The conversion table can be read from there conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" table = create_conversion_table( @@ -42,7 +39,6 @@ project_to_super_animal=read_conversion_table_from_csv(conversion_table_path), ) -# make sure to merge this weight init with the example given by Niels below weight_init = WeightInitialization( dataset=superanimal_name, conversion_array=table.to_array(), @@ -59,11 +55,7 @@ engine=Engine.PYTORCH, userfeedback=False, ) -""" -# check the max individual thing one more time -deeplabcut.train_network(config_path, - shuffle = shuffle, - superanimal_name = superanimal_name, - max_individuals = max_individuals, - device = device) -""" + +# passing pose_threshold controls the behavior of memory replay. We discard predictions that are lower than the threshold +deeplabcut.train_network(config_path, shuffle=shuffle, device=device, pose_threshold = 0.1) + diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index a24939bfce..d7f80b1d1d 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -1226,7 +1226,9 @@ def create_training_dataset( from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config from deeplabcut.pose_estimation_pytorch.modelzoo.config import make_super_animal_finetune_config - pose_cfg_path = path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml") + pose_cfg_path = path_train_config.replace( + "pose_cfg.yaml", "pytorch_config.yaml" + ) if weight_init is not None and weight_init.with_decoder: pytorch_cfg = make_super_animal_finetune_config( project_config=cfg, diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py index 0a20439643..cc7edcece4 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py @@ -14,8 +14,8 @@ import numpy as np import pandas as pd -import deeplabcut from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset +from deeplabcut.utils import auxiliaryfunctions class BaseDLCPoseDataset(BasePoseDataset): @@ -37,15 +37,14 @@ def __init__(self, proj_root, dataset_name, shuffle=1, modelprefix=""): else: config_file = os.path.join(self.proj_root, "config.yaml") - cfg = deeplabcut.auxiliaryfunctions.read_config(config_file) + cfg = auxiliaryfunctions.read_config(config_file) task = cfg["Task"] scorer = cfg["scorer"] datasets_folder = os.path.join( - self.proj_root, - deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(cfg), + self.proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg), ) self.datasets_folder = datasets_folder diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py index d01eb914ec..30a6404654 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -18,10 +18,15 @@ import scipy.io as sio import yaml -import deeplabcut +import deeplabcut.compat as compat +from deeplabcut.utils import auxiliaryfunctions from deeplabcut.generate_training_dataset.multiple_individuals_trainingsetmanipulation import ( + create_multianimaltraining_dataset, format_multianimal_training_data, ) +from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( + create_training_dataset, +) from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( format_training_data as format_single_training_data, ) @@ -39,24 +44,24 @@ def modify_train_test_cfg(config_path, shuffle=1, modelprefix=""): # use gradient masking # set batch size as 8 trainposeconfigfile, testposeconfigfile, snapshotfolder = ( - deeplabcut.return_train_network_path( + compat.return_train_network_path( config_path, shuffle=shuffle, modelprefix=modelprefix, trainingsetindex=0 ) ) - train_cfg = deeplabcut.auxiliaryfunctions.read_plainconfig(trainposeconfigfile) + train_cfg = auxiliaryfunctions.read_plainconfig(trainposeconfigfile) train_cfg["multi_stage"] = True train_cfg["batch_size"] = 8 train_cfg["gradient_masking"] = True - deeplabcut.auxiliaryfunctions.write_plainconfig(trainposeconfigfile, train_cfg) + auxiliaryfunctions.write_plainconfig(trainposeconfigfile, train_cfg) - test_cfg = deeplabcut.auxiliaryfunctions.read_plainconfig(testposeconfigfile) + test_cfg = auxiliaryfunctions.read_plainconfig(testposeconfigfile) test_cfg["multi_stage"] = True test_cfg["batch_size"] = 8 test_cfg["gradient_masking"] = True - deeplabcut.auxiliaryfunctions.write_plainconfig(testposeconfigfile, test_cfg) + auxiliaryfunctions.write_plainconfig(testposeconfigfile, test_cfg) class NpEncoder(json.JSONEncoder): @@ -332,7 +337,7 @@ def _generic2madlc( mode="w", ) # paf_graph default as None. But I am not sure how to do better - deeplabcut.create_multianimaltraining_dataset( + create_multianimaltraining_dataset( os.path.join(proj_root, "config.yaml"), paf_graph=None ) @@ -341,14 +346,14 @@ def _generic2madlc( config_path = os.path.join(proj_root, "config.yaml") - cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + cfg = auxiliaryfunctions.read_config(config_path) train_folder = os.path.join( - proj_root, deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(cfg) + proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg) ) datafilename, metafilename = ( - deeplabcut.auxiliaryfunctions.GetDataandMetaDataFilenames( + auxiliaryfunctions.GetDataandMetaDataFilenames( train_folder, train_fraction, 1, cfg ) ) @@ -566,22 +571,20 @@ def _generic2sdlc( mode="w", ) - deeplabcut.create_training_dataset( - os.path.join(proj_root, "config.yaml"), - ) + create_training_dataset(os.path.join(proj_root, "config.yaml")) # dlc's merge_annotation messes up my indices, so I will need to overwrite the documentation file # I could have done it in a more elegant way if I could modify part of DLC source code, but for backward compatibility reasons, overriding documentation is smarter config_path = os.path.join(proj_root, "config.yaml") - cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + cfg = auxiliaryfunctions.read_config(config_path) train_folder = os.path.join( - proj_root, deeplabcut.auxiliaryfunctions.GetTrainingSetFolder(cfg) + proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg) ) datafilename, metafilename = ( - deeplabcut.auxiliaryfunctions.GetDataandMetaDataFilenames( + auxiliaryfunctions.GetDataandMetaDataFilenames( train_folder, train_fraction, 1, cfg ) ) diff --git a/deeplabcut/modelzoo/generalized_data_converter/utils.py b/deeplabcut/modelzoo/generalized_data_converter/utils.py index 490639088c..368e0380b9 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/utils.py +++ b/deeplabcut/modelzoo/generalized_data_converter/utils.py @@ -11,18 +11,15 @@ import glob import os import pickle -import sys from pathlib import Path import numpy as np import pandas as pd -import deeplabcut +from deeplabcut.utils import auxiliaryfunctions from deeplabcut.modelzoo.generalized_data_converter.datasets.materialize import ( - MaDLC_config, SingleDLC_config, ) -from deeplabcut.pose_estimation_tensorflow.config import load_config def threshold_kpts(config_path, h5path, threshold_mean=0.9, threshold_min=0.1): @@ -35,7 +32,7 @@ def threshold_kpts(config_path, h5path, threshold_mean=0.9, threshold_min=0.1): except: data = df[scorer] - cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + cfg = auxiliaryfunctions.read_config(config_path) bodyparts = cfg["multianimalbodyparts"] @@ -206,11 +203,12 @@ def create_video_h5_from_pickle(proj_root, cfg, reference_pickle, videopath): trainFraction = cfg["TrainingFraction"][0] modelfolder = os.path.join( cfg["project_path"], - str(deeplabcut.auxiliaryfunctions.get_model_folder(trainFraction, 0, cfg)), + str(auxiliaryfunctions.get_model_folder(trainFraction, 0, cfg)), ) path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml" - test_cfg = load_config(str(path_test_config)) + test_cfg = auxiliaryfunctions.read_plainconfig(path_test_config) + start = 0 stop = 10 fps = 10 @@ -274,17 +272,17 @@ def add_skeleton(config_path, pretrain_model_name): skeleton = skeleton_dict[pretrain_model_name] - cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + cfg = auxiliaryfunctions.read_config(config_path) cfg["skeleton"] = skeleton print(f"overwriting skeleton for {config_path}") - deeplabcut.auxiliaryfunctions.write_config(config_path, cfg) + auxiliaryfunctions.write_config(config_path, cfg) def customized_colormap(config_path): # look for all symmetric keypoints # make symmetric keypoints the same color - cfg = deeplabcut.auxiliaryfunctions.read_config(config_path) + cfg = auxiliaryfunctions.read_config(config_path) bodyparts = cfg["multianimalbodyparts"] n_bodyparts = len(cfg["multianimalbodyparts"]) diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index ccf8445f43..61774cb712 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -19,6 +19,7 @@ from torch.utils.data import DataLoader import deeplabcut.pose_estimation_pytorch.config as torch_config +import deeplabcut.pose_estimation_pytorch.modelzoo.utils as modelzoo_utils import deeplabcut.pose_estimation_pytorch.utils as utils from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.pose_estimation_pytorch.data import ( @@ -173,6 +174,7 @@ def train_network( save_epochs: int | None = None, display_iters: int | None = None, max_snapshots_to_keep: int | None = None, + pose_threshold: float | None = 0.1, **kwargs, ) -> None: """Trains a network for a project @@ -194,6 +196,7 @@ def train_network( display_iters: overrides the number of iterations between each log of the loss within an epoch max_snapshots_to_keep: the maximum number of snapshots to save for each model + pose_threshold: used for memory-replay. pseudo predictions that are below this are discarded for memory-replay **kwargs : could be any entry of the pytorch_config dictionary. Examples are to see the full list see the pytorch_cfg.yaml file in your project folder """ @@ -206,21 +209,27 @@ def train_network( if weight_init_cfg := loader.model_cfg["train_settings"].get("weight_init"): weight_init = WeightInitialization.from_dict(weight_init_cfg) + if weight_init.memory_replay: - raise ValueError(f"Memory replay is not yet implemented! Come back soon!") - # dlc_proj_root = Path(config).parent - # superanimal_model_config = prepare_memory_replay( - # dlc_proj_root, - # shuffle, - # superanimal_name, - # model_name, - # device, - # max_individuals=3, - # ) - # loader = COCOLoader( - # project_root=loader.model_folder / "memory-replay", - # model_config_path=loader.model_config_path, - # ) + dataset_params = loader.get_dataset_parameters() + backbone_name = loader.model_cfg["model"]["backbone"]["model_name"] + model_name = modelzoo_utils.get_pose_model_type(backbone_name) + # at some point train_network should support a different train_file passing so memory replay can also take the same train file + superanimal_model_config = prepare_memory_replay( + loader.project_path, + shuffle, + weight_init.dataset, + model_name, + device, + train_file = "train.json", + max_individuals=dataset_params.max_num_animals, + pose_threshold = pose_threshold + ) + loader = COCOLoader( + project_root=Path(loader.model_folder).parent / "memory_replay", + model_config_path=loader.model_config_path, + train_json_filename = "memory_replay_train.json" + ) if batch_size is not None: loader.model_cfg["train_settings"]["batch_size"] = batch_size diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index eca6f87723..992f54f798 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -201,8 +201,8 @@ def load_ground_truth( splits = self.load_split(self._project_config, trainset_index, shuffle) dfs = self.split_data(df, splits) dfs["full"] = df - - dfs = _validate_dataframes(dfs, df_train) + # let's not validate for now + # dfs = _validate_dataframes(dfs, df_train) return dfs, image_sizes @staticmethod diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py index d3ea6798be..41141fd409 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py @@ -13,6 +13,7 @@ from pathlib import Path import deeplabcut.utils.auxiliaryfunctions as af +from deeplabcut.core.engine import Engine from deeplabcut.core.weight_init import WeightInitialization from deeplabcut.modelzoo.generalized_data_converter.datasets import ( COCOPoseDataset, @@ -24,6 +25,195 @@ get_config_model_paths, update_config, ) +import json +import os +from scipy.optimize import linear_sum_assignment +from scipy.spatial import distance +from scipy.spatial.distance import cdist +from deeplabcut.utils.pseudo_label import xywh2xyxy, optimal_match,calculate_iou +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners +import glob +import numpy as np +from collections import defaultdict + +# this is reading from a coco project +def prepare_memory_replay_dataset( + source_dataset_folder, + superanimal_name, + model_name, + max_individuals=1, + train_file="train.json", + test_file="test.json", + pose_threshold=0.0, + device=None, + pose_model_path="", + detector_path="", + customized_pose_checkpoint=None, +): + """ + Need to first run inference on the source project train file + """ + + ( + model_config, + project_config, + pose_model_path, + detector_path, + ) = get_config_model_paths(superanimal_name, model_name) + + if customized_pose_checkpoint is not None: + pose_model_path = customized_pose_checkpoint + + config = {**project_config, **model_config} + config = update_config(config, max_individuals, device) + individuals = [f"animal{i}" for i in range(max_individuals)] + config["individuals"] = individuals + num_bodyparts = len(config["bodyparts"]) + train_file_path = os.path.join(source_dataset_folder, "annotations", train_file) + + pose_runner, detector_runner = get_inference_runners( + config, + snapshot_path=pose_model_path, + max_individuals=max_individuals, + num_bodyparts=len(model_config["metadata"]["bodyparts"]), + num_unique_bodyparts=0, + detector_path=detector_path, + ) + + with open(train_file_path, "r") as f: + train_obj = json.load(f) + + images = train_obj["images"] + annotations = train_obj["annotations"] + categories = train_obj["categories"] + imagename2id = {} + imageid2name = {} + imagename2gt = defaultdict(list) + + for image in images: + # this only works with relative path as the testing image can be at a different folder + imagename = image["file_name"].split(os.sep)[-1] + imagename2id[imagename] = image["id"] + imageid2name[image["id"]] = imagename + + imagename2bbox = defaultdict(list) + for anno in annotations: + imagename = imageid2name[anno["image_id"]] + imagename2gt[imagename].append(anno) + imagename2bbox[imagename].append(anno["bbox"]) + + imageid2annotations = defaultdict(list) + + imageids = list(imagename2id.values()) + for annotation in annotations: + image_id = annotation["image_id"] + if annotation["image_id"] in imageids: + imageid2annotations[image_id].append(annotation) + + + # need to support more image types + image_extensions = ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.tiff"] + + images_in_folder = [] + for ext in image_extensions: + images_in_folder.extend( + glob.glob(os.path.join(source_dataset_folder, "images", ext)) + ) + + corresponded_images = [] + for image in images_in_folder: + image_path = image + imagename = image.split(os.sep)[-1] + if imagename in imagename2id: + corresponded_images.append(image_path) + + images = corresponded_images + + bbox_predictions = detector_runner.inference(images=images) + + bbox_gts = [ + {"bboxes": np.array(imagename2bbox[image.split(os.sep)[-1]])} + for image in images + ] + + pose_inputs = list(zip(images, bbox_gts)) + + # pose inference should return meta data for pseudo labeling + predictions = pose_runner.inference(pose_inputs) + + assert len(images) == len(predictions) + + imagename2prediction = {} + + for image_path, prediction in zip(images, predictions): + imagename = image_path.split(os.sep)[-1] + imagename2prediction[imagename] = prediction + + def xywh2xyxy(bbox): + temp_bbox = np.copy(bbox) + temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:] + return temp_bbox + + def optimal_match(gts_list, preds_list): + arranged_preds_list = [] + num_gts = len(gts_list) + num_preds = len(preds_list) + cost_matrix = np.zeros((num_gts, num_preds)) + + for i in range(num_gts): + for j in range(num_preds): + cost_matrix[i, j] = distance.euclidean( + gts_list[i][..., :2].flatten(), preds_list[j][..., :2].flatten() + ) + row_ind, col_ind = linear_sum_assignment(cost_matrix) + + return col_ind + + for imagename, gts in imagename2gt.items(): + bbox_gts = [np.array(gt["bbox"]) for gt in gts] + bbox_gts = [xywh2xyxy(e) for e in bbox_gts] + prediction = imagename2prediction[imagename] + bbox_preds = [xywh2xyxy(pred) for pred in prediction["bboxes"]] + optimal_pred_indices = optimal_match(bbox_gts, bbox_preds) + + for idx in range(len(bbox_gts)): + if idx == len(optimal_pred_indices): + break + + optimal_index = optimal_pred_indices[idx] + matched_gt = np.array(gts[idx]["keypoints"]) + matched_pred = prediction["bodyparts"][optimal_index] + bbox_gt = bbox_gts[idx] + bbox_pred = bbox_preds[idx] + + # maybe check iou of two bbox + iou = calculate_iou(bbox_gt, bbox_pred) + if iou < 0.7: + matched_gt = np.ones_like(matched_gt) * -1 + gts[idx]["keypoints"] = list(matched_gt.flatten()) + else: + matched_gt = matched_gt.reshape(num_bodyparts, -1) + matched_pred = matched_pred.reshape(num_bodyparts, -1) + mask = matched_gt == -1 + matched_gt[mask] = matched_pred[mask] + # after the mixing, we don't care about confidence anymore + + for kpt_idx in range(len(matched_gt)): + if matched_gt[kpt_idx][2] < pose_threshold and matched_gt[kpt_idx][2] > 0: + matched_gt[kpt_idx][2] = -1 + elif matched_gt[kpt_idx][2] > 0: + matched_gt[kpt_idx][2] = 2 + + gts[idx]["keypoints"] = list(matched_gt.flatten()) + + # memory replay path + memory_replay_train_file_path = os.path.join( + source_dataset_folder, "annotations", "memory_replay_train.json") + + with open(memory_replay_train_file_path, "w") as f: + json.dump(train_obj, f, indent=4) + + def prepare_memory_replay( @@ -34,6 +224,8 @@ def prepare_memory_replay( device: str, max_individuals=3, trainingsetindex: int = 0, + train_file = "train.json", + pose_threshold = 0.1 ): """TODO: Documentation""" ( @@ -45,10 +237,17 @@ def prepare_memory_replay( # in order to fill the num_bodyparts stuff + config_path = Path(dlc_proj_root, "config.yaml") + cfg = af.read_config(config_path) + if "individuals" in cfg: - temp_dataset = MaDLCPoseDataset(str(dlc_proj_root), "temp_dataset") + temp_dataset = MaDLCPoseDataset( + str(dlc_proj_root), "temp_dataset", shuffle=shuffle + ) else: - temp_dataset = SingleDLCPoseDataset(str(dlc_proj_root), "temp_dataset") + temp_dataset = SingleDLCPoseDataset( + str(dlc_proj_root), "temp_dataset", shuffle=shuffle + ) superanimal_model_config = {**project_config, **superanimal_model_config} superanimal_model_config = update_config( @@ -92,8 +291,6 @@ def prepare_memory_replay( "``with_decoder=True`` for your ``WeightInitialization``." ) - # keypoint matching removed here - dataset = COCOPoseDataset(memory_replay_folder, "memory_replay_dataset") conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" @@ -101,17 +298,12 @@ def prepare_memory_replay( dataset.project_with_conversion_table(str(conversion_table_path)) dataset.materialize(memory_replay_folder, deepcopy=False, framework="coco") - """ - # but we can copy all training related parameters from the original model config - pose_epochs = original_model_config["train_settings"].get("epochs", 10) - save_epochs = original_model_config["runner"]["snapshots"].get("save_epochs", 10) - batch_size = original_model_config["train_settings"].get("batch_size", 16) - - detector_cfg = original_model_config.get("detector", {}) - detector_train_settings = detector_cfg.get("train_settings", {}) - detector_epochs = detector_train_settings.get("epochs", 1) - detector_batch_size = detector_train_settings.get("batch_size", 4) - detector_save_epochs = ( - detector_cfg.get("runner", {}).get("snapshots", {}).get("save_epochs", 1) - ) - """ + prepare_memory_replay_dataset(memory_replay_folder, + superanimal_name, + model_name, + max_individuals = max_individuals, + device = device, + train_file = train_file, + pose_threshold = pose_threshold + + ) diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py index d94ed82947..d440c71ab4 100644 --- a/deeplabcut/utils/pseudo_label.py +++ b/deeplabcut/utils/pseudo_label.py @@ -39,6 +39,32 @@ ) +class NumpyEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, np.ndarray): + return obj.tolist() # Convert ndarray to list + return super().default(obj) + +def xywh2xyxy(bbox): + temp_bbox = np.copy(bbox) + temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:] + return temp_bbox + +def optimal_match(gts_list, preds_list): + arranged_preds_list = [] + num_gts = len(gts_list) + num_preds = len(preds_list) + cost_matrix = np.zeros((num_gts, num_preds)) + + for i in range(num_gts): + for j in range(num_preds): + cost_matrix[i, j] = distance.euclidean( + gts_list[i][..., :2].flatten(), preds_list[j][..., :2].flatten() + ) + row_ind, col_ind = linear_sum_assignment(cost_matrix) + + return col_ind + def calculate_iou(box1, box2): # Unpack the coordinates x1_1, y1_1, x2_1, y2_1 = box1 @@ -116,7 +142,7 @@ def plot_cost_matrix( def keypoint_matching( - config_path, superanimal_name, model_name, device=None, train_file="train.json" + config_path, superanimal_name, model_name, device=None, train_file="train.json", pose_threshold = 0.1 ): cfg = af.read_config(config_path) @@ -225,6 +251,10 @@ def keypoint_matching( # pose inference should return meta data for pseudo labeling predictions = pose_runner.inference(pose_inputs) + with open(str(memory_replay_folder / 'pseudo_predictions.json'), 'w') as f: + + json.dump(pose_inputs, f, cls = NumpyEncoder) + assert len(images) == len(predictions) imagename2prediction = {} @@ -233,33 +263,12 @@ def keypoint_matching( imagename = image_path.split(os.sep)[-1] imagename2prediction[imagename] = prediction - def xywh2xyxy(bbox): - temp_bbox = np.copy(bbox) - temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:] - return temp_bbox - - def optimal_match(gts_list, preds_list): - arranged_preds_list = [] - num_gts = len(gts_list) - num_preds = len(preds_list) - cost_matrix = np.zeros((num_gts, num_preds)) - - for i in range(num_gts): - for j in range(num_preds): - cost_matrix[i, j] = distance.euclidean( - gts_list[i][..., :2].flatten(), preds_list[j][..., :2].flatten() - ) - row_ind, col_ind = linear_sum_assignment(cost_matrix) - - return col_ind - pred_keypoint_names = config["bodyparts"] num_pred_keypoints = len(pred_keypoint_names) gt_keypoint_names = categories[0]["keypoints"] num_gt_keypoints = len(gt_keypoint_names) match_matrix = np.zeros((num_pred_keypoints, num_gt_keypoints)) - match_dict = defaultdict(lambda: defaultdict(int)) for imagename, gts in imagename2gt.items(): @@ -329,184 +338,6 @@ def optimal_match(gts_list, preds_list): out += f"{source}, {target}\n" f.write(out) - -# this is reading from a coco project -def prepare_memory_replay_dataset( - source_dataset_folder, - superanimal_name, - model_name, - max_individuals=1, - train_file="train.json", - test_file="test.json", - pose_threshold=0.0, - bbox_threshold=0.0, - device=None, - pose_model_path="", - detector_path="", - customized_pose_checkpoint=None, -): - """ - Need to first run inference on the source project train file - """ - - ( - model_config, - project_config, - pose_model_path, - detector_path, - ) = get_config_model_paths(superanimal_name, model_name) - - if customized_pose_checkpoint is not None: - pose_model_path = customized_pose_checkpoint - - if device is None: - device = select_device() - - config = {**project_config, **model_config} - config = update_config(config, max_individuals, device) - individuals = [f"animal{i}" for i in range(max_individuals)] - config["individuals"] = individuals - num_bodyparts = len(config["bodyparts"]) - train_file_path = os.path.join(source_dataset_folder, "annotations", train_file) - - pose_runner, detector_runner = get_inference_runners( - model_config, - snapshot_path=pose_model_path, - max_individuals=max_individuals, - num_bodyparts=len(model_config["metadata"]["bodyparts"]), - num_unique_bodyparts=0, - detector_path=detector_path, - ) - - import json - - with open(train_file_path, "r") as f: - train_obj = json.load(f) - - images = train_obj["images"] - annotations = train_obj["annotations"] - categories = train_obj["categories"] - imagename2id = {} - imageid2name = {} - imagename2gt = defaultdict(list) - - for image in images: - # this only works with relative path as the testing image can be at a different folder - imagename = image["file_name"].split(os.sep)[-1] - imagename2id[imagename] = image["id"] - imageid2name[image["id"]] = imagename - - imagename2bbox = defaultdict(list) - for anno in annotations: - imagename = imageid2name[anno["image_id"]] - imagename2gt[imagename].append(anno) - imagename2bbox[imagename].append(anno["bbox"]) - - imageid2annotations = defaultdict(list) - - imageids = list(imagename2id.values()) - for annotation in annotations: - image_id = annotation["image_id"] - if annotation["image_id"] in imageids: - imageid2annotations[image_id].append(annotation) - - # need to support more image types - images_in_folder = glob.glob(os.path.join(source_dataset_folder, "images", "*.png")) - - corresponded_images = [] - for image in images_in_folder: - image_path = image - imagename = image.split(os.sep)[-1] - if imagename in imagename2id: - corresponded_images.append(image_path) - - images = corresponded_images - - bbox_predictions = detector_runner.inference(images=images) - - bbox_gts = [ - {"bboxes": np.array(imagename2bbox[image.split(os.sep)[-1]])} - for image in images - ] - - pose_inputs = list(zip(images, bbox_gts)) - - # pose inference should return meta data for pseudo labeling - predictions = pose_runner.inference(pose_inputs) - - assert len(images) == len(predictions) - - imagename2prediction = {} - - for image_path, prediction in zip(images, predictions): - imagename = image_path.split(os.sep)[-1] - imagename2prediction[imagename] = prediction - - def xywh2xyxy(bbox): - temp_bbox = np.copy(bbox) - temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:] - return temp_bbox - - def optimal_match(gts_list, preds_list): - arranged_preds_list = [] - num_gts = len(gts_list) - num_preds = len(preds_list) - cost_matrix = np.zeros((num_gts, num_preds)) - - for i in range(num_gts): - for j in range(num_preds): - cost_matrix[i, j] = distance.euclidean( - gts_list[i][..., :2].flatten(), preds_list[j][..., :2].flatten() - ) - row_ind, col_ind = linear_sum_assignment(cost_matrix) - - return col_ind - - for imagename, gts in imagename2gt.items(): - bbox_gts = [np.array(gt["bbox"]) for gt in gts] - bbox_gts = [xywh2xyxy(e) for e in bbox_gts] - prediction = imagename2prediction[imagename] - bbox_preds = [xywh2xyxy(pred) for pred in prediction["bboxes"]] - optimal_pred_indices = optimal_match(bbox_gts, bbox_preds) - - for idx in range(len(bbox_gts)): - if idx == len(optimal_pred_indices): - break - - optimal_index = optimal_pred_indices[idx] - matched_gt = np.array(gts[idx]["keypoints"]) - matched_pred = prediction["bodyparts"][optimal_index] - bbox_gt = bbox_gts[idx] - bbox_pred = bbox_preds[idx] - - # maybe check iou of two bbox - iou = calculate_iou(bbox_gt, bbox_pred) - if iou < 0.7: - matched_gt = np.ones_like(matched_gt) * -1 - gts[idx]["keypoints"] = list(matched_gt.flatten()) - else: - matched_gt = matched_gt.reshape(num_bodyparts, -1) - matched_pred = matched_pred.reshape(num_bodyparts, -1) - mask = matched_gt == -1 - matched_gt[mask] = matched_pred[mask] - # after the mixing, we don't care about confidence anymore - - for kpt_idx in range(len(matched_gt)): - if matched_gt[kpt_idx][2] < 0.7 and matched_gt[kpt_idx][2] > 0: - matched_gt[kpt_idx][2] = 0 - elif matched_gt[kpt_idx][2] > 0: - matched_gt[kpt_idx][2] = 2 - - gts[idx]["keypoints"] = list(matched_gt.flatten()) - - # memory replay path - memory_replay_train_file_path = os.path.join( - source_dataset_folder, "annotations", "memory_replay_train.json" - ) - with open(memory_replay_train_file_path, "w") as f: - json.dump(train_obj, f, indent=4) - - # this is to generate a coco project as an intermediate data def dlc3predictions_2_annotation_from_video( predictions, @@ -669,3 +500,4 @@ def dlc3predictions_2_annotation_from_video( with open(os.path.join(dest_proj_folder, "annotations", "train.json"), "w") as f: json.dump(train_obj, f, indent=4) + From 27b2e954f737a967e060e4c548ad87facc44272a Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Sat, 8 Jun 2024 14:44:20 +0200 Subject: [PATCH 103/293] Add button to edit conversion table (#213) --- deeplabcut/gui/tabs/create_training_dataset.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 65db307984..341538e68c 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -30,6 +30,7 @@ _create_grid_layout, _create_label_widget, ) +from deeplabcut.gui.widgets import launch_napari from deeplabcut.utils.auxiliaryfunctions import ( get_data_and_metadata_filenames, get_training_set_folder, @@ -47,10 +48,14 @@ def __init__(self, root, parent, h1_description): self._generate_layout_attributes(self.layout_attributes) self.main_layout.addLayout(self.layout_attributes) + self.mapping_button = QtWidgets.QPushButton("Edit Conversion Table") + self.mapping_button.clicked.connect(self.edit_conversion_table) + self.ok_button = QtWidgets.QPushButton("Create Training Dataset") self.ok_button.setMinimumWidth(150) self.ok_button.clicked.connect(self.create_training_dataset) + self.main_layout.addWidget(self.mapping_button, alignment=Qt.AlignRight) self.main_layout.addWidget(self.ok_button, alignment=Qt.AlignRight) self.view_shuffles_button = QtWidgets.QPushButton("View Existing Shuffles") @@ -145,6 +150,11 @@ def log_net_choice(self, net): def log_augmentation_choice(self, augmentation): self.root.logger.info(f"Image augmentation set to {augmentation.upper()}") + def edit_conversion_table(self): + # Test beforehand whether a conversion table exists + weight_init = self.weight_init_selector.get_weight_init() + _ = launch_napari(self.root.config) + def create_training_dataset(self): shuffle = self.shuffle.value() cfg = self.root.cfg From 4ea984506d9eca1a4fcf6a8281364eba7db896db Mon Sep 17 00:00:00 2001 From: Lucas Stoffl <37299767+LucZot@users.noreply.github.com> Date: Sat, 8 Jun 2024 15:40:25 +0200 Subject: [PATCH 104/293] Luc/multi gpu training (#174) --- benchmark/coco/train.py | 7 ++-- .../pose_estimation_pytorch/apis/train.py | 3 ++ .../config/base/base.yaml | 2 ++ .../data/transforms.py | 3 ++ .../pose_estimation_pytorch/runners/base.py | 3 ++ .../pose_estimation_pytorch/runners/train.py | 36 ++++++++++++++----- deeplabcut/pose_estimation_pytorch/utils.py | 3 +- 7 files changed, 45 insertions(+), 12 deletions(-) diff --git a/benchmark/coco/train.py b/benchmark/coco/train.py index 675aa43f22..4c5ebb97d8 100644 --- a/benchmark/coco/train.py +++ b/benchmark/coco/train.py @@ -6,7 +6,6 @@ import copy from pathlib import Path -import torch from deeplabcut.pose_estimation_pytorch import COCOLoader, utils from deeplabcut.pose_estimation_pytorch.apis.train import train from deeplabcut.pose_estimation_pytorch.runners.logger import setup_file_logging @@ -19,6 +18,7 @@ def main( test_file: str, model_config_path: str, device: str | None, + gpus: list[int] | None, epochs: int | None, save_epochs: int | None, detector_epochs: int | None, @@ -39,7 +39,6 @@ def main( train_json_filename=train_file, test_json_filename=test_file, ) - utils.fix_seeds(loader.model_cfg["train_settings"]["seed"]) if epochs is None: @@ -87,6 +86,7 @@ def main( run_config=loader.model_cfg["detector"], task=Task.DETECT, device=device, + gpus=gpus, logger_config=logger_config, snapshot_path=detector_path, ) @@ -97,6 +97,7 @@ def main( run_config=loader.model_cfg, task=pose_task, device=device, + gpus=gpus, logger_config=loader.model_cfg.get("logger"), snapshot_path=snapshot_path, ) @@ -109,6 +110,7 @@ def main( parser.add_argument("--train_file", default="train.json") parser.add_argument("--test_file", default="test.json") parser.add_argument("--device", default=None) + parser.add_argument("--gpus", default=None, nargs="+", type=int) parser.add_argument("--epochs", type=int, default=None) parser.add_argument("--save-epochs", type=int, default=None) parser.add_argument("--detector-epochs", type=int, default=None) @@ -122,6 +124,7 @@ def main( args.test_file, args.pytorch_config, args.device, + args.gpus, args.epochs, args.save_epochs, args.detector_epochs, diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 61774cb712..6e1bee7eee 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -47,6 +47,7 @@ def train( run_config: dict, task: Task, device: str | None = "cpu", + gpus: list[int] | None = None, logger_config: dict | None = None, snapshot_path: str | None = None, transform: A.BaseCompose | None = None, @@ -60,6 +61,7 @@ def train( run_config: the model and run configuration task: the task to train the model for device: the torch device to train on (such as "cpu", "cuda", "mps") + gpus: the list of GPU indices to use for multi-GPU training logger_config: the configuration of a logger to use snapshot_path: if continuing to train from a snapshot, the path containing the weights to load @@ -108,6 +110,7 @@ def train( task=task, model=model, device=device, + gpus=gpus, snapshot_path=snapshot_path, logger=logger, ) diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml index 2d2c715693..df02bcf145 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -2,6 +2,7 @@ data: colormode: RGB inference: normalize_images: true + longest_max_size: 1600 train: affine: p: 0.9 @@ -24,6 +25,7 @@ device: auto method: bu runner: type: PoseTrainingRunner + gpus: null key_metric: "test.mAP" key_metric_asc: true eval_interval: 1 diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 4b85413842..5c7e1bbb9a 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -87,6 +87,9 @@ def build_transforms(augmentations: dict) -> A.BaseCompose: ) ) + if (longest_max_size := augmentations.get("longest_max_size")) is not None: + transforms.append(A.LongestMaxSize(longest_max_size)) + if augmentations.get("hist_eq", False): transforms.append(A.Equalize(p=0.5)) if augmentations.get("motion_blur", False): diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py index 30c799d506..e08dfd6be1 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/base.py +++ b/deeplabcut/pose_estimation_pytorch/runners/base.py @@ -31,16 +31,19 @@ def __init__( self, model: ModelType, device: str = "cpu", + gpus: list[int] | None = None, snapshot_path: str | Path | None = None, ): """ Args: model: the model to run device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) + gpus: the list of GPU indices to use for multi-GPU training snapshot_path: the path of a snapshot from which to load model weights """ self.model = model self.device = device + self.gpus = gpus self.snapshot_path = snapshot_path @staticmethod diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index 1ae383b02a..ecdf5b96b9 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -20,6 +20,7 @@ import torch import torch.nn as nn from torch.utils.data import DataLoader +from torch.nn.parallel import DataParallel from deeplabcut.pose_estimation_pytorch.metrics import compute_bbox_metrics from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( @@ -51,6 +52,7 @@ def __init__( optimizer: torch.optim.Optimizer, snapshot_manager: TorchSnapshotManager, device: str = "cpu", + gpus: list[int] | None = None, eval_interval: int = 1, snapshot_path: Path | None = None, scheduler: torch.optim.lr_scheduler.LRScheduler | None = None, @@ -62,13 +64,14 @@ def __init__( optimizer: the optimizer to use when fitting the model snapshot_manager: the module to use to manage snapshots device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) - eval_interval: how often evaluation is run on the test set in epochs + gpus: the list of GPU indices to use for multi-GPU training + eval_interval: how often evaluation is run on the test set (in epochs) snapshot_path: if defined, the path of a snapshot from which to load pretrained weights scheduler: scheduler for adjusting the lr of the optimizer logger: logger to monitor training (e.g WandB logger) """ - super().__init__(model=model, device=device, snapshot_path=snapshot_path) + super().__init__(model=model, device=device, gpus=gpus, snapshot_path=snapshot_path) self.eval_interval = eval_interval self.optimizer = optimizer self.scheduler = scheduler @@ -144,7 +147,11 @@ def fit( runner = Runner(model, optimizer, cfg, device='cuda') runner.fit(train_loader, valid_loader, "example/models" epochs=50) """ - self.model.to(self.device) + if self.gpus: + self.model = DataParallel(self.model, device_ids=self.gpus).cuda() + else: + self.model.to(self.device) + if isinstance(self.logger, ImageLoggerMixin): self.logger.select_images_to_log(train_loader, valid_loader) @@ -293,8 +300,14 @@ def step( inputs = batch["image"] inputs = inputs.to(self.device).float() outputs = self.model(inputs) - target = self.model.get_target(outputs, batch["annotations"]) - losses_dict = self.model.get_loss(outputs, target) + + if self.gpus: + underlying_model = self.model.module + else: + underlying_model = self.model + + target = underlying_model.get_target(outputs, batch["annotations"]) + losses_dict = underlying_model.get_loss(outputs, target) if mode == "train": losses_dict["total_loss"].backward() self.optimizer.step() @@ -305,7 +318,7 @@ def step( if mode == "eval": predictions = { name: {k: v.detach().cpu().numpy() for k, v in pred.items()} - for name, pred in self.model.get_predictions(outputs).items() + for name, pred in underlying_model.get_predictions(outputs).items() } ground_truth = batch["annotations"]["keypoints"] @@ -385,7 +398,6 @@ def _update_epoch_predictions( for path, gt, pred, scale, offset in zip( paths, gt_keypoints, pred_keypoints, scales, offsets, ): - # ground_truth now should already have visibility flag ground_truth = gt.detach().cpu().numpy() gt_with_vis = ground_truth @@ -465,7 +477,12 @@ def step( images = batch["image"] images = images.to(self.device) - target = self.model.get_target(batch["annotations"]) + if self.gpus: + underlying_model = self.model.module + else: + underlying_model = self.model + + target = underlying_model.get_target(batch["annotations"]) for item in target: # target is a list here for key in item: if item[key] is not None: @@ -558,6 +575,7 @@ def build_training_runner( task: Task, model: nn.Module, device: str, + gpus: list[int] | None = None, snapshot_path: str | None = None, logger: BaseLogger | None = None, ) -> TrainingRunner: @@ -570,6 +588,7 @@ def build_training_runner( task: the task the runner will perform model: the model to run device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) + gpus: the list of GPU indices to use for multi-GPU training snapshot_path: the snapshot from which to load the weights logger: the logger to use, if any @@ -593,6 +612,7 @@ def build_training_runner( save_optimizer_state=runner_config["snapshots"]["save_optimizer_state"], ), device=device, + gpus=gpus, eval_interval=runner_config.get("eval_interval"), snapshot_path=snapshot_path, scheduler=scheduler, diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index c9a2242652..f91acdf87a 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -10,7 +10,6 @@ # from __future__ import annotations -import abc import os import random from pathlib import Path @@ -64,7 +63,7 @@ def resolve_device(model_config: dict) -> str: supports_mps = "resnet" in model_config.get("net_type", "resnet") if device == "auto": if torch.cuda.is_available(): - return "cuda:0" + return "cuda" elif supports_mps and torch.backends.mps.is_available(): return "mps" return "cpu" From 6efb2e3a8bf4029d9aedf24c7be810976de9503b Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Sat, 8 Jun 2024 16:10:08 +0200 Subject: [PATCH 105/293] changed default net in GUI (#215) --- .../gui/tabs/create_training_dataset.py | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 341538e68c..fe0624d58d 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -159,8 +159,7 @@ def create_training_dataset(self): shuffle = self.shuffle.value() cfg = self.root.cfg existing_indices = get_existing_shuffle_indices( - cfg=cfg, - train_fraction=cfg["TrainingFraction"][self.root.trainingset_index] + cfg=cfg, train_fraction=cfg["TrainingFraction"][self.root.trainingset_index] ) overwrite = self.overwrite.isChecked() @@ -293,7 +292,7 @@ def _confirm_overwrite(self, shuffle: int, existing_indices: list[int]) -> bool: description=( f"As shuffle {shuffle} already exists{engine_str}, " f"the training-dataset files would be overwritten." - ) + ), ) result = conf.exec() if result != QtWidgets.QMessageBox.Yes: @@ -315,6 +314,7 @@ def update_nets(self, engine: Engine | None) -> None: if engine is None: engine = self.root.engine + default_net = None if engine == Engine.TF: nets = DLCParams.NNETS.copy() if not self.root.is_multianimal: @@ -324,14 +324,18 @@ def update_nets(self, engine: Engine | None) -> None: from deeplabcut.pose_estimation_pytorch import available_models nets = available_models() net_filter = self.get_net_filter() + default_net = self.get_default_net() td_prefix = "top_down_" if net_filter is not None: nets = [ n for n in nets if ( - n in net_filter or - (n.startswith(td_prefix) and n[len(td_prefix):] in net_filter) + n in net_filter + or ( + n.startswith(td_prefix) + and n[len(td_prefix) :] in net_filter + ) ) ] @@ -339,9 +343,11 @@ def update_nets(self, engine: Engine | None) -> None: self.net_choice.removeItem(0) self.net_choice.addItems(nets) - default_net_type = self.root.cfg.get("default_net_type", "resnet_50") - if default_net_type in nets: - self.net_choice.setCurrentIndex(nets.index(default_net_type)) + if default_net is None: + default_net = self.root.cfg.get("default_net_type", "resnet_50") + + if default_net in nets: + self.net_choice.setCurrentIndex(nets.index(default_net)) @Slot(Engine) def update_aug_methods(self, engine: Engine) -> None: @@ -371,7 +377,19 @@ def get_net_filter(self) -> list[str] | None: if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS: return None - return _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init]["model_filter"] + weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init] + return weight_init_cfg["model_filter"] + + def get_default_net(self) -> str | None: + """Returns: the net type that can be used based on weight initialization""" + if self.root.engine != Engine.PYTORCH: + return None + + if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS: + return None + + weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init] + return weight_init_cfg.get("default_net") def view_shuffles(self) -> None: viewer = ShuffleMetadataViewer(root=self.root, parent=self) @@ -566,6 +584,7 @@ def _create_confirmation_box(title, description): "model_filter": None, }, "Transfer Learning - SuperAnimal Quadruped": { + "default_net": "top_down_hrnet_w32", "model_filter": [ "dekr_w32", "hrnet_w32", @@ -573,6 +592,7 @@ def _create_confirmation_box(title, description): "super_animal": "superanimal_quadruped", }, "Transfer Learning - SuperAnimal TopViewMouse": { + "default_net": "top_down_hrnet_w32", "model_filter": [ "dekr_w32", "hrnet_w32", @@ -580,10 +600,12 @@ def _create_confirmation_box(title, description): "super_animal": "superanimal_topviewmouse", }, "Fine-tuning - SuperAnimal Quadruped": { + "default_net": "top_down_hrnet_w32", "model_filter": ["hrnet_w32"], # FIXME - Add ResNet "super_animal": "superanimal_quadruped", }, "Fine-tuning - SuperAnimal TopViewMouse": { + "default_net": "top_down_hrnet_w32", "model_filter": ["hrnet_w32"], # FIXME - Add ResNet "super_animal": "superanimal_topviewmouse", }, From 7ba531c7eae99176967b6aab80a1fd1c7505555f Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Sat, 8 Jun 2024 21:21:53 +0200 Subject: [PATCH 106/293] colormaps dlc3 (#216) * Update superanimal_quadruped.yaml - set colormap * Update superanimal_topviewmouse.yaml * Update make_labeled_video.py --- .../modelzoo/project_configs/superanimal_quadruped.yaml | 2 +- .../modelzoo/project_configs/superanimal_topviewmouse.yaml | 2 +- deeplabcut/utils/make_labeled_video.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml index 6f543aa9c5..4363d51814 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml @@ -70,7 +70,7 @@ skeleton_color: black pcutoff: dotsize: alphavalue: -colormap: +colormap: rainbow # Training,Evaluation and Analysis configuration diff --git a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml index ac93a84e0c..08d2cd752a 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml @@ -61,7 +61,7 @@ skeleton_color: black pcutoff: dotsize: alphavalue: -colormap: +colormap: rainbow # Training,Evaluation and Analysis configuration diff --git a/deeplabcut/utils/make_labeled_video.py b/deeplabcut/utils/make_labeled_video.py index 66e14c185c..4ee2ec11b9 100644 --- a/deeplabcut/utils/make_labeled_video.py +++ b/deeplabcut/utils/make_labeled_video.py @@ -853,8 +853,8 @@ def _create_labeled_video( animals2show="all", skeleton_edges=None, pcutoff=0.6, - dotsize=8, - cmap="cool", + dotsize=6, + cmap="rainbow", color_by="bodypart", skeleton_color="k", trailpoints=0, From 5a2bb8caf9338d285df9b460476931cdbed0003c Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Sun, 9 Jun 2024 09:34:06 +0200 Subject: [PATCH 107/293] niels - GUI detector epochs (#217) --- deeplabcut/compat.py | 9 +- deeplabcut/gui/components.py | 8 +- deeplabcut/gui/tabs/modelzoo.py | 9 +- deeplabcut/gui/tabs/train_network.py | 198 ++++++++++++------ deeplabcut/gui/window.py | 2 + .../pose_estimation_pytorch/apis/train.py | 34 ++- 6 files changed, 186 insertions(+), 74 deletions(-) diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index f1b2f36276..ce3af84b73 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -155,9 +155,12 @@ def train_network( torch_kwargs: You can add any keyword arguments for the deeplabcut.pose_estimation_pytorch train_network method here. These arguments are passed to the downstream method. - Some of the parameters that can be passed are "epochs" (maximum number of - epochs to train the network for), "save_epochs" (the number of epochs between - each snapshot saved), "batch_size" (the batch size to use while training). + Some of the parameters that can be passed are ``epochs`` (maximum number of + epochs to train the network for), ``save_epochs`` (the number of epochs between + each snapshot saved), ``batch_size`` (the batch size to use while training). + When training a top-down model, these parameters are also available for the + detector, with the parameters ``detector_batch_size``, ``detector_epochs`` and + ``detector_save_epochs``. Returns ------- diff --git a/deeplabcut/gui/components.py b/deeplabcut/gui/components.py index 170d43fd1e..b586d1c098 100644 --- a/deeplabcut/gui/components.py +++ b/deeplabcut/gui/components.py @@ -11,7 +11,7 @@ import os from PySide6 import QtWidgets -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, Slot from deeplabcut.gui.dlc_params import DLCParams from deeplabcut.gui.widgets import ConfigEditor @@ -193,6 +193,12 @@ def __init__(self, root, parent): self.setMaximum(10_000) self.setValue(self.root.shuffle_value) self.valueChanged.connect(self.root.update_shuffle) + self.root.shuffle_change.connect(self.update_shuffle) + + @Slot(int) + def update_shuffle(self, new_shuffle: int): + if new_shuffle != self.value(): + self.setValue(new_shuffle) class DefaultTab(QtWidgets.QWidget): diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index 03b0a6eb69..d99ec5989b 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -58,7 +58,14 @@ def _set_page(self): model_combo_text = QtWidgets.QLabel("Supermodel name") self.model_combo = QtWidgets.QComboBox() - supermodels = [model for model in MODELOPTIONS if "superanimal" in model] + supermodels = [ + model + for model in MODELOPTIONS + if ( + "superanimal" in model + and model not in ("superanimal_topviewmouse", "superanimal_quadruped") + ) + ] self.model_combo.addItems(supermodels) scales_label = QtWidgets.QLabel("Scale list") diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py index 0d6b5fd334..115f5e6027 100644 --- a/deeplabcut/gui/tabs/train_network.py +++ b/deeplabcut/gui/tabs/train_network.py @@ -8,6 +8,8 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + import os from dataclasses import dataclass @@ -33,6 +35,13 @@ class IntTrainAttribute: default: int min: int max: int + tooltip: str | None = None + + +@dataclass +class TrainAttributeRow: + attributes: list[IntTrainAttribute] + description: str | None = None class TrainNetwork(DefaultTab): @@ -86,30 +95,52 @@ def show_help_dialog(self): dialog.exec_() def _generate_layout_attributes(self) -> None: + row_margin = 25 for engine in Engine: train_attributes = get_train_attributes(engine) layout = _create_grid_layout(margins=(20, 0, 0, 0)) + layout.setVerticalSpacing(0) # Shuffle shuffle_label = QtWidgets.QLabel("Shuffle") self._shuffles[engine] = ShuffleSpinBox(root=self.root, parent=self) + + # Add spacing + shuffle_label.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") + self._shuffles[engine].setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") + layout.addWidget(shuffle_label, 0, 0) layout.addWidget(self._shuffles[engine], 0, 1) # Other parameters self._attribute_kwargs[engine] = {} - for i, attribute in enumerate(train_attributes): - label = QtWidgets.QLabel(attribute.label) - spin_box = QtWidgets.QSpinBox() - spin_box.setMinimum(attribute.min) - spin_box.setMaximum(attribute.max) - spin_box.setValue(attribute.default) - spin_box.valueChanged.connect( - lambda new_val: self.log_attribute_change(attribute, new_val) - ) - self._attribute_kwargs[engine][attribute.fn_key] = spin_box - layout.addWidget(label, 0, 2 * (i + 1)) - layout.addWidget(spin_box, 0, 2 * (i + 1) + 1) + row_index = 0 + for row in train_attributes: + if row.description is not None: + row_label = QtWidgets.QLabel(row.description) + row_label.setStyleSheet("font-weight: bold") + layout.addWidget(row_label, row_index, 2) + row_index += 1 + + for j, attribute in enumerate(row.attributes): + label = QtWidgets.QLabel(attribute.label) + spin_box = QtWidgets.QSpinBox() + spin_box.setMinimum(attribute.min) + spin_box.setMaximum(attribute.max) + spin_box.setValue(attribute.default) + spin_box.valueChanged.connect( + lambda new_val: self.log_attribute_change(attribute, new_val) + ) + self._attribute_kwargs[engine][attribute.fn_key] = spin_box + + # Pad below to create spacing with other rows + label.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") + spin_box.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") + + layout.addWidget(label, row_index, 2 * (j + 1)) + layout.addWidget(spin_box, row_index, 2 * (j + 1) + 1) + + row_index += 1 layout_widget = QtWidgets.QWidget() layout_widget.setLayout(layout) @@ -150,67 +181,104 @@ def train_network(self): msg.exec_() -def get_train_attributes(engine: Engine) -> list[IntTrainAttribute]: +def get_train_attributes(engine: Engine) -> list[TrainAttributeRow]: if engine == Engine.TF: return [ - IntTrainAttribute( - label="Display iterations", - fn_key="displayiters", - default=1000, - min=1, - max=1000, - ), - IntTrainAttribute( - label="Save iterations", - fn_key="saveiters", - default=50_000, - min=1, - max=50_000, + TrainAttributeRow( + attributes=[ + IntTrainAttribute( + label="Display iterations", + fn_key="displayiters", + default=1000, + min=1, + max=1000, + ), + IntTrainAttribute( + label="Number of snapshots to keep", + fn_key="max_snapshots_to_keep", + default=5, + min=1, + max=100, + ), + ], ), - IntTrainAttribute( - label="Maximum iterations", - fn_key="maxiters", - default=100_000, - min=1, - max=1_030_000, - ), - IntTrainAttribute( - label="Number of snapshots to keep", - fn_key="max_snapshots_to_keep", - default=5, - min=1, - max=100, + TrainAttributeRow( + attributes=[ + IntTrainAttribute( + label="Maximum iterations", + fn_key="maxiters", + default=100_000, + min=1, + max=1_030_000, + ), + IntTrainAttribute( + label="Save iterations", + fn_key="saveiters", + default=50_000, + min=1, + max=50_000, + ), + ], ), ] elif engine == Engine.PYTORCH: - return[ - IntTrainAttribute( - label="Display iterations", - fn_key="display_iters", - default=1_000, - min=1, - max=100_000, - ), - IntTrainAttribute( - label="Save epochs", - fn_key="save_epochs", - default=50, - min=1, - max=250, + return [ + TrainAttributeRow( + attributes=[ + IntTrainAttribute( + label="Display iterations", + fn_key="display_iters", + default=1_000, + min=1, + max=100_000, + ), + IntTrainAttribute( + label="Number of snapshots to keep", + fn_key="max_snapshots_to_keep", + default=5, + min=1, + max=100, + ), + ], ), - IntTrainAttribute( - label="Maximum epochs", - fn_key="epochs", - default=200, - min=1, - max=1000, + TrainAttributeRow( + attributes=[ + IntTrainAttribute( + label="Maximum epochs", + fn_key="epochs", + default=200, + min=1, + max=1000, + ), + IntTrainAttribute( + label="Save epochs", + fn_key="save_epochs", + default=50, + min=1, + max=250, + ), + ], ), - IntTrainAttribute( # FIXME: Implement - label="Number of snapshots to keep", - fn_key="max_snapshots_to_keep", - default=5, - min=1, - max=100, + TrainAttributeRow( + description="Top-down models parameters", + attributes=[ + IntTrainAttribute( + label="Detector max epochs", + fn_key="detector_epochs", + default=200, + min=1, + max=1000, + tooltip="", + ), + IntTrainAttribute( + label="Detector save epochs", + fn_key="detector_save_epochs", + default=50, + min=1, + max=250, + tooltip="", + ), + ], ), ] diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index ca3078a6f3..e627959245 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -77,6 +77,7 @@ class MainWindow(QMainWindow): video_type_ = QtCore.Signal(str) video_files_ = QtCore.Signal(set) engine_change = QtCore.Signal(Engine) + shuffle_change = QtCore.Signal(int) def __init__(self, app): super(MainWindow, self).__init__() @@ -230,6 +231,7 @@ def update_cfg(self, text): def update_shuffle(self, value): self.shuffle_value = value + self.shuffle_change.emit(value) self.logger.info(f"Shuffle set to {self.shuffle_value}") @property diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 6e1bee7eee..6181ca23bd 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -128,10 +128,6 @@ def train( valid_dataset = loader.create_dataset( transform=inference_transform, mode="test", task=task ) - logging.info( - f"Using {len(train_dataset)} images to train {task} and {len(valid_dataset)}" - f" for testing" - ) collate_fn = None if collate_fn_cfg := run_config["data"]["train"].get("collate"): @@ -156,6 +152,15 @@ def train( num_workers=num_workers, pin_memory=pin_memory, ) + + logging.info( + f"Using {len(train_dataset)} images and {len(valid_dataset)} for testing" + ) + if task == task.DETECT: + logging.info("\nStarting object detector training...\n" + (50 * "-")) + else: + logging.info("\nStarting pose model training...\n" + (50 * "-")) + runner.fit( train_dataloader, valid_dataloader, @@ -175,6 +180,9 @@ def train_network( batch_size: int | None = None, epochs: int | None = None, save_epochs: int | None = None, + detector_batch_size: int | None = None, + detector_epochs: int | None = None, + detector_save_epochs: int | None = None, display_iters: int | None = None, max_snapshots_to_keep: int | None = None, pose_threshold: float | None = 0.1, @@ -196,6 +204,13 @@ def train_network( batch_size: overrides the batch size to train with epochs: overrides the maximum number of epochs to train the model for save_epochs: overrides the number of epochs between each snapshot save + detector_batch_size: Only for top-down models. Overrides the batch size with + which to train the detector. + detector_epochs: Only for top-down models. Overrides the maximum number of + epochs to train the model for. Setting to 0 means the detector will not be + trained. + detector_save_epochs: Only for top-down models. Overrides the number of epochs + between each snapshot of the detector is saved. display_iters: overrides the number of iterations between each log of the loss within an epoch max_snapshots_to_keep: the maximum number of snapshots to save for each model @@ -243,6 +258,17 @@ def train_network( if display_iters is not None: loader.model_cfg["train_settings"]["display_iters"] = display_iters + detector_cfg = loader.model_cfg.get("detector") + if detector_cfg is not None: + if detector_batch_size is not None: + detector_cfg["train_settings"]["batch_size"] = detector_batch_size + if detector_epochs is not None: + detector_cfg["train_settings"]["epochs"] = detector_epochs + if detector_save_epochs is not None: + detector_cfg["runner"]["snapshots"]["save_epochs"] = detector_save_epochs + if display_iters is not None: + detector_cfg["train_settings"]["display_iters"] = display_iters + loader.update_model_cfg(kwargs) setup_file_logging(loader.model_folder / "train.txt") From 6705364950a4007dedecc53eb4322a10b830e9cd Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Sun, 9 Jun 2024 13:11:16 +0200 Subject: [PATCH 108/293] filter modelzoo options based on engine (#218) --- deeplabcut/gui/tabs/modelzoo.py | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index d99ec5989b..9b48feedd5 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -13,8 +13,10 @@ import deeplabcut from PySide6 import QtWidgets -from PySide6.QtCore import Qt, Signal, QTimer, QRegularExpression +from PySide6.QtCore import Qt, Signal, QTimer, QRegularExpression, Slot from PySide6.QtGui import QPixmap, QRegularExpressionValidator + +from deeplabcut.core.engine import Engine from deeplabcut.gui.components import ( DefaultTab, VideoSelectionWidget, @@ -40,6 +42,7 @@ def __init__(self, root, parent, h1_description): super().__init__(root, parent, h1_description) self._val_pattern = QRegularExpression(r"(\d{3,5},\s*)+\d{3,5}") self._set_page() + self.root.engine_change.connect(self._update_available_models) @property def files(self): @@ -58,15 +61,7 @@ def _set_page(self): model_combo_text = QtWidgets.QLabel("Supermodel name") self.model_combo = QtWidgets.QComboBox() - supermodels = [ - model - for model in MODELOPTIONS - if ( - "superanimal" in model - and model not in ("superanimal_topviewmouse", "superanimal_quadruped") - ) - ] - self.model_combo.addItems(supermodels) + self._update_available_models(self.root.engine) scales_label = QtWidgets.QLabel("Scale list") self.scales_line = QtWidgets.QLineEdit("", parent=self) @@ -204,3 +199,22 @@ def run_video_adaptation(self): pseudo_threshold=self.pseudo_threshold_spinbox.value(), adapt_iterations=self.adapt_iter_spinbox.value(), ) + + @Slot(Engine) + def _update_available_models(self, engine: Engine) -> None: + while self.model_combo.count() > 0: + self.model_combo.removeItem(0) + + supermodels = [ + model + for model in MODELOPTIONS + if ( + "superanimal" in model + and model not in ("superanimal_topviewmouse", "superanimal_quadruped") + and ( + (engine == Engine.TF and "dlcrnet" in model) + or (engine == Engine.PYTORCH and "dlcrnet" not in model) + ) + ) + ] + self.model_combo.addItems(supermodels) From 179947b472712259e00acba41eef9531aaeae956 Mon Sep 17 00:00:00 2001 From: shaokai Date: Sun, 9 Jun 2024 22:20:08 +0200 Subject: [PATCH 109/293] Shaokai/fix docs for modelzoo apis (#212) --- .../superanimal_image_inference.py | 15 ++ deeplabcut/modelzoo/utils.py | 94 +++++++++++- deeplabcut/modelzoo/video_inference.py | 143 +++++++++++++++--- .../apis/analyze_images.py | 141 ++++++++++++++++- 4 files changed, 354 insertions(+), 39 deletions(-) create mode 100644 benchmark_superanimal/superanimal_image_inference.py diff --git a/benchmark_superanimal/superanimal_image_inference.py b/benchmark_superanimal/superanimal_image_inference.py new file mode 100644 index 0000000000..4c3c5dff8c --- /dev/null +++ b/benchmark_superanimal/superanimal_image_inference.py @@ -0,0 +1,15 @@ +import deeplabcut +from deeplabcut.pose_estimation_pytorch.apis.analyze_images import superanimal_analyze_images +import glob + +superanimal_name = 'superanimal_quadruped' +model_name = 'hrnetw32' +device = 'cuda' +max_individuals = 3 + +ret = superanimal_analyze_images(superanimal_name, + model_name, + 'test_rodent_images', + max_individuals, + 'vis_test_rodent_images') + diff --git a/deeplabcut/modelzoo/utils.py b/deeplabcut/modelzoo/utils.py index e18d77819f..b78da9f249 100644 --- a/deeplabcut/modelzoo/utils.py +++ b/deeplabcut/modelzoo/utils.py @@ -132,24 +132,26 @@ def get_conversion_table(cfg: dict | str | Path, super_animal: str) -> Conversio ) return conversion_table + def read_conversion_table_from_csv(csv_path): df = pd.read_csv(csv_path, skiprows=1, header=None) df = df.dropna() - df[0] = df[0].str.replace(r'\s+', '', regex=True) - df[1] = df[1].str.replace(r'\s+', '', regex=True) + df[0] = df[0].str.replace(r"\s+", "", regex=True) + df[1] = df[1].str.replace(r"\s+", "", regex=True) _map = dict(zip(df[0], df[1])) return _map + def parse_project_model_name(superanimal_name: str) -> tuple[str, str]: """Parses model zoo model names for SuperAnimal models - Args: - superanimal_name: the name of the SuperAnimal model name to parse + Args: + superanimal_name: the name of the SuperAnimal model name to parse - Returns: - project_name: the parsed SuperAnimal model name - model_name: the model architecture (e.g., dlcrnet, hrnetw32) - """ + Returns: + project_name: the parsed SuperAnimal model name + model_name: the model architecture (e.g., dlcrnet, hrnetw32) + """ if superanimal_name == "superanimal_quadruped": warnings.warn( @@ -192,3 +194,79 @@ def parse_project_model_name(superanimal_name: str) -> tuple[str, str]: ] return project_name, model_name + + +def get_superanimal_colormaps(): + superanimal_colormaps = { + "superanimal_topviewmouse": [ + [127, 0, 255], + [109, 28, 254], + [91, 56, 253], + [71, 86, 251], + [53, 112, 248], + [33, 139, 244], + [15, 162, 239], + [4, 185, 234], + [22, 203, 228], + [42, 220, 220], + [60, 233, 213], + [80, 244, 204], + [98, 250, 195], + [118, 254, 185], + [136, 254, 175], + [156, 250, 163], + [174, 244, 152], + [194, 233, 139], + [212, 220, 127], + [232, 203, 113], + [250, 185, 100], + [255, 162, 86], + [255, 139, 72], + [255, 112, 57], + [255, 86, 43], + [255, 56, 28], + [255, 28, 14], + ], + "superanimal_quadruped": [ + [255.0, 0.0, 0.0], + [255.0, 39.63408568671726, 0.0], + [255.0, 79.26817137343453, 0.0], + [255.0, 118.9022570601518, 0.0], + [255.0, 158.53634274686905, 0.0], + [255.0, 198.17042843358632, 0.0], + [255.0, 237.8045141203036, 0.0], + [232.56140019297916, 255.0, 0.0], + [192.92731450626187, 255.0, 0.0], + [153.2932288195446, 255.0, 0.0], + [113.65914313282731, 255.0, 0.0], + [74.02505744611004, 255.0, 0.0], + [34.390971759392784, 255.0, 0.0], + [3.5647953575585385, 255.0, 8.807909284882923], + [0.0, 255.0, 44.87701729490043], + [0.0, 255.0, 84.51085328820125], + [0.0, 255.0, 124.14468928150207], + [0.0, 255.0, 163.77852527480275], + [0.0, 255.0, 203.4123612681037], + [0.0, 255.0, 243.04619726140453], + [0, 220, 255], + [0, 255, 255], + [0, 165, 255], + [0, 150, 255], + [0.0, 68.78344961404169, 255.0], + [0.0, 29.14936392732455, 255.0], + [10.484721759392611, 0.0, 255.0], + [50.11880744611004, 0.0, 255.0], + [89.75289313282732, 0.0, 255.0], + [129.38697881954448, 0.0, 255.0], + [169.02106450626192, 0.0, 255.0], + [169.02106450626192, 0.0, 255.0], + [255.0, 0.0, 142.80850706015173], + [169.02106450626192, 0.0, 255.0], + [255.0, 0.0, 142.80850706015173], + [255.0, 0.0, 142.80850706015173], + [255.0, 0.0, 103.17442137343447], + [255.0, 0.0, 63.54033568671722], + [255.0, 0.0, 23.90625], + ], + } + return superanimal_colormaps diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index f4af1cb0e3..37b5b92c3b 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -51,49 +51,142 @@ def video_inference_superanimal( customized_detector_checkpoint: Optional[str] = None, ): """ - This function performs inference on videos using a superanimal model. The model is downloaded from hugginface to the `modelzoo/checkpoints` folder. + This function performs inference on videos using a SuperAnimal model. It does not require you to have a DeepLabCut project. So it can be seen as a plug-and-play solution. + If the predictions are jittery, you should run video adaptation by setting video_adapt = True. This will take some time but it will generally improve the inference results. + If you want to further improve the results, you can try finetune it on your own data. - Args: + IMPORTANT: Note that since we have both TensorFlow and PyTorch Engines, we will route the engine based on the model you select. - videos (str or list): The path to the video or a list of paths to videos. - superanimal_name (str): The name of the superanimal model. - The name should be in the format: {project_name}_{modelname}. - For example: `superanimal_topviewmouse_dlcrnet` or `superanimal_quadruped_hrnet`. - scale_list (list): A list of different resolutions for the spatial pyramid. Used only for bottom up models. + * superanimal_topviewmouse_hrnetw32 - > PyTorch + * superanimal_quadruped_hrnetw32 -> PyTorch + * superanimal_topviewmouse_dlcrnet -> TensorFlow + * superanimal_quadruped_dlcrnet -> TensorFlow - videotype (str): Checks for the extension of the video in case the input to the video is a directory. - Only videos with this extension are analyzed. The default is ``.mp4``. - dest_folder (str): The path to the folder where the results should be saved. + More details about those models in the examples section. In general, currently PyTorch models are better but slower. - video_adapt (bool): Whether to perform video adaptation. The default is False. - Current we do not support video adaptation in pytorch models. - plot_trajectories (bool): Whether to plot the trajectories. The default is False. + Parameters + ---------- + + videos (str or list): + The path to the video or a list of paths to videos. + superanimal_name (str): + The name of the SuperAnimal model. + The name should be in the format: {project_name}_{modelname}. + For example: `superanimal_topviewmouse_dlcrnet` or `superanimal_quadruped_hrnetw32`. + scale_list (list): + A list of different resolutions for the spatial pyramid. Used only for bottom up models. - pcutoff (float): The p-value cutoff for the confidence of the prediction. The default is 0.1. + videotype (str): + Checks for the extension of the video in case the input to the video is a directory. + Only videos with this extension are analyzed. The default is ``.mp4``. + dest_folder (str): The path to the folder where the results should be saved. - adapt_iterations (int): Number of iterations for adaptation training. Empirically 1000 is sufficient. + video_adapt (bool): + Whether to perform video adaptation. The default is False. + You only need to perform it on one video because the adaptation generalizes to all videos that are similar. + plot_trajectories (bool): + Whether to plot the trajectories. The default is False. - bbox_threshold (float): The pseudo-label threshold for the confidence of the detector. The default is 0.9 + pcutoff (float): + The p-value cutoff for the confidence of the prediction. The default is 0.1. - detector_epochs (int): Used in the pytorch engine. The number of epochs for training the detector. The default is 4. + adapt_iterations (int): + Number of iterations for adaptation training. Empirically 1000 is sufficient. - pose_epochs (int): Used in the pytorch engine. The number of epochs for training the pose estimator. The default is 4. + bbox_threshold (float): + The pseudo-label threshold for the confidence of the detector. The default is 0.9 - pseudo_threshold (float): The pseudo-label threshold for the confidence of the prediction. The default is 0.1. + detector_epochs (int): + Used in the PyTorch engine. The number of epochs for training the detector. The default is 4. - max_individuals (int): The maximum number of individuals in the video. The default is 30. Used only for top down models. + pose_epochs (int): + Used in the PyTorch engine. The number of epochs for training the pose estimator. The default is 4. - video_adapt_batch_size (int): The batch size to use for video adaptation. + pseudo_threshold (float): + The pseudo-label threshold for the confidence of the prediction. The default is 0.1. - device (str): The device to use for inference. The default is None (CPU). Used only for pytorch models. + max_individuals (int): + The maximum number of individuals in the video. The default is 30. Used only for top down models. - customized_pose_checkpoint (str): Used in the pytorch engine. If specified, replacing the default pose checkpoint. + video_adapt_batch_size (int): + The batch size to use for video adaptation. - customized_detector_checkpoint (str): Used in the pytorch engine. If specified, replacing the default detector checkpoint. + device (str): + The device to use for inference. The default is None (CPU). Used only for PyTorch models. + + customized_pose_checkpoint (str): + Used in the PyTorch engine. If specified, it replaces the default pose checkpoint. + + customized_detector_checkpoint (str): + Used in the PyTorch engine. If specified, it replaces the default detector checkpoint. Raises: - NotImplementedError: If the model is not found in the modelzoo. + NotImplementedError: + If the model is not found in the modelzoo. Warning: If the superanimal_name will be deprecated in the future. + + Examples (PyTorch Engine) + -------- + + In PyTorch, we currently only support + - superanimal_topviewmouse_hrnetw32 + - superanimal_quadruped_hrnetw32 + + topviewmouse series are for topview lab mice + quadruped series are for quadruped animals (across many different species) + + The prefix hrnetw32 denotes the backbone of the pose estimator. Compared to resnet, they are stronger but slower. + Check the official repo for more details (https://github.com/HRNet/HRNet-Image-Classification) + + superanimal_topviewmouse_hrnetw32 and superanimal_quadruped_hrnetw32 are top-down models. That means + they take the cropped image from an object detector and predicts the keypoints. It's generally more accurate but slower. + These 2 superanimal models come with a ResNet50-based Faster-RCNN object detector. They are automatically downloaded + to modelzoo/checkpoints. + + For object detectors, Check https://pytorch.org/vision/stable/models/faster_rcnn.html for more details + + Note in PyTorch, we don't support bottom-up models SuperAnimal models yet. We will add them in the future. + + >>> import deeplabcut.modelzoo.video_inference.video_inference_superanimal as video_inference_superanimal + >>> video_inference_superanimal( + videos=["/mnt/md0/shaokai/DLCdev/3mice_video1_short.mp4"], + superanimal_name="superanimal_topviewmouse_hrnetw32", + video_adapt=True, + max_individuals=3, + pseudo_threshold=0.1, + bbox_threshold=0.9, + detector_epochs=4, + pose_epochs=4, + ) + + Tips: + * max_individuals: make sure you correclty give the number of individuals. Our inference api will only give up to max_individuals number of predictions. + * pseudo_threshold: the higher you set, the more aggressive you filter low confidence predictions during video adaptation. + * bbox_threshold: the higher you set, the more aggressive you filter low confidence bounding boxes during video adaptation. + Different from our paper, we now add video adaptation to the object detector as well. + * detector_epochs and pose_epochs do not need to be to high as video adaptation does not require too much training. + However, you can make them higher if you see a substaintial gain in the training logs. + + + Examples (TensorFlow Engine) + -------- + + >>> import deeplabcut.modelzoo.video_inference.video_inference_superanimal as video_inference_superanimal + >>> superanimal_name = 'superanimal_topviewmouse_dlcrnet' + >>> videotype = 'mp4' + >>> scale_list = [200, 300, 400] + >>> video_inference_superanimal( + video, + video_adapt = True, + superanimal_name, + videotype = '.avi', + scale_list = scale_list, + ) + + Tips: + scale_list: it's recommended to leave this as empty list []. Empirically [200, 300, 400] works well. + We needed to do this as bottom-up models in TensorFlow are sensitive to the scales of the image. + If you find your predictions not good without scale_list or it's too hard to find the right scale_list, you can try to use the PyTorch engine. """ project_name, model_name = parse_project_model_name(superanimal_name) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py index 75a0b11a88..c1882b5ca6 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py @@ -36,7 +36,100 @@ auxfun_videos, auxiliaryfunctions, ) +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( + get_config_model_paths, + update_config +) +from deeplabcut.modelzoo.utils import get_superanimal_colormaps + +def superanimal_analyze_images(superanimal_name: str, + model_name: str, + images: str | Path | list[str] | list[Path], + max_individuals: int, + out_folder: str, + progress_bar: bool = True, + device: str | None = None, +): + """ + This funciton inferences a superanimal model on a set of images and saves the results as labeled images. + + Parameters + ---------- + superanimal_name: str + The name of the superanimal to analyze. + supported list: + superanimal_topviewmouse + superanimal_quadruped + model_name: str + The name of the model to use for inference. + supported list: + hrnetw32 + images: str | Path | list[str] | list[Path] + The images to analyze. Can either be a directory containing images, or + a list of paths of images. + max_individuals: int + The maximum number of individuals to detect in each image. + out_folder: str + The directory where the labeled images will be saved. + progress_bar: bool + Whether to display a progress bar when running inference. + device: str | None + The device to use to run image analysis. + + Returns + ------- + The predictions over the images + + Examples + -------- + >>> import deeplabcut + >>> from deeplabcut.pose_estimation_pytorch.apis.analyze_images import superanimal_analyze_images + >>> superanimal_name = 'superanimal_quadruped' + >>> model_name = 'hrnetw32' + >>> device = 'cuda' + >>> max_individuals = 3 + >>> test_images_folder = 'test_rodent_images' + >>> out_images_folder = 'vis_test_rodent_images' + >>> ret = superanimal_analyze_images(superanimal_name, + model_name, + test_images_folder, + max_individuals, + out_images_folder) + """ + + os.makedirs(out_folder, exist_ok = True) + + ( + model_cfg, + project_config, + snapshot_path, + detector_path, + ) = get_config_model_paths(superanimal_name, model_name) + + config = {**project_config, **model_cfg} + config = update_config(config, max_individuals, device) + individuals = [f"animal{i}" for i in range(max_individuals)] + config["individuals"] = individuals + + predictions = analyze_image_folder( + model_cfg=config, + images=images, + snapshot_path=snapshot_path, + detector_path=detector_path, + max_individuals=max_individuals, + device=device, + progress_bar=progress_bar, + ) + superanimal_colormaps = get_superanimal_colormaps() + kpts_color = superanimal_colormaps[superanimal_name] + + create_labeled_images_from_predictions(predictions, + out_folder, + kpts_color) + + return predictions + def analyze_images( config: str | Path, @@ -50,6 +143,9 @@ def analyze_images( device: str | None = None, max_individuals: int | None = None, progress_bar: bool = True, + superanimal_name = None, + model_name = None + ) -> dict[str, dict]: """Runs analysis on images using a pose model. @@ -146,7 +242,7 @@ def analyze_image_folder( detector_path: str | Path | None = None, device: str | None = None, max_individuals: int | None = None, - progress_bar: bool = True, + progress_bar: bool = True ) -> dict[str, dict[str, np.ndarray | np.ndarray]]: """Runs pose inference on a folder of images @@ -171,7 +267,7 @@ def analyze_image_folder( """ if not isinstance(model_cfg, dict): model_cfg = config_utils.read_config_as_dict(model_cfg) - + pose_task = Task(model_cfg["method"]) if pose_task == Task.TOP_DOWN and detector_path is None: raise ValueError( @@ -209,19 +305,52 @@ def analyze_image_folder( if progress_bar: detector_image_paths = tqdm(detector_image_paths) bbox_predictions = detector_runner.inference(images=detector_image_paths) - image_paths = list(zip(image_paths, bbox_predictions)) + pose_inputs = list(zip(image_paths, bbox_predictions)) logging.info(f"Running pose estimation with {detector_path}") - pose_image_paths = image_paths + if progress_bar: - pose_image_paths = tqdm(pose_image_paths) - predictions = pose_runner.inference(pose_image_paths) + pose_image_paths = tqdm(pose_inputs) + predictions = pose_runner.inference(pose_inputs) + return { image_path: image_predictions for image_path, image_predictions in zip(image_paths, predictions) } +def create_labeled_images_from_predictions(predictions, + out_folder, + kpts_color, +): + + for image_path, prediction in predictions.items(): + + frame = auxfun_videos.imread(str(image_path), mode="skimage") + fig, ax = plt.subplots() + ax.imshow(frame) + + for idx, pose in enumerate(prediction["bodyparts"]): + x, y, confidence = pose[:, 0], pose[:, 1], pose[:, 2] + if np.sum(pose) < 0: + continue + mask = confidence > 0.0 + x = x[mask] + y = y[mask] + rgb_1 = np.array(kpts_color)/ 255 + ax.scatter(x, y, c=rgb_1) + bboxes = prediction["bboxes"] + for bbox in bboxes: + # Draw bounding boxes around detected objects + xmin, ymin, w, h = bbox + rect = plt.Rectangle( + (xmin, ymin), w, h, fill=False, edgecolor="green", linewidth=2 + ) + + ax.add_patch(rect) + image_name = image_path.split(os.sep)[-1] + fig.savefig(os.path.join(out_folder, f'vis_{image_name}')) + def plot_images_coco( model_cfg: str | Path | dict, From 89ff49226e4a05f5e73f0a973c686e2f9683c8ac Mon Sep 17 00:00:00 2001 From: shaokai Date: Sun, 9 Jun 2024 22:54:11 +0200 Subject: [PATCH 110/293] Fixed bugs in analyze_images and add superanimal image inference (#208) Co-authored-by: Niels Poulsen --- .../superanimal_image_inference.py | 42 +++-- deeplabcut/modelzoo/utils.py | 162 ++++++++++-------- deeplabcut/modelzoo/video_inference.py | 125 ++++++++------ .../apis/analyze_images.py | 91 +++++----- .../modelzoo/inference.py | 6 + 5 files changed, 247 insertions(+), 179 deletions(-) diff --git a/benchmark_superanimal/superanimal_image_inference.py b/benchmark_superanimal/superanimal_image_inference.py index 4c3c5dff8c..79eb0f478b 100644 --- a/benchmark_superanimal/superanimal_image_inference.py +++ b/benchmark_superanimal/superanimal_image_inference.py @@ -1,15 +1,31 @@ -import deeplabcut -from deeplabcut.pose_estimation_pytorch.apis.analyze_images import superanimal_analyze_images +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# import glob -superanimal_name = 'superanimal_quadruped' -model_name = 'hrnetw32' -device = 'cuda' -max_individuals = 3 - -ret = superanimal_analyze_images(superanimal_name, - model_name, - 'test_rodent_images', - max_individuals, - 'vis_test_rodent_images') - +import deeplabcut +from deeplabcut.pose_estimation_pytorch.apis.analyze_images import ( + superanimal_analyze_images, +) + + +if __name__ == "__main__": + superanimal_name = "superanimal_quadruped" + model_name = "hrnetw32" + device = "cuda" + max_individuals = 3 + + ret = superanimal_analyze_images( + superanimal_name, + model_name, + "test_rodent_images", + max_individuals, + "vis_test_rodent_images", + ) diff --git a/deeplabcut/modelzoo/utils.py b/deeplabcut/modelzoo/utils.py index b78da9f249..869ade6abe 100644 --- a/deeplabcut/modelzoo/utils.py +++ b/deeplabcut/modelzoo/utils.py @@ -15,6 +15,10 @@ from glob import glob from pathlib import Path +import numpy as np +import pandas as pd +from matplotlib.colors import ListedColormap + from deeplabcut.core.conversion_table import ConversionTable from deeplabcut.utils.auxiliaryfunctions import ( get_bodyparts, @@ -22,7 +26,6 @@ read_config, write_config, ) -import pandas as pd def dlc_modelzoo_path() -> Path: @@ -197,76 +200,93 @@ def parse_project_model_name(superanimal_name: str) -> tuple[str, str]: def get_superanimal_colormaps(): + superanimal_topviewmouse_colors = ( + np.array( + [ + [127, 0, 255], + [109, 28, 254], + [91, 56, 253], + [71, 86, 251], + [53, 112, 248], + [33, 139, 244], + [15, 162, 239], + [4, 185, 234], + [22, 203, 228], + [42, 220, 220], + [60, 233, 213], + [80, 244, 204], + [98, 250, 195], + [118, 254, 185], + [136, 254, 175], + [156, 250, 163], + [174, 244, 152], + [194, 233, 139], + [212, 220, 127], + [232, 203, 113], + [250, 185, 100], + [255, 162, 86], + [255, 139, 72], + [255, 112, 57], + [255, 86, 43], + [255, 56, 28], + [255, 28, 14], + ] + ) + / 255 + ) + superanimal_quadruped_colors = ( + np.array( + [ + [255.0, 0.0, 0.0], + [255.0, 39.63408568671726, 0.0], + [255.0, 79.26817137343453, 0.0], + [255.0, 118.9022570601518, 0.0], + [255.0, 158.53634274686905, 0.0], + [255.0, 198.17042843358632, 0.0], + [255.0, 237.8045141203036, 0.0], + [232.56140019297916, 255.0, 0.0], + [192.92731450626187, 255.0, 0.0], + [153.2932288195446, 255.0, 0.0], + [113.65914313282731, 255.0, 0.0], + [74.02505744611004, 255.0, 0.0], + [34.390971759392784, 255.0, 0.0], + [3.5647953575585385, 255.0, 8.807909284882923], + [0.0, 255.0, 44.87701729490043], + [0.0, 255.0, 84.51085328820125], + [0.0, 255.0, 124.14468928150207], + [0.0, 255.0, 163.77852527480275], + [0.0, 255.0, 203.4123612681037], + [0.0, 255.0, 243.04619726140453], + [0, 220, 255], + [0, 255, 255], + [0, 165, 255], + [0, 150, 255], + [0.0, 68.78344961404169, 255.0], + [0.0, 29.14936392732455, 255.0], + [10.484721759392611, 0.0, 255.0], + [50.11880744611004, 0.0, 255.0], + [89.75289313282732, 0.0, 255.0], + [129.38697881954448, 0.0, 255.0], + [169.02106450626192, 0.0, 255.0], + [169.02106450626192, 0.0, 255.0], + [255.0, 0.0, 142.80850706015173], + [169.02106450626192, 0.0, 255.0], + [255.0, 0.0, 142.80850706015173], + [255.0, 0.0, 142.80850706015173], + [255.0, 0.0, 103.17442137343447], + [255.0, 0.0, 63.54033568671722], + [255.0, 0.0, 23.90625], + ] + ) + / 255 + ) + superanimal_colormaps = { - "superanimal_topviewmouse": [ - [127, 0, 255], - [109, 28, 254], - [91, 56, 253], - [71, 86, 251], - [53, 112, 248], - [33, 139, 244], - [15, 162, 239], - [4, 185, 234], - [22, 203, 228], - [42, 220, 220], - [60, 233, 213], - [80, 244, 204], - [98, 250, 195], - [118, 254, 185], - [136, 254, 175], - [156, 250, 163], - [174, 244, 152], - [194, 233, 139], - [212, 220, 127], - [232, 203, 113], - [250, 185, 100], - [255, 162, 86], - [255, 139, 72], - [255, 112, 57], - [255, 86, 43], - [255, 56, 28], - [255, 28, 14], - ], - "superanimal_quadruped": [ - [255.0, 0.0, 0.0], - [255.0, 39.63408568671726, 0.0], - [255.0, 79.26817137343453, 0.0], - [255.0, 118.9022570601518, 0.0], - [255.0, 158.53634274686905, 0.0], - [255.0, 198.17042843358632, 0.0], - [255.0, 237.8045141203036, 0.0], - [232.56140019297916, 255.0, 0.0], - [192.92731450626187, 255.0, 0.0], - [153.2932288195446, 255.0, 0.0], - [113.65914313282731, 255.0, 0.0], - [74.02505744611004, 255.0, 0.0], - [34.390971759392784, 255.0, 0.0], - [3.5647953575585385, 255.0, 8.807909284882923], - [0.0, 255.0, 44.87701729490043], - [0.0, 255.0, 84.51085328820125], - [0.0, 255.0, 124.14468928150207], - [0.0, 255.0, 163.77852527480275], - [0.0, 255.0, 203.4123612681037], - [0.0, 255.0, 243.04619726140453], - [0, 220, 255], - [0, 255, 255], - [0, 165, 255], - [0, 150, 255], - [0.0, 68.78344961404169, 255.0], - [0.0, 29.14936392732455, 255.0], - [10.484721759392611, 0.0, 255.0], - [50.11880744611004, 0.0, 255.0], - [89.75289313282732, 0.0, 255.0], - [129.38697881954448, 0.0, 255.0], - [169.02106450626192, 0.0, 255.0], - [169.02106450626192, 0.0, 255.0], - [255.0, 0.0, 142.80850706015173], - [169.02106450626192, 0.0, 255.0], - [255.0, 0.0, 142.80850706015173], - [255.0, 0.0, 142.80850706015173], - [255.0, 0.0, 103.17442137343447], - [255.0, 0.0, 63.54033568671722], - [255.0, 0.0, 23.90625], - ], + "superanimal_topviewmouse": ListedColormap( + list(superanimal_topviewmouse_colors), name="superanimal_topviewmouse" + ), + "superanimal_quadruped": ListedColormap( + list(superanimal_quadruped_colors), name="superanimal_quadruped" + ), } return superanimal_colormaps diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index 37b5b92c3b..3e3ba091c1 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -33,8 +33,8 @@ def video_inference_superanimal( videos: Union[str, list], superanimal_name: str, - scale_list: list = [], - videotype: str = "mp4", + scale_list: list | None = None, + videotype: str = ".mp4", dest_folder: Optional[str] = None, video_adapt: bool = False, plot_trajectories: bool = False, @@ -51,101 +51,116 @@ def video_inference_superanimal( customized_detector_checkpoint: Optional[str] = None, ): """ - This function performs inference on videos using a SuperAnimal model. It does not require you to have a DeepLabCut project. So it can be seen as a plug-and-play solution. - If the predictions are jittery, you should run video adaptation by setting video_adapt = True. This will take some time but it will generally improve the inference results. - If you want to further improve the results, you can try finetune it on your own data. + This function performs inference on videos using a SuperAnimal model. It does not + require you to have a DeepLabCut project. So it can be seen as a plug-and-play + solution. - IMPORTANT: Note that since we have both TensorFlow and PyTorch Engines, we will route the engine based on the model you select. + If the predictions are jittery, you should run video adaptation by setting + video_adapt = True. This will take some time but it will generally improve the + inference results. + + If you want to further improve the results, you can try finetune it on your own + data. + + IMPORTANT: Note that since we have both TensorFlow and PyTorch Engines, we will + route the engine based on the model you select. * superanimal_topviewmouse_hrnetw32 - > PyTorch * superanimal_quadruped_hrnetw32 -> PyTorch * superanimal_topviewmouse_dlcrnet -> TensorFlow * superanimal_quadruped_dlcrnet -> TensorFlow - More details about those models in the examples section. In general, currently PyTorch models are better but slower. + More details about those models in the examples section. In general, currently + PyTorch models are better but slower. Parameters - ---------- - - videos (str or list): + ---------- + + videos (str or list): The path to the video or a list of paths to videos. - superanimal_name (str): + + superanimal_name (str): The name of the SuperAnimal model. The name should be in the format: {project_name}_{modelname}. For example: `superanimal_topviewmouse_dlcrnet` or `superanimal_quadruped_hrnetw32`. - scale_list (list): + + scale_list (list): A list of different resolutions for the spatial pyramid. Used only for bottom up models. - videotype (str): + videotype (str): Checks for the extension of the video in case the input to the video is a directory. Only videos with this extension are analyzed. The default is ``.mp4``. dest_folder (str): The path to the folder where the results should be saved. - video_adapt (bool): + video_adapt (bool): Whether to perform video adaptation. The default is False. You only need to perform it on one video because the adaptation generalizes to all videos that are similar. - plot_trajectories (bool): + plot_trajectories (bool): Whether to plot the trajectories. The default is False. - pcutoff (float): + pcutoff (float): The p-value cutoff for the confidence of the prediction. The default is 0.1. - adapt_iterations (int): + adapt_iterations (int): Number of iterations for adaptation training. Empirically 1000 is sufficient. - bbox_threshold (float): + bbox_threshold (float): The pseudo-label threshold for the confidence of the detector. The default is 0.9 - detector_epochs (int): + detector_epochs (int): Used in the PyTorch engine. The number of epochs for training the detector. The default is 4. - pose_epochs (int): + pose_epochs (int): Used in the PyTorch engine. The number of epochs for training the pose estimator. The default is 4. - pseudo_threshold (float): + pseudo_threshold (float): The pseudo-label threshold for the confidence of the prediction. The default is 0.1. - max_individuals (int): + max_individuals (int): The maximum number of individuals in the video. The default is 30. Used only for top down models. - video_adapt_batch_size (int): + video_adapt_batch_size (int): The batch size to use for video adaptation. - device (str): + device (str): The device to use for inference. The default is None (CPU). Used only for PyTorch models. - customized_pose_checkpoint (str): + customized_pose_checkpoint (str): Used in the PyTorch engine. If specified, it replaces the default pose checkpoint. - customized_detector_checkpoint (str): + customized_detector_checkpoint (str): Used in the PyTorch engine. If specified, it replaces the default detector checkpoint. Raises: - NotImplementedError: + NotImplementedError: If the model is not found in the modelzoo. Warning: If the superanimal_name will be deprecated in the future. - + Examples (PyTorch Engine) -------- - In PyTorch, we currently only support + In PyTorch, we currently only support - superanimal_topviewmouse_hrnetw32 - superanimal_quadruped_hrnetw32 - topviewmouse series are for topview lab mice + topviewmouse series are for topview lab mice quadruped series are for quadruped animals (across many different species) - The prefix hrnetw32 denotes the backbone of the pose estimator. Compared to resnet, they are stronger but slower. + The prefix hrnetw32 denotes the backbone of the pose estimator. Compared to resnet, + they are stronger but slower. Check the official repo for more details (https://github.com/HRNet/HRNet-Image-Classification) - superanimal_topviewmouse_hrnetw32 and superanimal_quadruped_hrnetw32 are top-down models. That means - they take the cropped image from an object detector and predicts the keypoints. It's generally more accurate but slower. - These 2 superanimal models come with a ResNet50-based Faster-RCNN object detector. They are automatically downloaded - to modelzoo/checkpoints. + superanimal_topviewmouse_hrnetw32 and superanimal_quadruped_hrnetw32 are top-down + models. That means they take the cropped image from an object detector and predicts + the keypoints. It's generally more accurate but slower. These 2 superanimal models + come with a ResNet50-based Faster-RCNN object detector. They are automatically + downloaded to modelzoo/checkpoints. - For object detectors, Check https://pytorch.org/vision/stable/models/faster_rcnn.html for more details + For object detectors, Check https://pytorch.org/vision/stable/models/faster_rcnn.html + for more details - Note in PyTorch, we don't support bottom-up models SuperAnimal models yet. We will add them in the future. + Note in PyTorch, we don't support bottom-up models SuperAnimal models yet. We will + add them in the future. >>> import deeplabcut.modelzoo.video_inference.video_inference_superanimal as video_inference_superanimal >>> video_inference_superanimal( @@ -159,17 +174,20 @@ def video_inference_superanimal( pose_epochs=4, ) - Tips: - * max_individuals: make sure you correclty give the number of individuals. Our inference api will only give up to max_individuals number of predictions. - * pseudo_threshold: the higher you set, the more aggressive you filter low confidence predictions during video adaptation. - * bbox_threshold: the higher you set, the more aggressive you filter low confidence bounding boxes during video adaptation. - Different from our paper, we now add video adaptation to the object detector as well. - * detector_epochs and pose_epochs do not need to be to high as video adaptation does not require too much training. - However, you can make them higher if you see a substaintial gain in the training logs. - + Tips: + * max_individuals: make sure you correclty give the number of individuals. Our + inference api will only give up to max_individuals number of predictions. + * pseudo_threshold: the higher you set, the more aggressive you filter low + confidence predictions during video adaptation. + * bbox_threshold: the higher you set, the more aggressive you filter low confidence + bounding boxes during video adaptation. Different from our paper, we now add + video adaptation to the object detector as well. + * detector_epochs and pose_epochs do not need to be to high as video adaptation does + not require too much training. However, you can make them higher if you see a + substaintial gain in the training logs. Examples (TensorFlow Engine) - -------- + -------- >>> import deeplabcut.modelzoo.video_inference.video_inference_superanimal as video_inference_superanimal >>> superanimal_name = 'superanimal_topviewmouse_dlcrnet' @@ -184,11 +202,15 @@ def video_inference_superanimal( ) Tips: - scale_list: it's recommended to leave this as empty list []. Empirically [200, 300, 400] works well. - We needed to do this as bottom-up models in TensorFlow are sensitive to the scales of the image. - If you find your predictions not good without scale_list or it's too hard to find the right scale_list, you can try to use the PyTorch engine. - + scale_list: it's recommended to leave this as empty list []. Empirically + [200, 300, 400] works well. We needed to do this as bottom-up models in TensorFlow + are sensitive to the scales of the image. + If you find your predictions not good without scale_list or it's too hard to find + the right scale_list, you can try to use the PyTorch engine. """ + if scale_list is None: + scale_list = [] + project_name, model_name = parse_project_model_name(superanimal_name) dlc_root_path = get_deeplabcut_path() @@ -347,7 +369,8 @@ def video_inference_superanimal( f"and detector ({adapted_detector_checkpoint}) already exist. To " "rerun video adaptation training, delete the checkpoints or select" "a different number of adaptation epochs. Continuing with the" - "existing checkpoints.") + "existing checkpoints." + ) else: adaptation_train( project_root=pseudo_dataset_folder, diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py index c1882b5ca6..caab30fdf0 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py @@ -23,6 +23,7 @@ import deeplabcut.pose_estimation_pytorch.config.utils as config_utils from deeplabcut.core.engine import Engine +from deeplabcut.modelzoo.utils import get_superanimal_colormaps from deeplabcut.pose_estimation_pytorch.apis.utils import ( get_inference_runners, get_model_snapshots, @@ -30,25 +31,23 @@ get_scorer_uid, parse_snapshot_index_for_analysis, ) -from deeplabcut.pose_estimation_pytorch.task import Task -from deeplabcut.pose_estimation_pytorch.utils import resolve_device -from deeplabcut.utils import ( - auxfun_videos, - auxiliaryfunctions, -) from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( get_config_model_paths, - update_config + update_config, ) -from deeplabcut.modelzoo.utils import get_superanimal_colormaps +from deeplabcut.pose_estimation_pytorch.task import Task +from deeplabcut.pose_estimation_pytorch.utils import resolve_device +from deeplabcut.utils import auxfun_videos, auxiliaryfunctions -def superanimal_analyze_images(superanimal_name: str, - model_name: str, - images: str | Path | list[str] | list[Path], - max_individuals: int, - out_folder: str, - progress_bar: bool = True, - device: str | None = None, + +def superanimal_analyze_images( + superanimal_name: str, + model_name: str, + images: str | Path | list[str] | list[Path], + max_individuals: int, + out_folder: str, + progress_bar: bool = True, + device: str | None = None, ): """ This funciton inferences a superanimal model on a set of images and saves the results as labeled images. @@ -97,20 +96,20 @@ def superanimal_analyze_images(superanimal_name: str, out_images_folder) """ - os.makedirs(out_folder, exist_ok = True) - + os.makedirs(out_folder, exist_ok=True) + ( model_cfg, project_config, snapshot_path, detector_path, - ) = get_config_model_paths(superanimal_name, model_name) - + ) = get_config_model_paths(superanimal_name, model_name) + config = {**project_config, **model_cfg} config = update_config(config, max_individuals, device) individuals = [f"animal{i}" for i in range(max_individuals)] config["individuals"] = individuals - + predictions = analyze_image_folder( model_cfg=config, images=images, @@ -123,13 +122,11 @@ def superanimal_analyze_images(superanimal_name: str, superanimal_colormaps = get_superanimal_colormaps() kpts_color = superanimal_colormaps[superanimal_name] - - create_labeled_images_from_predictions(predictions, - out_folder, - kpts_color) - + + create_labeled_images_from_predictions(predictions, out_folder, kpts_color) + return predictions - + def analyze_images( config: str | Path, @@ -143,9 +140,8 @@ def analyze_images( device: str | None = None, max_individuals: int | None = None, progress_bar: bool = True, - superanimal_name = None, - model_name = None - + superanimal_name=None, + model_name=None, ) -> dict[str, dict]: """Runs analysis on images using a pose model. @@ -179,7 +175,11 @@ def analyze_images( cfg = auxiliaryfunctions.read_config(config) train_frac = cfg["TrainingFraction"][trainingsetindex] model_folder = Path(cfg["project_path"]) / auxiliaryfunctions.get_model_folder( - train_frac, shuffle, cfg, engine=Engine.PYTORCH, modelprefix=modelprefix, + train_frac, + shuffle, + cfg, + engine=Engine.PYTORCH, + modelprefix=modelprefix, ) train_folder = model_folder / "train" @@ -242,7 +242,7 @@ def analyze_image_folder( detector_path: str | Path | None = None, device: str | None = None, max_individuals: int | None = None, - progress_bar: bool = True + progress_bar: bool = True, ) -> dict[str, dict[str, np.ndarray | np.ndarray]]: """Runs pose inference on a folder of images @@ -267,7 +267,7 @@ def analyze_image_folder( """ if not isinstance(model_cfg, dict): model_cfg = config_utils.read_config_as_dict(model_cfg) - + pose_task = Task(model_cfg["method"]) if pose_task == Task.TOP_DOWN and detector_path is None: raise ValueError( @@ -298,6 +298,7 @@ def analyze_image_folder( ) image_paths = parse_images_and_image_folders(images) + pose_inputs = image_paths if detector_runner is not None: logging.info(f"Running object detection with {detector_path}") @@ -310,26 +311,29 @@ def analyze_image_folder( logging.info(f"Running pose estimation with {detector_path}") if progress_bar: - pose_image_paths = tqdm(pose_inputs) + pose_inputs = tqdm(pose_inputs) + predictions = pose_runner.inference(pose_inputs) - return { image_path: image_predictions for image_path, image_predictions in zip(image_paths, predictions) } -def create_labeled_images_from_predictions(predictions, - out_folder, - kpts_color, + +def create_labeled_images_from_predictions( + predictions, + out_folder, + cmap, + kpts_color, ): - + for image_path, prediction in predictions.items(): - + frame = auxfun_videos.imread(str(image_path), mode="skimage") fig, ax = plt.subplots() ax.imshow(frame) - + for idx, pose in enumerate(prediction["bodyparts"]): x, y, confidence = pose[:, 0], pose[:, 1], pose[:, 2] if np.sum(pose) < 0: @@ -337,8 +341,7 @@ def create_labeled_images_from_predictions(predictions, mask = confidence > 0.0 x = x[mask] y = y[mask] - rgb_1 = np.array(kpts_color)/ 255 - ax.scatter(x, y, c=rgb_1) + ax.scatter(x, y, c=np.arange(len(x)), cmap=cmap) bboxes = prediction["bboxes"] for bbox in bboxes: # Draw bounding boxes around detected objects @@ -348,8 +351,8 @@ def create_labeled_images_from_predictions(predictions, ) ax.add_patch(rect) - image_name = image_path.split(os.sep)[-1] - fig.savefig(os.path.join(out_folder, f'vis_{image_name}')) + image_name = image_path.split(os.sep)[-1] + fig.savefig(os.path.join(out_folder, f"vis_{image_name}")) def plot_images_coco( diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index e6276e5d10..fe2d1fd973 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -15,6 +15,7 @@ import numpy as np +from deeplabcut.modelzoo.utils import get_superanimal_colormaps from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import ( create_df_from_prediction, video_inference, @@ -191,12 +192,17 @@ def _video_inference_superanimal( output_video = output_path / f"{output_prefix}_labeled_after_adapt.mp4" else: output_video = output_path / f"{output_prefix}_labeled.mp4" + + superanimal_colormaps = get_superanimal_colormaps() + colormap = superanimal_colormaps[project_name] + _create_labeled_video( video_path, output_h5, pcutoff=pcutoff, fps=video_metadata["fps"], bbox=bbox, + cmap=colormap, output_path=str(output_video), ) From 02ba04c6d7f7078dea33f952b95d63edebb91506 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Mon, 10 Jun 2024 12:38:49 +0200 Subject: [PATCH 111/293] Open the confusion matrix when editing conversion tables (#223) --- deeplabcut/gui/tabs/create_training_dataset.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index fe0624d58d..7a14abe519 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -33,6 +33,7 @@ from deeplabcut.gui.widgets import launch_napari from deeplabcut.utils.auxiliaryfunctions import ( get_data_and_metadata_filenames, + get_model_folder, get_training_set_folder, ) @@ -153,7 +154,15 @@ def log_augmentation_choice(self, augmentation): def edit_conversion_table(self): # Test beforehand whether a conversion table exists weight_init = self.weight_init_selector.get_weight_init() - _ = launch_napari(self.root.config) + model_folder = self.root / get_model_folder( + self.root.cfg["TrainingFraction"][0], + self.shuffle.value(), + self.root.cfg, + engine=Engine.PYTORCH, + ) + memory_replay_folder = model_folder / "memory_replay" + conversion_matrix_out_path = str(memory_replay_folder / "confusion_matrix.png") + _ = launch_napari([self.root.config, conversion_matrix_out_path]) def create_training_dataset(self): shuffle = self.shuffle.value() @@ -322,6 +331,7 @@ def update_nets(self, engine: Engine | None) -> None: else: # FIXME: Circular imports make it impossible to import this at the top from deeplabcut.pose_estimation_pytorch import available_models + nets = available_models() net_filter = self.get_net_filter() default_net = self.get_default_net() From 6cba9cb38e6c92c817d6d8ded599a176592dfecb Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Mon, 10 Jun 2024 15:26:50 +0200 Subject: [PATCH 112/293] bug fix: future annotations (#225) --- deeplabcut/modelzoo/video_inference.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index 3e3ba091c1..b54f6d2679 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -8,6 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations import json import os From b74b67c30f73ba2605cb2846628c17256a790fd9 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Mon, 10 Jun 2024 15:37:15 +0200 Subject: [PATCH 113/293] Add reactive engine icons (#224) * Add engine icons to setup.py * Add reactive engine icons --- deeplabcut/gui/window.py | 19 +++++++++++++++---- setup.py | 2 ++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index e627959245..3c4b7e664d 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -34,7 +34,7 @@ QSizePolicy, ) from PySide6 import QtCore -from PySide6.QtGui import QIcon, QAction +from PySide6.QtGui import QIcon, QAction, QPixmap from PySide6 import QtWidgets, QtGui from PySide6.QtCore import Qt @@ -67,9 +67,7 @@ def _check_for_updates(): _ = msg.addButton("Skip", msg.RejectRole) msg.exec_() if msg.clickedButton() is update_btn: - subprocess.check_call( - [sys.executable, "-m", *command] - ) + subprocess.check_call([sys.executable, "-m", *command]) class MainWindow(QMainWindow): @@ -428,11 +426,23 @@ def create_toolbar(self): engine_label.setText("Engine") engine_label.setStyleSheet("background: transparent;") + engine_icon = QLabel() + engine_icon.setStyleSheet("background: transparent;") + + def _update_icon(engine: str): + pixmap = QPixmap(f"deeplabcut/gui/media/dlc-{engine}.png") + engine_icon.setPixmap( + pixmap.scaled(56, 56, Qt.AspectRatioMode.KeepAspectRatio) + ) + + _update_icon("pt" if self.engine == Engine.PYTORCH else "tf") + engines = [engine for engine in Engine] def _update_engine(index: int) -> None: self.logger.info(f"Changed engine to {engines[index]}") self.engine = engines[index] + _update_icon("pt" if self.engine == Engine.PYTORCH else "tf") change_engine_widget = QComboBox() change_engine_widget.addItems([e.aliases[0] for e in engines]) @@ -441,6 +451,7 @@ def _update_engine(index: int) -> None: change_engine_widget.setCurrentIndex(engines.index(self.engine)) self.toolbar.addWidget(spacer) + self.toolbar.addWidget(engine_icon) self.toolbar.addWidget(engine_label) self.toolbar.addWidget(change_engine_widget) diff --git a/setup.py b/setup.py index 776a3b0341..2f30276408 100644 --- a/setup.py +++ b/setup.py @@ -105,6 +105,8 @@ def pytorch_config_paths() -> list[str]: "deeplabcut/gui/style.qss", "deeplabcut/gui/media/logo.png", "deeplabcut/gui/media/dlc_1-01.png", + "deeplabcut/gui/media/dlc-pt.png", + "deeplabcut/gui/media/dlc-tf.png", "deeplabcut/gui/assets/logo.png", "deeplabcut/gui/assets/logo_transparent.png", "deeplabcut/gui/assets/welcome.png", From afba0c1e5b9d532d09c6df827ea838d3cf40b71b Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Mon, 10 Jun 2024 16:07:20 +0200 Subject: [PATCH 114/293] Hide edit conversion table button if unnecessary (#222) * Hide edit conversion table button if unnecessary * Make button responsive to change in engine * Minor fix --- deeplabcut/gui/tabs/create_training_dataset.py | 5 +++++ deeplabcut/modelzoo/video_inference.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 7a14abe519..11219ca6a5 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -51,6 +51,7 @@ def __init__(self, root, parent, h1_description): self.mapping_button = QtWidgets.QPushButton("Edit Conversion Table") self.mapping_button.clicked.connect(self.edit_conversion_table) + self.root.engine_change.connect(self.set_edit_table_visibility) self.ok_button = QtWidgets.QPushButton("Create Training Dataset") self.ok_button.setMinimumWidth(150) @@ -67,6 +68,10 @@ def __init__(self, root, parent, h1_description): self.help_button.clicked.connect(self.show_help_dialog) self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) + def set_edit_table_visibility(self) -> None: + has_conversion_tables = "SuperAnimalConversionTables" in self.root.cfg + self.mapping_button.setVisible(has_conversion_tables & (self.root.engine == Engine.PYTORCH)) + def show_help_dialog(self): dialog = QtWidgets.QDialog(self) layout = QtWidgets.QVBoxLayout() diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index b54f6d2679..054781cb11 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -34,7 +34,7 @@ def video_inference_superanimal( videos: Union[str, list], superanimal_name: str, - scale_list: list | None = None, + scale_list: Optional[list] = None, videotype: str = ".mp4", dest_folder: Optional[str] = None, video_adapt: bool = False, From 1713f724d195e4a689c59f34d9b2fc314e874298 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Mon, 10 Jun 2024 19:26:50 +0200 Subject: [PATCH 115/293] bug fix: mps float64 for target weights (#227) --- .../target_generators/heatmap_targets.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py index 6ee40f0cd2..f9409d3164 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py @@ -57,7 +57,7 @@ def __init__( heatmap_mode: str | Mode = Mode.KEYPOINT, generate_locref: bool = True, locref_std: float = 7.2801, - **kwargs + **kwargs, ): """ Args: @@ -79,7 +79,7 @@ def __init__( super().__init__(**kwargs) self.num_heatmaps = num_heatmaps self.dist_thresh = float(pos_dist_thresh) - self.dist_thresh_sq = self.dist_thresh ** 2 + self.dist_thresh_sq = self.dist_thresh**2 self.std = 2 * self.dist_thresh / 3 if isinstance(heatmap_mode, str): @@ -145,7 +145,9 @@ def forward( heatmap = np.zeros((*map_size, self.num_heatmaps), dtype=np.float32) # coords shape: (batch_size, n_keypoints, 1, 2) - weights = np.ones((batch_size, coords.shape[1], height, width)) + weights = np.ones( + (batch_size, coords.shape[1], height, width), dtype=np.float32 + ) locref_map, locref_mask = None, None if self.generate_locref: @@ -183,7 +185,7 @@ def forward( target = { "heatmap": { "target": torch.tensor(heatmap, device=hm_device), - "weights": torch.tensor(weights.astype(float), device=hm_device) + "weights": torch.tensor(weights, device=hm_device), } } @@ -199,7 +201,10 @@ def forward( return target def get_locref( - self, locref_map_or_mask: np.ndarray | None, batch_idx: int, heatmap_idx: int, + self, + locref_map_or_mask: np.ndarray | None, + batch_idx: int, + heatmap_idx: int, ) -> np.ndarray | None: """ Args: @@ -259,9 +264,9 @@ def update( """Updates the heatmap (and locref if defined) with gaussian values""" # revert keypoints to follow image convention: from x,y to y,x keypoint = keypoint.copy()[::-1] - + dist = np.linalg.norm(grid - keypoint, axis=2) ** 2 - heatmap_j = np.exp(-dist / (2 * self.std ** 2)) + heatmap_j = np.exp(-dist / (2 * self.std**2)) heatmap[:, :] = np.maximum(heatmap, heatmap_j) if locref_map is not None: From 3aa413b5f2e7008c29cbf5ffffb2c81311c8b51a Mon Sep 17 00:00:00 2001 From: shaokai Date: Tue, 11 Jun 2024 09:46:45 +0200 Subject: [PATCH 116/293] Shaokai/customized checkpoints (#220) --- deeplabcut/core/weight_init.py | 26 +++++- deeplabcut/gui/tabs/train_network.py | 2 +- .../datasets/base.py | 5 +- .../datasets/materialize.py | 30 +++---- .../apis/analyze_images.py | 24 +++-- .../pose_estimation_pytorch/apis/train.py | 14 ++- .../models/detectors/base.py | 6 +- .../models/heads/simple_head.py | 10 ++- .../pose_estimation_pytorch/models/model.py | 20 +++-- .../target_generators/heatmap_targets.py | 1 + .../modelzoo/inference.py | 8 +- .../modelzoo/memory_replay.py | 87 ++++++++++--------- .../pose_estimation_pytorch/modelzoo/utils.py | 17 ++-- 13 files changed, 153 insertions(+), 97 deletions(-) diff --git a/deeplabcut/core/weight_init.py b/deeplabcut/core/weight_init.py index a879ae7e24..d671f09459 100644 --- a/deeplabcut/core/weight_init.py +++ b/deeplabcut/core/weight_init.py @@ -42,22 +42,28 @@ class WeightInitialization: Args: dataset: The dataset on which the model weights were trained. Must be one of the SuperAnimal weights. - `with_decoder`: Whether to load the decoder weights as well. + with_decoder: Whether to load the decoder weights as well. memory_replay: Only when ``with_decoder=True``. Whether to train the model with memory replay, so that it predicts all SuperAnimal bodyparts. conversion_array: The mapping from SuperAnimal to project bodyparts. Required when `with_decoder=True`. - An array [7, 0, 1] means the project has 3 bodyparts, where the 1st bodypart corresponds to the 8th bodypart in the pretrained model, the 2nd to the 1st and the 3rd to the 2nd (as arrays are 0-indexed). bodyparts: Optionally, the name of each bodypart entry in the conversion array. + customized_pose_checkpoint: A customized SuperAnimal pose checkpoint, as an + alternative to the Hugging Face one + customized_detector_checkpoint: A customized SuperAnimal detector + checkpoint, as an alternative to the Hugging Face one """ + dataset: str with_decoder: bool = False memory_replay: bool = False conversion_array: np.ndarray | None = None bodyparts: list[str] | None = None + customized_pose_checkpoint: str | None = None + customized_detector_checkpoint: str | None = None def __post_init__(self): # check that the dataset exists; raises a ValueError if it doesn't @@ -96,8 +102,13 @@ def to_dict(self) -> dict: "with_decoder": self.with_decoder, "memory_replay": self.memory_replay, } + if self.conversion_array is not None: data["conversion_array"] = self.conversion_array.tolist() + if self.customized_pose_checkpoint is not None: + data["customized_pose_checkpoint"] = self.customized_pose_checkpoint + if self.customized_detector_checkpoint is not None: + data["customized_detector_checkpoint"] = self.customized_detector_checkpoint return data @@ -105,6 +116,7 @@ def to_dict(self) -> dict: def from_dict(data: dict) -> "WeightInitialization": conversion_array = data.get("conversion_array") if conversion_array is not None: + conversion_array = np.array(conversion_array, dtype=int) return WeightInitialization( @@ -112,6 +124,8 @@ def from_dict(data: dict) -> "WeightInitialization": with_decoder=data["with_decoder"], memory_replay=data["memory_replay"], conversion_array=conversion_array, + customized_pose_checkpoint=data.get("customized_pose_checkpoint"), + customized_detector_checkpoint=data.get("customized_detector_checkpoint"), ) @staticmethod @@ -120,6 +134,8 @@ def build( super_animal: str, with_decoder: bool = False, memory_replay: bool = False, + customized_pose_checkpoint: str | None = None, + customized_detector_checkpoint: str | None = None, ) -> "WeightInitialization": """Builds a WeightInitialization for a project @@ -133,6 +149,10 @@ def build( conversion table. memory_replay: Only when ``with_decoder=True``. Whether to train the model with memory replay, so that it predicts all SuperAnimal bodyparts. + customized_pose_checkpoint: A customized SuperAnimal pose checkpoint, as an + alternative to the Hugging Face one + customized_detector_checkpoint: A customized SuperAnimal detector + checkpoint, as an alternative to the Hugging Face one Returns: The built WeightInitialization. @@ -150,4 +170,6 @@ def build( memory_replay=memory_replay, conversion_array=conversion_array, bodyparts=bodyparts, + customized_pose_checkpoint=customized_pose_checkpoint, + customized_detector_checkpoint=customized_detector_checkpoint, ) diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py index 115f5e6027..05f65a3b28 100644 --- a/deeplabcut/gui/tabs/train_network.py +++ b/deeplabcut/gui/tabs/train_network.py @@ -266,7 +266,7 @@ def get_train_attributes(engine: Engine) -> list[TrainAttributeRow]: label="Detector max epochs", fn_key="detector_epochs", default=200, - min=1, + min=0, max=1000, tooltip="", ), diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py index 7e058ccfa7..77f5b635a4 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py @@ -152,7 +152,9 @@ def summary(self): def populate_generic(self): raise NotImplementedError("Must implement this function") - def materialize(self, proj_root, framework="coco", deepcopy=False): + def materialize( + self, proj_root, framework="coco", deepcopy=False, append_image_id=True + ): mat_func = mat_func_factory(framework) self.meta["mat_datasets"] = {self.meta["dataset_name"]: self} self.meta["imageid2datasetname"] = self.imageid2datasetname @@ -164,6 +166,7 @@ def materialize(self, proj_root, framework="coco", deepcopy=False): self.generic_test_annotations, self.meta, deepcopy=deepcopy, + append_image_id=append_image_id, ) def whether_anno_image_match(self, images, annotations): diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py index 30a6404654..a066e75fe4 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -19,7 +19,6 @@ import yaml import deeplabcut.compat as compat -from deeplabcut.utils import auxiliaryfunctions from deeplabcut.generate_training_dataset.multiple_individuals_trainingsetmanipulation import ( create_multianimaltraining_dataset, format_multianimal_training_data, @@ -30,6 +29,7 @@ from deeplabcut.generate_training_dataset.trainingsetmanipulation import ( format_training_data as format_single_training_data, ) +from deeplabcut.utils import auxiliaryfunctions def get_filename(filename): @@ -348,14 +348,10 @@ def _generic2madlc( cfg = auxiliaryfunctions.read_config(config_path) - train_folder = os.path.join( - proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg) - ) + train_folder = os.path.join(proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg)) - datafilename, metafilename = ( - auxiliaryfunctions.GetDataandMetaDataFilenames( - train_folder, train_fraction, 1, cfg - ) + datafilename, metafilename = auxiliaryfunctions.GetDataandMetaDataFilenames( + train_folder, train_fraction, 1, cfg ) modify_train_test_cfg(config_path) @@ -579,14 +575,10 @@ def _generic2sdlc( config_path = os.path.join(proj_root, "config.yaml") cfg = auxiliaryfunctions.read_config(config_path) - train_folder = os.path.join( - proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg) - ) + train_folder = os.path.join(proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg)) - datafilename, metafilename = ( - auxiliaryfunctions.GetDataandMetaDataFilenames( - train_folder, train_fraction, 1, cfg - ) + datafilename, metafilename = auxiliaryfunctions.GetDataandMetaDataFilenames( + train_folder, train_fraction, 1, cfg ) modify_train_test_cfg(config_path) @@ -656,6 +648,7 @@ def _generic2coco( meta, deepcopy=False, full_image_path=True, + append_image_id=True, ): """ Take generic data and create coco structure @@ -712,8 +705,11 @@ def _generic2coco( # this does not work for image file that looks like image9.5.jpg.. pre, suffix = image_name.split(".") - dest_image_name = f"{pre}_{image_id}.{suffix}" - + # not to repeatedly add image id in memory replay training + if append_image_id: + dest_image_name = f"{pre}_{image_id}.{suffix}" + else: + dest_image_name = image_name dest = os.path.join(proj_root, "images", dest_image_name) # now, we will also need to update the path in the config files diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py index caab30fdf0..ef7d84190a 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py @@ -48,6 +48,8 @@ def superanimal_analyze_images( out_folder: str, progress_bar: bool = True, device: str | None = None, + customized_pose_checkpoint: str | None = None, + customized_detector_checkpoint: str | None = None, ): """ This funciton inferences a superanimal model on a set of images and saves the results as labeled images. @@ -74,6 +76,12 @@ def superanimal_analyze_images( Whether to display a progress bar when running inference. device: str | None The device to use to run image analysis. + customized_pose_checkpoint: str | None + A customized SuperAnimal pose checkpoint, as an alternative to the Hugging Face + model SuperAnimal models. + customized_detector_checkpoint: str | None + A customized SuperAnimal detector checkpoint, as an alternative to the Hugging + Face SuperAnimal models. Returns ------- @@ -105,6 +113,11 @@ def superanimal_analyze_images( detector_path, ) = get_config_model_paths(superanimal_name, model_name) + if customized_pose_checkpoint is not None: + snapshot_path = customized_pose_checkpoint + if customized_detector_checkpoint is not None: + detector_path = customized_detector_checkpoint + config = {**project_config, **model_cfg} config = update_config(config, max_individuals, device) individuals = [f"animal{i}" for i in range(max_individuals)] @@ -121,9 +134,9 @@ def superanimal_analyze_images( ) superanimal_colormaps = get_superanimal_colormaps() - kpts_color = superanimal_colormaps[superanimal_name] + colormap = superanimal_colormaps[superanimal_name] - create_labeled_images_from_predictions(predictions, out_folder, kpts_color) + create_labeled_images_from_predictions(predictions, out_folder, colormap) return predictions @@ -321,12 +334,7 @@ def analyze_image_folder( } -def create_labeled_images_from_predictions( - predictions, - out_folder, - cmap, - kpts_color, -): +def create_labeled_images_from_predictions(predictions, out_folder, cmap): for image_path, prediction in predictions.items(): diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 6181ca23bd..7efbc4e5be 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -72,6 +72,7 @@ def train( """ weight_init = None pretrained = True + if weight_init_cfg := run_config["train_settings"].get("weight_init"): weight_init = WeightInitialization.from_dict(weight_init_cfg) pretrained = False @@ -82,6 +83,7 @@ def train( weight_init=weight_init, pretrained=pretrained, ) + else: model = PoseModel.build( run_config["model"], @@ -233,20 +235,23 @@ def train_network( backbone_name = loader.model_cfg["model"]["backbone"]["model_name"] model_name = modelzoo_utils.get_pose_model_type(backbone_name) # at some point train_network should support a different train_file passing so memory replay can also take the same train file - superanimal_model_config = prepare_memory_replay( + + prepare_memory_replay( loader.project_path, shuffle, weight_init.dataset, model_name, device, - train_file = "train.json", + train_file="train.json", max_individuals=dataset_params.max_num_animals, - pose_threshold = pose_threshold + pose_threshold=pose_threshold, + customized_pose_checkpoint=weight_init.customized_pose_checkpoint, ) + loader = COCOLoader( project_root=Path(loader.model_folder).parent / "memory_replay", model_config_path=loader.model_config_path, - train_json_filename = "memory_replay_train.json" + train_json_filename="memory_replay_train.json", ) if batch_size is not None: @@ -280,6 +285,7 @@ def train_network( # get the pose task pose_task = Task(loader.model_cfg.get("method", "bu")) + # We should allow people to set detector epochs to 0 if it was already trained. because they will most likely tune the pose estimator if ( pose_task == Task.TOP_DOWN and loader.model_cfg["detector"]["train_settings"]["epochs"] > 0 diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py index bf7d6db670..1b1f9f131c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py @@ -10,6 +10,7 @@ # from __future__ import annotations +import logging from abc import ABC, abstractmethod import torch @@ -17,7 +18,7 @@ import deeplabcut.pose_estimation_pytorch.modelzoo.utils as modelzoo_utils from deeplabcut.core.weight_init import WeightInitialization -from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg +from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry def _build_detector( @@ -46,6 +47,9 @@ def _build_detector( pose_model_type="hrnetw32", # pose model does not matter here detector_type="fasterrcnn", # TODO: include variant ) + if weight_init.customized_detector_checkpoint is not None: + snapshot_path = weight_init.customized_detector_checkpoint + logging.info(f"Loading detector checkpoint from {snapshot_path}") snapshot = torch.load(snapshot_path, map_location="cpu") detector.load_state_dict(snapshot["model"]) diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 1997f17ae2..4010ec5834 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -19,8 +19,8 @@ ) from deeplabcut.pose_estimation_pytorch.models.heads.base import ( BaseHead, - WeightConversionMixin, HEADS, + WeightConversionMixin, ) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator @@ -115,7 +115,9 @@ def convert_weights( conversion: the mapping of old indices to new indices """ state_dict = DeconvModule.convert_weights( - state_dict, f"{module_prefix}heatmap_head.", conversion, + state_dict, + f"{module_prefix}heatmap_head.", + conversion, ) locref_conversion = torch.stack( @@ -123,7 +125,9 @@ def convert_weights( dim=1, ).reshape(-1) state_dict = DeconvModule.convert_weights( - state_dict, f"{module_prefix}locref_head.", locref_conversion, + state_dict, + f"{module_prefix}locref_head.", + locref_conversion, ) return state_dict diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py index 75092712f9..c73ac3196d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/model.py +++ b/deeplabcut/pose_estimation_pytorch/models/model.py @@ -11,12 +11,14 @@ from __future__ import annotations import copy +import logging import torch import torch.nn as nn import deeplabcut.pose_estimation_pytorch.modelzoo.utils as modelzoo_utils -from deeplabcut.pose_estimation_pytorch.models.backbones import BaseBackbone, BACKBONES +from deeplabcut.core.weight_init import WeightInitialization +from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES, BaseBackbone from deeplabcut.pose_estimation_pytorch.models.criterions import ( CRITERIONS, LOSS_AGGREGATORS, @@ -27,7 +29,6 @@ from deeplabcut.pose_estimation_pytorch.models.target_generators import ( TARGET_GENERATORS, ) -from deeplabcut.core.weight_init import WeightInitialization class PoseModel(nn.Module): @@ -187,17 +188,22 @@ def build( model = PoseModel(cfg=cfg, backbone=backbone, neck=neck, heads=heads) if weight_init is not None: - print(f"Loading pretrained model weights: {weight_init}") + logging.info(f"Loading pretrained model weights: {weight_init}") # TODO: Should we specify the pose_model_type in WeightInitialization? backbone_name = cfg["backbone"]["model_name"] pose_model_type = modelzoo_utils.get_pose_model_type(backbone_name) # load pretrained weights - _, _, snapshot_path, _ = modelzoo_utils.get_config_model_paths( - project_name=weight_init.dataset, - pose_model_type=pose_model_type, - ) + if weight_init.customized_pose_checkpoint is None: + _, _, snapshot_path, _ = modelzoo_utils.get_config_model_paths( + project_name=weight_init.dataset, + pose_model_type=pose_model_type, + ) + else: + snapshot_path = weight_init.customized_pose_checkpoint + + logging.info(f"The pose model is loading from {snapshot_path}") snapshot = torch.load(snapshot_path, map_location="cpu") state_dict = snapshot["model"] diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py index f9409d3164..8f3688757d 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py @@ -170,6 +170,7 @@ def forward( elif keypoint[-1] == -1: # full gradient masking weights[b, heatmap_idx] = 0.0 + elif keypoint[-1] > 0: # keypoint visible self.update( diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index fe2d1fd973..f407baaa6d 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -83,11 +83,15 @@ def _video_inference_superanimal( If not specified, the device is automatically determined by the `select_device` function. Defaults to None, which triggers automatic device selection. + dest_folder: Destination folder for the results. If not specified, the results are saved in the same folder as the video. Defaults to None. - customized_pose_checkpoint: A customized checkpoint to replace the default superanimal pose checkpoint - customized_detector_checkpoint: A customized checkpoint to replace the default superanimal detector checkpoint + customized_pose_checkpoint: A customized checkpoint to replace the default + SuperAnimal pose checkpoint + + customized_detector_checkpoint: A customized checkpoint to replace the default + SuperAnimal detector checkpoint Returns: results: Dictionary with the result pd.DataFrame for each video diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py index 41141fd409..f7e547f705 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py @@ -10,8 +10,17 @@ # from __future__ import annotations +import glob +import json +import os +from collections import defaultdict from pathlib import Path +import numpy as np +from scipy.optimize import linear_sum_assignment +from scipy.spatial import distance +from scipy.spatial.distance import cdist + import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.core.engine import Engine from deeplabcut.core.weight_init import WeightInitialization @@ -21,20 +30,13 @@ MultiSourceDataset, SingleDLCPoseDataset, ) +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( get_config_model_paths, update_config, ) -import json -import os -from scipy.optimize import linear_sum_assignment -from scipy.spatial import distance -from scipy.spatial.distance import cdist -from deeplabcut.utils.pseudo_label import xywh2xyxy, optimal_match,calculate_iou -from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners -import glob -import numpy as np -from collections import defaultdict +from deeplabcut.utils.pseudo_label import calculate_iou, optimal_match, xywh2xyxy + # this is reading from a coco project def prepare_memory_replay_dataset( @@ -62,7 +64,10 @@ def prepare_memory_replay_dataset( ) = get_config_model_paths(superanimal_name, model_name) if customized_pose_checkpoint is not None: - pose_model_path = customized_pose_checkpoint + print( + "memory replay fine-tuning pose checkpoint is replaced by", + customized_pose_checkpoint, + ) config = {**project_config, **model_config} config = update_config(config, max_individuals, device) @@ -110,10 +115,9 @@ def prepare_memory_replay_dataset( if annotation["image_id"] in imageids: imageid2annotations[image_id].append(annotation) - # need to support more image types image_extensions = ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.tiff"] - + images_in_folder = [] for ext in image_extensions: images_in_folder.extend( @@ -199,7 +203,10 @@ def optimal_match(gts_list, preds_list): # after the mixing, we don't care about confidence anymore for kpt_idx in range(len(matched_gt)): - if matched_gt[kpt_idx][2] < pose_threshold and matched_gt[kpt_idx][2] > 0: + if ( + matched_gt[kpt_idx][2] < pose_threshold + and matched_gt[kpt_idx][2] > 0 + ): matched_gt[kpt_idx][2] = -1 elif matched_gt[kpt_idx][2] > 0: matched_gt[kpt_idx][2] = 2 @@ -208,14 +215,13 @@ def optimal_match(gts_list, preds_list): # memory replay path memory_replay_train_file_path = os.path.join( - source_dataset_folder, "annotations", "memory_replay_train.json") - + source_dataset_folder, "annotations", "memory_replay_train.json" + ) + with open(memory_replay_train_file_path, "w") as f: json.dump(train_obj, f, indent=4) - - def prepare_memory_replay( dlc_proj_root: str | Path, shuffle: int, @@ -224,16 +230,11 @@ def prepare_memory_replay( device: str, max_individuals=3, trainingsetindex: int = 0, - train_file = "train.json", - pose_threshold = 0.1 + train_file="train.json", + pose_threshold=0.1, + customized_pose_checkpoint=None, ): """TODO: Documentation""" - ( - superanimal_model_config, - project_config, - pose_model_path, - detector_path, - ) = get_config_model_paths(superanimal_name, model_name) # in order to fill the num_bodyparts stuff @@ -249,11 +250,6 @@ def prepare_memory_replay( str(dlc_proj_root), "temp_dataset", shuffle=shuffle ) - superanimal_model_config = {**project_config, **superanimal_model_config} - superanimal_model_config = update_config( - superanimal_model_config, max_individuals, device - ) - dlc_proj_root = Path(dlc_proj_root) config_path = dlc_proj_root / "config.yaml" @@ -267,7 +263,9 @@ def prepare_memory_replay( memory_replay_folder = model_folder / "memory_replay" - temp_dataset.materialize(memory_replay_folder, framework="coco") + temp_dataset.materialize( + memory_replay_folder, framework="coco", append_image_id=False + ) original_model_config = af.read_config( str(model_folder / "train" / "pytorch_config.yaml") @@ -295,15 +293,22 @@ def prepare_memory_replay( conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" + # here we project the original DLC projects to superanimal space and save them into a coco project format dataset.project_with_conversion_table(str(conversion_table_path)) dataset.materialize(memory_replay_folder, deepcopy=False, framework="coco") - prepare_memory_replay_dataset(memory_replay_folder, - superanimal_name, - model_name, - max_individuals = max_individuals, - device = device, - train_file = train_file, - pose_threshold = pose_threshold - - ) + # then in this function, we do pseudo label to match prediction and gts to create memory-replay dataset that will be named memory_replay_train.json + memory_replay_train_file = os.path.join( + memory_replay_folder, "annotations", "memory_replay_train.json" + ) + + prepare_memory_replay_dataset( + memory_replay_folder, + superanimal_name, + model_name, + max_individuals=max_individuals, + device=device, + train_file=train_file, + pose_threshold=pose_threshold, + customized_pose_checkpoint=customized_pose_checkpoint, + ) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py index 4ab1d449ca..9ec4b444ae 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py @@ -16,6 +16,7 @@ import torch from dlclibrary import download_huggingface_model + import deeplabcut.pose_estimation_pytorch.config.utils as config_utils from deeplabcut.pose_estimation_pytorch.config.make_pose_config import add_metadata from deeplabcut.utils import auxiliaryfunctions @@ -63,8 +64,9 @@ def get_config_model_paths( f"{project_name}_{pose_model_type}", target_dir=str(weight_folder), rename_mapping={ - "pose_model.pth": pose_model_name, "detector.pt": detector_name - } + "pose_model.pth": pose_model_name, + "detector.pt": detector_name, + }, ) # FIXME: Needed due to changes in code - remove when new snapshots are uploaded @@ -92,11 +94,7 @@ def get_gpu_memory_map(): def select_device(): if torch.cuda.is_available(): - gpu_memory_map = get_gpu_memory_map() - selected_device = max(gpu_memory_map, key=gpu_memory_map.get) - print(f"Device was set to cuda:{selected_device}") - - return torch.device(f"cuda:{selected_device}") + return torch.device(f"cuda:0") else: return torch.device("cpu") @@ -118,7 +116,7 @@ def update_config(config, max_individuals, device): config, num_bodyparts=len(config["bodyparts"]), num_individuals=max_individuals, - backbone_output_channels=config["model"]["backbone_output_channels"] + backbone_output_channels=config["model"]["backbone_output_channels"], ) config["device"] = device config_utils.pretty_print(config) @@ -127,6 +125,7 @@ def update_config(config, max_individuals, device): def _parse_model_snapshot(base: Path, device: str, print_keys: bool = False) -> Path: """FIXME: A new snapshot should be uploaded and used""" + def _map_model_keys(state_dict: dict) -> dict: updated_dict = {} for k, v in state_dict.items(): @@ -167,5 +166,3 @@ def get_pose_model_type(backbone: str) -> str: return backbone.replace("_", "") raise ValueError(f"Unknown backbone for SuperAnimal Weights") - - From cf0ebe780f16704cb88f9fdbe89f0abc87cfe175 Mon Sep 17 00:00:00 2001 From: shaokai Date: Tue, 11 Jun 2024 10:38:41 +0200 Subject: [PATCH 117/293] Shaokai/improve video adaptation (#221) --- benchmark_superanimal/video_adapt_example.py | 6 ++-- deeplabcut/modelzoo/video_inference.py | 35 +++++++++++++++++-- .../pose_estimation_pytorch/modelzoo/utils.py | 1 + deeplabcut/pose_estimation_pytorch/utils.py | 1 + deeplabcut/utils/pseudo_label.py | 30 ++++++++++------ 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/benchmark_superanimal/video_adapt_example.py b/benchmark_superanimal/video_adapt_example.py index 8299bbb2e6..dd76bcff6c 100644 --- a/benchmark_superanimal/video_adapt_example.py +++ b/benchmark_superanimal/video_adapt_example.py @@ -3,14 +3,14 @@ def main(): modelzoo.video_inference_superanimal( - videos=["/mnt/md0/shaokai/DLCdev/3mice_video1_short.mp4"], + videos=["/mnt/md0/shaokai/tom_video.mp4"], superanimal_name="superanimal_topviewmouse_hrnetw32", video_adapt=True, max_individuals=3, pseudo_threshold=0.1, bbox_threshold=0.9, - detector_epochs=4, - pose_epochs=4, + detector_epochs=1, + pose_epochs=1, ) diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index 054781cb11..dad1ad2488 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Optional, Union +import numpy as np from dlclibrary.dlcmodelzoo.modelzoo_download import download_huggingface_model from ruamel.yaml import YAML @@ -47,7 +48,7 @@ def video_inference_superanimal( pose_epochs: int = 4, max_individuals: int = 10, video_adapt_batch_size: int = 8, - device: Optional[str] = None, + device: Optional[str] = "auto", customized_pose_checkpoint: Optional[str] = None, customized_detector_checkpoint: Optional[str] = None, ): @@ -214,6 +215,8 @@ def video_inference_superanimal( project_name, model_name = parse_project_model_name(superanimal_name) + print(f"running video inference on {videos} with {project_name}_{model_name}") + dlc_root_path = get_deeplabcut_path() modelzoo_path = os.path.join(dlc_root_path, "modelzoo") available_architectures = json.load( @@ -318,11 +321,16 @@ def video_inference_superanimal( print(f"{image_folder} exists, skipping the frame extraction") else: image_folder.mkdir() + print( + f"Video frames being extracted to {image_folder} for video adaptation." + ) video_to_frames(video_path, pseudo_dataset_folder) anno_folder = pseudo_dataset_folder / "annotations" if anno_folder.exists(): - print(f"{anno_folder} exists, skipping the annotation construction") + print( + f"{anno_folder} exists, skipping the annotation construction. Delete the folder if you want to re-construct pseudo annotations" + ) else: anno_folder.mkdir() @@ -337,6 +345,7 @@ def video_inference_superanimal( # make sure we tune parameters inside this function such as pseudo # threshold etc. + print(f"Constructing pseudo dataset at {pseudo_dataset_folder}") dlc3predictions_2_annotation_from_video( predictions, pseudo_dataset_folder, @@ -373,6 +382,28 @@ def video_inference_superanimal( "existing checkpoints." ) else: + + print( + f""" +Running video adaptation with following parameters: +(pose training) pose_epochs: {pose_epochs} +(pose) save_epochs: 1 +detector_epochs: {detector_epochs} +detector_save_epochs: 1 +video adaptation batch size: {video_adapt_batch_size}""" + ) + with open( + os.path.join(pseudo_dataset_folder, "annotations", "train.json"), + "r", + ) as f: + temp_obj = json.load(f) + annotations = temp_obj["annotations"] + if len(annotations) == 0: + print( + f"No valid predictions from {str(video_path)}. Check the quality of the video" + ) + return + adaptation_train( project_root=pseudo_dataset_folder, model_folder=model_folder, diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py index 9ec4b444ae..0a265f4f85 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py @@ -89,6 +89,7 @@ def get_gpu_memory_map(): ) gpu_memory = [int(x) for x in result.strip().split("\n")] gpu_memory_map = dict(zip(range(len(gpu_memory)), gpu_memory)) + return gpu_memory_map diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py index f91acdf87a..7ccacaa904 100644 --- a/deeplabcut/pose_estimation_pytorch/utils.py +++ b/deeplabcut/pose_estimation_pytorch/utils.py @@ -61,6 +61,7 @@ def resolve_device(model_config: dict) -> str: """ device = model_config["device"] supports_mps = "resnet" in model_config.get("net_type", "resnet") + if device == "auto": if torch.cuda.is_available(): return "cuda" diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py index d440c71ab4..5e279e7ed9 100644 --- a/deeplabcut/utils/pseudo_label.py +++ b/deeplabcut/utils/pseudo_label.py @@ -45,11 +45,13 @@ def default(self, obj): return obj.tolist() # Convert ndarray to list return super().default(obj) + def xywh2xyxy(bbox): temp_bbox = np.copy(bbox) temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:] return temp_bbox + def optimal_match(gts_list, preds_list): arranged_preds_list = [] num_gts = len(gts_list) @@ -64,7 +66,8 @@ def optimal_match(gts_list, preds_list): row_ind, col_ind = linear_sum_assignment(cost_matrix) return col_ind - + + def calculate_iou(box1, box2): # Unpack the coordinates x1_1, y1_1, x2_1, y2_1 = box1 @@ -142,7 +145,12 @@ def plot_cost_matrix( def keypoint_matching( - config_path, superanimal_name, model_name, device=None, train_file="train.json", pose_threshold = 0.1 + config_path, + superanimal_name, + model_name, + device=None, + train_file="train.json", + pose_threshold=0.1, ): cfg = af.read_config(config_path) @@ -251,10 +259,10 @@ def keypoint_matching( # pose inference should return meta data for pseudo labeling predictions = pose_runner.inference(pose_inputs) - with open(str(memory_replay_folder / 'pseudo_predictions.json'), 'w') as f: - - json.dump(pose_inputs, f, cls = NumpyEncoder) - + with open(str(memory_replay_folder / "pseudo_predictions.json"), "w") as f: + + json.dump(pose_inputs, f, cls=NumpyEncoder) + assert len(images) == len(predictions) imagename2prediction = {} @@ -338,6 +346,7 @@ def keypoint_matching( out += f"{source}, {target}\n" f.write(out) + # this is to generate a coco project as an intermediate data def dlc3predictions_2_annotation_from_video( predictions, @@ -369,9 +378,6 @@ def dlc3predictions_2_annotation_from_video( """ - print("pose threshold", pose_threshold) - print("bbox threshold", bbox_threshold) - category_id = 1 # the default for superanimal. But it might be changed images = [] @@ -380,10 +386,12 @@ def dlc3predictions_2_annotation_from_video( annotation_id = 0 image_folder = os.path.join(dest_proj_folder, "images") - print("image folder", image_folder) # video_to_frames function by default outputs png or jpg image_paths = sorted(glob.glob(os.path.join(image_folder, "*.png"))) + # skipping every 4 frames should speed up and not impact the performance + predictions, image_paths = predictions[::10], image_paths[::10] + # because inference api does not return image path. I am assuming the predictions come in an oder from the video assert len(image_paths) == len( predictions @@ -479,6 +487,7 @@ def dlc3predictions_2_annotation_from_video( annotation_id += 1 annotations.append(anno) + # this is to prevent images that do not have annotations if len(imageid2annotations[image_id]) > 0: images.append(image) @@ -500,4 +509,3 @@ def dlc3predictions_2_annotation_from_video( with open(os.path.join(dest_proj_folder, "annotations", "train.json"), "w") as f: json.dump(train_obj, f, indent=4) - From 30344eb3569e02673352a64df75d8476dbbcbcb8 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:39:35 +0200 Subject: [PATCH 118/293] niels/default configuration updates (#226) --- .../gui/tabs/create_training_dataset.py | 2 +- .../pose_estimation_pytorch/apis/evaluate.py | 14 ++- .../pose_estimation_pytorch/apis/train.py | 20 +++++ .../animaltokenpose_base.yaml} | 24 +----- .../config/backbones/hrnet_w18.yaml | 4 +- .../config/backbones/hrnet_w32.yaml | 4 +- .../config/backbones/hrnet_w48.yaml | 4 +- .../config/backbones/resnet_101.yaml | 8 +- .../config/backbones/resnet_50.yaml | 8 +- .../config/base/aug_default.yaml | 22 +++++ .../config/base/aug_top_down.yaml | 8 +- .../config/base/base.yaml | 23 ----- .../config/base/detector.yaml | 3 +- .../config/base/head_bodyparts.yaml | 7 +- .../config/base/head_bodyparts_with_paf.yaml | 7 +- .../config/base/head_topdown.yaml | 5 +- .../config/dekr/dekr_w18.yaml | 10 ++- .../config/dekr/dekr_w32.yaml | 10 ++- .../config/dekr/dekr_w48.yaml | 12 ++- .../config/dlcrnet/dlcrnet_stride16_ms5.yaml | 2 + .../config/dlcrnet/dlcrnet_stride32_ms5.yaml | 2 + .../config/make_pose_config.py | 8 +- .../pose_estimation_pytorch/data/dataset.py | 28 ++++-- .../data/transforms.py | 6 +- .../pose_estimation_pytorch/data/utils.py | 32 +------ .../models/criterions/__init__.py | 4 + .../models/criterions/dekr.py | 85 +++++++++++++++++++ .../models/criterions/weighted.py | 11 ++- .../models/heads/base.py | 20 +++++ .../models/heads/dekr.py | 9 +- .../models/heads/dlcrnet.py | 10 ++- .../models/heads/simple_head.py | 22 ++--- .../models/predictors/dekr_predictor.py | 7 ++ .../models/predictors/paf_predictor.py | 11 ++- .../models/target_generators/pafs_targets.py | 1 + .../models/weight_init.py | 31 ++++++- 36 files changed, 329 insertions(+), 155 deletions(-) rename deeplabcut/pose_estimation_pytorch/config/{tokenpose/tokenpose_base.yaml => animaltokenpose/animaltokenpose_base.yaml} (71%) create mode 100644 deeplabcut/pose_estimation_pytorch/config/base/aug_default.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/models/criterions/dekr.py diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 11219ca6a5..e4a82df5df 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -112,7 +112,7 @@ def _generate_layout_attributes(self, layout): # Neural Network nnet_label = QtWidgets.QLabel("Network architecture") self.net_choice = QtWidgets.QComboBox() - self.net_choice.setMinimumWidth(80) + self.net_choice.setMinimumWidth(200) self.update_nets(self.root.engine) self.root.engine_change.connect(self.update_nets) self.net_choice.currentTextChanged.connect(self.log_net_choice) diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 1e65ee44e1..7306869153 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse +import logging from pathlib import Path from typing import Iterable @@ -298,7 +299,7 @@ def evaluate_network( show_errors: bool = True, transform: A.Compose = None, modelprefix: str = "", - detector_snapshot_index: int | None = -1, + detector_snapshot_index: int | None = None, ) -> None: """Evaluates a snapshot. @@ -366,6 +367,9 @@ def evaluate_network( if snapshotindex is None: snapshotindex = cfg["snapshotindex"] + if detector_snapshot_index is None: + detector_snapshot_index = cfg["detector_snapshotindex"] + for train_set_index in train_set_indices: for shuffle in shuffles: loader = DLCLoader( @@ -394,7 +398,9 @@ def evaluate_network( detector_snapshot_index, loader.model_folder, Task.DETECT, )[0] else: - print("Using GT bounding boxes to compute evaluation metrics") + logging.info( + "Using GT bounding boxes to compute evaluation metrics" + ) for snapshot in snapshots: scorer = get_scorer_name( @@ -446,8 +452,8 @@ def save_evaluation_results( pcutoff: the pcutoff used to get the evaluation results """ if print_results: - print(f"Evaluation results for {scores_path.name} (pcutoff: {pcutoff}):") - print(df_scores.iloc[0]) + logging.info(f"Evaluation results for {scores_path.name} (pcutoff: {pcutoff}):") + logging.info(df_scores.iloc[0]) # Save scores file df_scores.to_csv(scores_path) diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 7efbc4e5be..aca4a05630 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -155,6 +155,26 @@ def train( pin_memory=pin_memory, ) + if ( + loader.model_cfg["model"].get("freeze_bn_stats", False) + or loader.model_cfg["model"].get("backbone", {}).get("freeze_bn_stats", False) + or batch_size == 1 + ): + logging.info( + "\nNote: According to your model configuration, you're training with batch " + "size 1 and/or ``freeze_bn_stats=false``. This is not an optimal setting " + "if you have powerful GPUs.\n" + "This is good for small batch sizes (e.g., when training on a CPU), where " + "you should keep ``freeze_bn_stats=true``.\n" + "If you're using a GPU to train, you can obtain faster performance by " + "setting a larger batch size (the biggest power of 2 where you don't get" + "a CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the " + "model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` " + "for the backbone of your model. \n" + "This also allows you to increase the learning rate (empirically you can " + "scale the learning rate by sqrt(batch_size) times).\n" + ) + logging.info( f"Using {len(train_dataset)} images and {len(valid_dataset)} for testing" ) diff --git a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml b/deeplabcut/pose_estimation_pytorch/config/animaltokenpose/animaltokenpose_base.yaml similarity index 71% rename from deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml rename to deeplabcut/pose_estimation_pytorch/config/animaltokenpose/animaltokenpose_base.yaml index 582fcce235..4c45a347d5 100644 --- a/deeplabcut/pose_estimation_pytorch/config/tokenpose/tokenpose_base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/animaltokenpose/animaltokenpose_base.yaml @@ -1,33 +1,13 @@ # TODO: This default configuration file needs to be reviewed so it matches the original # base TokenPose configuration, as defined in # https://github.com/leeyegy/TokenPose/blob/main/experiments/coco/tokenpose/tokenpose_b_256_192_patch43_dim192_depth12_heads8.yaml -data: - colormode: RGB - inference: - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 - normalize_images: true - train: - affine: - p: 0.5 - rotation: 30 - translation: 0 - auto_padding: # Required for HRNet backbones - pad_width_divisor: 32 - pad_height_divisor: 32 - collate: null - covering: true - gaussian_noise: 12.75 - hist_eq: true - motion_blur: true - normalize_images: true method: td # Need to add a detector model: backbone: type: HRNet model_name: hrnet_w32 - pretrained: true + freeze_bn_stats: true + freeze_bn_weights: false interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 32 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml index 180d28e2ea..f70c1d4330 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml @@ -7,8 +7,8 @@ model: backbone: type: HRNet model_name: hrnet_w18 - freeze_bn_stats: True - freeze_bn_weights: False + freeze_bn_stats: true + freeze_bn_weights: false interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 18 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml index 1a23155f41..5db0927af8 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml @@ -7,8 +7,8 @@ model: backbone: type: HRNet model_name: hrnet_w32 - freeze_bn_stats: True - freeze_bn_weights: False + freeze_bn_stats: true + freeze_bn_weights: false interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 32 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml index 4d9eddd395..d5079a4564 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml @@ -7,8 +7,8 @@ model: backbone: type: HRNet model_name: hrnet_w48 - freeze_bn_stats: True - freeze_bn_weights: False + freeze_bn_stats: true + freeze_bn_weights: false interpolate_branches: false increased_channel_count: false # changes backbone_output_channels to 128 when true backbone_output_channels: 48 diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml index cbaba267e9..0d3a7101bf 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml @@ -3,16 +3,16 @@ model: type: ResNet model_name: resnet101 output_stride: 16 - freeze_bn_stats: True - freeze_bn_weights: False + freeze_bn_stats: true + freeze_bn_weights: false backbone_output_channels: 2048 runner: optimizer: type: AdamW params: - lr: 0.001 + lr: 0.0001 scheduler: type: LRListScheduler params: - lr_list: [ [ 1e-4 ], [ 1e-5 ] ] + lr_list: [ [ 1e-5 ], [ 1e-6 ] ] milestones: [ 160, 190 ] \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml index 5c189c9e24..25687c07f5 100644 --- a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml @@ -3,16 +3,16 @@ model: type: ResNet model_name: resnet50_gn output_stride: 16 - freeze_bn_stats: True - freeze_bn_weights: False + freeze_bn_stats: true + freeze_bn_weights: false backbone_output_channels: 2048 runner: optimizer: type: AdamW params: - lr: 0.001 + lr: 0.0001 scheduler: type: LRListScheduler params: - lr_list: [ [ 1e-4 ], [ 1e-5 ] ] + lr_list: [ [ 1e-5 ], [ 1e-6 ] ] milestones: [ 160, 190 ] \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/base/aug_default.yaml b/deeplabcut/pose_estimation_pytorch/config/base/aug_default.yaml new file mode 100644 index 0000000000..7a2cbec18d --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/base/aug_default.yaml @@ -0,0 +1,22 @@ +colormode: RGB +inference: + normalize_images: true +train: + affine: + p: 0.5 + rotation: 30 + scaling: [1.0, 1.0] + translation: 0 + collate: + type: ResizeFromDataSizeCollate + min_scale: 0.4 + max_scale: 1.0 + min_short_side: 128 + max_short_side: 1152 + multiple_of: 32 + to_square: false + covering: false + gaussian_noise: 12.75 + hist_eq: false + motion_blur: false + normalize_images: true diff --git a/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml b/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml index 233f6b8497..325d5c9e85 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml @@ -4,12 +4,12 @@ inference: train: affine: p: 0.5 - scaling: [1.0, 1.0] rotation: 30 + scaling: [1.0, 1.0] translation: 0 collate: null - covering: true + covering: false gaussian_noise: 12.75 - hist_eq: true - motion_blur: true + hist_eq: false + motion_blur: false normalize_images: true diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml index df02bcf145..a507625aaf 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -1,26 +1,3 @@ -data: - colormode: RGB - inference: - normalize_images: true - longest_max_size: 1600 - train: - affine: - p: 0.9 - rotation: 30 - translation: 40 - collate: - type: ResizeFromDataSizeCollate - min_scale: 0.4 - max_scale: 1.0 - min_short_side: 128 - max_short_side: 1152 - multiple_of: 32 - to_square: false - covering: true - gaussian_noise: 12.75 - hist_eq: true - motion_blur: true - normalize_images: true device: auto method: bu runner: diff --git a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml index 21698fe251..7cd0f7387e 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml @@ -22,9 +22,8 @@ detector: device: auto model: type: FasterRCNN - freeze_bn_stats: false + freeze_bn_stats: true freeze_bn_weights: false - pretrained: true variant: fasterrcnn_mobilenet_v3_large_fpn runner: type: DetectorTrainingRunner diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml index 186237cf79..f0734d4e3c 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml @@ -1,10 +1,13 @@ type: HeatmapHead +weight_init: normal predictor: type: HeatmapPredictor + apply_sigmoid: false + clip_scores: true location_refinement: true locref_std: 7.2801 target_generator: - type: HeatmapPlateauGenerator + type: HeatmapGaussianGenerator num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT @@ -12,7 +15,7 @@ target_generator: locref_std: 7.2801 criterion: heatmap: - type: WeightedBCECriterion + type: WeightedMSECriterion weight: 1.0 locref: type: WeightedHuberCriterion diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml index e61788e373..833b045d29 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml @@ -1,4 +1,5 @@ type: DLCRNetHead +weight_init: normal predictor: type: PartAffinityFieldPredictor num_animals: "num_individuals" @@ -10,10 +11,12 @@ predictor: min_affinity: 0.05 graph: "paf_graph" edges_to_keep: "paf_edges_to_keep" + apply_sigmoid: false + clip_scores: true target_generator: type: SequentialGenerator generators: - - type: HeatmapPlateauGenerator + - type: HeatmapGaussianGenerator num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT @@ -24,7 +27,7 @@ target_generator: width: 20 criterion: heatmap: - type: WeightedBCECriterion + type: WeightedMSECriterion weight: 1.0 locref: type: WeightedHuberCriterion diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml index 6233b37a29..8e87759825 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml @@ -1,6 +1,9 @@ type: HeatmapHead +weight_init: normal predictor: type: HeatmapPredictor + apply_sigmoid: false + clip_scores: true location_refinement: false target_generator: type: HeatmapGaussianGenerator @@ -10,7 +13,7 @@ target_generator: generate_locref: false criterion: heatmap: - type: WeightedBCECriterion + type: WeightedMSECriterion weight: 1.0 heatmap_config: channels: diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml index 8891e67c6c..6bf7132004 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml @@ -7,13 +7,15 @@ model: backbone: type: HRNet model_name: hrnet_w18 - pretrained: true + freeze_bn_stats: false + freeze_bn_weights: false interpolate_branches: true increased_channel_count: false backbone_output_channels: 270 heads: bodypart: type: DEKRHead + weight_init: dekr target_generator: type: DEKRGenerator num_joints: "num_bodyparts" @@ -21,13 +23,15 @@ model: bg_weight: 0.1 criterion: heatmap: - type: WeightedBCECriterion + type: DEKRHeatmapLoss weight: 1 offset: - type: WeightedHuberCriterion + type: DEKROffsetLoss weight: 0.03 predictor: type: DEKRPredictor + apply_sigmoid: false + clip_scores: true num_animals: "num_individuals" keypoint_score_type: combined max_absorb_distance: 75 diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml index c6e9176aba..684556d04e 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml @@ -7,13 +7,15 @@ model: backbone: type: HRNet model_name: hrnet_w32 - pretrained: true + freeze_bn_stats: false + freeze_bn_weights: false interpolate_branches: true increased_channel_count: false backbone_output_channels: 480 heads: bodypart: type: DEKRHead + weight_init: dekr target_generator: type: DEKRGenerator num_joints: "num_bodyparts" @@ -21,13 +23,15 @@ model: bg_weight: 0.1 criterion: heatmap: - type: WeightedBCECriterion + type: DEKRHeatmapLoss weight: 1 offset: - type: WeightedHuberCriterion + type: DEKROffsetLoss weight: 0.03 predictor: type: DEKRPredictor + apply_sigmoid: false + clip_scores: true num_animals: "num_individuals" keypoint_score_type: combined max_absorb_distance: 75 diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml index 9b21e352c0..c0fb5d8b26 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml @@ -6,14 +6,16 @@ data: model: backbone: type: HRNet - model_name: hrnet_w32 - pretrained: true + model_name: hrnet_w48 + freeze_bn_stats: false + freeze_bn_weights: false interpolate_branches: true increased_channel_count: false backbone_output_channels: 720 heads: bodypart: type: DEKRHead + weight_init: dekr target_generator: type: DEKRGenerator num_joints: "num_bodyparts" @@ -21,13 +23,15 @@ model: bg_weight: 0.1 criterion: heatmap: - type: WeightedBCECriterion + type: DEKRHeatmapLoss weight: 1 offset: - type: WeightedHuberCriterion + type: DEKROffsetLoss weight: 0.03 predictor: type: DEKRPredictor + apply_sigmoid: false + clip_scores: true num_animals: "num_individuals" keypoint_score_type: combined max_absorb_distance: 75 diff --git a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml index e1660ddf4a..6b9036c6ce 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml @@ -68,3 +68,5 @@ model: strides: - 2 num_stages: 5 +runner: + eval_interval: 25 # slow evaluation with poor Part-Affinity fields diff --git a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml index ea5a3aad1b..26ec928ee7 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml @@ -77,3 +77,5 @@ model: - 2 - 2 num_stages: 5 +runner: + eval_interval: 25 # slow evaluation with poor Part-Affinity fields diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index 631b1f2ca6..f8fe4f7f0c 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -114,11 +114,13 @@ def make_pytorch_pose_config( is_top_down = model_cfg.get("method", "BU").upper() == "TD" if is_top_down: - model_cfg["data"] = read_config_as_dict( - configs_dir / "base" / "aug_top_down.yaml" - ) model_cfg = add_detector(configs_dir, model_cfg, len(individuals)) + # add the default augmentations to the config + aug_filename = "aug_top_down.yaml" if is_top_down else "aug_default.yaml" + aug_cfg = {"data": read_config_as_dict(configs_dir / "base" / aug_filename)} + pose_config = update_config(pose_config, aug_cfg) + # add the model to the config pose_config = update_config(pose_config, model_cfg) diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index de70ce375d..ffd2ee1d26 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -404,9 +404,25 @@ def extract_keypoints_and_bboxes( @staticmethod def add_center_keypoints(keypoints: np.ndarray) -> np.ndarray: - """Adds a keypoint in the mean of each individual""" - center_keypoints = keypoints.copy() - center_keypoints[center_keypoints == -1] = np.nan - center_keypoints = np.nanmean(center_keypoints, axis=1) - np.nan_to_num(center_keypoints, copy=False, nan=-1) - return np.concatenate((keypoints, center_keypoints[:, None, :]), axis=1) + """Adds a keypoint in the mean of each individual + + Args: + keypoints: shape (num_idv, num_kpts, 3) + + Returns: + keypoints with centers, of shape (num_idv, num_kpts + 1, 3) + """ + num_idv = keypoints.shape[0] + centers = np.full((num_idv, 1, 3), np.nan) + + keypoints_xy = keypoints.copy()[..., :2] + keypoints_xy[keypoints[..., 2] <= 0] = np.nan + if np.sum(~np.isnan(keypoints_xy)) > 0: + centers[:, 0, :2] = np.nanmean(keypoints_xy, axis=1) + + masked_centers = np.any(np.isnan(centers), axis=2) + centers[masked_centers, 2] = 0 + centers[~masked_centers, 2] = 2 + np.nan_to_num(centers, copy=False, nan=0) + + return np.concatenate((keypoints, centers), axis=1) diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py index 5c7e1bbb9a..6e5bd0d1e9 100644 --- a/deeplabcut/pose_estimation_pytorch/data/transforms.py +++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py @@ -45,6 +45,9 @@ def build_transforms(augmentations: dict) -> A.BaseCompose: if resize_aug := augmentations.get("resize", False): transforms += build_resize_transforms(resize_aug) + if (lms_cfg := augmentations.get("longest_max_size")) is not None: + transforms.append(A.LongestMaxSize(lms_cfg)) + if hflip_cfg := augmentations.get("hflip"): hflip_proba = 0.5 symmetries = None @@ -87,9 +90,6 @@ def build_transforms(augmentations: dict) -> A.BaseCompose: ) ) - if (longest_max_size := augmentations.get("longest_max_size")) is not None: - transforms.append(A.LongestMaxSize(longest_max_size)) - if augmentations.get("hist_eq", False): transforms.append(A.Equalize(p=0.5)) if augmentations.get("motion_blur", False): diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 273d6a1a8d..34e9c56616 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -504,7 +504,7 @@ def apply_transform( oob_mask = _out_of_bounds_keypoints(transformed["keypoints"], out_shape) # out-of-bound keypoints have visibility flag 0. Don't touch coordinates if np.sum(oob_mask) > 0: - transformed["keypoints"][oob_mask, -1] = 0.0 + transformed["keypoints"][oob_mask, 2] = 0.0 # TODO: Check that the transformed bboxes are still within the image if len(transformed["bboxes"]) > 0: @@ -551,11 +551,6 @@ def _apply_transform( bbox_labels=np.arange(len(bboxes)), ) - # why are we repeatedly doing this? - # transformed = _set_invalid_keypoints_to_neg_one( - # transformed, keypoints, class_labels - # ) - bboxes_out = np.zeros(bboxes.shape) for bbox, bbox_id in zip(transformed["bboxes"], transformed["bbox_labels"]): bboxes_out[bbox_id] = bbox @@ -564,31 +559,6 @@ def _apply_transform( return transformed -def _set_invalid_keypoints_to_neg_one( - transformed: dict[str, list], keypoints: np.ndarray, class_labels: list -) -> dict[str, list]: - """ - Updates keypoints that are out of bounds or undefined to (-1, -1). - - Args: - transformed: A dictionary containing the transformed image and keypoints. - keypoints: Array of keypoints to be transformed along with the image. - class_labels: List of class labels corresponding to the keypoints. - - Returns: - A dictionary containing the transformed image and with masked invalid keypoints. - - """ - undef_class_labels = [ - class_labels[i] for i, kpt in enumerate(keypoints) if kpt[2] == 0 - ] - for label in undef_class_labels: - new_index = transformed["class_labels"].index(label) - transformed["keypoints"][new_index] = (-1, -1, -1) - - return transformed - - def _out_of_bounds_keypoints(keypoints: np.ndarray, shape: tuple) -> np.ndarray: """Computes which visible keypoints are outside an image diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py index b2d2af3408..6799227b46 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py @@ -17,6 +17,10 @@ BaseCriterion, BaseLossAggregator, ) +from deeplabcut.pose_estimation_pytorch.models.criterions.dekr import ( + DEKRHeatmapLoss, + DEKROffsetLoss, +) from deeplabcut.pose_estimation_pytorch.models.criterions.weighted import ( WeightedBCECriterion, WeightedHuberCriterion, diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/dekr.py b/deeplabcut/pose_estimation_pytorch/models/criterions/dekr.py new file mode 100644 index 0000000000..ab18007884 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/dekr.py @@ -0,0 +1,85 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Loss criterions for DEKR models""" +from __future__ import annotations + +import torch + +from deeplabcut.pose_estimation_pytorch.models.criterions.base import ( + BaseCriterion, + CRITERIONS, +) + + +@CRITERIONS.register_module +class DEKRHeatmapLoss(BaseCriterion): + """DEKR Heatmap loss""" + + def forward( + self, + output: torch.Tensor, + target: torch.Tensor, + weights: torch.Tensor | float = 1.0, + **kwargs, + ) -> torch.Tensor: + """ + Args: + output: the output from which to compute the loss + target: the target for the loss + weights: the weights for the loss + + Returns: + the DEKR offset loss + """ + assert output.size() == target.size() + loss = ((output - target) ** 2) * weights + return loss.mean(dim=3).mean(dim=2).mean(dim=1).mean(dim=0) + + +@CRITERIONS.register_module +class DEKROffsetLoss(BaseCriterion): + """DEKR Offset loss""" + + def __init__(self, beta: float = 1 / 9): + super().__init__() + self.beta = beta + + def smooth_l1_loss(self, pred, gt): + l1_loss = torch.abs(pred - gt) + return torch.where( + l1_loss < self.beta, + 0.5 * l1_loss ** 2 / self.beta, + l1_loss - 0.5 * self.beta, + ) + + def forward( + self, + output: torch.Tensor, + target: torch.Tensor, + weights: torch.Tensor | float = 1.0, + **kwargs, + ) -> torch.Tensor: + """ + Args: + output: the output from which to compute the loss + target: the target for the loss + weights: the weights for the loss + + Returns: + the DEKR offset loss + """ + assert output.size() == target.size() + num_pos = torch.nonzero(weights > 0).size()[0] + loss = self.smooth_l1_loss(output, target) * weights + if num_pos == 0: + num_pos = 1.0 + loss = loss.sum() / num_pos + return loss diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py index eb46e8ade8..65d5f8f425 100644 --- a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py +++ b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py @@ -48,12 +48,11 @@ def forward( """ # shape of loss: (batch_size, n_kpts, heatmap_size, heatmap_size) loss = self.criterion(output, target) - loss *= weights n_elems = utils.count_nonzero_elems(loss, weights) if n_elems == 0: - return torch.tensor(0.0, device=output.device) + n_elems = 1 - return torch.mean(loss) + return torch.sum(loss * weights) / n_elems @CRITERIONS.register_module @@ -89,11 +88,11 @@ def forward( """ # shape of loss: (batch_size, n_kpts, h, w) loss = self.criterion(output, target) - loss *= weights n_elems = utils.count_nonzero_elems(loss, weights) if n_elems == 0: - return torch.tensor(0.0, device=output.device) - return torch.mean(loss) + n_elems = 1 + + return torch.sum(loss * weights) / n_elems @CRITERIONS.register_module diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py index 438021dc1a..b0d0a8c49f 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/base.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py @@ -21,6 +21,10 @@ ) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator +from deeplabcut.pose_estimation_pytorch.models.weight_init import ( + BaseWeightInitializer, + WEIGHT_INIT, +) from deeplabcut.pose_estimation_pytorch.registry import build_from_cfg, Registry HEADS = Registry("heads", build_func=build_from_cfg) @@ -55,6 +59,7 @@ def __init__( target_generator: BaseGenerator, criterion: dict[str, BaseCriterion] | BaseCriterion, aggregator: BaseLossAggregator | None = None, + weight_init: str | dict | BaseWeightInitializer | None = None, ) -> None: super().__init__() if stride == 0: @@ -66,6 +71,16 @@ def __init__( self.criterion = criterion self.aggregator = aggregator + self.weight_init: BaseWeightInitializer | None = None + if isinstance(weight_init, BaseWeightInitializer): + self.weight_init = weight_init + elif isinstance(weight_init, (str, dict)): + self.weight_init = WEIGHT_INIT.build(weight_init) + elif weight_init is not None: + raise ValueError( + f"Could not parse ``weight_init`` parameter: {weight_init}." + ) + if isinstance(criterion, dict): if aggregator is None: raise ValueError( @@ -120,6 +135,11 @@ def get_loss( losses["total_loss"] = self.aggregator(losses) return losses + def _init_weights(self) -> None: + """Should be called once all modules for the class are created""" + if self.weight_init is not None: + self.weight_init.init_weights(self) + class WeightConversionMixin(ABC): """A mixin for heads that can re-order and/or filter the output channels. diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py index 1e3527d5ea..d61da6a4e9 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py @@ -25,6 +25,7 @@ ) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator +from deeplabcut.pose_estimation_pytorch.models.weight_init import BaseWeightInitializer @HEADS.register_module @@ -45,11 +46,15 @@ def __init__( aggregator: BaseLossAggregator, heatmap_config: dict, offset_config: dict, - stride: int | float = 1, # the stride for the head - should always be 1 for DEKR + weight_init: str | dict | BaseWeightInitializer | None = "dekr", + stride: int | float = 1, # head stride - should always be 1 for DEKR ) -> None: - super().__init__(stride, predictor, target_generator, criterion, aggregator) + super().__init__( + stride, predictor, target_generator, criterion, aggregator, weight_init + ) self.heatmap_head = DEKRHeatmap(**heatmap_config) self.offset_head = DEKROffset(**offset_config) + self._init_weights() def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: return {"heatmap": self.heatmap_head(x), "offset": self.offset_head(x)} diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py index f1eb312945..b83a740316 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py @@ -24,6 +24,7 @@ ) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator +from deeplabcut.pose_estimation_pytorch.models.weight_init import BaseWeightInitializer @HEADS.register_module @@ -41,6 +42,7 @@ def __init__( paf_config: dict, num_stages: int = 5, features_dim: int = 128, + weight_init: str | dict | BaseWeightInitializer | None = None, ) -> None: self.num_stages = num_stages # FIXME Cleaner __init__ to avoid initializing unused layers @@ -49,9 +51,9 @@ def __init__( num_limbs = paf_config["channels"][-1] # Already has the 2x multiplier in_refined_channels = features_dim + num_keypoints + num_limbs if num_stages > 0: - heatmap_config["channels"][0] = paf_config["channels"][ - 0 - ] = in_refined_channels + heatmap_config["channels"][0] = paf_config["channels"][0] = ( + in_refined_channels + ) locref_config["channels"][0] = locref_config["channels"][-1] super().__init__( predictor, @@ -60,6 +62,7 @@ def __init__( aggregator, heatmap_config, locref_config, + weight_init, ) self.paf_head = DeconvModule(**paf_config) @@ -88,6 +91,7 @@ def __init__( in_channels=in_refined_channels, out_channels=num_limbs ) ) + self._init_weights() def _make_layer_same_padding( self, in_channels: int, out_channels: int diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py index 4010ec5834..334e674237 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py @@ -24,10 +24,7 @@ ) from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator -from deeplabcut.pose_estimation_pytorch.models.weight_init import ( - BaseWeightInitializer, - WEIGHT_INIT, -) +from deeplabcut.pose_estimation_pytorch.models.weight_init import BaseWeightInitializer @HEADS.register_module @@ -81,19 +78,16 @@ def __init__( ) super().__init__( - heatmap_head.stride, predictor, target_generator, criterion, aggregator + heatmap_head.stride, + predictor, + target_generator, + criterion, + aggregator, + weight_init, ) self.heatmap_head = heatmap_head self.locref_head = locref_head - - if weight_init is not None: - if isinstance(weight_init, (str, dict)): - weight_init = WEIGHT_INIT.build(weight_init) - elif not isinstance(weight_init, BaseWeightInitializer): - raise ValueError( - f"Could not parse ``weight_init`` parameter: {weight_init}." - ) - weight_init.init_weights(self) + self._init_weights() def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: outputs = {"heatmap": self.heatmap_head(x)} diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py index eaedc2964c..b7f2b57820 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py @@ -71,6 +71,7 @@ def __init__( num_animals: int, detection_threshold: float = 0.01, apply_sigmoid: bool = True, + clip_scores: bool = False, use_heatmap: bool = True, keypoint_score_type: str = "combined", max_absorb_distance: int = 75, @@ -80,6 +81,8 @@ def __init__( num_animals: Number of animals in the project. detection_threshold: Threshold for detection apply_sigmoid: Apply sigmoid to heatmaps + clip_scores: If a sigmoid is not applied, this can be used to clip scores + for predicted keypoints to values in [0, 1]. use_heatmap: Use heatmap to refine the keypoint predictions. keypoint_score_type: Type of score to compute for keypoints. "heatmap" applies the heatmap score to each keypoint. "center" applies the score @@ -90,6 +93,7 @@ def __init__( self.num_animals = num_animals self.detection_threshold = detection_threshold self.apply_sigmoid = apply_sigmoid + self.clip_scores = clip_scores self.use_heatmap = use_heatmap self.keypoint_score_type = keypoint_score_type if self.keypoint_score_type not in ("heatmap", "center", "combined"): @@ -166,6 +170,9 @@ def forward( poses[:, :, :, 1] * scale_factors[0] + 0.5 * scale_factors[0] ) + if self.clip_scores: + score = torch.clip(score, min=0, max=1) + poses_w_scores = torch.cat([poses, score], dim=3) # self.pose_nms(heatmaps, poses_w_scores) return {"poses": poses_w_scores} diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index 4428871f2c..d4b622e644 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -62,6 +62,8 @@ def __init__( sigma: float, min_affinity: float, add_discarded: bool = False, + apply_sigmoid: bool = True, + clip_scores: bool = False, force_fusion: bool = False, return_preds: bool = False, ): @@ -92,6 +94,8 @@ def __init__( self.nms_radius = nms_radius self.return_preds = return_preds self.sigma = sigma + self.apply_sigmoid = apply_sigmoid + self.clip_scores = clip_scores self.sigmoid = torch.nn.Sigmoid() self.assembler = inferenceutils.Assembler.empty( num_animals, @@ -130,7 +134,9 @@ def forward( pafs = outputs["paf"] scale_factors = stride, stride batch_size, n_channels, height, width = heatmaps.shape - heatmaps = self.sigmoid(heatmaps) + + if self.apply_sigmoid: + heatmaps = self.sigmoid(heatmaps) # Filter predicted heatmaps with a 2D Gaussian kernel as in: # https://openaccess.thecvf.com/content_CVPR_2020/papers/Huang_The_Devil_Is_in_the_Details_Delving_Into_Unbiased_Data_CVPR_2020_paper.pdf @@ -173,6 +179,9 @@ def forward( if unique is not None: poses_unique[i, 0, :, :4] = torch.from_numpy(unique) + if self.clip_scores: + poses[..., 2] = torch.clip(poses[..., 2], min=0, max=1) + out = {"poses": poses} if self.return_preds: out["preds"] = preds diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py index 9610a56c54..a0a372293c 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py @@ -69,6 +69,7 @@ def forward( for b in range(batch_size): for _, kpts_animal in enumerate(coords[b]): visible = set(np.flatnonzero(kpts_animal[..., -1])) + kpts_animal = kpts_animal[..., :2] for l, (bp1, bp2) in enumerate(self.graph): if not (bp1 in visible and bp2 in visible): continue diff --git a/deeplabcut/pose_estimation_pytorch/models/weight_init.py b/deeplabcut/pose_estimation_pytorch/models/weight_init.py index 32a82b9484..1a91bbc7db 100644 --- a/deeplabcut/pose_estimation_pytorch/models/weight_init.py +++ b/deeplabcut/pose_estimation_pytorch/models/weight_init.py @@ -1,4 +1,5 @@ """Ways to initialize weights for PyTorch modules""" + from __future__ import annotations from abc import ABC, abstractmethod @@ -53,7 +54,35 @@ def __init__(self, std: float = 0.001): def init_weights(self, model: nn.Module) -> None: for name, module in model.named_parameters(): - if 'bias' in name: + if "bias" in name: + nn.init.constant_(module, 0) + else: + nn.init.normal_(module, std=self.std) + + +@WEIGHT_INIT.register_module +class Dekr(BaseWeightInitializer): + """Class to used to initialize model weights in the same way as DEKR + + Attributes: + std: the standard deviation to use to initialize weights + """ + + def __init__(self, std: float = 0.001): + self.std = std + + def init_weights(self, model: nn.Module) -> None: + for name, module in model.named_parameters(): + if "bias" in name: nn.init.constant_(module, 0) else: nn.init.normal_(module, std=self.std) + + if hasattr(module, "transform_matrix_conv"): + nn.init.constant_(module.transform_matrix_conv.weight, 0) + if hasattr(module, "bias"): + nn.init.constant_(module.transform_matrix_conv.bias, 0) + if hasattr(module, "translation_conv"): + nn.init.constant_(module.translation_conv.weight, 0) + if hasattr(module, "bias"): + nn.init.constant_(module.translation_conv.bias, 0) From 52a238529b1a9a89ee344cc3bf671b2cf1d4373d Mon Sep 17 00:00:00 2001 From: shaokai Date: Tue, 11 Jun 2024 10:49:53 +0200 Subject: [PATCH 119/293] Shaokai/customized configs (#228) --- deeplabcut/modelzoo/video_inference.py | 18 ++++++++---- .../apis/analyze_images.py | 28 +++++++++++++------ .../modelzoo/inference.py | 27 ++++++++++++------ 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index dad1ad2488..7b9cbd6e24 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -51,6 +51,7 @@ def video_inference_superanimal( device: Optional[str] = "auto", customized_pose_checkpoint: Optional[str] = None, customized_detector_checkpoint: Optional[str] = None, + customized_model_config: Optional[str] = None, ): """ This function performs inference on videos using a SuperAnimal model. It does not @@ -133,6 +134,9 @@ def video_inference_superanimal( customized_detector_checkpoint (str): Used in the PyTorch engine. If specified, it replaces the default detector checkpoint. + customized_model_config (str): + Used for loading customized model config. Only supported in Pytorch + Raises: NotImplementedError: If the model is not found in the modelzoo. @@ -292,10 +296,11 @@ def video_inference_superanimal( model_name, max_individuals, pcutoff, - device, - dest_folder, - customized_pose_checkpoint=None, - customized_detector_checkpoint=None, + device=device, + dest_folder=dest_folder, + customized_pose_checkpoint=customized_pose_checkpoint, + customized_detector_checkpoint=customized_detector_checkpoint, + customized_model_config=customized_model_config, ) ( @@ -431,8 +436,9 @@ def video_inference_superanimal( model_name, max_individuals, pcutoff, - device, - dest_folder, + device=device, + dest_folder=dest_folder, customized_pose_checkpoint=customized_pose_checkpoint, customized_detector_checkpoint=customized_detector_checkpoint, + customized_model_config=customized_model_config, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py index ef7d84190a..55be8452ba 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py @@ -38,6 +38,7 @@ from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.pose_estimation_pytorch.utils import resolve_device from deeplabcut.utils import auxfun_videos, auxiliaryfunctions +from deeplabcut.utils.auxiliaryfunctions import read_config def superanimal_analyze_images( @@ -50,6 +51,7 @@ def superanimal_analyze_images( device: str | None = None, customized_pose_checkpoint: str | None = None, customized_detector_checkpoint: str | None = None, + customized_model_config: str | None = None, ): """ This funciton inferences a superanimal model on a set of images and saves the results as labeled images. @@ -82,6 +84,8 @@ def superanimal_analyze_images( customized_detector_checkpoint: str | None A customized SuperAnimal detector checkpoint, as an alternative to the Hugging Face SuperAnimal models. + customized_model_config: str | None + A customized SuperAnimal model config. This is for internal dev/testing use Returns ------- @@ -106,20 +110,27 @@ def superanimal_analyze_images( os.makedirs(out_folder, exist_ok=True) - ( - model_cfg, - project_config, - snapshot_path, - detector_path, - ) = get_config_model_paths(superanimal_name, model_name) + snapshot_path = None + detector_path = None + if customized_model_config is None: + ( + model_cfg, + project_config, + snapshot_path, + detector_path, + ) = get_config_model_paths(superanimal_name, model_name) + config = {**project_config, **model_cfg} + config = update_config(config, max_individuals, device) + else: + config = read_config(customized_model_config) + if customized_pose_checkpoint is None: + raise ValueError(f"You must pass a custom checkpoint") if customized_pose_checkpoint is not None: snapshot_path = customized_pose_checkpoint if customized_detector_checkpoint is not None: detector_path = customized_detector_checkpoint - config = {**project_config, **model_cfg} - config = update_config(config, max_individuals, device) individuals = [f"animal{i}" for i in range(max_individuals)] config["individuals"] = individuals @@ -211,6 +222,7 @@ def analyze_images( detector_snapshot_index, train_folder, Task.DETECT )[0] + predictions = analyze_image_folder( model_cfg=model_cfg, images=images, diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index f407baaa6d..893fdeb71c 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -29,7 +29,7 @@ ) from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.utils.make_labeled_video import _create_labeled_video - +from deeplabcut.utils.auxiliaryfunctions import read_config class NumpyEncoder(json.JSONEncoder): """Special json encoder for numpy types""" @@ -58,6 +58,7 @@ def _video_inference_superanimal( dest_folder: Optional[str] = None, customized_pose_checkpoint: Optional[str] = None, customized_detector_checkpoint: Optional[str] = None, + customized_model_config: Optional[str] = None ) -> dict: """ Perform inference on a video using a superanimal model from the model zoo specified by `superanimal_name`. @@ -101,13 +102,21 @@ def _video_inference_superanimal( """ raise_warning_if_called_directly() - ( - model_config, - project_config, - pose_model_path, - detector_path, - ) = get_config_model_paths(project_name, model_name) + if customized_model_config is None: + ( + model_config, + project_config, + pose_model_path, + detector_path, + ) = get_config_model_paths(project_name, model_name) + + config = {**project_config, **model_config} + config = update_config(config, max_individuals, device) + else: + config = read_config(customized_model_config) + config['bodyparts'] = config['metadata']['bodyparts'] + if customized_pose_checkpoint is not None: pose_model_path = customized_pose_checkpoint if customized_detector_checkpoint is not None: @@ -116,8 +125,7 @@ def _video_inference_superanimal( if device is None: device = select_device() - config = {**project_config, **model_config} - config = update_config(config, max_individuals, device) + individuals = [f"animal{i}" for i in range(max_individuals)] config["individuals"] = individuals @@ -193,6 +201,7 @@ def _video_inference_superanimal( customized_pose_checkpoint is not None and customized_detector_checkpoint is not None ): + # FIXME: customized pose and customized detector passed does not mean it's adapted anymore output_video = output_path / f"{output_prefix}_labeled_after_adapt.mp4" else: output_video = output_path / f"{output_prefix}_labeled.mp4" From 6e6f1f22132dea3374e51702a626888c0e884320 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:25:53 +0200 Subject: [PATCH 120/293] niels - GUI improvements displaying shuffle information (#230) --- .../gui/displays/selected_shuffle_display.py | 114 ++++++++++++++++++ .../gui/tabs/create_training_dataset.py | 1 + deeplabcut/gui/tabs/evaluate_network.py | 3 + deeplabcut/gui/tabs/train_network.py | 78 +++++++++--- deeplabcut/gui/window.py | 1 + 5 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 deeplabcut/gui/displays/selected_shuffle_display.py diff --git a/deeplabcut/gui/displays/selected_shuffle_display.py b/deeplabcut/gui/displays/selected_shuffle_display.py new file mode 100644 index 0000000000..ebf3d98af2 --- /dev/null +++ b/deeplabcut/gui/displays/selected_shuffle_display.py @@ -0,0 +1,114 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Module to display information about the selected shuffle in the GUI""" +from pathlib import Path + +import PySide6.QtCore as QtCore +from PySide6 import QtWidgets + +from deeplabcut.core.engine import Engine +from deeplabcut.utils import auxiliaryfunctions + + +class SelectedShuffleDisplay(QtWidgets.QWidget): + """A widget displaying information about the selected shuffle""" + pose_cfg_signal = QtCore.Signal(dict) + + def __init__(self, root, row_margin: int = 25): + super().__init__() + self.root = root + + self._row_margin = row_margin + + self._current_index: int | None = None + self._engine: Engine | None = None + self._is_top_down: bool = False + self._net_type: str | None = None + self._pose_cfg: dict | None = None + + self._label = QtWidgets.QLabel("Shuffle info:") + self._label.setStyleSheet(f"margin: 0px 0px {self._row_margin}px 0px") + layout = QtWidgets.QHBoxLayout() + layout.addWidget(self._label) + self.setLayout(layout) + + # initialize the display + self._update_display(self.root.shuffle_value) + + # update the display when the shuffle or selected engine changes, or when a new + # shuffle has been created + self.root.shuffle_change.connect(self._update_display) + self.root.engine_change.connect(self._update_display) + self.root.shuffle_created.connect(self._update_display) + + @property + def pose_cfg(self) -> dict | None: + return self._pose_cfg + + @pose_cfg.setter + def pose_cfg(self, value: dict | None) -> None: + self._pose_cfg = value + self.pose_cfg_signal.emit(self._pose_cfg) + + @QtCore.Slot(int) + def _update_display(self, new_index: int) -> None: + self._current_index = new_index + + try: + pose_cfg_path = Path(self.root.pose_cfg_path) + except ValueError as err: + self._set_text_error() + return + + if not pose_cfg_path.exists(): + self._set_text_error() + return + + self._read_pose_config(pose_cfg_path) + self._set_text() + + def _set_text(self) -> None: + engine_str = "None" + if self._engine is not None: + engine_str = self._engine.aliases[0] + + text = f"net type: {self._net_type} | engine: {engine_str}" + if self._engine == Engine.PYTORCH and self._is_top_down: + text += f" | top-down" + + style = f"margin: 0px 0px {self._row_margin}px 0px;" + if self._engine != self.root.engine: + warning = "Change the selected Engine in the top-right to use this shuffle!" + text = warning + " | " + text + style += " color: orange;" + + self._label.setStyleSheet(style) + self._label.setText(text) + + def _set_text_error(self) -> None: + self._label.setText( + f"Failed to read shuffle {self._current_index} - check that it exists!" + ) + style = f"margin: 0px 0px {self._row_margin}px 0px; color: orange;" + self._label.setStyleSheet(style) + self.pose_cfg = None + + def _read_pose_config(self, pose_cfg_path: Path) -> None: + pose_cfg = auxiliaryfunctions.read_plainconfig(str(pose_cfg_path)) + + self._engine = ( + Engine.PYTORCH if "pytorch" in pose_cfg_path.stem.lower() else Engine.TF + ) + self._net_type = pose_cfg.get("net_type", "UNKNOWN") + self._is_top_down = ( + self._engine == Engine.PYTORCH and pose_cfg.get("method").lower() == "td" + ) + self.pose_cfg = pose_cfg diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index e4a82df5df..63a52ed11a 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -268,6 +268,7 @@ def create_training_dataset(self): os.path.exists(os.path.join(self.root.project_folder, file)) for file in filenames ): + self.root.shuffle_created.emit(self.shuffle.value()) msg = _create_message_box( "The training dataset is successfully created.", "Use the function 'train_network' to start training. Happy training!", diff --git a/deeplabcut/gui/tabs/evaluate_network.py b/deeplabcut/gui/tabs/evaluate_network.py index 633552903b..4147745757 100644 --- a/deeplabcut/gui/tabs/evaluate_network.py +++ b/deeplabcut/gui/tabs/evaluate_network.py @@ -19,6 +19,7 @@ from PySide6.QtCore import Qt import deeplabcut +from deeplabcut.gui.displays.selected_shuffle_display import SelectedShuffleDisplay from deeplabcut.utils.auxiliaryfunctions import get_evaluation_folder from deeplabcut.gui.components import ( BodypartListWidget, @@ -108,9 +109,11 @@ def show_help_dialog(self): def _generate_layout_attributes(self, layout): opt_text = QtWidgets.QLabel("Shuffle") self.shuffle = ShuffleSpinBox(root=self.root, parent=self) + self.shuffle_display = SelectedShuffleDisplay(self.root, row_margin=0) layout.addWidget(opt_text) layout.addWidget(self.shuffle) + layout.addWidget(self.shuffle_display) def open_inferencecfg_editor(self): editor = ConfigEditor(self.root.inference_cfg_path) diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py index 05f65a3b28..502ce3546a 100644 --- a/deeplabcut/gui/tabs/train_network.py +++ b/deeplabcut/gui/tabs/train_network.py @@ -25,6 +25,7 @@ _create_grid_layout, _create_label_widget, ) +from deeplabcut.gui.displays.selected_shuffle_display import SelectedShuffleDisplay from deeplabcut.gui.widgets import ConfigEditor @@ -42,17 +43,23 @@ class IntTrainAttribute: class TrainAttributeRow: attributes: list[IntTrainAttribute] description: str | None = None + show_when_cfg: tuple[str, str] | None = None class TrainNetwork(DefaultTab): def __init__(self, root, parent, h1_description): super(TrainNetwork, self).__init__(root, parent, h1_description) - self.root.engine_change.connect(self._on_engine_change) + self._shuffle: ShuffleSpinBox = ShuffleSpinBox(root=self.root, parent=self) + self._shuffle_display = SelectedShuffleDisplay(self.root) + self._attribute_layouts: dict[Engine, QtWidgets.QWidget] = {} - self._shuffles: dict[Engine, ShuffleSpinBox] = {} self._attribute_kwargs: dict[Engine, dict] = {} + self._rows_with_requirements: list = [] self._set_page() + self.root.engine_change.connect(self._on_engine_change) + self._shuffle_display.pose_cfg_signal.connect(self._pose_cfg_change) + @Slot(Engine) def _on_engine_change(self, engine: Engine) -> None: for e, layout in self._attribute_layouts.items(): @@ -96,30 +103,37 @@ def show_help_dialog(self): def _generate_layout_attributes(self) -> None: row_margin = 25 - for engine in Engine: - train_attributes = get_train_attributes(engine) - layout = _create_grid_layout(margins=(20, 0, 0, 0)) - layout.setVerticalSpacing(0) - # Shuffle - shuffle_label = QtWidgets.QLabel("Shuffle") - self._shuffles[engine] = ShuffleSpinBox(root=self.root, parent=self) + # top layout + shuffle_label = QtWidgets.QLabel("Shuffle") + shuffle_label.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") + self._shuffle.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") + self._shuffle_display.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") - # Add spacing - shuffle_label.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") - self._shuffles[engine].setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") + base_layout = _create_grid_layout(margins=(20, 0, 0, 0)) + base_layout.addWidget(shuffle_label, 0, 0) + base_layout.addWidget(self._shuffle, 0, 1) + base_layout.addWidget(self._shuffle_display, 0, 2) + base_layout_widget = QtWidgets.QWidget() + base_layout_widget.setLayout(base_layout) + self.main_layout.addWidget(base_layout_widget) - layout.addWidget(shuffle_label, 0, 0) - layout.addWidget(self._shuffles[engine], 0, 1) + for engine in Engine: + train_attributes = get_train_attributes(engine) # Other parameters + param_layout = _create_grid_layout(margins=(20, 0, 0, 0)) + param_layout.setVerticalSpacing(0) + self._attribute_kwargs[engine] = {} - row_index = 0 + row_index = 1 for row in train_attributes: + row_elements = [] if row.description is not None: row_label = QtWidgets.QLabel(row.description) row_label.setStyleSheet("font-weight: bold") - layout.addWidget(row_label, row_index, 2) + row_elements.append(row_label) + param_layout.addWidget(row_label, row_index, 0) row_index += 1 for j, attribute in enumerate(row.attributes): @@ -137,18 +151,27 @@ def _generate_layout_attributes(self) -> None: label.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") spin_box.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px") - layout.addWidget(label, row_index, 2 * (j + 1)) - layout.addWidget(spin_box, row_index, 2 * (j + 1) + 1) + row_elements.append(label) + row_elements.append(spin_box) + + param_layout.addWidget(label, row_index, 2 * j) + param_layout.addWidget(spin_box, row_index, 2 * j + 1) + + if row.show_when_cfg is not None: + self._rows_with_requirements.append( + (row.show_when_cfg, row_elements) + ) row_index += 1 layout_widget = QtWidgets.QWidget() - layout_widget.setLayout(layout) + layout_widget.setLayout(param_layout) self._attribute_layouts[engine] = layout_widget if engine != self.root.engine: layout_widget.hide() self.main_layout.addWidget(layout_widget) + self._pose_cfg_change(self._shuffle_display.pose_cfg) def log_attribute_change(self, attribute: IntTrainAttribute, value: int) -> None: self.root.logger.info(f"{attribute.label} set to {value}") @@ -180,6 +203,20 @@ def train_network(self): msg.setStandardButtons(QtWidgets.QMessageBox.Ok) msg.exec_() + @Slot(dict) + def _pose_cfg_change(self, pose_cfg: dict | None) -> None: + if pose_cfg is None: + return + + for requirement, widgets in self._rows_with_requirements: + key, value = requirement + show = pose_cfg.get(key) == value + for w in widgets: + if show: + w.show() + else: + w.hide() + def get_train_attributes(engine: Engine) -> list[TrainAttributeRow]: if engine == Engine.TF: @@ -260,7 +297,8 @@ def get_train_attributes(engine: Engine) -> list[TrainAttributeRow]: ], ), TrainAttributeRow( - description="Top-down models parameters", + description="Detector parameters", + show_when_cfg=("method", "td"), attributes=[ IntTrainAttribute( label="Detector max epochs", diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 3c4b7e664d..45a76283c1 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -76,6 +76,7 @@ class MainWindow(QMainWindow): video_files_ = QtCore.Signal(set) engine_change = QtCore.Signal(Engine) shuffle_change = QtCore.Signal(int) + shuffle_created = QtCore.Signal(int) def __init__(self, app): super(MainWindow, self).__init__() From a550d0a9c0747e733c0c636111c511e5c9f009b8 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:56:47 +0200 Subject: [PATCH 121/293] added pycocotools extra (#219) --- .../pose_estimation_pytorch/runners/train.py | 50 +++++++++++++------ setup.py | 1 + 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index ecdf5b96b9..e1fe105ec9 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -71,7 +71,9 @@ def __init__( scheduler: scheduler for adjusting the lr of the optimizer logger: logger to monitor training (e.g WandB logger) """ - super().__init__(model=model, device=device, gpus=gpus, snapshot_path=snapshot_path) + super().__init__( + model=model, device=device, gpus=gpus, snapshot_path=snapshot_path + ) self.eval_interval = eval_interval self.optimizer = optimizer self.scheduler = scheduler @@ -82,6 +84,9 @@ def __init__( self.starting_epoch = 0 self.current_epoch = 0 + # some models cannot compute a validation loss (e.g. detectors) + self._print_valid_loss = True + if self.snapshot_path is not None and self.snapshot_path != "": self.starting_epoch = self.load_snapshot( self.snapshot_path, @@ -98,7 +103,7 @@ def state_dict(self) -> dict: return { "metadata": self._metadata, "model": self.model.state_dict(), - "optimizer": self.optimizer.state_dict() + "optimizer": self.optimizer.state_dict(), } @abstractmethod @@ -168,7 +173,7 @@ def fit( if self.scheduler: self.scheduler.step() - lr = self.optimizer.param_groups[0]['lr'] + lr = self.optimizer.param_groups[0]["lr"] msg = f"Epoch {e}/{epochs} (lr={lr}), train loss {float(train_loss):.5f}" if e % self.eval_interval == 0: with torch.no_grad(): @@ -176,7 +181,8 @@ def fit( valid_loss = self._epoch( valid_loader, mode="eval", display_iters=display_iters ) - msg += f", valid loss {float(valid_loss):.5f}" + if self._print_valid_loss: + msg += f", valid loss {float(valid_loss):.5f}" self.snapshot_manager.update(e, self.state_dict(), last=(e == epochs)) logging.info(msg) @@ -247,7 +253,10 @@ def _epoch( metrics_to_log[name] = score for key in loss_metrics: - name, val = f"{mode}.{key}", np.nanmean(loss_metrics[key]).item() + name = f"{mode}.{key}" + val = np.nan + if np.sum(~np.isnan(loss_metrics[key])) > 0: + val = np.nanmean(loss_metrics[key]).item() self._metadata["losses"][name] = val metrics_to_log[f"losses/{name}"] = val @@ -354,8 +363,7 @@ def _compute_epoch_metrics(self) -> dict[str, float]: [len(kpts) for kpts in self._epoch_ground_truth["bodyparts"].values()] ) poses = pair_predicted_individuals_with_gt( - self._epoch_predictions["bodyparts"], - self._epoch_ground_truth["bodyparts"] + self._epoch_predictions["bodyparts"], self._epoch_ground_truth["bodyparts"] ) # pad predictions if there are any missing (needed for top-down models) @@ -364,7 +372,7 @@ def _compute_epoch_metrics(self) -> dict[str, float]: for kpt_dict, kpts in [(gt, img_gt), (pred, poses[path])]: if len(kpts) < num_animals: padded_kpts = -np.ones((num_animals, *kpts.shape[1:])) - padded_kpts[:len(kpts)] = kpts + padded_kpts[: len(kpts)] = kpts kpt_dict[path] = padded_kpts else: kpt_dict[path] = kpts @@ -396,7 +404,11 @@ def _update_epoch_predictions( offsets = offsets.detach().cpu().numpy() for path, gt, pred, scale, offset in zip( - paths, gt_keypoints, pred_keypoints, scales, offsets, + paths, + gt_keypoints, + pred_keypoints, + scales, + offsets, ): # ground_truth now should already have visibility flag ground_truth = gt.detach().cpu().numpy() @@ -421,9 +433,7 @@ def _update_epoch_predictions( epoch_gt_metric[path] = np.concatenate( [epoch_gt_metric[path], gt_with_vis], axis=0 ) - epoch_metric[path] = np.concatenate( - [epoch_metric[path], pred], axis=0 - ) + epoch_metric[path] = np.concatenate([epoch_metric[path], pred], axis=0) else: epoch_gt_metric[path] = gt_with_vis epoch_metric[path] = pred @@ -443,6 +453,8 @@ def __init__(self, model: BaseDetector, optimizer: torch.optim.Optimizer, **kwar **kwargs: TrainingRunner kwargs """ super().__init__(model, optimizer, **kwargs) + self._pycoco_warning_displayed = False + self._print_valid_loss = False def step( self, batch: dict[str, Any], mode: str = "train" @@ -511,7 +523,7 @@ def step( return losses def _compute_epoch_metrics(self) -> dict[str, float]: - """Returns: bounding box metrics, if """ + """Returns: bounding box metrics, if""" try: return { f"metrics/test.{k}": v @@ -520,9 +532,15 @@ def _compute_epoch_metrics(self) -> dict[str, float]: ).items() } except ModuleNotFoundError: - logging.info( - "Cannot compute bounding box metrics; pycocotools is not installed" - ) + if not self._pycoco_warning_displayed: + logging.info( + "\nNote:\n" + "Cannot compute bounding box metrics as ``pycocotools`` is not " + "installed. If you want bounding box mAP metrics when training " + "detectors for top-down models, please run ``pip install " + "pycocotools``.\n" + ) + self._pycoco_warning_displayed = True return {} diff --git a/setup.py b/setup.py index 2f30276408..34652654be 100644 --- a/setup.py +++ b/setup.py @@ -62,6 +62,7 @@ def pytorch_config_paths() -> list[str]: "torch>=2.0.0", "torchvision", "tqdm", + "pycocotools", "pyyaml", "Pillow>=7.1", ], From 35b51a80d24ce6d94c0764f16c4e062d2559f501 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:05:49 +0200 Subject: [PATCH 122/293] bug fix (#232) --- deeplabcut/gui/tabs/train_network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py index 502ce3546a..cfead8eedd 100644 --- a/deeplabcut/gui/tabs/train_network.py +++ b/deeplabcut/gui/tabs/train_network.py @@ -182,7 +182,7 @@ def open_posecfg_editor(self): def train_network(self): config = self.root.config - shuffle = int(self._shuffles[self.root.engine].value()) + shuffle = int(self._shuffle.value()) kwargs = dict(gputouse=None, autotune=False) for k, spin_box in self._attribute_kwargs[self.root.engine].items(): kwargs[k] = int(spin_box.value()) From a4e87d6370c234338d9b3469e2c44dbaa6512a87 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:31:27 +0200 Subject: [PATCH 123/293] Fix confusion matrix image path (#231) * Fix confusion matrix image path * Fix memory replay folder location --- deeplabcut/gui/tabs/create_training_dataset.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 63a52ed11a..69fd5ff784 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -11,6 +11,7 @@ from __future__ import annotations import os +from pathlib import Path from PySide6 import QtWidgets from PySide6.QtCore import Qt, Slot @@ -33,7 +34,6 @@ from deeplabcut.gui.widgets import launch_napari from deeplabcut.utils.auxiliaryfunctions import ( get_data_and_metadata_filenames, - get_model_folder, get_training_set_folder, ) @@ -159,15 +159,12 @@ def log_augmentation_choice(self, augmentation): def edit_conversion_table(self): # Test beforehand whether a conversion table exists weight_init = self.weight_init_selector.get_weight_init() - model_folder = self.root / get_model_folder( - self.root.cfg["TrainingFraction"][0], - self.shuffle.value(), - self.root.cfg, - engine=Engine.PYTORCH, - ) - memory_replay_folder = model_folder / "memory_replay" + memory_replay_folder = Path(self.root.project_folder) / "memory_replay" conversion_matrix_out_path = str(memory_replay_folder / "confusion_matrix.png") - _ = launch_napari([self.root.config, conversion_matrix_out_path]) + files = [self.root.config] + if os.path.exists(conversion_matrix_out_path): + files.append(conversion_matrix_out_path) + _ = launch_napari(files) def create_training_dataset(self): shuffle = self.shuffle.value() From 58e67a51e0607e4e813b1d0a43921dafcd2168b8 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:43:49 +0200 Subject: [PATCH 124/293] Make button responsive to change in weight init selection (#235) --- deeplabcut/gui/tabs/create_training_dataset.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 69fd5ff784..f32e47a6d1 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -51,6 +51,7 @@ def __init__(self, root, parent, h1_description): self.mapping_button = QtWidgets.QPushButton("Edit Conversion Table") self.mapping_button.clicked.connect(self.edit_conversion_table) + self.mapping_button.setVisible(False) self.root.engine_change.connect(self.set_edit_table_visibility) self.ok_button = QtWidgets.QPushButton("Create Training Dataset") @@ -70,7 +71,9 @@ def __init__(self, root, parent, h1_description): def set_edit_table_visibility(self) -> None: has_conversion_tables = "SuperAnimalConversionTables" in self.root.cfg - self.mapping_button.setVisible(has_conversion_tables & (self.root.engine == Engine.PYTORCH)) + is_pytorch_engine = self.root.engine == Engine.PYTORCH + is_finetuning = self.weight_init_selector.with_decoder + self.mapping_button.setVisible(has_conversion_tables & is_pytorch_engine & is_finetuning) def show_help_dialog(self): dialog = QtWidgets.QDialog(self) @@ -121,6 +124,9 @@ def _generate_layout_attributes(self, layout): self.weight_init_selector.weight_init_choice.currentTextChanged.connect( lambda _: self.update_nets(None) ) + self.weight_init_selector.weight_init_choice.currentTextChanged.connect( + lambda _: self.set_edit_table_visibility() + ) # Overwrite selection self.overwrite = QtWidgets.QCheckBox("Overwrite if exists") From 8767a1cda87c4e46668dd7f819476d69a86c600e Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:54:40 +0200 Subject: [PATCH 125/293] Stricter test for the presence of conversion tables (#236) --- deeplabcut/gui/tabs/create_training_dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index f32e47a6d1..58930fbe58 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -70,7 +70,7 @@ def __init__(self, root, parent, h1_description): self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) def set_edit_table_visibility(self) -> None: - has_conversion_tables = "SuperAnimalConversionTables" in self.root.cfg + has_conversion_tables = bool(self.root.cfg.get("SuperAnimalConversionTables", {})) is_pytorch_engine = self.root.engine == Engine.PYTORCH is_finetuning = self.weight_init_selector.with_decoder self.mapping_button.setVisible(has_conversion_tables & is_pytorch_engine & is_finetuning) From 6f96f43912e7937b5150cc2881669d2323517676 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 14:02:24 +0200 Subject: [PATCH 126/293] niels - GUI fix CheckBoxes (#234) * fixed checkboxes * removed commented code --------- Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/tabs/analyze_videos.py | 28 ++++++++---------- deeplabcut/gui/tabs/create_videos.py | 29 ++++++++++++------- deeplabcut/gui/tabs/evaluate_network.py | 10 +++---- .../pose_estimation_pytorch/apis/utils.py | 4 +-- 4 files changed, 38 insertions(+), 33 deletions(-) diff --git a/deeplabcut/gui/tabs/analyze_videos.py b/deeplabcut/gui/tabs/analyze_videos.py index 40f651f044..649430015c 100644 --- a/deeplabcut/gui/tabs/analyze_videos.py +++ b/deeplabcut/gui/tabs/analyze_videos.py @@ -204,40 +204,40 @@ def _generate_layout_multianimal(self, layout): layout.addLayout(tmp_layout) def update_create_video_detections(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Create video with all detections {s}") def update_assemble_with_ID_only(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Assembly with ID only {s}") def update_calibrate_assembly(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Assembly calibration {s}") def update_tracker_type(self, method): self.root.logger.info(f"Using {method.upper()} tracker") def update_csv_choice(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Save results as CSV {s}") def update_nwb_choice(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Save results as NWB {s}") def update_filter_choice(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Filtering predictions {s}") def update_showfigs_choice(self, state): - if state == Qt.Checked: + if Qt.CheckState(state) == Qt.Checked: self.root.logger.info("Plots will show as pop ups.") else: self.root.logger.info("Plots will not show up.") def update_crop_choice(self, state): - if state == Qt.Checked: + if Qt.CheckState(state) == Qt.Checked: self.root.logger.info("Dynamic bodypart cropping ENABLED.") self.dynamic_cropping = True else: @@ -245,7 +245,7 @@ def update_crop_choice(self, state): self.dynamic_cropping = False def update_plot_trajectory_choice(self, state): - if state == Qt.Checked: + if Qt.CheckState(state) == Qt.Checked: self.bodyparts_list_widget.show() self.bodyparts_list_widget.setEnabled(True) self.show_trajectory_plots.setEnabled(True) @@ -269,16 +269,12 @@ def analyze_videos(self): shuffle = self.root.shuffle_value videos = list(self.files) - save_as_csv = self.save_as_csv.checkState() == Qt.Checked + save_as_csv = self.save_as_csv.isChecked() videotype = self.video_selection_widget.videotype_widget.currentText() if self.root.is_multianimal: - calibrate_assembly = ( - self.calibrate_assembly_checkbox.checkState() == Qt.Checked - ) - assemble_with_ID_only = ( - self.assemble_with_ID_only_checkbox.checkState() == Qt.Checked - ) + calibrate_assembly = self.calibrate_assembly_checkbox.isChecked() + assemble_with_ID_only = self.assemble_with_ID_only_checkbox.isChecked() track_method = self.tracker_type_widget.currentText() edit_config(self.root.config, {"default_track_method": track_method}) num_animals_in_videos = self.num_animals_in_videos.value() diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index e1f140762c..443c3619a9 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -179,11 +179,11 @@ def _generate_layout_video_parameters(self, layout): layout.addLayout(tmp_layout, Qt.AlignLeft) def update_high_quality_video(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"High quality {s}.") def update_plot_trajectory_choice(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Plot trajectories {s}.") def update_selected_bodyparts(self): @@ -196,7 +196,7 @@ def update_selected_bodyparts(self): self.bodyparts_to_use = selected_bodyparts def update_use_all_bodyparts(self, s): - if s == Qt.Checked: + if Qt.CheckState(s) == Qt.Checked: self.bodyparts_list_widget.setEnabled(False) self.bodyparts_list_widget.hide() self.root.logger.info("Plot all bodyparts ENABLED.") @@ -207,15 +207,15 @@ def update_use_all_bodyparts(self, s): self.root.logger.info("Plot all bodyparts DISABLED.") def update_use_filtered_data(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Use filtered data {s}") def update_draw_skeleton(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Draw skeleton {s}") def update_overwrite_videos(self, state): - s = "ENABLED" if state == Qt.Checked else "DISABLED" + s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED" self.root.logger.info(f"Overwrite videos {s}") def update_color_by(self, text): @@ -243,12 +243,12 @@ def create_videos(self): # Single animal scenario. # Color is based on bodypart. color_by = "bodypart" - filtered = bool(self.use_filtered_data_checkbox.checkState()) + filtered = self.use_filtered_data_checkbox.isChecked() bodyparts = "all" if ( len(self.bodyparts_to_use) != 0 - and self.plot_all_bodyparts.checkState() != Qt.Checked + and self.plot_all_bodyparts.isChecked() ): self.update_selected_bodyparts() bodyparts = self.bodyparts_to_use @@ -258,9 +258,9 @@ def create_videos(self): videos=videos, shuffle=shuffle, filtered=filtered, - save_frames=bool(self.create_high_quality_video.checkState()), + save_frames=self.create_high_quality_video.isChecked(), displayedbodyparts=bodyparts, - draw_skeleton=bool(self.draw_skeleton_checkbox.checkState()), + draw_skeleton=self.draw_skeleton_checkbox.isChecked(), trailpoints=trailpoints, color_by=color_by, ) @@ -273,6 +273,15 @@ def create_videos(self): failed_videos_str = ", ".join(failed_videos) self.root.writer.write(f"Failed to create videos from {failed_videos_str}.") + if all(videos_created): + self.root.writer.write("Labeled videos created.") + else: + failed_videos = [ + video for success, video in zip(videos_created, videos) if not success + ] + failed_videos_str = ", ".join(failed_videos) + self.root.writer.write(f"Failed to create videos from {failed_videos_str}.") + if self.plot_trajectories.checkState(): deeplabcut.plot_trajectories( config=config, diff --git a/deeplabcut/gui/tabs/evaluate_network.py b/deeplabcut/gui/tabs/evaluate_network.py index 4147745757..2848ab5a26 100644 --- a/deeplabcut/gui/tabs/evaluate_network.py +++ b/deeplabcut/gui/tabs/evaluate_network.py @@ -163,19 +163,19 @@ def _generate_additional_attributes(self, layout): layout.addWidget(self.bodyparts_list_widget, alignment=Qt.AlignLeft) def update_map_choice(self, state): - if state == Qt.Checked: + if Qt.CheckState(state) == Qt.Checked: self.root.logger.info("Plot scoremaps ENABLED") else: self.root.logger.info("Plot predictions DISABLED") def update_plot_predictions(self, s): - if s == Qt.Checked: + if Qt.CheckState(s) == Qt.Checked: self.root.logger.info("Plot predictions ENABLED") else: self.root.logger.info("Plot predictions DISABLED") def update_bodypart_choice(self, s): - if s == Qt.Checked: + if Qt.CheckState(s) == Qt.Checked: self.bodyparts_list_widget.setEnabled(False) self.bodyparts_list_widget.hide() self.root.logger.info("Use all bodyparts") @@ -190,13 +190,13 @@ def evaluate_network(self): config = self.root.config Shuffles = [self.root.shuffle_value] - plotting = self.plot_predictions.checkState() == Qt.Checked + plotting = self.plot_predictions.isChecked() bodyparts_to_use = "all" if ( len(self.root.all_bodyparts) != len(self.bodyparts_list_widget.selected_bodyparts) - ) and self.use_all_bodyparts.checkState() == False: + ) and not self.use_all_bodyparts.isChecked(): bodyparts_to_use = self.bodyparts_list_widget.selected_bodyparts deeplabcut.evaluate_network( diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 4eca4d8f41..0c8025b754 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -185,10 +185,10 @@ def get_scorer_uid(snapshot: Snapshot, detector_snapshot: Snapshot | None) -> st Returns: the uid to use for the scorer """ - snapshot_id = snapshot.uid() + snapshot_id = f"snapshot_{snapshot.uid()}" if detector_snapshot is not None: detect_id = detector_snapshot.uid() - snapshot_id = f"detector_{detect_id}_snapshot_{snapshot_id}" + snapshot_id = f"detector_{detect_id}_{snapshot_id}" return snapshot_id From e95d1f0986f946ccfd55e0e34f39d6c7f3e6c294 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 14:43:47 +0200 Subject: [PATCH 127/293] niels/fix_evaluate_network (#237) --- .../gui/displays/selected_shuffle_display.py | 2 + deeplabcut/gui/tabs/evaluate_network.py | 33 ++++++++++--- .../pose_estimation_pytorch/apis/evaluate.py | 30 ++++++++---- deeplabcut/utils/auxiliaryfunctions.py | 48 +++++++++++-------- 4 files changed, 78 insertions(+), 35 deletions(-) diff --git a/deeplabcut/gui/displays/selected_shuffle_display.py b/deeplabcut/gui/displays/selected_shuffle_display.py index ebf3d98af2..081723858a 100644 --- a/deeplabcut/gui/displays/selected_shuffle_display.py +++ b/deeplabcut/gui/displays/selected_shuffle_display.py @@ -9,6 +9,8 @@ # Licensed under GNU Lesser General Public License v3.0 # """Module to display information about the selected shuffle in the GUI""" +from __future__ import annotations + from pathlib import Path import PySide6.QtCore as QtCore diff --git a/deeplabcut/gui/tabs/evaluate_network.py b/deeplabcut/gui/tabs/evaluate_network.py index 2848ab5a26..3eaf7762af 100644 --- a/deeplabcut/gui/tabs/evaluate_network.py +++ b/deeplabcut/gui/tabs/evaluate_network.py @@ -8,6 +8,8 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations + import os import matplotlib.image as mpimg from matplotlib.backends.backend_qt5agg import ( @@ -20,7 +22,6 @@ import deeplabcut from deeplabcut.gui.displays.selected_shuffle_display import SelectedShuffleDisplay -from deeplabcut.utils.auxiliaryfunctions import get_evaluation_folder from deeplabcut.gui.components import ( BodypartListWidget, DefaultTab, @@ -30,6 +31,7 @@ _create_vertical_layout, ) from deeplabcut.gui.widgets import ConfigEditor, launch_napari +from deeplabcut.utils import auxiliaryfunctions class GridCanvas(QtWidgets.QDialog): @@ -128,7 +130,7 @@ def plot_maps(self): dest_folder = os.path.join( self.root.project_folder, str( - get_evaluation_folder( + auxiliaryfunctions.get_evaluation_folder( self.root.cfg["TrainingFraction"][0], shuffle, self.root.cfg ) ), @@ -188,8 +190,7 @@ def update_bodypart_choice(self, s): def evaluate_network(self): config = self.root.config - - Shuffles = [self.root.shuffle_value] + shuffle = self.root.shuffle_value plotting = self.plot_predictions.isChecked() bodyparts_to_use = "all" @@ -201,12 +202,30 @@ def evaluate_network(self): deeplabcut.evaluate_network( config, - Shuffles=Shuffles, + Shuffles=[shuffle], plotting=plotting, show_errors=True, comparisonbodyparts=bodyparts_to_use, ) if plotting: - labeled_images = list(Path(config).parent / "evaluation-results").rglob("**/Labeled*/*.png") - _ = launch_napari(labeled_images) \ No newline at end of file + project_cfg = self.root.cfg + eval_folder = auxiliaryfunctions.get_evaluation_folder( + trainFraction=project_cfg["TrainingFraction"][0], + shuffle=shuffle, + cfg=project_cfg, + ) + scorer, _ = auxiliaryfunctions.get_scorer_name( + cfg=project_cfg, + shuffle=shuffle, + trainFraction=project_cfg["TrainingFraction"][0], + ) + + image_dir = ( + Path(self.root.project_folder) + / eval_folder + / f"LabeledImages_{scorer}" + ) + labeled_images = [str(p) for p in image_dir.rglob("*.png")] + if len(labeled_images) > 0: + _ = launch_napari(image_dir) diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 7306869153..d69c92c7a7 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -24,12 +24,12 @@ from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_predictions_dataframe, ensure_multianimal_df_format, - get_model_snapshots, get_inference_runners, + get_model_snapshots, get_scorer_name, get_scorer_uid, ) -from deeplabcut.pose_estimation_pytorch.data import Loader, DLCLoader +from deeplabcut.pose_estimation_pytorch.data import DLCLoader, Loader from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( compute_identity_scores, get_scores, @@ -148,8 +148,7 @@ def evaluate( # TODO: Evaluate identity predictions if loader.model_cfg["metadata"]["with_identity"]: pred_id_scores = { - filename: pred["identity_scores"] - for filename, pred in predictions.items() + filename: pred["identity_scores"] for filename, pred in predictions.items() } id_scores = compute_identity_scores( individuals=parameters.individuals, @@ -213,14 +212,17 @@ def evaluate_snapshot( with_identity=loader.model_cfg["metadata"]["with_identity"], transform=transform, detector_path=detector_path, - detector_transform=None + detector_transform=None, ) predictions = {} scores = { - "Training epochs": snapshot.uid(), "%Training dataset": loader.train_fraction, "Shuffle number": loader.shuffle, + "Training epochs": snapshot.epochs, + "Detector epochs (TD only)": ( + -1 if detector_snapshot is None else detector_snapshot.epochs + ), "pcutoff": pcutoff, } for split in ["train", "test"]: @@ -250,7 +252,13 @@ def evaluate_snapshot( df_predictions.to_hdf(output_filename, key="df_with_missing") df_scores = pd.DataFrame([scores]).set_index( - ["Training epochs", "%Training dataset", "Shuffle number", "pcutoff"] + [ + "%Training dataset", + "Shuffle number", + "Training epochs", + "Detector epochs (TD only)", + "pcutoff", + ] ) scores_filepath = output_filename.with_suffix(".csv") scores_filepath = scores_filepath.with_stem(scores_filepath.stem + "-results") @@ -395,7 +403,9 @@ def evaluate_network( if task == Task.TOP_DOWN: if detector_snapshot_index is not None: detector_snapshot = get_model_snapshots( - detector_snapshot_index, loader.model_folder, Task.DETECT, + detector_snapshot_index, + loader.model_folder, + Task.DETECT, )[0] else: logging.info( @@ -461,7 +471,9 @@ def save_evaluation_results( # Update combined results combined_scores_path = scores_path.parent.parent / "CombinedEvaluation-results.csv" if combined_scores_path.exists(): - df_existing_results = pd.read_csv(combined_scores_path, index_col=[0, 1, 2, 3]) + df_existing_results = pd.read_csv( + combined_scores_path, index_col=[0, 1, 2, 3, 4] + ) df_scores = df_scores.combine_first(df_existing_results) df_scores = df_scores.sort_index() diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 2f648b12a3..0136bd5e78 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -578,26 +578,36 @@ def get_model_folder( def get_evaluation_folder( - trainFraction, - shuffle, - cfg, - engine: Engine = Engine.TF, - modelprefix="", -): + trainFraction: float, + shuffle: int, + cfg: dict, + engine: Engine | None = None, + modelprefix: str = "", +) -> Path: """ - Args: - trainFraction: the training fraction (as defined in the project configuration) - for which to get the evaluation folder - shuffle: the index of the shuffle for which to get the evaluation folder - cfg: the project configuration - engine: The engine for which we want the model folder. Defaults to `tensorflow` - for backwards compatibility with DeepLabCut 2.X - modelprefix: The name of the folder - - Returns: - the relative path from the project root to the folder containing the model files - for a shuffle (configuration files, snapshots, training logs, ...) - """ + Args: + trainFraction: the training fraction (as defined in the project configuration) + for which to get the evaluation folder + shuffle: the index of the shuffle for which to get the evaluation folder + cfg: the project configuration + engine: The engine for which we want the model folder. Defaults to None, + which automatically gets the engine for the shuffle from the training + dataset metadata file. + modelprefix: The name of the folder + + Returns: + the relative path from the project root to the folder containing the model files + for a shuffle (configuration files, snapshots, training logs, ...) + """ + if engine is None: + from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine + engine = get_shuffle_engine( + cfg=cfg, + trainingsetindex=cfg["TrainingFraction"].index(trainFraction), + shuffle=shuffle, + modelprefix=modelprefix, + ) + Task = cfg["Task"] date = cfg["date"] iterate = "iteration-" + str(cfg["iteration"]) From 55cc604bec4daaaa74e007ec4806fc4163911f1e Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 11 Jun 2024 16:31:49 +0200 Subject: [PATCH 128/293] Open all labeled images as stack (#240) Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/tabs/label_frames.py | 2 +- deeplabcut/gui/widgets.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py index 24d81e912e..c7dd8e7a87 100644 --- a/deeplabcut/gui/tabs/label_frames.py +++ b/deeplabcut/gui/tabs/label_frames.py @@ -62,4 +62,4 @@ def label_frames(self): def check_labels(self): check_labels(self.root.config, visualizeindividuals=self.root.is_multianimal) labeled_images = (Path(self.root.config).parent / "labeled-data").rglob("*_labeled/*.png") - _ = launch_napari(labeled_images) + _ = launch_napari(labeled_images, stack=True) diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index 948b1fdc7b..16eb78fd83 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -34,7 +34,7 @@ from deeplabcut.utils.auxfun_videos import VideoWriter -def launch_napari(files=None): +def launch_napari(files=None, stack=False): viewer = napari.Viewer() # Automatically activate the napari-deeplabcut plugin for action in viewer.window.plugins_menu.actions(): @@ -42,7 +42,7 @@ def launch_napari(files=None): action.trigger() break if files is not None: - viewer.open(files, plugin="napari-deeplabcut") + viewer.open(files, plugin="napari-deeplabcut", stack=stack) return viewer From 8716805797aba56c689149e9b838e8385105d03d Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Tue, 11 Jun 2024 10:32:44 -0400 Subject: [PATCH 129/293] Update python-package.yml (#238) --- .github/workflows/python-package.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 6abe23eecf..4822155993 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -13,8 +13,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-13, windows-latest] - python-version: [3.9, "3.10"] + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.11"] include: - os: ubuntu-latest path: ~/.cache/pip From 66d46ae02b9ba69085c10cbec498ce01f9298e08 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 17:14:23 +0200 Subject: [PATCH 130/293] niels - modelzoo video inference fixes (#239) --- deeplabcut/gui/tabs/create_videos.py | 2 +- .../superanimal_topviewmouse.yaml | 5 +--- .../apis/analyze_images.py | 1 - .../apis/analyze_videos.py | 26 +++++++++---------- .../pose_estimation_pytorch/apis/train.py | 3 +++ .../pose_estimation_pytorch/apis/utils.py | 22 +++++++++++++--- .../modelzoo/inference.py | 23 ++++++++++------ .../modelzoo/train_from_coco.py | 2 +- 8 files changed, 52 insertions(+), 32 deletions(-) diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index 443c3619a9..7f0300d216 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -282,7 +282,7 @@ def create_videos(self): failed_videos_str = ", ".join(failed_videos) self.root.writer.write(f"Failed to create videos from {failed_videos_str}.") - if self.plot_trajectories.checkState(): + if self.plot_trajectories.isChecked(): deeplabcut.plot_trajectories( config=config, videos=videos, diff --git a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml index 08d2cd752a..ace8da8f3b 100644 --- a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml +++ b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml @@ -7,7 +7,7 @@ identity: # Project path (change when moving around) -project_path: /mnt/md0/shaokai/DLCdev/deeplabcut/modelzoo/project_configs +project_path: # Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow) @@ -46,9 +46,6 @@ bodyparts: - head_midpoint -# Fraction of video to start/stop when extracting frames for labeling/refinement - - # Fraction of video to start/stop when extracting frames for labeling/refinement start: stop: diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py index 55be8452ba..6263cf3759 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py @@ -222,7 +222,6 @@ def analyze_images( detector_snapshot_index, train_folder, Task.DETECT )[0] - predictions = analyze_image_folder( model_cfg=model_cfg, images=images, diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 2a2cc51792..4e0a57e768 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -32,6 +32,7 @@ get_scorer_name, get_scorer_uid, list_videos_in_folder, + parse_snapshot_index_for_analysis, ) from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner @@ -230,19 +231,9 @@ def analyze_videos( pose_cfg_path = model_folder / "test" / "pose_cfg.yaml" pose_cfg = auxiliaryfunctions.read_plainconfig(pose_cfg_path) - if snapshot_index is None: - snapshot_index = cfg["snapshotindex"] - if snapshot_index == "all": - logging.warning( - "snapshotindex is set to 'all' (in the config.yaml file or as given to " - "`analyze_videos`). Running video analysis with all snapshots is very " - "costly! Use the function 'evaluate_network' to choose the best the " - "snapshot. For now, changing snapshot index to -1. To evaluate another " - "snapshot, you can change the value in the config file or call " - "`analyze_videos` with your desired snapshot index." - ) - snapshot_index = -1 - snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0] + snapshot_index, detector_snapshot_index = parse_snapshot_index_for_analysis( + cfg, model_cfg, snapshot_index, detector_snapshot_index, + ) # Get general project parameters bodyparts = model_cfg["metadata"]["bodyparts"] @@ -254,11 +245,18 @@ def analyze_videos( if device is not None: model_cfg["device"] = device + snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0] print(f"Analyzing videos with {snapshot.path}") + detector_path, detector_snapshot = None, None if pose_task == Task.TOP_DOWN: if detector_snapshot_index is None: - detector_snapshot_index = -1 + raise ValueError( + "Cannot run videos analysis for top-down models without a detector " + "snapshot! Please specify your desired detector_snapshotindex in your " + "project's configuration file." + ) + detector_snapshot = get_model_snapshots( detector_snapshot_index, train_folder, Task.DETECT )[0] diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index aca4a05630..4a006d3f09 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -101,6 +101,9 @@ def train( if device is None: device = utils.resolve_device(run_config) + elif device == "auto": + run_config["device"] = device + device = utils.resolve_device(run_config) if device == "mps" and task == Task.DETECT: device = "cpu" # FIXME: Cannot train detectors on MPS diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index 0c8025b754..b9e3464284 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -85,8 +85,19 @@ def parse_snapshot_index_for_analysis( if pose_task == Task.TOP_DOWN: if detector_snapshot_index is None: detector_snapshot_index = cfg.get("detector_snapshotindex", -1) - if detector_snapshot_index is None or detector_snapshot_index == "all": + + if detector_snapshot_index == "all": + logging.warning( + f"detector_snapshotindex is set to '{detector_snapshot_index}' (in the " + "config.yaml file or as given to `analyze_...`). Running data analysis " + "with all snapshots is very costly! Use 'evaluate_network' to choose " + "the best detector snapshot. For now, changing the detector snapshot " + "index to -1. To evaluate another detector snapshot, you can change " + "the value in the config file or call `analyze_videos` or " + "`analyze_images` with your desired detector snapshot index." + ) detector_snapshot_index = -1 + else: detector_snapshot_index = None @@ -243,7 +254,7 @@ def get_scorer_name( snapshot = get_model_snapshots(snapshot_index, train_dir, pose_task)[0] detector_snapshot = None - if pose_task == Task.TOP_DOWN: + if detector_index is not None and pose_task == Task.TOP_DOWN: detector_snapshot = get_model_snapshots( detector_index, train_dir, Task.DETECT )[0] @@ -404,6 +415,11 @@ def get_inference_runners( with_identity=with_identity, ) else: + # FIXME: Cannot run detectors on MPS + detector_device = device + if device == "mps": + detector_device = "cpu" + pose_preprocessor = build_top_down_preprocessor( color_mode=model_config["data"]["colormode"], transform=transform, @@ -428,7 +444,7 @@ def get_inference_runners( detector_runner = build_inference_runner( task=Task.DETECT, model=DETECTORS.build(detector_config), - device=device, + device=detector_device, snapshot_path=detector_path, preprocessor=build_bottom_up_preprocessor( color_mode=model_config["detector"]["data"]["colormode"], diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index 893fdeb71c..0a5cf9fae0 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -31,6 +31,7 @@ from deeplabcut.utils.make_labeled_video import _create_labeled_video from deeplabcut.utils.auxiliaryfunctions import read_config + class NumpyEncoder(json.JSONEncoder): """Special json encoder for numpy types""" @@ -58,7 +59,7 @@ def _video_inference_superanimal( dest_folder: Optional[str] = None, customized_pose_checkpoint: Optional[str] = None, customized_detector_checkpoint: Optional[str] = None, - customized_model_config: Optional[str] = None + customized_model_config: Optional[str] = None, ) -> dict: """ Perform inference on a video using a superanimal model from the model zoo specified by `superanimal_name`. @@ -100,9 +101,13 @@ def _video_inference_superanimal( Raises: Warning: If the function is called directly. """ - raise_warning_if_called_directly() + if device is None: + device = select_device() + + pose_model_path = None + detector_path = None if customized_model_config is None: ( model_config, @@ -115,17 +120,19 @@ def _video_inference_superanimal( config = update_config(config, max_individuals, device) else: config = read_config(customized_model_config) - config['bodyparts'] = config['metadata']['bodyparts'] - + config["bodyparts"] = config["metadata"]["bodyparts"] + + if customized_pose_checkpoint is None: + raise ValueError( + "When specifying a `customized_model_config`, you must also specify " + "the `customized_pose_checkpoint` that goes with it." + ) + if customized_pose_checkpoint is not None: pose_model_path = customized_pose_checkpoint if customized_detector_checkpoint is not None: detector_path = customized_detector_checkpoint - if device is None: - device = select_device() - - individuals = [f"animal{i}" for i in range(max_individuals)] config["individuals"] = individuals diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py b/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py index 534dadb83d..14d3884a59 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py @@ -56,7 +56,7 @@ def adaptation_train( ), model=dict(backbone=dict(freeze_bn_stats=True)), runner=dict(snapshots=dict(max_snapshots=5, save_epochs=1)), - train_settings=dict(batch_size=batch_size, epochs=4, dataloader_workers=12), + train_settings=dict(batch_size=batch_size, epochs=4), ) if epochs is not None: From 96679d05b6c10db283397ffc0e1405388a6c17b9 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 17:30:15 +0200 Subject: [PATCH 131/293] print to logging (#241) --- .../apis/analyze_videos.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 4e0a57e768..d04221a141 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -99,7 +99,7 @@ def video_inference( video = VideoIterator(str(video_path)) n_frames = video.get_n_frames() vid_w, vid_h = video.dimensions - print( + logging.info( f"Video metadata: \n" f" n_frames: {n_frames}\n" f" fps: {video.fps}\n" @@ -116,12 +116,12 @@ def video_inference( if detector_runner is None: raise ValueError("Must use a detector for top-down video analysis") - print("Running Detector") + logging.info("Running Detector") bbox_predictions = detector_runner.inference(images=tqdm(video)) video.set_context(bbox_predictions) - print("Running Pose Prediction") + logging.info("Running Pose Prediction") predictions = pose_runner.inference(images=tqdm(video)) if with_identity: @@ -131,8 +131,20 @@ def video_inference( ) for i, p_with_id in enumerate(bodypart_predictions): predictions[i]["bodyparts"] = p_with_id + + if len(predictions) != n_frames: + tip_url = "https://deeplabcut.github.io/DeepLabCut/docs/recipes/io.html" + header = "#tips-on-video-re-encoding-and-preprocessing" + logging.warning( + f"The video metadata indicates that there {n_frames} in the video, but " + f"only {len(predictions)} were able to be processed. This can happen if " + "the video is corrupted. You can try to fix the issue by re-encoding your " + f"video (tips on how to do that: {tip_url}{header})" + ) + if return_video_metadata: return predictions, video_metadata + return predictions @@ -246,8 +258,7 @@ def analyze_videos( model_cfg["device"] = device snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0] - print(f"Analyzing videos with {snapshot.path}") - + logging.info(f"Analyzing videos with {snapshot.path}") detector_path, detector_snapshot = None, None if pose_task == Task.TOP_DOWN: if detector_snapshot_index is None: @@ -261,7 +272,7 @@ def analyze_videos( detector_snapshot_index, train_folder, Task.DETECT )[0] detector_path = detector_snapshot.path - print(f" -> Using detector {detector_path}") + logging.info(f" -> Using detector {detector_path}") dlc_scorer = get_scorer_name( cfg, @@ -296,7 +307,7 @@ def analyze_videos( output_pkl = output_path / f"{output_prefix}_full.pickle" if not overwrite and output_pkl.exists(): - print(f"Video already analyzed at {output_pkl}!") + logging.info(f"Video already analyzed at {output_pkl}!") else: runtime = [time.time()] predictions = video_inference( @@ -389,7 +400,7 @@ def create_df_from_prediction( output_h5 = Path(output_path) / f"{output_prefix}.h5" output_pkl = Path(output_path) / f"{output_prefix}_full.pickle" - print(f"Saving results in {output_h5} and {output_pkl}") + logging.info(f"Saving results in {output_h5} and {output_pkl}") cols = [ [dlc_scorer], list(auxiliaryfunctions.get_bodyparts(cfg)), @@ -468,7 +479,7 @@ def _validate_destfolder(destfolder: str | None) -> None: if destfolder is not None and destfolder != "": output_folder = Path(destfolder) if not output_folder.exists(): - print(f"Creating the output folder {output_folder}") + logging.info(f"Creating the output folder {output_folder}") output_folder.mkdir(parents=True) assert Path( From 9dac79da0a7820c5c22252bb97edf6539b5925e1 Mon Sep 17 00:00:00 2001 From: shaokai Date: Tue, 11 Jun 2024 17:39:38 +0200 Subject: [PATCH 132/293] Shaokai/add docs link to modelzootab (#233) * Added link * dynamic content change for modelzoo help button * Update video_inference.py * Adapted docstring to add model explanation. Added go to button * Update modelzoo.py --------- Co-authored-by: Mackenzie Mathis --- .../gui/displays/selected_shuffle_display.py | 1 - deeplabcut/gui/tabs/modelzoo.py | 7 +- deeplabcut/modelzoo/video_inference.py | 61 ++++++---- docs/ModelZoo.md | 110 ++++++++++++------ 4 files changed, 117 insertions(+), 62 deletions(-) diff --git a/deeplabcut/gui/displays/selected_shuffle_display.py b/deeplabcut/gui/displays/selected_shuffle_display.py index 081723858a..f0fb263651 100644 --- a/deeplabcut/gui/displays/selected_shuffle_display.py +++ b/deeplabcut/gui/displays/selected_shuffle_display.py @@ -10,7 +10,6 @@ # """Module to display information about the selected shuffle in the GUI""" from __future__ import annotations - from pathlib import Path import PySide6.QtCore as QtCore diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index 9b48feedd5..6900c7ec5f 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -26,6 +26,7 @@ from deeplabcut.gui import BASE_DIR from deeplabcut.gui.utils import move_to_separate_thread from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS +import webbrowser class RegExpValidator(QRegularExpressionValidator): @@ -122,7 +123,11 @@ def _set_page(self): self.help_button = QtWidgets.QPushButton("Help") self.help_button.clicked.connect(self.show_help_dialog) self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) - + + self.go_to_button = QtWidgets.QPushButton("Read Documentation") + # go to url https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html#about-the-superanimal-models when button is clicked + self.go_to_button.clicked.connect(lambda: webbrowser.open('https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html#about-the-superanimal-models')) + self.main_layout.addWidget(self.go_to_button, alignment=Qt.AlignLeft) def show_help_dialog(self): dialog = QtWidgets.QDialog(self) layout = QtWidgets.QVBoxLayout() diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index 7b9cbd6e24..b139b17706 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -141,33 +141,42 @@ def video_inference_superanimal( NotImplementedError: If the model is not found in the modelzoo. Warning: If the superanimal_name will be deprecated in the future. + + ### (Model Explanation) SuperAnimal-Quadruped: + + - `superanimal_quadruped_x` models aim to work across a large range of quadruped animals, from horses, dogs, sheep, rodents, to elephants. The camera perspective is orthogonal to the animal ("side view"), and most of the data includes the animals face (thus the front and side of the animal). You will note we have several variants that differ in speed vs. performance, so please do test them out on your data to see which is best suited for your application. Also note we have a "video adaptation" feature, which lets you adapt your data to the model in a self-supervised way. No labeling needed! + - [PLEASE SEE THE FULL DATASHEET HERE](https://zenodo.org/records/10619173) + - [MORE DETAILS ON THE MODELS (detector, pose estimators)](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-Quadruped) + - We provide several models: + - `superanimal_quadruped_hrnetw32` (pytorch engine) + - `superanimal_quadruped_hrnetw32` is a top-down model that is paired with a detector. That means it takes a cropped image from an object detector and predicts the keypoints. The object detector is currently a trained [ResNet50-based Faster-RCNN](https://pytorch.org/vision/stable/models/faster_rcnn.html). + - `superanimal_quadruped_dlcrnet` (tensorflow engine) + - `superanimal_quadruped_dlcrnet` is a bottom-up model that predicts all keypoints then groups them into individuals. This can be faster, but more error prone. + - `superanimal_quadruped` -> This is the same as `superanimal_quadruped_dlcrnet`, this was the old naming and being depreciated. + - For all models, they are automatically downloaded to modelzoo/checkpoints when used. + + - Here are example images of what the model is trained on: + ![SA_Q](https://user-images.githubusercontent.com/28102185/209957688-954fb616-7750-4521-bb52-20a51c3a7718.png) + + + ### (Model Explanation) SuperAnimal-TopViewMouse: + + - `superanimal_topviewmouse_x` aims to work across lab mice in different lab settings from a top-view perspective; this is very polar in many behavioral assays in freely moving mice. + - [PLEASE SEE THE FULL DATASHEET HERE](https://zenodo.org/records/10618947) + - [MORE DETAILS ON THE MODELS (detector, pose estimators)](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-TopViewMouse) + - We provide several models: + - `superanimal_topviewmouse_hrnetw32` (pytorch engine) + - `superanimal_topviewmouse_hrnetw32` is a top-down model that is paired with a detector. That means it takes a cropped image from an object detector and predicts the keypoints. The object detector is currently a trained [ResNet50-based Faster-RCNN](https://pytorch.org/vision/stable/models/faster_rcnn.html). + - `superanimal_topviewmouse_dlcrnet` (tensorflow engine) + - `superanimal_topviewmouse_dlcrnet` is a bottom-up model that predicts all keypoints then groups them into individuals. This can be faster, but more error prone. + - `superanimal_topviewmouse` -> This is the same as `superanimal_topviewmouse_dlcrnet`, this was the old naming and being depreciated. + - For all models, they are automatically downloaded to modelzoo/checkpoints when used. + + - Here are example images of what the model is trained on: + ![SA-TVM](https://user-images.githubusercontent.com/28102185/209957260-c0db72e0-4fdf-434c-8579-34bc5f27f907.png) Examples (PyTorch Engine) -------- - - In PyTorch, we currently only support - - superanimal_topviewmouse_hrnetw32 - - superanimal_quadruped_hrnetw32 - - topviewmouse series are for topview lab mice - quadruped series are for quadruped animals (across many different species) - - The prefix hrnetw32 denotes the backbone of the pose estimator. Compared to resnet, - they are stronger but slower. - Check the official repo for more details (https://github.com/HRNet/HRNet-Image-Classification) - - superanimal_topviewmouse_hrnetw32 and superanimal_quadruped_hrnetw32 are top-down - models. That means they take the cropped image from an object detector and predicts - the keypoints. It's generally more accurate but slower. These 2 superanimal models - come with a ResNet50-based Faster-RCNN object detector. They are automatically - downloaded to modelzoo/checkpoints. - - For object detectors, Check https://pytorch.org/vision/stable/models/faster_rcnn.html - for more details - - Note in PyTorch, we don't support bottom-up models SuperAnimal models yet. We will - add them in the future. - >>> import deeplabcut.modelzoo.video_inference.video_inference_superanimal as video_inference_superanimal >>> video_inference_superanimal( videos=["/mnt/md0/shaokai/DLCdev/3mice_video1_short.mp4"], @@ -213,6 +222,8 @@ def video_inference_superanimal( are sensitive to the scales of the image. If you find your predictions not good without scale_list or it's too hard to find the right scale_list, you can try to use the PyTorch engine. + + """ if scale_list is None: scale_list = [] @@ -284,7 +295,7 @@ def video_inference_superanimal( if video_adapt: # the users can pass in many videos. For now, we only use one video for - # video adaptation. As reported in Ye et al. 2023, one video should be + # video adaptation. As reported in Ye et al. 2024, one video should be # sufficient for video adaptation. video_path = Path(videos[0]) print(f"using {video_path} for video adaptation training") diff --git a/docs/ModelZoo.md b/docs/ModelZoo.md index 0ef8864d1e..7076178e9d 100644 --- a/docs/ModelZoo.md +++ b/docs/ModelZoo.md @@ -1,20 +1,21 @@ # The DeepLabCut Model Zoo! -🦒 🐈 🐕‍🦺 🐀 🐁 🦡 🦦 🐏 🐫 🐆 🦓 🐖 🐄 🐂 🦖 🐿 🦍 🦥 +![image](https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/8957c690-4f27-4430-8581-4161fd58d052/68747470733a2f2f696d616765732e73717561726573706163652d63646e2e636f6d2f636f6e74656e742f76312f3537663664353163396637343536366635356563663237312f313631363439323337333730302d50474f41433732494f4236415545343756544a582f6b6531375a77644742546f646449.png?format=450w) + ## 🏠 [Home page](http://modelzoo.deeplabcut.org/) -Started in 2020 and expanded in 2022, the model zoo is four things: +Started in 2020, expanded in 20222 with PhD student [Shaokai Ye et al.](https://arxiv.org/abs/2203.07436v1), and the first proper [SuperAnimal Foundation Models]() published in 2024 🔥, the Model Zoo is four things: -- (1) a collection of models that are trained on diverse data across (typically) large datasets, which means you do not need to train models yourself -- (2) a contribution website for community crowd sourcing of expertly labeled keypoints to improve models in part 1! -- (3) a no-install DeepLabCut that you can use on ♾[Google Colab](https://colab.research.google.com/github/DeepLabCut/DeepLabCut/blob/master/examples/COLAB/COLAB_DLC_ModelZoo.ipynb), +- (1) a collection of models that are trained on diverse data across (typically) large datasets, which means you do not need to train models yourself, rather you can use them in your research applications. +- (2) a contribution website for community crowd sourcing of expertly labeled keypoints to improve models! You can get involved here: [contrib.deeplabcut.org](https://contrib.deeplabcut.org/). +- (3) a no-install DeepLabCut that you can use on ♾[Google Colab](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_DEMO_SuperAnimal.ipynb), test our models in 🕸[the browser](https://contrib.deeplabcut.org/), or on our 🤗[HuggingFace](https://huggingface.co/spaces/DeepLabCut/MegaDetector_DeepLabCut) app! -- (4) new methods to make SuperAnimal Models that combine data across different labs/datasets, keypoints, animals/species, and use on your data! +- (4) new methods to make SuperAnimal Foundation Models that combine data across different labs/datasets, keypoints, animals/species, and use on your data! ## Quick Start: ``` -pip install deeplabcut[tf,gui,modelzoo] +pip install deeplabcut[gui,modelzoo] ``` ## About the SuperAnimal Models @@ -23,36 +24,67 @@ Animal pose estimation is critical in applications ranging from neuroscience to To provide the community with easy access to such high performance models across diverse environments and species, we present a new paradigm for building pre-trained animal pose models -- which we call SuperAnimal models -- and the ability to use them for transfer learning (e.g., fine-tune them if needed). -### We now introduce two SuperAnimal members, namely, `superanimal_quadruped` and `superanimal_topviewmouse`. +## SuperAnimal members: +- Models are based on what they are trained on, for example `superanimal_quadruped_x` is trained on [SuperAnimal-Quadruped-80K](https://zenodo.org/records/10619173). Each model class is described below: + + +### (Model Explanation) SuperAnimal-Quadruped: -#### `superanimal_quadruped` model aim to work across a large range of quadruped animals, from horses, dogs, sheep, rodents, to elephants. The camera perspective is orthogonal to the animal ("side view"), and most of the data includes the animals face (thus the front and side of the animal). Here are example images of what the model is trained on: +- `superanimal_quadruped_x` models aim to work across a large range of quadruped animals, from horses, dogs, sheep, rodents, to elephants. The camera perspective is orthogonal to the animal ("side view"), and most of the data includes the animals face (thus the front and side of the animal). You will note we have several variants that differ in speed vs. performance, so please do test them out on your data to see which is best suited for your application. Also note we have a "video adaptation" feature, which lets you adapt your data to the model in a self-supervised way. No labeling needed! +- [PLEASE SEE THE FULL DATASHEET HERE](https://zenodo.org/records/10619173) +- [MORE DETAILS ON THE MODELS (detector, pose estimators)](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-Quadruped) +- We provide several models: + - `superanimal_quadruped_hrnetw32` (pytorch engine) + - `superanimal_quadruped_hrnetw32` is a top-down model that is paired with a detector. That means it takes a cropped image from an object detector and predicts the keypoints. The object detector is currently a trained [ResNet50-based Faster-RCNN](https://pytorch.org/vision/stable/models/faster_rcnn.html). + - `superanimal_quadruped_dlcrnet` (tensorflow engine) + - `superanimal_quadruped_dlcrnet` is a bottom-up model that predicts all keypoints then groups them into individuals. This can be faster, but more error prone. + - `superanimal_quadruped` -> This is the same as `superanimal_quadruped_dlcrnet`, this was the old naming and being depreciated. + - For all models, they are automatically downloaded to modelzoo/checkpoints when used. +- Here are example images of what the model is trained on: ![SA_Q](https://user-images.githubusercontent.com/28102185/209957688-954fb616-7750-4521-bb52-20a51c3a7718.png) -#### `superanimal_topviewmouse` aims to work across lab mice in different lab settings from a top-view perspective; this is very polar in many behavioral assays in freely moving mice. Here are example images of what the model is trained on: +### (Model Explanation) SuperAnimal-TopViewMouse: + +- `superanimal_topviewmouse_x` aims to work across lab mice in different lab settings from a top-view perspective; this is very polar in many behavioral assays in freely moving mice. +- [PLEASE SEE THE FULL DATASHEET HERE](https://zenodo.org/records/10618947) +- [MORE DETAILS ON THE MODELS (detector, pose estimators)](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-TopViewMouse) +- We provide several models: + - `superanimal_topviewmouse_hrnetw32` (pytorch engine) + - `superanimal_topviewmouse_hrnetw32` is a top-down model that is paired with a detector. That means it takes a cropped image from an object detector and predicts the keypoints. The object detector is currently a trained [ResNet50-based Faster-RCNN](https://pytorch.org/vision/stable/models/faster_rcnn.html). + - `superanimal_topviewmouse_dlcrnet` (tensorflow engine) + - `superanimal_topviewmouse_dlcrnet` is a bottom-up model that predicts all keypoints then groups them into individuals. This can be faster, but more error prone. + - `superanimal_topviewmouse` -> This is the same as `superanimal_topviewmouse_dlcrnet`, this was the old naming and being depreciated. + - For all models, they are automatically downloaded to modelzoo/checkpoints when used. + +- Here are example images of what the model is trained on: ![SA-TVM](https://user-images.githubusercontent.com/28102185/209957260-c0db72e0-4fdf-434c-8579-34bc5f27f907.png) -IMPORTANT: we currently only support single animal scenarios - -### Our perspective. +#### Practical example: Using SuperAnimal models for inference without training. -Via DeepLabCut Model Zoo, we aim to provide plug and play models that do not need any labeling and will just work decently on novel videos. If the predictions are not great enough due to failure modes described below, please give us feedback! We are rapidly improving our models and adaptation methods. +You can simply call the model and run video inference. +To note, a good step is typically to use our self-supervised video adaptation method to reduce jitter. In the `deeplabcut.video_inference_superanimal` simply function set the `video_adapt` option to __True__. Be aware, that enabling this option will (minimally) extend the processing time. -### To use our models in DeepLabCut (versions 2.3+), please use the following API +```python +video_path = 'demo-video.mp4' +superanimal_name = 'superanimal_quadruped_hrnetw32' -``` -pip install deeplabcut[tf,modelzoo] +deeplabcut.video_inference_superanimal([video_path], + superanimal_name, + video_adapt = False) ``` -#### Practical example: Using SuperAnimal models for inference without training. -In the `deeplabcut.video_inference_superanimal` function, if the output video appears to be jittery, consider setting the `video_adapt` option to __True__. Be aware, that enabling this option might extend the processing time. + +#### Practical example: Using SuperAnimal model bottom up, considering video/animal size. + +In our work we introduced a spatial-pyramid for smartly rescaling images. Imagine if you frames are much larger than what we trained on, it would be hard for the model to find the animal! Here, you can simply guide the model with the `scale_list`: ```python video_path = 'demo-video.mp4' -superanimal_name = 'superanimal_quadruped' +superanimal_name = 'superanimal_quadruped_dlcrnet' # The purpose of the scale list is to aggregate predictions from various image sizes. We anticipate the appearance size of the animal in the images to be approximately 400 pixels. scale_list = range(200, 600, 50) @@ -62,12 +94,13 @@ deeplabcut.video_inference_superanimal([video_path], superanimal_name, scale_lis #### Practical example: Using transfer learning with superanimal weights. In the `deeplabcut.train_network` function, the `superanimal_transfer_learning` option plays a pivotal role. If it's set to __True__, it uses a new decoding layer and allows you to use superanimal weights in any project, no matter the number of keypoints. However, if it's set to __False__, you are doing fine-tuning. So, make sure your dataset has the right number of keypoints. - Specifically: -* `superquadruped` uses 39 keypoints and, -* `supertopview` uses 27 keypoints + +Specifically: +* `superanimal_quadruped_x` uses 39 keypoints and, +* `superanimal_topviewmouse_x` uses 27 keypoints ```python -superanimal_name = "superanimal_topviewmouse" +superanimal_name = "ssuperanimal_topviewmouse_hrnetw32" config_path = os.path.join(os.getcwd(), "openfield-Pranav-2018-10-30", "config.yaml") deeplabcut.create_training_dataset(config_path, superanimal_name = superanimal_name) @@ -80,25 +113,32 @@ deeplabcut.train_network(config_path, -### To see the list of available models, check out the [Home page](http://modelzoo.deeplabcut.org/). - -**Coming soon:** The DeepLabCut Project Manager GUI will allow you to use the SuperAnimal Models. You can run the model and do ``active learning" to improve performance on your data. -Specifically, we have *new* video adaptation methods to make your tracking extra smooth and robust! ### Potential failure modes for SuperAnimal Models and how to fix it. Spatial domain shift: typical DNN models suffer from the spatial resolution shift between training datasets and test videos. To help find the proper resolution for our model, please try a range of `scale_list` in the API (details in the API docs). For `superanimal_quadruped`, we empirically observe that if your video is larger than 1500 pixels, it is better to pass `scale_list` in the range within 1000. Pixel statistics domain shift: The brightness of your video might look very different from our training datasets. This might either result in jittering predictions in the video or fail modes for lab mice videos (if the brightness of the mice is unusual compared to our training dataset). You can use our "video adaptation" model (released soon) to counter this. -### To see our first preprint on the work, check out [our paper](https://arxiv.org/abs/2203.07436v1): + + + +### Our longer term perspective ... + +Via DeepLabCut Model Zoo, we aim to provide plug and play models that do not need any labeling and will just work decently on novel videos. If the predictions are not great enough due to failure modes described below, please give us feedback! We are rapidly improving our models and adaptation methods. We will also continue to expand this project to new model/data classes. Please do get in touch is you have data or ideas: modelzoo@deeplabcut.org + +## Publication: + +To see the first preprint on the work, click [here](https://arxiv.org/abs/2203.07436v1). + +Our first publication on this project is now published at Nature Communications: ```{hint} Here is the citation: -@article{Ye2022PanopticAP, - title={Panoptic animal pose estimators are zero-shot performers}, - author={Shaokai Ye and Alexander Mathis and Mackenzie W. Mathis}, - journal={ArXiv}, - year={2022}, - volume={abs/2203.07436} +@article{Ye2024, + title={SuperAnimal pretrained pose estimation models for behavioral analysis}, + author={Shaokai Ye and Anastasiia Filippova and Jessy Lauer and Steffen Schneider and Maxime Vidal and Tian Qiu and Alexander Mathis and Mackenzie Weygandt Mathis}, + journal={Nature Communications}, + year={2024}, + preprint={abs/2203.07436} } ``` From b6ec1ad8380c2b198b36d48c2f65fd7cccf7343d Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Tue, 11 Jun 2024 11:44:36 -0400 Subject: [PATCH 133/293] Update video_inference.py (#242) --- deeplabcut/modelzoo/video_inference.py | 30 ++++++-------------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index b139b17706..f5701b115a 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -54,16 +54,7 @@ def video_inference_superanimal( customized_model_config: Optional[str] = None, ): """ - This function performs inference on videos using a SuperAnimal model. It does not - require you to have a DeepLabCut project. So it can be seen as a plug-and-play - solution. - - If the predictions are jittery, you should run video adaptation by setting - video_adapt = True. This will take some time but it will generally improve the - inference results. - - If you want to further improve the results, you can try finetune it on your own - data. + This function performs inference on videos using a pretrained SuperAnimal model. IMPORTANT: Note that since we have both TensorFlow and PyTorch Engines, we will route the engine based on the model you select. @@ -73,9 +64,6 @@ def video_inference_superanimal( * superanimal_topviewmouse_dlcrnet -> TensorFlow * superanimal_quadruped_dlcrnet -> TensorFlow - More details about those models in the examples section. In general, currently - PyTorch models are better but slower. - Parameters ---------- @@ -86,6 +74,7 @@ def video_inference_superanimal( The name of the SuperAnimal model. The name should be in the format: {project_name}_{modelname}. For example: `superanimal_topviewmouse_dlcrnet` or `superanimal_quadruped_hrnetw32`. + See (model explanation section below). scale_list (list): A list of different resolutions for the spatial pyramid. Used only for bottom up models. @@ -142,11 +131,11 @@ def video_inference_superanimal( If the model is not found in the modelzoo. Warning: If the superanimal_name will be deprecated in the future. - ### (Model Explanation) SuperAnimal-Quadruped: + (Model Explanation) SuperAnimal-Quadruped: - `superanimal_quadruped_x` models aim to work across a large range of quadruped animals, from horses, dogs, sheep, rodents, to elephants. The camera perspective is orthogonal to the animal ("side view"), and most of the data includes the animals face (thus the front and side of the animal). You will note we have several variants that differ in speed vs. performance, so please do test them out on your data to see which is best suited for your application. Also note we have a "video adaptation" feature, which lets you adapt your data to the model in a self-supervised way. No labeling needed! - - [PLEASE SEE THE FULL DATASHEET HERE](https://zenodo.org/records/10619173) - - [MORE DETAILS ON THE MODELS (detector, pose estimators)](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-Quadruped) + - PLEASE SEE THE FULL DATASHEET: https://zenodo.org/records/10619173 + - MORE DETAILS ON THE MODELS (detector, pose estimators): https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-Quadruped - We provide several models: - `superanimal_quadruped_hrnetw32` (pytorch engine) - `superanimal_quadruped_hrnetw32` is a top-down model that is paired with a detector. That means it takes a cropped image from an object detector and predicts the keypoints. The object detector is currently a trained [ResNet50-based Faster-RCNN](https://pytorch.org/vision/stable/models/faster_rcnn.html). @@ -155,11 +144,7 @@ def video_inference_superanimal( - `superanimal_quadruped` -> This is the same as `superanimal_quadruped_dlcrnet`, this was the old naming and being depreciated. - For all models, they are automatically downloaded to modelzoo/checkpoints when used. - - Here are example images of what the model is trained on: - ![SA_Q](https://user-images.githubusercontent.com/28102185/209957688-954fb616-7750-4521-bb52-20a51c3a7718.png) - - - ### (Model Explanation) SuperAnimal-TopViewMouse: + (Model Explanation) SuperAnimal-TopViewMouse: - `superanimal_topviewmouse_x` aims to work across lab mice in different lab settings from a top-view perspective; this is very polar in many behavioral assays in freely moving mice. - [PLEASE SEE THE FULL DATASHEET HERE](https://zenodo.org/records/10618947) @@ -171,9 +156,6 @@ def video_inference_superanimal( - `superanimal_topviewmouse_dlcrnet` is a bottom-up model that predicts all keypoints then groups them into individuals. This can be faster, but more error prone. - `superanimal_topviewmouse` -> This is the same as `superanimal_topviewmouse_dlcrnet`, this was the old naming and being depreciated. - For all models, they are automatically downloaded to modelzoo/checkpoints when used. - - - Here are example images of what the model is trained on: - ![SA-TVM](https://user-images.githubusercontent.com/28102185/209957260-c0db72e0-4fdf-434c-8579-34bc5f27f907.png) Examples (PyTorch Engine) -------- From 626513d98a0ea5f41013e6ae5fbf0841b3ad1a6d Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 11 Jun 2024 19:22:06 +0200 Subject: [PATCH 134/293] Remove unused tabs (#245) Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 45a76283c1..91300f1ce5 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -610,8 +610,8 @@ def add_tabs(self): self.tab_widget.addTab(self.video_editor, "Video editor (*)") if not self.is_multianimal: - self.refine_tracklets.setEnabled(False) - self.unsupervised_id_tracking.setEnabled(self.is_transreid_available()) + self.tab_widget.removeTab(self.tab_widget.indexOf(self.unsupervised_id_tracking)) + self.tab_widget.removeTab(self.tab_widget.indexOf(self.refine_tracklets)) self.setCentralWidget(self.tab_widget) self.tab_widget.currentChanged.connect(self.refresh_active_tab) From 39ff4ea199f426c85ec236e90c27311bbb6bae58 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 19:30:45 +0200 Subject: [PATCH 135/293] hide plot maps for torch (#243) Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/tabs/evaluate_network.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/evaluate_network.py b/deeplabcut/gui/tabs/evaluate_network.py index 3eaf7762af..9b46ee09ce 100644 --- a/deeplabcut/gui/tabs/evaluate_network.py +++ b/deeplabcut/gui/tabs/evaluate_network.py @@ -18,9 +18,10 @@ from matplotlib.figure import Figure from pathlib import Path from PySide6 import QtWidgets -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, Slot import deeplabcut +from deeplabcut.core.engine import Engine from deeplabcut.gui.displays.selected_shuffle_display import SelectedShuffleDisplay from deeplabcut.gui.components import ( BodypartListWidget, @@ -95,6 +96,9 @@ def _set_page(self): self.help_button.clicked.connect(self.show_help_dialog) self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) + self.root.engine_change.connect(self._on_engine_change) + self._on_engine_change(self.root.engine) + def show_help_dialog(self): dialog = QtWidgets.QDialog(self) layout = QtWidgets.QVBoxLayout() @@ -229,3 +233,11 @@ def evaluate_network(self): labeled_images = [str(p) for p in image_dir.rglob("*.png")] if len(labeled_images) > 0: _ = launch_napari(image_dir) + + @Slot(Engine) + def _on_engine_change(self, engine: Engine) -> None: + if engine == Engine.PYTORCH: + self.opt_button.hide() + return + + self.opt_button.show() From e454e90b56b81a1db8f1d361dd0223dd3748717d Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 20:06:18 +0200 Subject: [PATCH 136/293] niels - modelzoo tab params (#246) * isort + black * split torch and tf params --------- Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/tabs/modelzoo.py | 195 +++++++++++++++++++++++--------- 1 file changed, 143 insertions(+), 52 deletions(-) diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index 6900c7ec5f..7bfe7910c1 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -9,24 +9,24 @@ # Licensed under GNU Lesser General Public License v3.0 # import os +import webbrowser from functools import partial -import deeplabcut +from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS from PySide6 import QtWidgets -from PySide6.QtCore import Qt, Signal, QTimer, QRegularExpression, Slot +from PySide6.QtCore import QRegularExpression, Qt, QTimer, Signal, Slot from PySide6.QtGui import QPixmap, QRegularExpressionValidator +import deeplabcut from deeplabcut.core.engine import Engine +from deeplabcut.gui import BASE_DIR from deeplabcut.gui.components import ( + _create_grid_layout, + _create_label_widget, DefaultTab, VideoSelectionWidget, - _create_label_widget, - _create_grid_layout, ) -from deeplabcut.gui import BASE_DIR from deeplabcut.gui.utils import move_to_separate_thread -from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS -import webbrowser class RegExpValidator(QRegularExpressionValidator): @@ -43,7 +43,7 @@ def __init__(self, root, parent, h1_description): super().__init__(root, parent, h1_description) self._val_pattern = QRegularExpression(r"(\d{3,5},\s*)+\d{3,5}") self._set_page() - self.root.engine_change.connect(self._update_available_models) + self.root.engine_change.connect(self._on_engine_change) @property def files(self): @@ -54,17 +54,52 @@ def _set_page(self): self.video_selection_widget = VideoSelectionWidget(self.root, self) self.main_layout.addWidget(self.video_selection_widget) - model_settings_layout = _create_grid_layout(margins=(20, 0, 0, 0)) + self._build_common_attributes() + self._build_tf_attributes() + self._build_torch_attributes() + self.run_button = QtWidgets.QPushButton("Run") + self.run_button.clicked.connect(self.run_video_adaptation) + self.main_layout.addWidget(self.run_button, alignment=Qt.AlignRight) + + self.help_button = QtWidgets.QPushButton("Help") + self.help_button.clicked.connect(self.show_help_dialog) + self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) + + self.go_to_button = QtWidgets.QPushButton("Read Documentation") + # go to url https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html#about-the-superanimal-models when button is clicked + self.go_to_button.clicked.connect( + lambda: webbrowser.open( + "https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html#about-the-superanimal-models" + ) + ) + self.main_layout.addWidget(self.go_to_button, alignment=Qt.AlignLeft) + self._on_engine_change(self.root.engine) + + def _build_common_attributes(self) -> None: + settings_layout = _create_grid_layout(margins=(20, 0, 0, 0)) section_title = _create_label_widget( "Supermodel Settings", "font:bold", (0, 50, 0, 0) ) model_combo_text = QtWidgets.QLabel("Supermodel name") + model_combo_text.setMinimumWidth(300) self.model_combo = QtWidgets.QComboBox() - self._update_available_models(self.root.engine) + self.model_combo.setMinimumWidth(250) + + settings_layout.addWidget(section_title, 0, 0) + settings_layout.addWidget(model_combo_text, 1, 0) + settings_layout.addWidget(self.model_combo, 1, 1) + + self.settings_widget = QtWidgets.QWidget() + self.settings_widget.setLayout(settings_layout) + self.main_layout.addWidget(self.settings_widget) + + def _build_tf_attributes(self) -> None: + model_settings_layout = _create_grid_layout(margins=(20, 0, 0, 0)) scales_label = QtWidgets.QLabel("Scale list") + scales_label.setMinimumWidth(300) self.scales_line = QtWidgets.QLineEdit("", parent=self) self.scales_line.setPlaceholderText( "Optionally input a list of integer sizes separated by commas..." @@ -75,10 +110,13 @@ def _set_page(self): tooltip_label = QtWidgets.QLabel() tooltip_label.setPixmap( - QPixmap(os.path.join(BASE_DIR, "assets", "icons", "help2.png")).scaledToWidth(30) + QPixmap( + os.path.join(BASE_DIR, "assets", "icons", "help2.png") + ).scaledToWidth(30) ) tooltip_label.setToolTip( - "Approximate animal sizes in pixels, for spatial pyramid search. If left blank, defaults to video height +/- 50 pixels", + "Approximate animal sizes in pixels, for spatial pyramid search. If left " + "blank, defaults to video height +/- 50 pixels" ) self.adapt_checkbox = QtWidgets.QCheckBox("Use video adaptation") @@ -96,6 +134,7 @@ def _set_page(self): self.pseudo_threshold_spinbox.setMaximumWidth(300) adapt_iter_label = QtWidgets.QLabel("Number of adaptation iterations") + adapt_iter_label.setMinimumWidth(300) self.adapt_iter_spinbox = QtWidgets.QSpinBox() self.adapt_iter_spinbox.setRange(100, 10000) self.adapt_iter_spinbox.setValue(1000) @@ -103,31 +142,63 @@ def _set_page(self): self.adapt_iter_spinbox.setGroupSeparatorShown(True) self.adapt_iter_spinbox.setMaximumWidth(300) - model_settings_layout.addWidget(section_title, 0, 0) - model_settings_layout.addWidget(model_combo_text, 1, 0) - model_settings_layout.addWidget(self.model_combo, 1, 1) - model_settings_layout.addWidget(scales_label, 2, 0) - model_settings_layout.addWidget(self.scales_line, 2, 1) - model_settings_layout.addWidget(tooltip_label, 2, 2) - model_settings_layout.addWidget(self.adapt_checkbox, 3, 0) - model_settings_layout.addWidget(pseudo_threshold_label, 4, 0) - model_settings_layout.addWidget(self.pseudo_threshold_spinbox, 4, 1) - model_settings_layout.addWidget(adapt_iter_label, 5, 0) - model_settings_layout.addWidget(self.adapt_iter_spinbox, 5, 1) - self.main_layout.addLayout(model_settings_layout) + model_settings_layout.addWidget(scales_label, 1, 0) + model_settings_layout.addWidget(self.scales_line, 1, 1) + model_settings_layout.addWidget(tooltip_label, 1, 2) + model_settings_layout.addWidget(self.adapt_checkbox, 2, 0) + model_settings_layout.addWidget(pseudo_threshold_label, 3, 0) + model_settings_layout.addWidget(self.pseudo_threshold_spinbox, 3, 1) + model_settings_layout.addWidget(adapt_iter_label, 4, 0) + model_settings_layout.addWidget(self.adapt_iter_spinbox, 4, 1) + self.tf_widget = QtWidgets.QWidget() + self.tf_widget.setLayout(model_settings_layout) + self.tf_widget.hide() + self.main_layout.addWidget(self.tf_widget) - self.run_button = QtWidgets.QPushButton("Run") - self.run_button.clicked.connect(self.run_video_adaptation) - self.main_layout.addWidget(self.run_button, alignment=Qt.AlignRight) + def _build_torch_attributes(self) -> None: + torch_settings_layout = _create_grid_layout(margins=(20, 0, 0, 0)) + + self.torch_adapt_checkbox = QtWidgets.QCheckBox("Use video adaptation") + self.torch_adapt_checkbox.setChecked(True) + + pseudo_threshold_label = QtWidgets.QLabel("Pseudo-label confidence threshold") + pseudo_threshold_label.setMinimumWidth(300) + self.torch_pseudo_threshold_spinbox = QtWidgets.QDoubleSpinBox( + decimals=2, + minimum=0.01, + maximum=1.0, + singleStep=0.05, + value=0.1, + wrapping=True, + ) + self.torch_pseudo_threshold_spinbox.setMaximumWidth(300) + + adapt_epoch_label = QtWidgets.QLabel("Number of adaptation epochs") + adapt_epoch_label.setMinimumWidth(300) + self.torch_adapt_epoch_spinbox = QtWidgets.QSpinBox() + self.torch_adapt_epoch_spinbox.setRange(1, 50) + self.torch_adapt_epoch_spinbox.setValue(4) + self.torch_adapt_epoch_spinbox.setMaximumWidth(300) + + adapt_det_epoch_label = QtWidgets.QLabel("Number of detector adaptation epochs") + adapt_det_epoch_label.setMinimumWidth(300) + self.torch_adapt_det_epoch_spinbox = QtWidgets.QSpinBox() + self.torch_adapt_det_epoch_spinbox.setRange(1, 50) + self.torch_adapt_det_epoch_spinbox.setValue(4) + self.torch_adapt_det_epoch_spinbox.setMaximumWidth(300) + + torch_settings_layout.addWidget(self.torch_adapt_checkbox, 1, 0) + torch_settings_layout.addWidget(pseudo_threshold_label, 2, 0) + torch_settings_layout.addWidget(self.torch_pseudo_threshold_spinbox, 2, 1) + torch_settings_layout.addWidget(adapt_epoch_label, 3, 0) + torch_settings_layout.addWidget(self.torch_adapt_epoch_spinbox, 3, 1) + torch_settings_layout.addWidget(adapt_det_epoch_label, 4, 0) + torch_settings_layout.addWidget(self.torch_adapt_det_epoch_spinbox, 4, 1) + self.torch_widget = QtWidgets.QWidget() + self.torch_widget.setLayout(torch_settings_layout) + self.torch_widget.hide() + self.main_layout.addWidget(self.torch_widget) - self.help_button = QtWidgets.QPushButton("Help") - self.help_button.clicked.connect(self.show_help_dialog) - self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) - - self.go_to_button = QtWidgets.QPushButton("Read Documentation") - # go to url https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html#about-the-superanimal-models when button is clicked - self.go_to_button.clicked.connect(lambda: webbrowser.open('https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html#about-the-superanimal-models')) - self.main_layout.addWidget(self.go_to_button, alignment=Qt.AlignLeft) def show_help_dialog(self): dialog = QtWidgets.QDialog(self) layout = QtWidgets.QVBoxLayout() @@ -163,16 +234,9 @@ def run_video_adaptation(self): msg.exec_() return - scales = [] - scales_ = self.scales_line.text() - if scales_: - if ( - self.scales_line.validator().validate(scales_, 0)[0] - == RegExpValidator.Acceptable - ): - scales = list(map(int, scales_.split(","))) supermodel_name = self.model_combo.currentText() videotype = self.video_selection_widget.videotype_widget.currentText() + kwargs = self._gather_kwargs() can_run_in_background = False if can_run_in_background: @@ -181,10 +245,7 @@ def run_video_adaptation(self): videos, supermodel_name, videotype=videotype, - video_adapt=self.adapt_checkbox.isChecked(), - scale_list=scales, - pseudo_threshold=self.pseudo_threshold_spinbox.value(), - adapt_iterations=self.adapt_iter_spinbox.value(), + **kwargs, ) self.worker, self.thread = move_to_separate_thread(func) @@ -195,17 +256,37 @@ def run_video_adaptation(self): self.root._progress_bar.show() else: + print(f"Calling video_inference_superanimal with kwargs={kwargs}") deeplabcut.video_inference_superanimal( videos, supermodel_name, videotype=videotype, - video_adapt=self.adapt_checkbox.isChecked(), - scale_list=scales, - pseudo_threshold=self.pseudo_threshold_spinbox.value(), - adapt_iterations=self.adapt_iter_spinbox.value(), + **kwargs, ) - @Slot(Engine) + def _gather_kwargs(self) -> dict: + kwargs = {} + if self.root.engine == Engine.TF: + scales = [] + scales_ = self.scales_line.text() + if scales_: + if ( + self.scales_line.validator().validate(scales_, 0)[0] + == RegExpValidator.Acceptable + ): + scales = list(map(int, scales_.split(","))) + kwargs["scale_list"] = scales + kwargs["video_adapt"] = self.adapt_checkbox.isChecked() + kwargs["pseudo_threshold"] = self.pseudo_threshold_spinbox.value() + kwargs["adapt_iterations"] = self.adapt_iter_spinbox.value() + else: + kwargs["video_adapt"] = self.torch_adapt_checkbox.isChecked() + kwargs["pseudo_threshold"] = self.torch_pseudo_threshold_spinbox.value() + kwargs["detector_epochs"] = self.torch_adapt_det_epoch_spinbox.value() + kwargs["pose_epochs"] = self.torch_adapt_epoch_spinbox.value() + + return kwargs + def _update_available_models(self, engine: Engine) -> None: while self.model_combo.count() > 0: self.model_combo.removeItem(0) @@ -223,3 +304,13 @@ def _update_available_models(self, engine: Engine) -> None: ) ] self.model_combo.addItems(supermodels) + + @Slot(Engine) + def _on_engine_change(self, engine: Engine) -> None: + self._update_available_models(engine) + if engine == Engine.PYTORCH: + self.tf_widget.hide() + self.torch_widget.show() + else: + self.torch_widget.hide() + self.tf_widget.show() From 3d2d47ecaab3e21675d28d255466810376dc1c49 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 11 Jun 2024 20:24:57 +0200 Subject: [PATCH 137/293] [wip] niels - api logging (#244) * fixed analyze_videos signature * logging to print * added logger in GUI * added more analyze videos prints --------- Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/window.py | 5 +++ .../apis/analyze_videos.py | 38 ++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 91300f1ce5..a252791cee 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -116,6 +116,10 @@ def __init__(self, app): self.receiver = StreamReceiver(self.writer.queue) self.receiver.new_text.connect(self.print_to_status_bar) + # create logger to also log to the console + logging.basicConfig() + logging.getLogger("console").setLevel(logging.INFO) + self._progress_bar = QtWidgets.QProgressBar() self._progress_bar.setMaximum(0) self._progress_bar.hide() @@ -124,6 +128,7 @@ def __init__(self, app): def print_to_status_bar(self, text): self.status_bar.showMessage(text) self.status_bar.repaint() + logging.getLogger("console").info(text) @property def toolbar(self): diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index d04221a141..6c44e0f14b 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -99,11 +99,13 @@ def video_inference( video = VideoIterator(str(video_path)) n_frames = video.get_n_frames() vid_w, vid_h = video.dimensions - logging.info( + print(f"Starting to analyze {video_path}") + print( f"Video metadata: \n" - f" n_frames: {n_frames}\n" - f" fps: {video.fps}\n" - f" resolution: w={vid_w}, h={vid_h}\n" + f" Overall # of frames: {n_frames}\n" + f" Duration of video [s]: {n_frames / max(1, video.fps):.2f}\n" + f" fps: {video.fps}\n" + f" resolution: w={vid_w}, h={vid_h}\n" ) video_metadata = { "n_frames": n_frames, @@ -116,12 +118,12 @@ def video_inference( if detector_runner is None: raise ValueError("Must use a detector for top-down video analysis") - logging.info("Running Detector") + print("Running Detector") bbox_predictions = detector_runner.inference(images=tqdm(video)) video.set_context(bbox_predictions) - logging.info("Running Pose Prediction") + print("Running Pose Prediction") predictions = pose_runner.inference(images=tqdm(video)) if with_identity: @@ -164,7 +166,7 @@ def analyze_videos( auto_track: bool | None = True, identity_only: bool | None = False, overwrite: bool = False, -) -> list[tuple[str, pd.DataFrame]]: +) -> str: """Makes prediction based on a trained network. # TODO: @@ -217,7 +219,7 @@ def analyze_videos( prediction. Returns: - A list containing tuples (video_name, df_video_predictions) + The scorer used to analyze the videos """ # Create the output folder _validate_destfolder(destfolder) @@ -258,7 +260,7 @@ def analyze_videos( model_cfg["device"] = device snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0] - logging.info(f"Analyzing videos with {snapshot.path}") + print(f"Analyzing videos with {snapshot.path}") detector_path, detector_snapshot = None, None if pose_task == Task.TOP_DOWN: if detector_snapshot_index is None: @@ -272,7 +274,7 @@ def analyze_videos( detector_snapshot_index, train_folder, Task.DETECT )[0] detector_path = detector_snapshot.path - logging.info(f" -> Using detector {detector_path}") + print(f" -> Using detector {detector_path}") dlc_scorer = get_scorer_name( cfg, @@ -307,7 +309,7 @@ def analyze_videos( output_pkl = output_path / f"{output_prefix}_full.pickle" if not overwrite and output_pkl.exists(): - logging.info(f"Video already analyzed at {output_pkl}!") + print(f"Video {video} already analyzed at {output_pkl}!") else: runtime = [time.time()] predictions = video_inference( @@ -386,7 +388,15 @@ def analyze_videos( destfolder=str(destfolder), ) - return results + print( + "The videos are analyzed. Now your research can truly start!\n" + "You can create labeled videos with 'create_labeled_video'.\n" + "If the tracking is not satisfactory for some videos, consider expanding the " + "training set. You can use the function 'extract_outlier_frames' to extract a " + "few representative outlier frames.\n" + ) + + return dlc_scorer def create_df_from_prediction( @@ -400,7 +410,7 @@ def create_df_from_prediction( output_h5 = Path(output_path) / f"{output_prefix}.h5" output_pkl = Path(output_path) / f"{output_prefix}_full.pickle" - logging.info(f"Saving results in {output_h5} and {output_pkl}") + print(f"Saving results in {output_h5} and {output_pkl}") cols = [ [dlc_scorer], list(auxiliaryfunctions.get_bodyparts(cfg)), @@ -479,7 +489,7 @@ def _validate_destfolder(destfolder: str | None) -> None: if destfolder is not None and destfolder != "": output_folder = Path(destfolder) if not output_folder.exists(): - logging.info(f"Creating the output folder {output_folder}") + print(f"Creating the output folder {output_folder}") output_folder.mkdir(parents=True) assert Path( From e6ab62d6e62a086860b217ac6faf553b09165bda Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 12 Jun 2024 13:55:18 +0200 Subject: [PATCH 138/293] isort + black --- deeplabcut/benchmark/metrics.py | 1 + deeplabcut/core/conversion_table.py | 1 + deeplabcut/core/crossvalutils.py | 8 +++++--- deeplabcut/core/inferenceutils.py | 17 +++++++++-------- deeplabcut/core/trackingutils.py | 4 ++-- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/deeplabcut/benchmark/metrics.py b/deeplabcut/benchmark/metrics.py index 626103d46a..2f8127b297 100644 --- a/deeplabcut/benchmark/metrics.py +++ b/deeplabcut/benchmark/metrics.py @@ -212,6 +212,7 @@ def calc_map_from_obj( oks_sigma, margin=margin, symmetric_kpts=symmetric_kpts, + greedy_matching=True, ) return oks["mAP"] diff --git a/deeplabcut/core/conversion_table.py b/deeplabcut/core/conversion_table.py index 4820a7dd7b..e5d9679fa9 100644 --- a/deeplabcut/core/conversion_table.py +++ b/deeplabcut/core/conversion_table.py @@ -24,6 +24,7 @@ class ConversionTable: - All SuperAnimal bodyparts must be valid (defined for the SuperAnimal model) - All project bodyparts must be valid (defined for the DLC project) """ + super_animal: str project_bodyparts: list[str] super_animal_bodyparts: list[str] diff --git a/deeplabcut/core/crossvalutils.py b/deeplabcut/core/crossvalutils.py index 59ef1bd260..4468b6e191 100644 --- a/deeplabcut/core/crossvalutils.py +++ b/deeplabcut/core/crossvalutils.py @@ -15,18 +15,18 @@ import shutil from collections import defaultdict from copy import deepcopy -from tqdm import tqdm import networkx as nx import numpy as np import pandas as pd from scipy.spatial import cKDTree from sklearn.metrics.cluster import contingency_matrix +from tqdm import tqdm from deeplabcut.core.inferenceutils import ( + _parse_ground_truth_data, Assembler, evaluate_assembly, - _parse_ground_truth_data, ) from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions @@ -56,7 +56,9 @@ def _unsorted_unique(array): return np.asarray(array)[np.sort(inds)] -def find_closest_neighbors(query: np.ndarray, ref: np.ndarray, k: int = 3) -> np.ndarray: +def find_closest_neighbors( + query: np.ndarray, ref: np.ndarray, k: int = 3 +) -> np.ndarray: """Greedy matching of predicted keypoints to ground truth keypoints Args: diff --git a/deeplabcut/core/inferenceutils.py b/deeplabcut/core/inferenceutils.py index 31b215283e..b5c3a93d5a 100644 --- a/deeplabcut/core/inferenceutils.py +++ b/deeplabcut/core/inferenceutils.py @@ -12,22 +12,23 @@ import heapq import itertools import multiprocessing -import networkx as nx -import numpy as np import operator -import pandas as pd import pickle import warnings from collections import defaultdict from dataclasses import dataclass -from math import sqrt, erf +from math import erf, sqrt +from typing import Tuple + +import networkx as nx +import numpy as np +import pandas as pd from scipy.optimize import linear_sum_assignment from scipy.spatial import cKDTree -from scipy.spatial.distance import pdist, cdist +from scipy.spatial.distance import cdist, pdist from scipy.special import softmax -from scipy.stats import gaussian_kde, chi2 +from scipy.stats import chi2, gaussian_kde from tqdm import tqdm -from typing import Tuple def _conv_square_to_condensed_indices(ind_row, ind_col, n): @@ -408,7 +409,7 @@ def calc_link_probability(self, link): ind = _conv_square_to_condensed_indices(i, j, self.n_multibodyparts) mu = self._kde.mean[ind] sigma = self._kde.covariance[ind, ind] - z = (link.length ** 2 - mu) / sigma + z = (link.length**2 - mu) / sigma return 2 * (1 - 0.5 * (1 + erf(abs(z) / sqrt(2)))) @staticmethod diff --git a/deeplabcut/core/trackingutils.py b/deeplabcut/core/trackingutils.py index b0a03eed5f..5ce20325f4 100644 --- a/deeplabcut/core/trackingutils.py +++ b/deeplabcut/core/trackingutils.py @@ -11,9 +11,10 @@ import abc import math -import numpy as np import warnings from collections import defaultdict + +import numpy as np from filterpy.common import kinematic_kf from filterpy.kalman import KalmanFilter from matplotlib import patches @@ -23,7 +24,6 @@ from scipy.stats import mode from tqdm import tqdm - warnings.simplefilter("ignore", category=NumbaPerformanceWarning) TRACK_METHODS = { From a910876d2059aa6c0c9cbe128ed0217fcf062951 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 12 Jun 2024 14:29:44 +0200 Subject: [PATCH 139/293] fixed mAP computation --- deeplabcut/core/inferenceutils.py | 297 +++++++++++++++++++++++------- 1 file changed, 233 insertions(+), 64 deletions(-) diff --git a/deeplabcut/core/inferenceutils.py b/deeplabcut/core/inferenceutils.py index b5c3a93d5a..bcae54147a 100644 --- a/deeplabcut/core/inferenceutils.py +++ b/deeplabcut/core/inferenceutils.py @@ -18,7 +18,7 @@ from collections import defaultdict from dataclasses import dataclass from math import erf, sqrt -from typing import Tuple +from typing import Any, Iterable, Tuple import networkx as nx import numpy as np @@ -112,6 +112,10 @@ def __add__(self, other): @classmethod def from_array(cls, array): n_bpts, n_cols = array.shape + + # if a single coordinate is NaN for a bodypart, set all to NaN + array[np.isnan(array).any(axis=-1)] = np.nan + ass = cls(size=n_bpts) ass.data[:, :n_cols] = array visible = np.flatnonzero(~np.isnan(array).any(axis=1)) @@ -916,6 +920,28 @@ def to_pickle(self, output_name): pickle.dump(data, file, pickle.HIGHEST_PROTOCOL) +@dataclass +class MatchedPrediction: + """A match between a prediction and a ground truth assembly + + The ground truth assembly should be None f the prediction was not matched to any GT, + and the OKS should be 0. + + Attributes: + prediction: A prediction made by a pose model. + score: The confidence score for the prediction. + ground_truth: If None, then this prediction is not matched to any ground truth + (this can happen when there are more predicted individuals than GT). + Otherwise, the ground truth assembly to which this prediction is matched. + oks: The OKS score between the prediction and the ground truth pose. + """ + + prediction: Assembly + score: float + ground_truth: Assembly | None + oks: float + + def calc_object_keypoint_similarity( xy_pred, xy_true, @@ -926,10 +952,12 @@ def calc_object_keypoint_similarity( visible_gt = ~np.isnan(xy_true).all(axis=1) if visible_gt.sum() < 2: # At least 2 points needed to calculate scale return np.nan + true = xy_true[visible_gt] scale_squared = np.product(np.ptp(true, axis=0) + np.spacing(1) + margin * 2) if np.isclose(scale_squared, 0): return np.nan + k_squared = (2 * sigma) ** 2 denom = 2 * scale_squared * k_squared if symmetric_kpts is None: @@ -961,47 +989,80 @@ def calc_object_keypoint_similarity( def match_assemblies( - ass_pred, ass_true, sigma, margin=0, symmetric_kpts=None, greedy_matching=False -): + predictions: list[Assembly], + ground_truth: list[Assembly], + sigma: float, + margin: int = 0, + symmetric_kpts: list[tuple[int, int]] | None = None, + greedy_matching: bool = False, + greedy_oks_threshold: float = 0.0, +) -> tuple[int, list[MatchedPrediction]]: + """Matches assemblies to ground truth predictions + + Returns: + int: the total number of valid ground truth assemblies + list[MatchedPrediction]: a list containing all valid predictions, potentially + matched to ground truth assemblies. + """ # Only consider assemblies of at least two keypoints - ass_pred = [a for a in ass_pred if len(a) > 1] - ass_true = [a for a in ass_true if len(a) > 1] - - matched = [] + predictions = [a for a in predictions if len(a) > 1] # TODO: SHOULD WE???? + ground_truth = [a for a in ground_truth if len(a) > 1] + num_ground_truth = len(ground_truth) + + # Sort predictions by score + inds_pred = np.argsort( + [ins.affinity if ins.n_links else ins.confidence for ins in predictions] + )[::-1] + predictions = np.asarray(predictions)[inds_pred] + + # indices of unmatched ground truth assemblies + matched = [ + MatchedPrediction( + prediction=p, + score=(p.affinity if p.n_links else p.confidence), + ground_truth=None, + oks=0.0, + ) + for p in predictions + ] # Greedy assembly matching like in pycocotools if greedy_matching: - inds_true = list(range(len(ass_true))) - inds_pred = np.argsort( - [ins.affinity if ins.n_links else ins.confidence for ins in ass_pred] - )[::-1] - for ind_pred in inds_pred: - xy_pred = ass_pred[ind_pred].xy - oks = [] - for ind_true in inds_true: - xy_true = ass_true[ind_true].xy - oks.append( - calc_object_keypoint_similarity( - xy_pred, - xy_true, - sigma, - margin, - symmetric_kpts, - ) + matched_gt_indices = set() + for idx, pred in enumerate(predictions): + oks = [ + calc_object_keypoint_similarity( + pred.xy, + gt.xy, + sigma, + margin, + symmetric_kpts, ) + for gt in ground_truth + ] if np.all(np.isnan(oks)): continue + ind_best = np.nanargmax(oks) - ind_true_best = inds_true.pop(ind_best) - matched.append((ass_pred[ind_pred], ass_true[ind_true_best], oks[ind_best])) - if not inds_true: - break + + # if this gt already matched, and not a crowd, continue + if ind_best in matched_gt_indices: + continue + + # Only match the pred to the GT if the OKS value is above a given threshold + if oks[ind_best] < greedy_oks_threshold: + continue + + matched_gt_indices.add(ind_best) + matched[idx].ground_truth = ground_truth[ind_best] + matched[idx].oks = oks[ind_best] # Global rather than greedy assembly matching else: - mat = np.zeros((len(ass_pred), len(ass_true))) - for i, a_pred in enumerate(ass_pred): - for j, a_true in enumerate(ass_true): + inds_true = list(range(len(ground_truth))) + mat = np.zeros((len(predictions), len(ground_truth))) + for i, a_pred in enumerate(predictions): + for j, a_true in enumerate(ground_truth): oks = calc_object_keypoint_similarity( a_pred.xy, a_true.xy, @@ -1012,13 +1073,12 @@ def match_assemblies( if ~np.isnan(oks): mat[i, j] = oks rows, cols = linear_sum_assignment(mat, maximize=True) - inds_true = list(range(len(ass_true))) for row, col in zip(rows, cols): - matched.append((ass_pred[row], ass_true[col], mat[row, col])) + matched[row].ground_truth = ground_truth[col] + matched[row].oks = mat[row, col] _ = inds_true.remove(col) - unmatched = [ass_true[ind] for ind in inds_true] - return matched, unmatched + return num_ground_truth, matched def parse_ground_truth_data_file(h5_file): @@ -1073,6 +1133,113 @@ def find_outlier_assemblies(dict_of_assemblies, criterion="area", qs=(5, 95)): return list(set(frame_inds[i] for i in inds)) +def _compute_precision_and_recall( + num_gt_assemblies: int, + oks_values: np.ndarray, + oks_threshold: float, + recall_thresholds: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + """Computes the precision and recall scores at a given OKS threshold + + Args: + num_gt_assemblies: the number of ground truth assemblies (used to compute false + negatives + true positives). + oks_values: the OKS value to the matched GT assembly for each prediction + oks_threshold: the OKS threshold at which recall and precision are being + computed + recall_thresholds: the recall thresholds to use to compute scores + + Returns: + The precision and recall arrays at each recall threshold + """ + tp = np.cumsum(oks_values >= oks_threshold) + fp = np.cumsum(oks_values < oks_threshold) + rc = tp / num_gt_assemblies + pr = tp / (fp + tp + np.spacing(1)) + recall = rc[-1] + + # Guarantee precision decreases monotonically, see + # https://jonathan-hui.medium.com/map-mean-average-precision-for-object-detection-45c121a31173 + for i in range(len(pr) - 1, 0, -1): + if pr[i] > pr[i - 1]: + pr[i - 1] = pr[i] + + inds_rc = np.searchsorted(rc, recall_thresholds, side="left") + precision = np.zeros(inds_rc.shape) + valid = inds_rc < len(pr) + precision[valid] = pr[inds_rc[valid]] + return precision, recall + + +def evaluate_assembly_greedy( + assemblies_gt: dict[Any, list[Assembly]], + assemblies_pred: dict[Any, list[Assembly]], + oks_sigma: float, + oks_thresholds: Iterable[float], + margin: int | float = 0, + symmetric_kpts: list[tuple[int, int]] | None = None, +) -> dict: + """Runs greedy mAP evaluation, as done by pycocotools + + Args: + assemblies_gt: A dictionary mapping image ID (e.g. filepath) to ground truth + assemblies. Should contain all the same keys as ``assemblies_pred``. + assemblies_pred: A dictionary mapping image ID (e.g. filepath) to predicted + assemblies. Should contain all the same keys as ``assemblies_gt``. + oks_sigma: The sigma to use to compute OKS values for keypoints . + oks_thresholds: The OKS thresholds at which to compute precision & recall. + margin: The margin to use to compute bounding boxes from keypoints. + symmetric_kpts: The symmetric keypoints in the dataset. + """ + recall_thresholds = np.linspace( # np.linspace(0, 1, 101) + start=0.0, stop=1.00, num=int(np.round((1.00 - 0.0) / 0.01)) + 1, endpoint=True + ) + precisions = [] + recalls = [] + for oks_t in oks_thresholds: + all_matched = [] + total_gt_assemblies = 0 + for ind, gt_assembly in assemblies_gt.items(): + pred_assemblies = assemblies_pred.get(ind, []) + num_gt_assemblies, matched = match_assemblies( + pred_assemblies, + gt_assembly, + oks_sigma, + margin, + symmetric_kpts, + greedy_matching=True, + greedy_oks_threshold=oks_t, + ) + all_matched.extend(matched) + total_gt_assemblies += num_gt_assemblies + + if len(all_matched) == 0: + precisions.append(0.0) + recalls.append(0.0) + continue + + # Global sort of assemblies (across all images) by score + scores = np.asarray([-m.score for m in all_matched]) + sorted_pred_indices = np.argsort(scores, kind="mergesort") + oks = np.asarray([match.oks for match in all_matched])[sorted_pred_indices] + + # Compute prediction and recall + p, r = _compute_precision_and_recall( + total_gt_assemblies, oks, oks_t, recall_thresholds + ) + precisions.append(p) + recalls.append(r) + + precisions = np.asarray(precisions) + recalls = np.asarray(recalls) + return { + "precisions": precisions, + "recalls": recalls, + "mAP": precisions.mean(), + "mAR": recalls.mean(), + } + + def evaluate_assembly( ass_pred_dict, ass_true_dict, @@ -1083,24 +1250,37 @@ def evaluate_assembly( greedy_matching=False, with_tqdm: bool = True, ): + if greedy_matching: + return evaluate_assembly_greedy( + ass_true_dict, + ass_pred_dict, + oks_sigma=oks_sigma, + oks_thresholds=oks_thresholds, + margin=margin, + symmetric_kpts=symmetric_kpts, + ) + # sigma is taken as the median of all COCO keypoint standard deviations all_matched = [] - all_unmatched = [] - items = ass_true_dict.items() + total_gt_assemblies = 0 + + gt_assemblies = ass_true_dict.items() if with_tqdm: - items = tqdm(items) - for ind, ass_true in items: - ass_pred = ass_pred_dict.get(ind, []) - matched, unmatched = match_assemblies( - ass_pred, - ass_true, + gt_assemblies = tqdm(gt_assemblies) + + for ind, gt_assembly in gt_assemblies: + pred_assemblies = ass_pred_dict.get(ind, []) + num_gt, matched = match_assemblies( + pred_assemblies, + gt_assembly, oks_sigma, margin, symmetric_kpts, greedy_matching, ) all_matched.extend(matched) - all_unmatched.extend(unmatched) + total_gt_assemblies += num_gt + if not all_matched: return { "precisions": np.array([]), @@ -1109,31 +1289,20 @@ def evaluate_assembly( "mAR": 0.0, } - conf_pred = np.asarray([match[0].affinity for match in all_matched]) + conf_pred = np.asarray([match.score for match in all_matched]) idx = np.argsort(-conf_pred, kind="mergesort") # Sort matching score (OKS) in descending order of assembly affinity - oks = np.asarray([match[2] for match in all_matched])[idx] - ntot = len(all_matched) + len(all_unmatched) + oks = np.asarray([match.oks for match in all_matched])[idx] recall_thresholds = np.linspace(0, 1, 101) precisions = [] recalls = [] - for th in oks_thresholds: - tp = np.cumsum(oks >= th) - fp = np.cumsum(oks < th) - rc = tp / ntot - pr = tp / (fp + tp + np.spacing(1)) - recall = rc[-1] - # Guarantee precision decreases monotonically - # See https://jonathan-hui.medium.com/map-mean-average-precision-for-object-detection-45c121a31173) - for i in range(len(pr) - 1, 0, -1): - if pr[i] > pr[i - 1]: - pr[i - 1] = pr[i] - inds_rc = np.searchsorted(rc, recall_thresholds) - precision = np.zeros(inds_rc.shape) - valid = inds_rc < len(pr) - precision[valid] = pr[inds_rc[valid]] - precisions.append(precision) - recalls.append(recall) + for t in oks_thresholds: + p, r = _compute_precision_and_recall( + total_gt_assemblies, oks, t, recall_thresholds + ) + precisions.append(p) + recalls.append(r) + precisions = np.asarray(precisions) recalls = np.asarray(recalls) return { From 7845a6f0a0bdda7e6ce5f60c42f9a475a975a0ba Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 12 Jun 2024 15:11:52 +0200 Subject: [PATCH 140/293] warnings when TF is not installed (#252) * warnings when TF is not installed * remove commented code --- .../gui/displays/selected_shuffle_display.py | 23 ++++-- .../gui/tabs/create_training_dataset.py | 81 ++++++++++++------- deeplabcut/gui/window.py | 26 ++++++ 3 files changed, 96 insertions(+), 34 deletions(-) diff --git a/deeplabcut/gui/displays/selected_shuffle_display.py b/deeplabcut/gui/displays/selected_shuffle_display.py index f0fb263651..fd370a76cf 100644 --- a/deeplabcut/gui/displays/selected_shuffle_display.py +++ b/deeplabcut/gui/displays/selected_shuffle_display.py @@ -66,7 +66,22 @@ def _update_display(self, new_index: int) -> None: try: pose_cfg_path = Path(self.root.pose_cfg_path) except ValueError as err: - self._set_text_error() + self._set_text_error( + f"Failed to read shuffle {self._current_index} - check that it exists!" + ) + return + except ModuleNotFoundError as err: + # Loading a TF shuffle but TF is not installed + self._set_text_error( + f"Failed to read shuffle {self._current_index} due to error `{err}`.\n" + "If the error is `ModuleNotFoundError: No module named 'tensorflow'`, " + f"this is because\nshuffle {self._current_index} uses the tensorflow " + " engine, but TensorFlow is not installed in your environment.\n" + "Ignore this error if you'll just train PyTorch models. To train " + "TensorFlow models, install it with \n" + " Windows/Linux: pip install 'deeplabcut[tf]'\n" + " Apple Silicon: pip install 'deeplabcut[apple_mchips]'" + ) return if not pose_cfg_path.exists(): @@ -94,10 +109,8 @@ def _set_text(self) -> None: self._label.setStyleSheet(style) self._label.setText(text) - def _set_text_error(self) -> None: - self._label.setText( - f"Failed to read shuffle {self._current_index} - check that it exists!" - ) + def _set_text_error(self, error: str) -> None: + self._label.setText(error) style = f"margin: 0px 0px {self._row_margin}px 0px; color: orange;" self._label.setStyleSheet(style) self.pose_cfg = None diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 58930fbe58..aa24b4c5db 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -214,8 +214,14 @@ def create_training_dataset(self): # augmenter_types=self.aug_type, # ) else: - if self.data_split_selection.selected: - try: + try: + engine = self.root.engine + if engine == Engine.TF: + import tensorflow + # try importing TF so they can't create shuffles for it if they + # don't have it installed + + if self.data_split_selection.selected: deeplabcut.create_training_dataset_from_existing_split( self.root.config, from_shuffle=self.data_split_selection.from_shuffle, @@ -223,37 +229,54 @@ def create_training_dataset(self): net_type=self.net_choice.currentText(), userfeedback=not overwrite, weight_init=weight_init, - engine=self.root.engine, + engine=engine, ) - except ValueError as err: - msg = _create_message_box( - f"The training dataset could not be created.", - str(err), - ) - msg.exec_() - return - elif self.root.is_multianimal: - deeplabcut.create_multianimaltraining_dataset( - self.root.config, - shuffle, - Shuffles=[self.shuffle.value()], - net_type=self.net_choice.currentText(), - userfeedback=not overwrite, - weight_init=weight_init, - engine=self.root.engine, + elif self.root.is_multianimal: + deeplabcut.create_multianimaltraining_dataset( + self.root.config, + shuffle, + Shuffles=[self.shuffle.value()], + net_type=self.net_choice.currentText(), + userfeedback=not overwrite, + weight_init=weight_init, + engine=engine, + ) + else: + deeplabcut.create_training_dataset( + self.root.config, + shuffle, + Shuffles=[self.shuffle.value()], + net_type=self.net_choice.currentText(), + augmenter_type=self.aug_choice.currentText(), + userfeedback=not overwrite, + weight_init=weight_init, + engine=engine, + ) + except ValueError as err: + msg = _create_message_box( + f"The training dataset could not be created.", + str(err), ) - else: - deeplabcut.create_training_dataset( - self.root.config, - shuffle, - Shuffles=[self.shuffle.value()], - net_type=self.net_choice.currentText(), - augmenter_type=self.aug_choice.currentText(), - userfeedback=not overwrite, - weight_init=weight_init, - engine=self.root.engine, + msg.exec_() + return + except ModuleNotFoundError as err: + info_text = ( + f"Error `{err}`. If the error is `ModuleNotFoundError: No module " + "named 'tensorflow'`, this is because you tried creating a " + "TensorFlow shuffle, but TensorFlow is not installed in your " + "environment. To create TensorFlow shuffles (and use TensorFlow " + "models), install it with\n" + " Windows/Linux:\n" + " pip install 'deeplabcut[tf]'\n" + " Apple Silicon:\n" + " pip install 'deeplabcut[apple_mchips]'" + ) + msg = _create_message_box( + f"The training dataset could not be created.", info_text ) + msg.exec_() + return # Check that training data files were indeed created. trainingsetfolder = get_training_set_folder(self.root.cfg) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index a252791cee..4f6843a0c0 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -177,6 +177,32 @@ def engine(self, e: Engine) -> None: if self._engine == e: return + if e == e.TF: + try: + import tensorflow + except ModuleNotFoundError as err: + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setText("Cannot use the TensorFlow engine.") + msg.setInformativeText( + f"Error `{err}`\nCannot use the TensorFlow engine as TensorFlow " + "is not installed. To use it, install TensorFlow with\n" + " Windows/Linux:\n" + " pip install 'deeplabcut[tf]'\n" + " Apple Silicon:\n" + " pip install 'deeplabcut[apple_mchips]'\n\n" + "Please switch back to the PyTorch engine to use DeepLabCut, or" + "install TensorFlow." + ) + + msg.setWindowTitle("Info") + msg.setMinimumWidth(900) + logo_dir = os.path.dirname(os.path.realpath("logo.png")) + os.path.sep + logo = logo_dir + "/assets/logo.png" + msg.setWindowIcon(QIcon(logo)) + msg.setStandardButtons(QtWidgets.QMessageBox.Ok) + msg.exec_() + self._engine = e self.engine_change.emit(e) From 42ad20f7275226a701420561f70b7818178c749a Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Wed, 12 Jun 2024 15:13:22 +0200 Subject: [PATCH 141/293] Add modelzoo's destination folder widget (#247) Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/tabs/modelzoo.py | 34 +++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index 7bfe7910c1..80d10681b9 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -15,7 +15,7 @@ from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS from PySide6 import QtWidgets from PySide6.QtCore import QRegularExpression, Qt, QTimer, Signal, Slot -from PySide6.QtGui import QPixmap, QRegularExpressionValidator +from PySide6.QtGui import QIcon, QPixmap, QRegularExpressionValidator import deeplabcut from deeplabcut.core.engine import Engine @@ -27,6 +27,8 @@ VideoSelectionWidget, ) from deeplabcut.gui.utils import move_to_separate_thread +from deeplabcut.gui.widgets import ClickableLabel +from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS class RegExpValidator(QRegularExpressionValidator): @@ -44,6 +46,8 @@ def __init__(self, root, parent, h1_description): self._val_pattern = QRegularExpression(r"(\d{3,5},\s*)+\d{3,5}") self._set_page() self.root.engine_change.connect(self._on_engine_change) + self.root.engine_change.connect(self._update_available_models) + self._destfolder = None @property def files(self): @@ -62,6 +66,9 @@ def _set_page(self): self.run_button.clicked.connect(self.run_video_adaptation) self.main_layout.addWidget(self.run_button, alignment=Qt.AlignRight) + self.home_button = QtWidgets.QPushButton("Return to Welcome page") + self.home_button.clicked.connect(self.root._generate_welcome_page) + self.main_layout.addWidget(self.home_button, alignment=Qt.AlignLeft) self.help_button = QtWidgets.QPushButton("Help") self.help_button.clicked.connect(self.show_help_dialog) self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) @@ -87,9 +94,21 @@ def _build_common_attributes(self) -> None: self.model_combo = QtWidgets.QComboBox() self.model_combo.setMinimumWidth(250) + loc_label = ClickableLabel("Folder to store results:", parent=self) + loc_label.signal.connect(self.select_folder) + self.loc_line = QtWidgets.QLineEdit(self.root.project_folder, self) + self.loc_line.setReadOnly(True) + action = self.loc_line.addAction( + QIcon(os.path.join(BASE_DIR, "assets", "icons", "open2.png")), + QtWidgets.QLineEdit.TrailingPosition, + ) + action.triggered.connect(self.select_folder) + settings_layout.addWidget(section_title, 0, 0) settings_layout.addWidget(model_combo_text, 1, 0) settings_layout.addWidget(self.model_combo, 1, 1) + settings_layout.addWidget(loc_label, 2, 0) + settings_layout.addWidget(self.loc_line, 2, 1) self.settings_widget = QtWidgets.QWidget() self.settings_widget.setLayout(settings_layout) @@ -199,6 +218,16 @@ def _build_torch_attributes(self) -> None: self.torch_widget.hide() self.main_layout.addWidget(self.torch_widget) + def select_folder(self): + dirname = QtWidgets.QFileDialog.getExistingDirectory( + self, "Please select a folder", self.root.project_folder + ) + if not dirname: + return + + self._destfolder = dirname + self.loc_line.setText(dirname) + def show_help_dialog(self): dialog = QtWidgets.QDialog(self) layout = QtWidgets.QVBoxLayout() @@ -245,6 +274,7 @@ def run_video_adaptation(self): videos, supermodel_name, videotype=videotype, + dest_folder=self._destfolder, **kwargs, ) @@ -254,13 +284,13 @@ def run_video_adaptation(self): self.thread.start() self.run_button.setEnabled(False) self.root._progress_bar.show() - else: print(f"Calling video_inference_superanimal with kwargs={kwargs}") deeplabcut.video_inference_superanimal( videos, supermodel_name, videotype=videotype, + dest_folder=self._destfolder, **kwargs, ) From 571818b36b2d229356725ba4b25c26fb242a8f04 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 12 Jun 2024 15:14:12 +0200 Subject: [PATCH 142/293] improved docs (#249) --- deeplabcut/compat.py | 51 ++++++++++++- ...ple_individuals_trainingsetmanipulation.py | 43 +++++++++-- .../trainingsetmanipulation.py | 73 +++++++++++++------ .../apis/analyze_videos.py | 8 +- 4 files changed, 141 insertions(+), 34 deletions(-) diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index ce3af84b73..97cb23d3d0 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -104,33 +104,42 @@ def train_network( ``None``, the value from there is used, otherwise it is overwritten! saveiters: optional, default=None + Only for the TensorFlow engine (for the PyTorch engine see the ``torch_kwargs``: + you can use ``save_epochs``). This variable is actually set in ``pose_config.yaml``. However, you can overwrite it with this hack. Don't use this regularly, just if you are too lazy to dig out the ``pose_config.yaml`` file for the corresponding project. If ``None``, the value from there is used, otherwise it is overwritten! maxiters: optional, default=None + Only for the TensorFlow engine (for the PyTorch engine see the ``torch_kwargs``: + you can use ``epochs``). This variable is actually set in ``pose_config.yaml``. However, you can overwrite it with this hack. Don't use this regularly, just if you are too lazy to dig out the ``pose_config.yaml`` file for the corresponding project. If ``None``, the value from there is used, otherwise it is overwritten! allow_growth: bool, optional, default=True. + Only for the TensorFlow engine. For some smaller GPUs the memory issues happen. If ``True``, the memory allocator does not pre-allocate the entire specified GPU memory region, instead starting small and growing as needed. See issue: https://forum.image.sc/t/how-to-stop-running-out-of-vram/30551/2 gputouse: optional, default=None + Only for the TensorFlow engine (for the PyTorch engine see the ``torch_kwargs``: + you can use ``device``). Natural number indicating the number of your GPU (see number in nvidia-smi). If you do not have a GPU put None. See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries autotune: bool, optional, default=False + Only for the TensorFlow engine. Property of TensorFlow, somehow faster if ``False`` (as Eldar found out, see https://github.com/tensorflow/tensorflow/issues/13317). keepdeconvweights: bool, optional, default=True + Only for the TensorFlow engine. Also restores the weights of the deconvolution layers (and the backbone) when training from a snapshot. Note that if you change the number of bodyparts, you need to set this to false for re-training. @@ -140,9 +149,13 @@ def train_network( By default, the models are assumed to exist in the project folder. superanimal_name: str, optional, default ="" + Only for the TensorFlow engine. For the PyTorch engine, you need to specify + this through the ``weight_init`` when creating the training dataset. Specified if transfer learning with superanimal is desired superanimal_transfer_learning: bool, optional, default = False. + Only for the TensorFlow engine. For the PyTorch engine, you need to specify + this through the ``weight_init`` when creating the training dataset. If set true, the training is transfer learning (new decoding layer). If set false, and superanimal_name is True, then the training is fine-tuning (reusing the decoding layer) @@ -155,9 +168,12 @@ def train_network( torch_kwargs: You can add any keyword arguments for the deeplabcut.pose_estimation_pytorch train_network method here. These arguments are passed to the downstream method. - Some of the parameters that can be passed are ``epochs`` (maximum number of - epochs to train the network for), ``save_epochs`` (the number of epochs between - each snapshot saved), ``batch_size`` (the batch size to use while training). + Some of the parameters that can be passed are + * ``device`` (the CUDA device to use for training) + * ``epochs`` (maximum number of epochs to train the network for) + * ``save_epochs`` (the number of epochs between each snapshot saved) + ` * ``batch_size`` (the batch size to use while training). + When training a top-down model, these parameters are also available for the detector, with the parameters ``detector_batch_size``, ``detector_epochs`` and ``detector_save_epochs``. @@ -609,6 +625,8 @@ def analyze_videos( By default the first (note that TrainingFraction is a list in config.yaml). gputouse: int or None, optional, default=None + Only for the TensorFlow engine (for the PyTorch engine see the ``torch_kwargs``: + you can use ``device``). Indicates the GPU to use (see number in ``nvidia-smi``). If you do not have a GPU put ``None``. See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries @@ -617,6 +635,7 @@ def analyze_videos( Saves the predictions in a .csv file. in_random_order: bool, optional (default=True) + Only for the TensorFlow engine. Whether or not to analyze videos in a random order. This is only relevant when specifying a video directory in `videos`. @@ -626,21 +645,25 @@ def analyze_videos( be passed. batchsize: int or None, optional, default=None + Currently not supported by the PyTorch engine. Change batch size for inference; if given overwrites value in ``pose_cfg.yaml``. cropping: list or None, optional, default=None + Currently not supported by the PyTorch engine. List of cropping coordinates as [x1, x2, y1, y2]. Note that the same cropping parameters will then be used for all videos. If different video crops are desired, run ``analyze_videos`` on individual videos with the corresponding cropping coordinates. TFGPUinference: bool, optional, default=True + Only for the TensorFlow engine. Perform inference on GPU with TensorFlow code. Introduced in "Pretraining boosts out-of-domain robustness for pose estimation" by Alexander Mathis, Mert Yüksekgönül, Byron Rogers, Matthias Bethge, Mackenzie W. Mathis. Source: https://arxiv.org/abs/1909.11229 dynamic: tuple(bool, float, int) triple containing (state, detectiontreshold, margin) + Currently not supported by the PyTorch engine. If the state is true, then dynamic cropping will be performed. That means that if an object is detected (i.e. any body part > detectiontreshold), then object boundaries are computed according to the smallest/largest x position and @@ -655,17 +678,20 @@ def analyze_videos( By default, the models are assumed to exist in the project folder. robust_nframes: bool, optional, default=False + Currently not supported by the PyTorch engine. Evaluate a video's number of frames in a robust manner. This option is slower (as the whole video is read frame-by-frame), but does not rely on metadata, hence its robustness against file corruption. allow_growth: bool, optional, default=False. + Only for the TensorFlow engine. For some smaller GPUs the memory issues happen. If ``True``, the memory allocator does not pre-allocate the entire specified GPU memory region, instead starting small and growing as needed. See issue: https://forum.image.sc/t/how-to-stop-running-out-of-vram/30551/2 use_shelve: bool, optional, default=False + Currently not supported by the PyTorch engine. By default, data are dumped in a pickle file at the end of the video analysis. Otherwise, data are written to disk on the fly using a "shelf"; i.e., a pickle-based, persistent, database-like object by default, resulting in @@ -688,6 +714,7 @@ def analyze_videos( rely exclusively on identity prediction. calibrate: bool, optional, default=False + Currently not supported by the PyTorch engine. If ``True``, use training data to calibrate the animal assembly procedure. This improves its robustness to wrong body part links, but requires very little missing data. @@ -699,6 +726,7 @@ def analyze_videos( trained on. use_openvino: str, optional + Only for the TensorFlow engine. Use "CPU" for inference if OpenVINO is available in the Python environment. engine: Engine, optional, default = None. @@ -706,6 +734,10 @@ def analyze_videos( overwrite this by passing the engine as an argument, but this should generally not be done. + torch_kwargs: + Any extra parameters to pass to the PyTorch API, such as ``device`` which can + be used to specify the CUDA device to use for training. + Returns ------- DLCScorer: str @@ -822,6 +854,7 @@ def analyze_videos( videotype=videotype, shuffle=shuffle, trainingsetindex=trainingsetindex, + save_as_csv=save_as_csv, destfolder=destfolder, batchsize=batchsize, modelprefix=modelprefix, @@ -1182,6 +1215,10 @@ def extract_maps( def visualize_scoremaps( image: np.ndarray, scmap: np.ndarray, engine: Engine = DEFAULT_ENGINE, ): + """ + This function is only implemented for tensorflow models/shuffles, and will throw + an error if called with a PyTorch shuffle. + """ if engine == Engine.TF: # TODO: also works for Pytorch, but should not import as then requires TF from deeplabcut.pose_estimation_tensorflow import visualize_scoremaps @@ -1199,6 +1236,10 @@ def visualize_locrefs( zoom_width: int = 0, engine: Engine = DEFAULT_ENGINE, ): + """ + This function is only implemented for tensorflow models/shuffles, and will throw + an error if called with a PyTorch shuffle. + """ if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import visualize_locrefs return visualize_locrefs(image, scmap, locref_x, locref_y, step=step, zoom_width=zoom_width) @@ -1213,6 +1254,10 @@ def visualize_paf( colors: list | None = None, engine: Engine = DEFAULT_ENGINE, ): + """ + This function is only implemented for tensorflow models/shuffles, and will throw + an error if called with a PyTorch shuffle. + """ if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import visualize_paf return visualize_paf(image, paf, step=step, colors=colors) diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index 73e7c6bb0a..7fe24f70c8 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -120,13 +120,15 @@ def create_multianimaltraining_dataset( engine: Engine | None = None, ): """ - Creates a training dataset for multi-animal datasets. Labels from all the extracted frames are merged into a single .h5 file.\n + Creates a training dataset for multi-animal datasets. Labels from all the extracted + frames are merged into a single .h5 file.\n Only the videos included in the config file are used to create this dataset.\n - [OPTIONAL] Use the function 'add_new_videos' at any stage of the project to add more videos to the project. + [OPTIONAL] Use the function 'add_new_videos' at any stage of the project to add more + videos to the project. Important differences to standard: - stores coordinates with numdigits as many digits - - creates + Parameter ---------- config : string @@ -139,17 +141,43 @@ def create_multianimaltraining_dataset( Alternatively the user can also give a list of shuffles (integers!). net_type: string - Type of networks. Currently resnet_50, resnet_101, and resnet_152, efficientnet-b0, efficientnet-b1, efficientnet-b2, efficientnet-b3, - efficientnet-b4, efficientnet-b5, and efficientnet-b6 as well as dlcrnet_ms5 are supported (not the MobileNets!). - See Lauer et al. 2021 https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1 + Type of networks. The options available depend on which engine is used. See + Lauer et al. 2021 https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1 + Currently supported options are: + TensorFlow + * ``resnet_50`` + * ``resnet_101`` + * ``resnet_152`` + * ``efficientnet-b0`` + * ``efficientnet-b1`` + * ``efficientnet-b2`` + * ``efficientnet-b3`` + * ``efficientnet-b4`` + * ``efficientnet-b5`` + * ``efficientnet-b6`` + PyTorch (call ``deeplabcut.pose_estimation.available_models()`` for a + complete list) + * ``resnet_50`` + * ``resnet_101`` + * ``dekr_w18`` + * ``dekr_w32`` + * ``dekr_w48`` + * ``top_down_resnet_50`` + * ``top_down_resnet_101`` + * ``top_down_hrnet_w18`` + * ``top_down_hrnet_w32`` + * ``top_down_hrnet_w48`` + * ``animaltokenpose_base`` numdigits: int, optional crop_size: tuple of int, optional + Only for the TensorFlow engine. Dimensions (width, height) of the crops for data augmentation. Default is 400x400. crop_sampling: str, optional + Only for the TensorFlow engine. Crop centers sampling method. Must be either: "uniform" (randomly over the image), "keypoints" (randomly over the annotated keypoints), @@ -158,6 +186,7 @@ def create_multianimaltraining_dataset( Default is "hybrid". paf_graph: list of lists, or "config" optional (default=None) + Only for the TensorFlow engine. If not None, overwrite the default complete graph. This is useful for advanced users who already know a good graph, or simply want to use a specific one. Note that, in that case, the data-driven selection procedure upon model evaluation will be skipped. @@ -172,9 +201,11 @@ def create_multianimaltraining_dataset( List of one or multiple lists containing test indexes. n_edges_threshold: int, optional (default=105) + Only for the TensorFlow engine. Number of edges above which the graph is automatically pruned. paf_graph_degree: int, optional (default=6) + Only for the TensorFlow engine. Degree of paf_graph when automatically pruning it (before training). userfeedback: bool, optional, default=True diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index d7f80b1d1d..92ae666504 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -813,40 +813,64 @@ def create_training_dataset( List of one or multiple lists containing test indexes. net_type: list, optional, default=None - Type of networks. Currently supported options are - - * ``resnet_50`` - * ``resnet_101`` - * ``resnet_152`` - * ``mobilenet_v2_1.0`` - * ``mobilenet_v2_0.75`` - * ``mobilenet_v2_0.5`` - * ``mobilenet_v2_0.35`` - * ``efficientnet-b0`` - * ``efficientnet-b1`` - * ``efficientnet-b2`` - * ``efficientnet-b3`` - * ``efficientnet-b4`` - * ``efficientnet-b5`` - * ``efficientnet-b6`` + Type of networks. The options available depend on which engine is used. + Currently supported options are: + TensorFlow + * ``resnet_50`` + * ``resnet_101`` + * ``resnet_152`` + * ``mobilenet_v2_1.0`` + * ``mobilenet_v2_0.75`` + * ``mobilenet_v2_0.5`` + * ``mobilenet_v2_0.35`` + * ``efficientnet-b0`` + * ``efficientnet-b1`` + * ``efficientnet-b2`` + * ``efficientnet-b3`` + * ``efficientnet-b4`` + * ``efficientnet-b5`` + * ``efficientnet-b6`` + PyTorch (call ``deeplabcut.pose_estimation.available_models()`` for a + complete list) + * ``resnet_50`` + * ``resnet_101`` + * ``hrnet_w18`` + * ``hrnet_w32`` + * ``hrnet_w48`` + * ``dekr_w18`` + * ``dekr_w32`` + * ``dekr_w48`` + * ``top_down_resnet_50`` + * ``top_down_resnet_101`` + * ``top_down_hrnet_w18`` + * ``top_down_hrnet_w32`` + * ``top_down_hrnet_w48`` + * ``animaltokenpose_base`` augmenter_type: string, optional, default=None - Type of augmenter. Currently supported augmenters are - - * ``default`` - * ``scalecrop`` - * ``imgaug`` - * ``tensorpack`` - * ``deterministic`` + Type of augmenter. The options available depend on which engine is used. + Currently supported options are: + TensorFlow + * ``default`` + * ``scalecrop`` + * ``imgaug`` + * ``tensorpack`` + * ``deterministic`` + PyTorch + * ``albumentations`` posecfg_template: string, optional, default=None + Only for the TensorFlow engine. Path to a ``pose_cfg.yaml`` file to use as a template for generating the new one for the current iteration. Useful if you would like to start with the same parameters a previous training iteration. None uses the default ``pose_cfg.yaml``. superanimal_name: string, optional, default="" - Specify the superanimal name is transfer learning with superanimal is desired. This makes sure the pose config template uses superanimal configs as template + Only for the TensorFlow engine. For the PyTorch engine, use the ``weight_init`` + parameter. + Specify the superanimal name is transfer learning with superanimal is desired. + This makes sure the pose config template uses superanimal configs as template. weight_init: WeightInitialisation, optional, default=None PyTorch engine only. Specify how model weights should be initialized. The @@ -901,6 +925,7 @@ def create_training_dataset( dlc_root_path = auxiliaryfunctions.get_deeplabcut_path() if superanimal_name != "": + # FIXME(niels): this is deprecated supermodels = parse_available_supermodels() posecfg_template = os.path.join( dlc_root_path, diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 6c44e0f14b..a74c4fee0c 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -156,6 +156,7 @@ def analyze_videos( videotype: str | None = None, shuffle: int = 1, trainingsetindex: int = 0, + save_as_csv: bool = False, snapshot_index: int | str | None = None, detector_snapshot_index: int | str | None = None, device: str | None = None, @@ -171,7 +172,7 @@ def analyze_videos( # TODO: - allow batch size greater than 1 - - other options such as save_as_csv + - other options missing options such as shelve - pass detector path or detector runner The index of the trained network is specified by parameters in the config file @@ -188,6 +189,7 @@ def analyze_videos( shuffle: An integer specifying the shuffle index of the training dataset used for training the network. trainingsetindex: Integer specifying which TrainingsetFraction to use. + save_as_csv: Saves the predictions in a .csv file. device: the device to use for video analysis destfolder: specifies the destination folder for analysis data. If ``None``, the path of the video is used. Note that for subsequent analysis this @@ -347,6 +349,7 @@ def analyze_videos( dlc_scorer=dlc_scorer, output_path=output_path, output_prefix=output_prefix, + save_as_csv=save_as_csv, ) results.append((str(video), df)) @@ -406,6 +409,7 @@ def create_df_from_prediction( cfg: dict, output_path: str | Path, output_prefix: str | Path, + save_as_csv: bool = False, ) -> pd.DataFrame: output_h5 = Path(output_path) / f"{output_prefix}.h5" output_pkl = Path(output_path) / f"{output_prefix}_full.pickle" @@ -448,6 +452,8 @@ def create_df_from_prediction( df = df.join(df_u, how="outer") df.to_hdf(output_h5, key="df_with_missing", format="table", mode="w") + if save_as_csv: + df.to_csv(output_h5.with_suffix(".csv")) return df From 8c39abb4d64ba57b816a42183da32ec33217af5b Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 12 Jun 2024 15:16:18 +0200 Subject: [PATCH 143/293] fixed failing tests due to bad path (#250) Co-authored-by: Mackenzie Mathis --- tests/pose_estimation_pytorch/config/test_make_pose_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py index f855f4a528..d7b0b44239 100644 --- a/tests/pose_estimation_pytorch/config/test_make_pose_config.py +++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py @@ -306,7 +306,7 @@ def test_make_dlcrnet_config( @pytest.mark.parametrize("bodyparts", [["nose", "eyes"], ["nose", "ear", "eye"]]) @pytest.mark.parametrize("identity", [False, True]) @pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]]) -@pytest.mark.parametrize("net_type", ["tokenpose_base"]) +@pytest.mark.parametrize("net_type", ["animaltokenpose_base"]) def test_make_tokenpose_config( individuals: list[str], bodyparts: list[str], From 6b94820d0bf0b49741dfff21c9bf7776762fb69e Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:05:09 +0200 Subject: [PATCH 144/293] niels - center keypoint computation (#255) --- .../config/dekr/dekr_w18.yaml | 10 ++++++++++ .../config/dekr/dekr_w32.yaml | 10 ++++++++++ .../config/dekr/dekr_w48.yaml | 10 ++++++++++ deeplabcut/pose_estimation_pytorch/data/dataset.py | 13 ++++++++----- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml index 6bf7132004..148c6b3f9b 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml @@ -52,4 +52,14 @@ model: num_blocks: 2 dilation_rate: 1 final_conv_kernel: 1 +runner: + optimizer: + type: AdamW + params: + lr: 0.0005 + scheduler: + type: LRListScheduler + params: + lr_list: [ [ 1e-4 ], [ 1e-5 ] ] + milestones: [ 90, 120 ] with_center_keypoints: true \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml index 684556d04e..79abd74821 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml @@ -52,4 +52,14 @@ model: num_blocks: 2 dilation_rate: 1 final_conv_kernel: 1 +runner: + optimizer: + type: AdamW + params: + lr: 0.0005 + scheduler: + type: LRListScheduler + params: + lr_list: [ [ 1e-4 ], [ 1e-5 ] ] + milestones: [ 90, 120 ] with_center_keypoints: true \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml index c0fb5d8b26..aa543d5587 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml @@ -52,4 +52,14 @@ model: num_blocks: 2 dilation_rate: 1 final_conv_kernel: 1 +runner: + optimizer: + type: AdamW + params: + lr: 0.0005 + scheduler: + type: LRListScheduler + params: + lr_list: [ [ 1e-4 ], [ 1e-5 ] ] + milestones: [ 90, 120 ] with_center_keypoints: true \ No newline at end of file diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py index ffd2ee1d26..cbb9b9726a 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dataset.py +++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py @@ -417,12 +417,15 @@ def add_center_keypoints(keypoints: np.ndarray) -> np.ndarray: keypoints_xy = keypoints.copy()[..., :2] keypoints_xy[keypoints[..., 2] <= 0] = np.nan - if np.sum(~np.isnan(keypoints_xy)) > 0: - centers[:, 0, :2] = np.nanmean(keypoints_xy, axis=1) - masked_centers = np.any(np.isnan(centers), axis=2) - centers[masked_centers, 2] = 0 - centers[~masked_centers, 2] = 2 + # only set centers for individuals where at least 1 bodypart is visible + vis_mask = (~np.isnan(keypoints_xy) > 0).all(axis=2).any(axis=1) + if np.any(vis_mask): + centers[vis_mask, 0, :2] = np.nanmean(keypoints_xy[vis_mask], axis=1) + + masked_centers = np.any(np.isnan(centers[:, 0, :2]), axis=1) + centers[masked_centers, 0, 2] = 0 + centers[~masked_centers, 0, 2] = 2 np.nan_to_num(centers, copy=False, nan=0) return np.concatenate((keypoints, centers), axis=1) From 08c34ee71af2c79e0eee1ce7868fd3cefcd5aeba Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Wed, 12 Jun 2024 17:11:28 +0200 Subject: [PATCH 145/293] Use napari's default plugin to simply view images (#256) --- deeplabcut/gui/tabs/label_frames.py | 2 +- deeplabcut/gui/widgets.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py index c7dd8e7a87..a9711cfb09 100644 --- a/deeplabcut/gui/tabs/label_frames.py +++ b/deeplabcut/gui/tabs/label_frames.py @@ -62,4 +62,4 @@ def label_frames(self): def check_labels(self): check_labels(self.root.config, visualizeindividuals=self.root.is_multianimal) labeled_images = (Path(self.root.config).parent / "labeled-data").rglob("*_labeled/*.png") - _ = launch_napari(labeled_images, stack=True) + _ = launch_napari(labeled_images, plugin="napari", stack=True) diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py index 16eb78fd83..af206baa6e 100644 --- a/deeplabcut/gui/widgets.py +++ b/deeplabcut/gui/widgets.py @@ -34,15 +34,16 @@ from deeplabcut.utils.auxfun_videos import VideoWriter -def launch_napari(files=None, stack=False): +def launch_napari(files=None, plugin="napari-deeplabcut", stack=False): viewer = napari.Viewer() - # Automatically activate the napari-deeplabcut plugin - for action in viewer.window.plugins_menu.actions(): - if "deeplabcut" in action.text(): - action.trigger() - break + if plugin == "napari-deeplabcut": + # Automatically activate the napari-deeplabcut plugin + for action in viewer.window.plugins_menu.actions(): + if "deeplabcut" in action.text(): + action.trigger() + break if files is not None: - viewer.open(files, plugin="napari-deeplabcut", stack=stack) + viewer.open(files, plugin=plugin, stack=stack) return viewer From 499ce0e1107f02781bd631b4fa8c8ee4a0dbb862 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Wed, 12 Jun 2024 11:16:57 -0400 Subject: [PATCH 146/293] Update window.py - missing space between "or and install" --- deeplabcut/gui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 4f6843a0c0..9179880b1d 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -191,7 +191,7 @@ def engine(self, e: Engine) -> None: " pip install 'deeplabcut[tf]'\n" " Apple Silicon:\n" " pip install 'deeplabcut[apple_mchips]'\n\n" - "Please switch back to the PyTorch engine to use DeepLabCut, or" + "Please switch back to the PyTorch engine to use DeepLabCut, or\n" "install TensorFlow." ) From b65f6ac6062a58a460c65ff7f4aee428ec1cbd3d Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Wed, 12 Jun 2024 11:18:28 -0400 Subject: [PATCH 147/293] Update window.py - minor typo --- deeplabcut/gui/window.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 9179880b1d..672b2c67d8 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -191,8 +191,7 @@ def engine(self, e: Engine) -> None: " pip install 'deeplabcut[tf]'\n" " Apple Silicon:\n" " pip install 'deeplabcut[apple_mchips]'\n\n" - "Please switch back to the PyTorch engine to use DeepLabCut, or\n" - "install TensorFlow." + "Please switch back to the PyTorch engine to use DeepLabCut, or install TensorFlow." ) msg.setWindowTitle("Info") From 8998e2bbc7da379b349ca249884fcbe3b49b7d3e Mon Sep 17 00:00:00 2001 From: Alexander Mathis Date: Wed, 12 Jun 2024 11:30:43 -0400 Subject: [PATCH 148/293] drop windows and 3.11 for the rapid testing ongoing --- .github/workflows/python-package.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4822155993..5308b17df9 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -13,8 +13,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - python-version: ["3.10", "3.11"] + os: [ubuntu-latest] #windows-latest + python-version: ["3.10"] include: - os: ubuntu-latest path: ~/.cache/pip From 8558d8ae6fd8206ef314e4f7b244d35275830a06 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:06:16 +0200 Subject: [PATCH 149/293] niels - detector variant for sa (#251) * set detector variant when using SuperAnimal models * improved information for fixmes --- .../config/make_pose_config.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index f8fe4f7f0c..2719027c32 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -114,7 +114,23 @@ def make_pytorch_pose_config( is_top_down = model_cfg.get("method", "BU").upper() == "TD" if is_top_down: - model_cfg = add_detector(configs_dir, model_cfg, len(individuals)) + # FIXME(niels): Currently, the variant used is the default MobileNet. In the + # future, we want users to be able to choose which detector variant they use + # when creating the configuration file, instead of having to update it once + # created + variant = None + if weight_init is not None: + # FIXME(niels): We only have fasterrcnn_resnet50_fpn_v2 SuperAnimal weights. + # This should be updated once more SuperAnimal detectors are uploaded, + # so that users can choose which pre-trained detector they use. + variant = "fasterrcnn_resnet50_fpn_v2" + + model_cfg = add_detector( + configs_dir, + model_cfg, + len(individuals), + variant=variant, + ) # add the default augmentations to the config aug_filename = "aug_top_down.yaml" if is_top_down else "aug_default.yaml" @@ -291,13 +307,20 @@ def create_backbone_with_paf_model( return model_config -def add_detector(configs_dir: Path, config: dict, num_individuals: int) -> dict: +def add_detector( + configs_dir: Path, + config: dict, + num_individuals: int, + variant: str | None = None +) -> dict: """Adds a detector to a model Args: configs_dir: path to the DeepLabCut "configs" directory config: model configuration to update num_individuals: the maximum number of individuals the model should detect + variant: the detector variant to use (if None, uses the variant set in the + default detector.yaml config) Returns: the model configuration with an added detector config @@ -308,6 +331,9 @@ def add_detector(configs_dir: Path, config: dict, num_individuals: int) -> dict: detector_config, num_individuals=num_individuals, ) + if variant is not None: + detector_config["detector"]["model"]["variant"] = variant + config = update_config(config, detector_config) return config From 0531560bf6bdd5a55e1f18b2712c960b708bc3e0 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:51:11 +0200 Subject: [PATCH 150/293] deal with no detectors when evaluating (#258) --- .../pose_estimation_pytorch/apis/evaluate.py | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index d69c92c7a7..1806d06a98 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -399,37 +399,49 @@ def evaluate_network( task=task, ) - detector_snapshot = None + detector_snapshots = [None] if task == Task.TOP_DOWN: if detector_snapshot_index is not None: - detector_snapshot = get_model_snapshots( - detector_snapshot_index, - loader.model_folder, - Task.DETECT, - )[0] + det_snapshots = get_model_snapshots( + "all", loader.model_folder, Task.DETECT + ) + if len(det_snapshots) == 0: + print( + "The detector_snapshot_index was set to " + f"{detector_snapshot_index} but no detector snapshots were " + f"found in {loader.model_folder}. Using ground truth " + "bounding boxes to compute metrics.\n" + "To analyze videos with a top-down model, you'll need to " + "train a detector!" + ) + else: + detector_snapshots = get_model_snapshots( + detector_snapshot_index, + loader.model_folder, + Task.DETECT, + ) else: - logging.info( - "Using GT bounding boxes to compute evaluation metrics" + print("Using GT bounding boxes to compute evaluation metrics") + + for detector_snapshot in detector_snapshots: + for snapshot in snapshots: + scorer = get_scorer_name( + cfg=cfg, + shuffle=shuffle, + train_fraction=loader.train_fraction, + snapshot_uid=get_scorer_uid(snapshot, detector_snapshot), + modelprefix=modelprefix, + ) + evaluate_snapshot( + loader=loader, + cfg=cfg, + scorer=scorer, + snapshot=snapshot, + transform=transform, + plotting=plotting, + show_errors=show_errors, + detector_snapshot=detector_snapshot, ) - - for snapshot in snapshots: - scorer = get_scorer_name( - cfg=cfg, - shuffle=shuffle, - train_fraction=loader.train_fraction, - snapshot_uid=get_scorer_uid(snapshot, detector_snapshot), - modelprefix=modelprefix, - ) - evaluate_snapshot( - loader=loader, - cfg=cfg, - scorer=scorer, - snapshot=snapshot, - transform=transform, - plotting=plotting, - show_errors=show_errors, - detector_snapshot=detector_snapshot, - ) def image_to_dlc_df_index(image: str) -> tuple[str, ...]: @@ -462,8 +474,8 @@ def save_evaluation_results( pcutoff: the pcutoff used to get the evaluation results """ if print_results: - logging.info(f"Evaluation results for {scores_path.name} (pcutoff: {pcutoff}):") - logging.info(df_scores.iloc[0]) + print(f"Evaluation results for {scores_path.name} (pcutoff: {pcutoff}):") + print(df_scores.iloc[0]) # Save scores file df_scores.to_csv(scores_path) From 59a12d45839f1b03c0e5e22febd8335bc94b2134 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:04:55 +0200 Subject: [PATCH 151/293] niels - add modelzoo checkpoints to gitignore (#260) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 515862966e..5e15e2e13f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ examples/.DS_Store *.ckpt snapshot-* +# Modelzoo checkpoints +deeplabcut/modelzoo/checkpoints/ + # Wandb files wandb/ From 0e9ea580b742204ebd73ad2e373d0693b81b0a49 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:07:15 +0200 Subject: [PATCH 152/293] niels - bug fix train fraction (#259) --- deeplabcut/core/inferenceutils.py | 1 + .../trainingsetmanipulation.py | 78 +++++++++++++++---- .../match_predictions_to_gt.py | 10 +-- .../test_trainingsetmanipulation.py | 40 ++++++++++ 4 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 tests/generate_training_dataset/test_trainingsetmanipulation.py diff --git a/deeplabcut/core/inferenceutils.py b/deeplabcut/core/inferenceutils.py index bcae54147a..b149fb685c 100644 --- a/deeplabcut/core/inferenceutils.py +++ b/deeplabcut/core/inferenceutils.py @@ -8,6 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations import heapq import itertools diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 92ae666504..9e33683d8f 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -1011,7 +1011,7 @@ def create_training_dataset( if engine == Engine.PYTORCH: if net_type.startswith("top_down_"): top_down = True - net_type = net_type[len("top_down_"):] + net_type = net_type[len("top_down_") :] augmenters = compat.get_available_aug_methods(engine) default_augmenter = augmenters[0] @@ -1062,9 +1062,7 @@ def create_training_dataset( if engine == Engine.PYTORCH: model_path = dlcparent_path else: - model_path = auxfun_models.check_for_weights( - net_type, Path(dlcparent_path) - ) + model_path = auxfun_models.check_for_weights(net_type, Path(dlcparent_path)) Shuffles = validate_shuffles(cfg, Shuffles, num_shuffles, userfeedback) @@ -1175,7 +1173,10 @@ def create_training_dataset( # Test files as well as pose_yaml files (containing training and testing information) ################################################################################# modelfoldername = auxiliaryfunctions.get_model_folder( - trainFraction, shuffle, cfg, engine=engine, + trainFraction, + shuffle, + cfg, + engine=engine, ) auxiliaryfunctions.attempt_to_make_folder( Path(config).parents[0] / modelfoldername, recursive=True @@ -1248,8 +1249,12 @@ def create_training_dataset( # Populate the pytorch config yaml file if engine == Engine.PYTORCH: - from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config - from deeplabcut.pose_estimation_pytorch.modelzoo.config import make_super_animal_finetune_config + from deeplabcut.pose_estimation_pytorch.config.make_pose_config import ( + make_pytorch_pose_config, + ) + from deeplabcut.pose_estimation_pytorch.modelzoo.config import ( + make_super_animal_finetune_config, + ) pose_cfg_path = path_train_config.replace( "pose_cfg.yaml", "pytorch_config.yaml" @@ -1302,6 +1307,7 @@ def get_existing_shuffle_indices( the indices of existing shuffles for this iteration of the project, sorted by ascending index """ + def is_valid_data_stem(stem: str) -> bool: if len(stem) == 0: return False @@ -1313,7 +1319,8 @@ def is_valid_data_stem(stem: str) -> bool: return False train_frac, idx = info return ( - train_frac.isdigit() and idx.isdigit() + train_frac.isdigit() + and idx.isdigit() and (train_fraction is None or int(train_frac) == int(100 * train_fraction)) ) @@ -1341,9 +1348,11 @@ def is_valid_data_stem(stem: str) -> bool: ) shuffle_indices = [ - idx for idx in shuffle_indices + idx + for idx in shuffle_indices if ( - project / auxiliaryfunctions.get_model_folder( + project + / auxiliaryfunctions.get_model_folder( trainFraction=train_fraction, shuffle=idx, cfg=cfg, @@ -1652,15 +1661,24 @@ def create_training_dataset_from_existing_split( if shuffles is not None: num_copies = len(shuffles) - train_indices = [shuffle.split.train_indices for _ in range(num_copies)] - test_indices = [shuffle.split.test_indices for _ in range(num_copies)] + # pad the train and test indices with -1s so the training fraction is exact + train_idx = list(shuffle.split.train_indices) + test_idx = list(shuffle.split.test_indices) + n_train, n_test = len(train_idx), len(test_idx) + + train_fraction = round(cfg["TrainingFraction"][from_trainsetindex], 2) + if round(n_train / (n_train + n_test), 2) != train_fraction: + train_padding, test_padding = _compute_padding(train_fraction, n_train, n_test) + train_idx = train_idx + (train_padding * [-1]) + test_idx = test_idx + (test_padding * [-1]) + return create_training_dataset( config=config, num_shuffles=num_shuffles, Shuffles=shuffles, userfeedback=userfeedback, - trainIndices=train_indices, - testIndices=test_indices, + trainIndices=[train_idx for _ in range(num_copies)], + testIndices=[test_idx for _ in range(num_copies)], net_type=net_type, augmenter_type=augmenter_type, posecfg_template=posecfg_template, @@ -1668,3 +1686,35 @@ def create_training_dataset_from_existing_split( weight_init=weight_init, engine=engine, ) + + +def _compute_padding( + train_fraction: float, + num_train: int, + num_test: int, +) -> tuple[int, int]: + """ + Computes the amount of padding to add to train/test indices such that + train_fraction = num_train / (num_train + num_test). + + Returns: + the number of padding indices to add to the train indices + the number of padding indices to add to the test indices + """ + if train_fraction <= 0 or train_fraction >= 1: + raise ValueError( + f"The training fraction must satisfy 0 < TrainingFraction < 1, but " + f"{train_fraction} was found" + ) + + base_images = 100 + train_step = int(round(round(train_fraction, 2) * base_images)) + test_step = base_images - train_step + + tgt_train = train_step + tgt_test = test_step + while tgt_train < num_train or tgt_test < num_test: + tgt_train += train_step + tgt_test += test_step + + return (tgt_train - num_train), (tgt_test - num_test) diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index 9fad9d80a2..babe368b39 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -65,13 +65,13 @@ def rmse_match_prediction_to_gt( return np.arange(num_idv) distance_matrix = np.full((len(valid_gt_indices), len(valid_pred_indices)), np.inf) - for g in valid_gt_indices: - gt_idv = gt_kpts[g] + for i, gt_idx in enumerate(valid_gt_indices): + gt_idv = gt_kpts[gt_idx] mask = gt_idv[:, 2] > 0 - for p in valid_pred_indices: - pred_idv = pred_kpts[p] + for j, pred_idx in enumerate(valid_pred_indices): + pred_idv = pred_kpts[pred_idx] d = (gt_idv[mask, :2] - pred_idv[mask, :2]) ** 2 - distance_matrix[g, p] = np.nanmean(d) + distance_matrix[i, j] = np.nanmean(d) _, col_ind = linear_sum_assignment(distance_matrix) # len == len(valid_gt_indices) diff --git a/tests/generate_training_dataset/test_trainingsetmanipulation.py b/tests/generate_training_dataset/test_trainingsetmanipulation.py new file mode 100644 index 0000000000..867d0ea0e4 --- /dev/null +++ b/tests/generate_training_dataset/test_trainingsetmanipulation.py @@ -0,0 +1,40 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Tests for deeplabcut/generate_training_dataset/metadata.py""" +from __future__ import annotations + +import pytest + +import deeplabcut.generate_training_dataset.trainingsetmanipulation as trainingsetmanipulation + + +@pytest.mark.parametrize( + "train_fraction", [1, 2, 5, 17, 24, 29, 34, 47, 50, 53, 61, 68, 75, 90, 95, 97, 99] +) +@pytest.mark.parametrize("n_train", [1, 2, 3, 5, 7, 11, 37, 62, 153]) +@pytest.mark.parametrize("n_test", [1, 2, 3, 5, 7, 13, 19, 85, 112]) +def test_compute_padding(train_fraction: int, n_train: int, n_test: int) -> None: + """ + More complete tests can be run with: + "train_fraction": list(range(1, 100)) + "n_train": list(range(1, 200)) + "n_test": list(range(1, 200)) + + This was done locally, but as it's many many tests to run a subset was selected here + """ + train_frac = train_fraction / 100 + train_pad, test_pad = trainingsetmanipulation._compute_padding( + train_frac, n_train, n_test + ) + print() + print(train_fraction, n_train, n_test, train_pad, test_pad) + frac = round((n_train + train_pad)/(n_train + n_test + train_pad + test_pad), 2) + assert train_frac == frac From 8682d78edc277fbe027ae0e1846786ab65fbd940 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Thu, 13 Jun 2024 10:44:36 -0400 Subject: [PATCH 153/293] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e8c8d56b5e..210fbdc192 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ setuptools.setup( name="deeplabcut", - version="2.3.10", + version="3.0.0rc1", author="A. & M.W. Mathis Labs", author_email="alexander@deeplabcut.org", description="Markerless pose-estimation of user-defined features with deep learning", From 0f2ea8fd93fa94260f5af5e613bb2404265e7000 Mon Sep 17 00:00:00 2001 From: shaokai Date: Thu, 13 Jun 2024 17:38:26 +0200 Subject: [PATCH 154/293] some adaptation needed in generalized data converter for benchmark (#248) * some adaptation needed in generalized data converter for benchmark * allows evaluate_network to work on memory_replay model * Added missed annotation * reverted some changes * isort + black * style fix * style fix * style fix --------- Co-authored-by: Niels Poulsen --- .../memory_replay_example.py | 2 +- .../datasets/base_dlc.py | 3 +- .../datasets/coco.py | 7 +++-- .../datasets/materialize.py | 28 ++++++++++++++----- .../pose_estimation_pytorch/apis/evaluate.py | 19 +++++++++++++ .../pose_estimation_pytorch/apis/utils.py | 26 +++++++++-------- .../pose_estimation_pytorch/data/base.py | 11 ++++++-- deeplabcut/utils/auxiliaryfunctions.py | 1 + 8 files changed, 72 insertions(+), 25 deletions(-) diff --git a/benchmark_superanimal/memory_replay_example.py b/benchmark_superanimal/memory_replay_example.py index 2db0a01759..58a1ac6eff 100644 --- a/benchmark_superanimal/memory_replay_example.py +++ b/benchmark_superanimal/memory_replay_example.py @@ -30,6 +30,7 @@ model_name, ) + # keypoint matching creates a memory_replay folder in the root. The conversion table can be read from there conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" @@ -58,4 +59,3 @@ # passing pose_threshold controls the behavior of memory replay. We discard predictions that are lower than the threshold deeplabcut.train_network(config_path, shuffle=shuffle, device=device, pose_threshold = 0.1) - diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py index cc7edcece4..d816449e4a 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py @@ -44,7 +44,8 @@ def __init__(self, proj_root, dataset_name, shuffle=1, modelprefix=""): scorer = cfg["scorer"] datasets_folder = os.path.join( - self.proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg), + self.proj_root, + auxiliaryfunctions.GetTrainingSetFolder(cfg), ) self.datasets_folder = datasets_folder diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py index 2472f37e60..7350a5fec0 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py @@ -20,6 +20,7 @@ def __init__( self, proj_root, dataset_name, + train_filename="train.json", shuffle=None, ): @@ -32,9 +33,11 @@ def __init__( self.annotations_by_category = {} self.train_json_obj = ( - self._load_json("train.json") + self._load_json(train_filename) if shuffle is None - else self._load_json(f"train_shuffle{shuffle}.json") + else self._load_json( + train_filename.replace(".json", f"_shuffle{shuffle}.json") + ) ) self.test_json_obj = ( self._load_json("test.json") diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py index a066e75fe4..dd5224a7a1 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -180,6 +180,7 @@ def _generic2madlc( meta, deepcopy=False, full_image_path=True, + append_image_id=True, ): """ Within DeepLabCut, if we don't explicitly call deeplabcut.create_traindataset(), the train and test split might just be arbitrarily messed up. So here we need to calculate train and test indices to @@ -257,7 +258,10 @@ def _generic2madlc( file_name = image["file_name"] image_name = file_name.split(os.sep)[-1] pre, suffix = image_name.split(".") - dest_image_name = f"{pre}_{image_id}.{suffix}" + if append_image_id == True: + dest_image_name = f"{pre}_{image_id}.{suffix}" + else: + dest_image_name = image_name # the generic data has original pointers to images in the original folders # Here, we have to change the image name and location of these to fit corresponding framework's convention @@ -368,7 +372,10 @@ def _filter(image): video_folder = file_name.split(os.sep)[-2] pre, suffix = image_name.split(".") image_id = image["id"] - ret = f"{pre}_{image_id}.{suffix}" + if append_image_id: + ret = f"{pre}_{image_id}.{suffix}" + else: + ret = image_name parent_trace[ret] = video_folder return ret @@ -427,6 +434,7 @@ def _generic2sdlc( meta, deepcopy=False, full_image_path=True, + append_image_id=True, ): assert full_image_path, "DLC wants full image path" @@ -490,7 +498,11 @@ def _generic2sdlc( image_name = file_name.split(os.sep)[-1] pre, suffix = image_name.split(".") - dest_image_name = f"{pre}_{image_id}.{suffix}" + + if append_image_id == True: + dest_image_name = f"{pre}_{image_id}.{suffix}" + else: + dest_image_name = image_name # the generic data has original pointers to images in the original folders # Here, we have to change the image name and location of these to fit corresponding framework's convention @@ -573,6 +585,7 @@ def _generic2sdlc( # I could have done it in a more elegant way if I could modify part of DLC source code, but for backward compatibility reasons, overriding documentation is smarter config_path = os.path.join(proj_root, "config.yaml") + cfg = auxiliaryfunctions.read_config(config_path) train_folder = os.path.join(proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg)) @@ -593,7 +606,10 @@ def _filter(image): video_folder = file_name.split(os.sep)[-2] pre, suffix = image_name.split(".") image_id = image["id"] - ret = f"{pre}_{image_id}.{suffix}" + if append_image_id: + ret = f"{pre}_{image_id}.{suffix}" + else: + ret = image_name parent_trace[ret] = video_folder @@ -634,9 +650,7 @@ def _filter(image): print(f"overwriting data file {datafilename}") - sio.savemat( - os.path.join(cfg["project_path"], datafilename), {"dataset": MatlabData} - ) + sio.savemat(os.path.join(datafilename), {"dataset": MatlabData}) def _generic2coco( diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index 1806d06a98..c11a69ad7f 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -30,6 +30,7 @@ get_scorer_uid, ) from deeplabcut.pose_estimation_pytorch.data import DLCLoader, Loader +from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( compute_identity_scores, get_scores, @@ -123,7 +124,15 @@ def evaluate( mode=mode, detector_runner=detector_runner, ) + if "weight_init" in loader.model_cfg["train_settings"]: + weight_init_cfg = loader.model_cfg["train_settings"]["weight_init"] + if weight_init_cfg["memory_replay"]: + conversion_array = weight_init_cfg["conversion_array"] + for filename, pred in predictions.items(): + pred["bodyparts"] = pred["bodyparts"][:, conversion_array] + poses = {filename: pred["bodyparts"] for filename, pred in predictions.items()} + gt_keypoints = loader.ground_truth_keypoints(mode) if parameters.max_num_animals > 1: poses = pair_predicted_individuals_with_gt(poses, gt_keypoints) @@ -197,6 +206,16 @@ def evaluate_snapshot( """ pose_task = Task(loader.model_cfg.get("method", "bu")) parameters = loader.get_dataset_parameters() + + if "weight_init" in loader.model_cfg["train_settings"]: + weight_init_cfg = loader.model_cfg["train_settings"]["weight_init"] + if weight_init_cfg["memory_replay"]: + conversion_array = weight_init_cfg["conversion_array"] + bodyparts = list(np.array(parameters.bodyparts)[conversion_array]) + parameters = PoseDatasetParameters( + bodyparts, parameters.unique_bpts, parameters.individuals + ) + pcutoff = cfg.get("pcutoff") detector_path = None diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index b9e3464284..c7d7d5f116 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -45,14 +45,14 @@ ) from deeplabcut.pose_estimation_pytorch.task import Task from deeplabcut.pose_estimation_pytorch.utils import resolve_device -from deeplabcut.utils import auxiliaryfunctions, auxfun_videos +from deeplabcut.utils import auxfun_videos, auxiliaryfunctions def parse_snapshot_index_for_analysis( cfg: dict, model_cfg: dict, snapshot_index: int | str | None, - detector_snapshot_index: int | str | None + detector_snapshot_index: int | str | None, ) -> tuple[int, int | None]: """Gets the index of the snapshots to use for data analysis (e.g. video analysis) @@ -105,10 +105,7 @@ def parse_snapshot_index_for_analysis( def return_train_network_path( - config: str, - shuffle: int = 1, - trainingsetindex: int = 0, - modelprefix: str = "" + config: str, shuffle: int = 1, trainingsetindex: int = 0, modelprefix: str = "" ) -> tuple[Path, Path, Path]: """ Args: @@ -137,7 +134,9 @@ def return_train_network_path( def get_model_snapshots( - index: int | str, model_folder: Path, task: Task, + index: int | str, + model_folder: Path, + task: Task, ) -> list[Snapshot]: """ Args: @@ -284,8 +283,12 @@ def list_videos_in_folder( else: video_suffixes = [video_type] - video_suffixes = [s if s.startswith(".") else "." + s for s in video_suffixes] - videos += [file for file in video_path.iterdir() if file.suffix in video_suffixes] + video_suffixes = [ + s if s.startswith(".") else "." + s for s in video_suffixes + ] + videos += [ + file for file in video_path.iterdir() if file.suffix in video_suffixes + ] else: assert ( video_path.exists() @@ -373,7 +376,7 @@ def get_inference_runners( with_identity: bool = False, transform: A.BaseCompose | None = None, detector_path: str | Path | None = None, - detector_transform: A.BaseCompose | None = None + detector_transform: A.BaseCompose | None = None, ) -> tuple[InferenceRunner, InferenceRunner | None]: """Builds the runners for pose estimation @@ -406,7 +409,8 @@ def get_inference_runners( detector_runner = None if pose_task == Task.BOTTOM_UP: pose_preprocessor = build_bottom_up_preprocessor( - color_mode=model_config["data"]["colormode"], transform=transform, + color_mode=model_config["data"]["colormode"], + transform=transform, ) pose_postprocessor = build_bottom_up_postprocessor( max_individuals=max_individuals, diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py index 24eac763ac..346eaca6b0 100644 --- a/deeplabcut/pose_estimation_pytorch/data/base.py +++ b/deeplabcut/pose_estimation_pytorch/data/base.py @@ -118,6 +118,12 @@ def ground_truth_keypoints( individuals = parameters.individuals num_bodyparts = parameters.num_joints + if "weight_init" in self.model_cfg["train_settings"]: + weight_init_cfg = self.model_cfg["train_settings"]["weight_init"] + if weight_init_cfg["memory_replay"]: + conversion_array = weight_init_cfg["conversion_array"] + num_bodyparts = len(conversion_array) + if mode not in self._loaded_data: self._loaded_data[mode] = self.load_data(mode) data = self._loaded_data[mode] @@ -234,9 +240,8 @@ def filter_annotations(annotations: list[dict], task: Task) -> list[dict]: filtered_annotations = [] for annotation in annotations: keypoints = annotation["keypoints"].reshape(-1, 3) - if ( - task in (Task.DETECT, Task.TOP_DOWN) and - (annotation["bbox"][2] <= 0 or annotation["bbox"][3] <= 0) + if task in (Task.DETECT, Task.TOP_DOWN) and ( + annotation["bbox"][2] <= 0 or annotation["bbox"][3] <= 0 ): continue elif task != Task.DETECT and np.all(keypoints[:, :2] <= 0): diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 0136bd5e78..634544e47a 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -544,6 +544,7 @@ def get_data_and_metadata_filenames(trainingsetfolder, trainFraction, shuffle, c + str(shuffle) + ".mat", ) + return datafn, metadatafn From fb05e2845b98944ecdb5c07f4fabe794bc6244ec Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Thu, 13 Jun 2024 11:49:35 -0400 Subject: [PATCH 155/293] Update python-package.yml - drop 3.9 --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 405aa402cd..04a4580456 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-13, windows-latest] - python-version: [3.9, "3.10"] + python-version: ["3.10"] include: - os: ubuntu-latest path: ~/.cache/pip From a4bcbb996f2ea9204408053da23c474f557432bc Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 14 Jun 2024 13:52:25 +0200 Subject: [PATCH 156/293] niels - COCO mAP tests (#261) --- deeplabcut/core/inferenceutils.py | 2 +- .../pose_estimation_pytorch/data/utils.py | 4 +- .../metrics/scoring.py | 1 + .../inferenceutils/test_map_computation.py | 433 ++++++++++++++++++ 4 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 tests/core/inferenceutils/test_map_computation.py diff --git a/deeplabcut/core/inferenceutils.py b/deeplabcut/core/inferenceutils.py index b149fb685c..64f94f5b00 100644 --- a/deeplabcut/core/inferenceutils.py +++ b/deeplabcut/core/inferenceutils.py @@ -1006,7 +1006,7 @@ def match_assemblies( matched to ground truth assemblies. """ # Only consider assemblies of at least two keypoints - predictions = [a for a in predictions if len(a) > 1] # TODO: SHOULD WE???? + predictions = [a for a in predictions if len(a) > 1] ground_truth = [a for a in ground_truth if len(a) > 1] num_ground_truth = len(ground_truth) diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py index 34e9c56616..756cfaecaa 100644 --- a/deeplabcut/pose_estimation_pytorch/data/utils.py +++ b/deeplabcut/pose_estimation_pytorch/data/utils.py @@ -59,8 +59,8 @@ def bbox_from_keypoints( keypoints = np.expand_dims(keypoints, axis=0) bboxes = np.full((keypoints.shape[0], 4), np.nan) - bboxes[:, :2] = np.floor(np.nanmin(keypoints[..., :2], axis=1)) - margin # X1, Y1 - bboxes[:, 2:4] = np.ceil(np.nanmax(keypoints[..., :2], axis=1)) + margin # X2, Y2 + bboxes[:, :2] = np.nanmin(keypoints[..., :2], axis=1) - margin # X1, Y1 + bboxes[:, 2:4] = np.nanmax(keypoints[..., :2], axis=1) + margin # X2, Y2 bboxes = np.clip( bboxes, a_min=[0, 0, 0, 0], diff --git a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py index 38c3c197d4..360601b228 100644 --- a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py @@ -216,6 +216,7 @@ def compute_oks( oks_sigma, margin=margin, symmetric_kpts=symmetric_kpts, + greedy_matching=True, with_tqdm=False, ) diff --git a/tests/core/inferenceutils/test_map_computation.py b/tests/core/inferenceutils/test_map_computation.py new file mode 100644 index 0000000000..2f96993d49 --- /dev/null +++ b/tests/core/inferenceutils/test_map_computation.py @@ -0,0 +1,433 @@ +"""Tests mAP computation from inferenceutils""" + +from __future__ import annotations + +import numpy as np +import pytest + +from deeplabcut.core import inferenceutils +from deeplabcut.pose_estimation_pytorch.data.utils import bbox_from_keypoints + + +@pytest.mark.parametrize( + "ground_truth", + [ + { + "img0": [ + [ + [100.0, 10.0, 2], + [150.0, 15.0, 2], + [202.0, 20.0, 2], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 2], + [140.0, 17.0, 2], + [192.0, 22.0, 2], + ], + ], + }, + ], +) +@pytest.mark.parametrize( + "predictions", + [ + { + "img0": [ + [ + [100.0, 10.0, 0.9], + [150.0, 15.0, 0.7], + [202.0, 20.0, 0.8], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 0.9], + [140.0, 17.0, 0.7], + [192.0, 22.0, 0.8], + ], + [ + [97.0, 11.0, 0.5], + [148.0, 14.0, 0.2], + [202.0, 21.0, 0.3], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 0.9], + [np.nan, np.nan, 0.0], + [192.0, 22.0, 0.8], + ], + [ + [97.0, 11.0, 0.5], + [148.0, 14.0, 0.2], + [202.0, 21.0, 0.3], + ], + ], + }, + ], +) +def test_map_single_image_simple(ground_truth: dict, predictions: dict): + gt = {k: np.array(v) for k, v in ground_truth.items()} + pred = {k: np.array(v) for k, v in predictions.items()} + _evaluate(gt, pred) + + +@pytest.mark.parametrize( + "ground_truth", + [ + { + "img0": [ + [ + [100.0, 10.0, 2], + [150.0, 15.0, 2], + [202.0, 20.0, 2], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 2], + [140.0, 17.0, 2], + [192.0, 22.0, 2], + ], + [ + [726.0, 325.0, 2], + [326.0, 236.0, 2], + [457.0, 832.0, 2], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 2], + [140.0, 17.0, 2], + [192.0, 22.0, 2], + ], + [ + [726.0, 325.0, 2], + [0.0, 0.0, 0], + [457.0, 832.0, 2], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 2], + [140.0, 17.0, 2], + [192.0, 22.0, 2], + ], + [ + [726.0, 325.0, 2], + [0, 0, 0], + [457.0, 832.0, 2], + ], + [ + [452.0, 321.0, 2], + [213.0, 387.0, 2], + [213.0, 832.0, 2], + ], + [ + [253.0, 238.0, 2], + [213.0, 238.0, 2], + [457.0, 832.0, 2], + ], + ], + }, + ], +) +def test_map_single_image_random_errors(ground_truth: dict): + rng = np.random.default_rng(seed=0) + + gt = {k: np.array(v) for k, v in ground_truth.items()} + pred = {} + for k, gt_kpts in gt.items(): + num_idv, num_bpt = gt_kpts.shape[:2] + + error = rng.integers(low=-30, high=30, size=(num_idv, num_bpt, 2)) + scores = rng.random(size=(num_idv, num_bpt)) + + pred[k] = np.zeros(shape=(num_idv, num_bpt, 3)) + pred[k][..., :2] = np.clip(gt_kpts[..., :2] + error, 0, 1024) + pred[k][..., 2] = scores + + _evaluate(gt, pred) + + +@pytest.mark.parametrize("num_images", [1, 2, 5, 10]) +@pytest.mark.parametrize("num_joints", [2, 5, 8, 20]) +@pytest.mark.parametrize("max_error", [1, 2, 5, 20, 40]) +def test_random_map_computation(num_images, num_joints, max_error): + rng = np.random.default_rng(seed=0) + + num_individuals = rng.integers(low=0, high=20, size=(num_images, 2)) + max_idv = num_individuals.max(initial=0) + + gt = {} + pred = {} + for i, (gt_idv, pred_idv) in enumerate(num_individuals): + # padding needed as we then stack + gt_kpts = np.zeros((max_idv, num_joints, 3)) + pred_kpts = -np.ones((max_idv, num_joints, 3)) + + gt_kpts[:gt_idv] = 2 * np.ones((gt_idv, num_joints, 3)) + gt_kpts[:gt_idv, :, :2] = rng.integers( + low=0, high=1024, size=(gt_idv, num_joints, 2) + ) + gt[f"img_{i}"] = gt_kpts + + # set scores + pred_kpts[:pred_idv, :, 2] = rng.random(size=(pred_idv, num_joints)) + + # predictions that are ground truth + error + matched = min(gt_idv, pred_idv) + if matched > 0: + error = rng.integers( + low=-max_error, high=max_error, size=(matched, num_joints, 2) + ) + matched_pred = gt_kpts[:matched, :, :2] + error + pred_kpts[:matched, :, :2] = np.clip(matched_pred, 0, 1024) + + # random predictions + unmatched = pred_idv - matched + if unmatched > 0: + pred_kpts[matched:pred_idv, :, :2] = rng.integers( + low=0, high=1024, size=(unmatched, num_joints, 2) + ) + + pred[f"img_{i}"] = pred_kpts + + _evaluate(gt, pred) + + +@pytest.mark.parametrize("num_images", [1, 2, 5, 10]) +@pytest.mark.parametrize("num_joints", [2, 5, 8, 20]) +@pytest.mark.parametrize("max_error", [1, 2, 5, 20, 40]) +def test_random_map_computation_with_missing_kpts(num_images, num_joints, max_error): + rng = np.random.default_rng(seed=0) + + num_individuals = rng.integers(low=0, high=20, size=(num_images, 2)) + max_idv = num_individuals.max(initial=0) + + gt = {} + pred = {} + for i, (gt_idv, pred_idv) in enumerate(num_individuals): + # padding needed as we then stack + gt_kpts = np.zeros((max_idv, num_joints, 3)) + pred_kpts = -np.ones((max_idv, num_joints, 3)) + + gt_kpts[:gt_idv] = 2 * np.ones((gt_idv, num_joints, 3)) + gt_kpts[:gt_idv, :, :2] = rng.integers( + low=0, high=1024, size=(gt_idv, num_joints, 2) + ) + gt[f"img_{i}"] = gt_kpts + + # drop some ground truth keypoints + gt_vis_mask = rng.random(size=(max_idv, num_joints)) < 0.2 + gt_kpts[gt_vis_mask, 2] = 0 + + # set scores + pred_kpts[:pred_idv, :, 2] = rng.random(size=(pred_idv, num_joints)) + + # predictions that are ground truth + error + matched = min(gt_idv, pred_idv) + if matched > 0: + error = rng.integers( + low=-max_error, high=max_error, size=(matched, num_joints, 2) + ) + matched_pred = gt_kpts[:matched, :, :2] + error + pred_kpts[:matched, :, :2] = np.clip(matched_pred, 0, 1024) + + # random predictions + unmatched = pred_idv - matched + if unmatched > 0: + pred_kpts[matched:pred_idv, :, :2] = rng.integers( + low=0, high=1024, size=(unmatched, num_joints, 2) + ) + + pred[f"img_{i}"] = pred_kpts + + _evaluate(gt, pred) + + +def _evaluate(gt: dict[str, np.ndarray], pred: dict[str, np.ndarray]): + for k, v in gt.items(): + print(20 * "-") + print(k) + print("GT") + print(v) + print("PR") + print(pred[k]) + + gt_assemblies = _to_assemblies(gt, ground_truth=True) + pred_assemblies = _to_assemblies(pred, ground_truth=False) + oks = inferenceutils.evaluate_assembly_greedy( + assemblies_gt=gt_assemblies, + assemblies_pred=pred_assemblies, + oks_sigma=0.1, + oks_thresholds=np.linspace(0.5, 0.95, 10), + margin=0.0, + symmetric_kpts=None, + ) + + num_joints = gt[list(gt.keys())[0]].shape[1] + coco_gt = _to_coco_ground_truth(gt, num_joints, bbox_margin=0) + coco_pred = _to_coco_predictions(coco_gt, pred, bbox_margin=0) + coco_oks = eval_coco(coco_gt, coco_pred, num_joints) + print(20 * "-") + print(f"dlc mAP:") + for k, v in oks.items(): + print(k) + print(v) + print() + print(20 * "-") + print(f"pycocotools mAP: {coco_oks}") + print() + assert oks["mAP"] == coco_oks + + +def _to_assemblies( + data: dict[str, np.ndarray], ground_truth: bool, +) -> dict[str, list[inferenceutils.Assembly]]: + images = list(data.keys()) + raw_data = np.stack([data[i] for i in images], axis=0) + + # mask not visible entries + mask = raw_data[..., 2] <= 0 + raw_data[mask] = np.nan + + # set the "score" to 1 for ground truth + if ground_truth: + raw_data[~mask, 2] = 1 + + return { + images[i]: assembly + for i, assembly in inferenceutils._parse_ground_truth_data(raw_data).items() + } + + +def _to_coco_ground_truth( + data: dict[str, np.ndarray], + num_joints: int, + bbox_margin: int = 0, + image_size: tuple[int, int] = (1024, 1024), +) -> dict[str, list[dict]]: + w, h = image_size + anns, images = [], [] + for path, image_keypoints in data.items(): + id_ = len(images) + 1 + images.append(dict(id=id_, file_name=path, width=w, height=h)) + + assert image_keypoints.shape[1] == num_joints + for idv_id, kpts in enumerate(image_keypoints): + visible = kpts[:, 2] > 0 + num_keypoints = visible.sum() + + if num_keypoints > 1: + bbox = bbox_from_keypoints( + keypoints=kpts, + image_h=h, + image_w=w, + margin=bbox_margin, + ) + area = bbox[2].item() * bbox[3].item() + anns.append( + { + "id": len(anns) + 1, + "image_id": id_, + "category_id": 1, + "area": area, + "bbox": bbox, + "keypoints": kpts.reshape(-1).tolist(), + "iscrowd": 0, + "num_keypoints": num_keypoints, + } + ) + + keypoints = [f"bpt{i}" for i in range(num_joints)] + category = dict(id=1, name="animal", supercategory="animal", keypoints=keypoints) + return {"annotations": anns, "categories": [category], "images": images} + + +def _to_coco_predictions( + ground_truth: dict, + predictions: dict[str, np.ndarray], + bbox_margin: int = 0, + image_size: tuple[int, int] = (1024, 1024), +) -> list[dict]: + w, h = image_size + num_joints = len(ground_truth["categories"][0]["keypoints"]) + path_to_id = {img["file_name"]: img["id"] for img in ground_truth["images"]} + + coco_predictions = [] + for path, image_keypoints in predictions.items(): + assert image_keypoints.shape[1] == num_joints + + img_id = path_to_id[path] + valid_predictions = [ + kpt for kpt in image_keypoints if np.any(np.all(~np.isnan(kpt), axis=-1)) + ] + for kpts in valid_predictions: + score = float(np.nanmean(kpts[:, 2]).item()) + kpts = kpts.copy() + kpts[:, 2] = 2 + + # NaN predictions to infinity + kpts[np.isnan(kpts)] = np.inf + + bbox = bbox_from_keypoints( + keypoints=kpts, + image_h=h, + image_w=w, + margin=bbox_margin, + ) + area = bbox[2].item() * bbox[3].item() + coco_predictions.append( + { + "image_id": img_id, + "category_id": 1, + "keypoints": kpts.reshape(-1).tolist(), + "bbox": bbox, + "area": area, + "score": score, + } + ) + + return coco_predictions + + +def eval_coco( + ground_truth: dict, + predictions: list[dict], + num_joints: int, +) -> float | None: + try: + from pycocotools.coco import COCO + from pycocotools.cocoeval import COCOeval + + coco = COCO() + coco.dataset["annotations"] = ground_truth["annotations"] + coco.dataset["categories"] = ground_truth["categories"] + coco.dataset["images"] = ground_truth["images"] + coco.createIndex() + + coco_det = coco.loadRes(predictions) + coco_eval = COCOeval(coco, coco_det, iouType="keypoints") + coco_eval.params.kpt_oks_sigmas = np.array(num_joints * [0.1]) + coco_eval.evaluate() + coco_eval.accumulate() + coco_eval.summarize() + return float(coco_eval.stats[0]) + + except ModuleNotFoundError as err: + print(f"pycocotools is not installed") From 28eb306653cafaf6d31d6bf65c871e86e86dfb38 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 14 Jun 2024 13:58:53 +0200 Subject: [PATCH 157/293] updated default value for draw skeleton checkbox (#263) --- deeplabcut/gui/tabs/create_videos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index 7f0300d216..1fa3dac33d 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -134,7 +134,7 @@ def _generate_layout_video_parameters(self, layout): # Skeleton self.draw_skeleton_checkbox = QtWidgets.QCheckBox("Draw skeleton") - self.draw_skeleton_checkbox.setCheckState(Qt.Checked) + self.draw_skeleton_checkbox.setCheckState(Qt.Unchecked) self.draw_skeleton_checkbox.stateChanged.connect(self.update_draw_skeleton) tmp_layout.addWidget(self.draw_skeleton_checkbox) From 8bd646d39d67ce485918a21ec4e69e8c8c356059 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 14 Jun 2024 17:24:13 +0200 Subject: [PATCH 158/293] cleaned benchmarking scripts (#264) --- benchmark/README.md | 122 ------ benchmark/benchmark_create_shuffle.py | 107 ------ benchmark/benchmark_lightning_pose.py | 142 ------- benchmark/benchmark_madlc.py | 160 -------- benchmark/benchmark_run_experiments.py | 301 --------------- benchmark/benchmark_train.py | 283 -------------- benchmark/coco/README.md | 112 ------ benchmark/coco/analyze_video.py | 104 ----- benchmark/coco/evaluate.py | 154 -------- benchmark/coco/make_config.py | 120 ------ benchmark/coco/train.py | 134 ------- benchmark/create_train_test_splits.py | 103 ----- benchmark/lightning_pose_ood_evaluation.py | 146 ------- benchmark/lightning_pose_tf_eval.py | 320 ---------------- benchmark/madlc_evaluation.py | 162 -------- benchmark/madlc_test_inference.py | 213 ----------- benchmark/projects.py | 46 --- benchmark/utils.py | 361 ------------------ benchmark/utils_augmentation.py | 131 ------- benchmark/utils_models.py | 174 --------- .../memory_replay_example.py | 61 --- benchmark_superanimal/train.py | 325 ---------------- .../SUPERANIMAL}/eval_zeroshot.py | 10 + .../SUPERANIMAL}/keypoint_space_conversion.py | 10 + examples/SUPERANIMAL/memory_replay_example.py | 75 ++++ .../superanimal_image_inference.py | 5 +- .../SUPERANIMAL}/video_adapt_example.py | 11 + 27 files changed, 107 insertions(+), 3785 deletions(-) delete mode 100644 benchmark/README.md delete mode 100644 benchmark/benchmark_create_shuffle.py delete mode 100644 benchmark/benchmark_lightning_pose.py delete mode 100644 benchmark/benchmark_madlc.py delete mode 100644 benchmark/benchmark_run_experiments.py delete mode 100644 benchmark/benchmark_train.py delete mode 100644 benchmark/coco/README.md delete mode 100644 benchmark/coco/analyze_video.py delete mode 100644 benchmark/coco/evaluate.py delete mode 100644 benchmark/coco/make_config.py delete mode 100644 benchmark/coco/train.py delete mode 100644 benchmark/create_train_test_splits.py delete mode 100644 benchmark/lightning_pose_ood_evaluation.py delete mode 100644 benchmark/lightning_pose_tf_eval.py delete mode 100644 benchmark/madlc_evaluation.py delete mode 100644 benchmark/madlc_test_inference.py delete mode 100644 benchmark/projects.py delete mode 100644 benchmark/utils.py delete mode 100644 benchmark/utils_augmentation.py delete mode 100644 benchmark/utils_models.py delete mode 100644 benchmark_superanimal/memory_replay_example.py delete mode 100644 benchmark_superanimal/train.py rename {benchmark_superanimal => examples/SUPERANIMAL}/eval_zeroshot.py (91%) rename {benchmark_superanimal => examples/SUPERANIMAL}/keypoint_space_conversion.py (75%) create mode 100644 examples/SUPERANIMAL/memory_replay_example.py rename {benchmark_superanimal => examples/SUPERANIMAL}/superanimal_image_inference.py (87%) rename {benchmark_superanimal => examples/SUPERANIMAL}/video_adapt_example.py (58%) diff --git a/benchmark/README.md b/benchmark/README.md deleted file mode 100644 index 003d79768e..0000000000 --- a/benchmark/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# Benchmarking with DeepLabCut - -This folder contains a few scripts that can be very useful when benchmarking datasets -with DeepLabCut. But first, some definitions: - -**Shuffle:** As always in DeepLabCut, a shuffle is an experiment. It has an index, and - -**Split:** A split (or data split) is a partition of labeled images into a train -and test set. Each shuffle has a split. - -## Creating Data Splits - -Data splits can be created with the `create_train_test_splits.py` file. This script can -create an arbitrary number of train/test splits for a project (or group of projects), -which can be very useful for -[k-fold cross-validation](https://en.wikipedia.org/wiki/Cross-validation_(statistics)). -The main method has the following signature: - -```python -def main( - projects: list[Project], - seeds: list[int], - num_splits: int, - train_fractions: list[float], - output_file: Path, -) -> None: - """Creates train/test splits for DeepLabCut projects - - Args: - projects: projects for which to create train/test splits - seeds: random seed to use for each project (must be the same len as projects) - num_splits: the number of train/test splits to create for each project - train_fractions: the train fractions for which to create train/test splits - output_file: the file where the splits should be output - """ -``` - -This outputs a `JSON` file which can be used by the benchmarking scripts to create -shuffles. - -## Benchmarking Models - -### Without Data Splits - -You might not care about train/test splits (e.g., because the test split is a actually -a validation split and you have a completely different test set, where the images are -in a different project), you can always create your shuffles yourself using the -DeepLabCut API. You can then modify the `pytorch_config.py` files for your shuffles if -you want to modify the base configurations. (e.g., a different number of deconvolutional -layers). - -Then, you can use the `benchmark_train.py` file to train models on your shuffles. - -All you need to do is define your `RunParameters`, and call `main`. This will -sequentially launch each training run. The first element is simply the shuffle which -should be benchmarked. The next few elements of `RunParameters` describe which -DeepLabCut API methods you want to call (train, evaluate, video analysis, ...). -Then, a bunch of other parameters allow you to select exactly how your model should be -trained and evaluated (batch size, ...). - - -```python -@dataclass -class RunParameters: - """Parameters on what to run for each shuffle""" - - shuffle: Shuffle - train: bool = False - evaluate: bool = False - analyze_videos: bool = False - track: bool = False - create_labeled_video: bool = False - device: str = "cuda:0" - train_params: TrainParameters | None = None - detector_train_params: TrainParameters | None = None - snapshot_path: Path | None = None - detector_path: Path | None = None - eval_params: EvalParameters | None = None - video_analysis_params: VideoAnalysisParameters | None = None - - def __post_init__(self): - if ( - self.analyze_videos is None or self.track or self.create_labeled_video - ) and self.video_analysis_params is None: - raise ValueError(f"Must specify video_analysis_params") - - -def main(runs: list[RunParameters]) -> None: - """Runs benchmarking scripts for DeepLabCut - - Args: - runs: - """ - for run in runs: - run.shuffle.project.update_iteration_in_config() - - if wandb.run is not None: # TODO: Finish wandb run in DLC - wandb.finish() - - print(f"Running {run.shuffle}") - try: - run_dlc(run) - except Exception as err: - print(f"Failed to run {run}: {err}") - raise err -``` - -### With Data Splits - -When benchmarking with data splits, a nice feature would be able to benchmark without -having to modify all `pytorch_config.yaml` files manually (as usually, you'll want to -train exactly the same model architecture on different data splits). This can be done -with the `benchmark_run_experiments.py` script. - -Here, you can define different variants of models with `ModelConfig` and train these -models on each one of your data splits. These parameters are made to customize -backbones and single animal heads, so they really can only be used for single animal or -top-down training. If you want to be able to easily update other parameters, you can -either make your own classes or simply pass the updates as a dictionary. - -There are also classes to update training parameters (batch size, epochs), augmentation, -optimizer, scheduler and logging parameters. diff --git a/benchmark/benchmark_create_shuffle.py b/benchmark/benchmark_create_shuffle.py deleted file mode 100644 index bb6c055281..0000000000 --- a/benchmark/benchmark_create_shuffle.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Training models on DLC benchmark datasets - -In a first step, shuffles can be created for your projects (pass an empty list and no -shuffles are created). - -Then you can train models using RunParameters. I usually create the shuffles first, -modify the PyTorch configuration files to add a logger and modify the data augmentation -for whatever I'm doing, and then start my training runs. A logger can be added with: -``` -logger: - type: 'WandbLogger' - project_name: 'dlc3-ff5f2af-fish' - run_name: 'dekr-w32-shuffle3' -``` - -Which specifies to log the run to wandb, (including the project and with which name each -shuffle should be logged). - -For single animal projects, benchmark splits were created using the -`create_train_test_splits.py` file. This script creates a JSON file for DLC projects -specifying train/test indices, which can then be passed in the ShuffleCreationParameters -to create new shuffles with the splits. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -import deeplabcut - -from projects import ( - MA_DLC_BENCHMARKS, - MA_DLC_DATA_ROOT, - SA_DLC_BENCHMARKS, - SA_DLC_DATA_ROOT, -) -from utils import create_shuffles, Project - - -@dataclass -class ShuffleCreationParameters: - """Parameters to create a shuffle - - Attributes: - project: the project for which to create shuffles - train_fraction: the training fraction to use to create the shuffles - net_types: the architectures to create - num_shuffles: the number of shuffles to create for each net type - splits_file: if you have specific train/test splits to use for your project, - they can be used by passing the path to the file containing the splits. - See the create_train_test_splits.py file for more information about this. - """ - - project: Project - train_fraction: float - net_types: tuple[str, ...] | list[str] - num_shuffles: int = 1 - splits_file: Path | None = None - - def __post_init__(self): - self.trainset_index = self.project.cfg["TrainingFraction"].index( - self.train_fraction - ) - - -def main(shuffles_to_create: list[ShuffleCreationParameters]) -> None: - """Creates new shuffles for DeepLabCut projects - - Args: - shuffles_to_create: the shuffles to create - """ - for m in shuffles_to_create: - m.project.update_iteration_in_config() - if m.splits_file is not None: - for net_type in m.net_types: - create_shuffles( - project=m.project, - splits_file=m.splits_file, - trainset_index=m.trainset_index, - net_type=net_type, - ) - else: - deeplabcut.create_training_model_comparison( - str(m.project.config_path()), - trainindex=m.trainset_index, - num_shuffles=m.num_shuffles, - net_types=list(m.net_types), - ) - - -if __name__ == "__main__": - main( - shuffles_to_create=[ - ShuffleCreationParameters( - project=MA_DLC_BENCHMARKS["fish"], - train_fraction=0.95, - net_types=("top_down_hrnet_w18", "dekr_w32", "dlcrnet_stride32_ms5"), - ), - ShuffleCreationParameters( - project=SA_DLC_BENCHMARKS["fly"], - train_fraction=0.8, - net_types=("resnet_50", "hrnet_w18", "hrnet_w32"), - splits_file=(SA_DLC_DATA_ROOT / "saDLC_benchmarking_splits.json"), - ), - ] - ) diff --git a/benchmark/benchmark_lightning_pose.py b/benchmark/benchmark_lightning_pose.py deleted file mode 100644 index c686a1e7cd..0000000000 --- a/benchmark/benchmark_lightning_pose.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Code to make an ablation study with different image augmentation parameters""" - -from __future__ import annotations - -from pathlib import Path -from deeplabcut.utils import get_bodyparts - -from benchmark_run_experiments import ( - AUG_TRAIN, - DEFAULT_OPTIMIZER, - DEFAULT_SCHEDULER, - HRNET_BACKBONE, - HRNET_BACKBONE_INCRE, - HRNET_BACKBONE_INTER, - main, - RESNET_BACKBONE, - RESNET_OPTIMIZER, - RESNET_SCHEDULER, -) -from utils import Project, WandBConfig -from utils_models import HeadConfig, ModelConfig - - -LP_DLC_DATA_ROOT = Path("/home/niels/datasets/lightning-pose") -LP_DLC_BENCHMARKS = { - "mirrorFish": Project( - root=LP_DLC_DATA_ROOT, - name="mirror-fish-rick-2023-10-26", - iteration=1, # ITERATION 0 IS THE PAPER DATA - ), - "mirrorMouse": Project( - root=LP_DLC_DATA_ROOT, - name="mirror-mouse-rick-2022-12-02", - iteration=1, # ITERATION 0 IS THE PAPER DATA - ), - "iblPaw": Project( - root=LP_DLC_DATA_ROOT, - name="ibl-paw-mic-2023-01-09", - iteration=1, # ITERATION 0 IS THE PAPER DATA - ), -} - - -if __name__ == "__main__": - # Project parameters - PROJECT_NAME = "mirrorFish" - PROJECT_BENCHMARKED = LP_DLC_BENCHMARKS[PROJECT_NAME] - SPLITS_PATH = LP_DLC_DATA_ROOT / "lightning_pose_splits.json" - CFG = PROJECT_BENCHMARKED.cfg - NUM_BPT = len(get_bodyparts(CFG)) - - # Train parameters - EPOCHS = 200 - SAVE_EPOCHS = 25 - RESNET_BATCH_SIZE = 8 - HRNET_BATCH_SIZE = 4 - - # logging params - WANDB_PROJECT = "dlc3-benchmark-dev" - BASE_TAGS = (f"project={PROJECT_NAME}", "server=m0") - GROUP_UID = "base" - - model_configs = [ - ModelConfig( - net_type="resnet_50", - batch_size=RESNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - train_aug=AUG_TRAIN, - inference_aug=None, - backbone_config=RESNET_BACKBONE, - head_config=HeadConfig.build_plateau_head( - c_in=2048, - c_out=NUM_BPT, - deconv=[(NUM_BPT, 3, 2)], - final_conv=False, - ), - optimizer_config=RESNET_OPTIMIZER, - scheduler_config=RESNET_SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-resnet50", - group=f"{PROJECT_NAME}-{GROUP_UID}-resnet50", - tags=(*BASE_TAGS, "arch=resnet50", "ndeconv=1"), - ), - ), - ModelConfig( - net_type="hrnet_w32", - batch_size=HRNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - train_aug=AUG_TRAIN, - inference_aug=None, - backbone_config=HRNET_BACKBONE_INTER, - head_config=HeadConfig.build_plateau_head( - c_in=480, - c_out=NUM_BPT, - deconv=[(NUM_BPT, 3, 2)], - final_conv=False, - ), - optimizer_config=DEFAULT_OPTIMIZER, - scheduler_config=DEFAULT_SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-inter", - group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-inter", - tags=(*BASE_TAGS, "arch=hrnet32-inter", "ndeconv=1"), - ), - ), - ModelConfig( - net_type="hrnet_w32", - batch_size=HRNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - train_aug=AUG_TRAIN, - inference_aug=None, - backbone_config=HRNET_BACKBONE, - head_config=HeadConfig.build_plateau_head( - c_in=32, - c_out=NUM_BPT, - deconv=[(NUM_BPT, 3, 2)], - final_conv=False, - ), - optimizer_config=DEFAULT_OPTIMIZER, - scheduler_config=DEFAULT_SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-base-hrnet32", - group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32", - tags=(*BASE_TAGS, "arch=hrnet32", "ndeconv=1"), - ), - ), - ] - - main( - project=PROJECT_BENCHMARKED, - splits_file=SPLITS_PATH, - trainset_index=0, - train_fraction=0.81, - models_to_train=model_configs, - splits_to_train=(0, 1, 2, 3, 4), - ) diff --git a/benchmark/benchmark_madlc.py b/benchmark/benchmark_madlc.py deleted file mode 100644 index f9d16a0281..0000000000 --- a/benchmark/benchmark_madlc.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Benchmark script for maDLC models""" -from __future__ import annotations - -from deeplabcut.utils import get_bodyparts - -from benchmark_run_experiments import main -from projects import MA_DLC_BENCHMARKS, MA_DLC_DATA_ROOT -from utils import WandBConfig -from utils_augmentation import ( - AffineAugmentation, - BatchCollate, - ImageAugmentations, -) -from utils_models import BackboneConfig, HeadConfig, DetectorConfig, ModelConfig - - -if __name__ == "__main__": - for PROJECT_NAME, TRAIN_FRACTION in [ - ("parenting", 0.95), - ("trimouse", 0.95), - ("fish", 0.94), - ("marmoset", 0.95), - ]: - PROJECT_BENCHMARKED = MA_DLC_BENCHMARKS[PROJECT_NAME] - SPLIT_FILE = MA_DLC_DATA_ROOT / "maDLC_benchmarking_splits.json" - NUM_BPT = len(get_bodyparts(PROJECT_BENCHMARKED.cfg)) - - AUG_TRAIN = ImageAugmentations( - normalize=True, - covering=True, - gaussian_noise=12.75, - hist_eq=True, - motion_blur=True, - affine=AffineAugmentation( - p=0.5, - rotation=30, - scale=(1, 1), - translation=40, - ), - collate=BatchCollate( - min_scale=0.4, - max_scale=1.0, - min_short_side=256, - max_short_side=1152, - multiple_of=32, - ), - ) - - # Optimization parameters - OPTIMIZER = {"type": "AdamW", "params": {"lr": 1e-4}} - SCHEDULER = { - "type": "LRListScheduler", - "params": {"lr_list": [[1e-5], [1e-6]], "milestones": [140, 190]}, - } - - # Train parameters - DETECTOR_EPOCHS = 250 - DETECTOR_SAVE_EPOCHS = 50 - DETECTOR_BATCH_SIZE = 8 - - EPOCHS = 200 - SAVE_EPOCHS = 25 - RESNET_BATCH_SIZE = 8 - DEKR_BATCH_SIZE = 4 - TD_HRNET_BATCH_SIZE = 16 - - # logging params - WANDB_PROJECT = "dlc3-benchmark-dev" - BASE_TAGS = (f"project={PROJECT_NAME}", "server=m0") - GROUP_UID = "base" - - RESNET_PAF = ModelConfig( - net_type="resnet_50", - batch_size=RESNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - dataloader_workers=2, - dataloader_pin_memory=True, - backbone_config=BackboneConfig( - model_name="resnet50_gn", - output_stride=16, - freeze_bn_stats=True, - freeze_bn_weights=False, - ), - train_aug=AUG_TRAIN, - inference_aug=None, # uses default - optimizer_config=OPTIMIZER, - scheduler_config=SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-resnet50PAF", - group=f"{PROJECT_NAME}-{GROUP_UID}-resnet50PAF", - tags=(*BASE_TAGS, "arch=resnet50PAF"), - ), - ) - DEKR_W32 = ModelConfig( - net_type="dekr_w32", - batch_size=DEKR_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - dataloader_workers=2, - dataloader_pin_memory=True, - train_aug=AUG_TRAIN, - inference_aug=None, # uses default - optimizer_config=OPTIMIZER, - scheduler_config=SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-dekr32", - group=f"{PROJECT_NAME}-{GROUP_UID}-dekr32", - tags=(*BASE_TAGS, "arch=dekr32"), - ), - ) - TD_HRNET_W32 = ( - DetectorConfig( - batch_size=DETECTOR_BATCH_SIZE, - epochs=DETECTOR_EPOCHS, - save_epochs=DETECTOR_SAVE_EPOCHS, - dataloader_workers=2, - dataloader_pin_memory=True, - train_aug=AUG_TRAIN, - inference_aug=None, - optimizer_config=None, - scheduler_config=None, - ), - ModelConfig( - net_type="top_down_hrnet_w32", - batch_size=TD_HRNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - dataloader_workers=2, - dataloader_pin_memory=True, - train_aug=AUG_TRAIN, - inference_aug=None, - backbone_config=None, - head_config=HeadConfig.build_plateau_head( - c_in=32, - c_out=NUM_BPT, - deconv=[], - final_conv=True, - ), - optimizer_config=OPTIMIZER, - scheduler_config=SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-td-hrnet32", - group=f"{PROJECT_NAME}-{GROUP_UID}-td-hrnet32", - tags=(*BASE_TAGS, "arch=td-hrnet32", "ndeconv=0"), - ), - ), - ) - - main( - project=PROJECT_BENCHMARKED, - splits_file=SPLIT_FILE, - trainset_index=0, - train_fraction=TRAIN_FRACTION, - models_to_train=[RESNET_PAF, DEKR_W32, TD_HRNET_W32], - splits_to_train=(0, ), - ) diff --git a/benchmark/benchmark_run_experiments.py b/benchmark/benchmark_run_experiments.py deleted file mode 100644 index 1996c081a4..0000000000 --- a/benchmark/benchmark_run_experiments.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Code to make an ablation study with different image augmentation parameters""" - -from __future__ import annotations - -from dataclasses import asdict -from pathlib import Path - -import wandb - -from deeplabcut.utils import get_bodyparts - -from benchmark_train import EvalParameters, run_dlc, RunParameters -from projects import SA_DLC_BENCHMARKS, SA_DLC_DATA_ROOT -from utils import create_shuffles, Project, Shuffle, WandBConfig -from utils_augmentation import ( - AffineAugmentation, - BatchCollate, - ImageAugmentations, -) -from utils_models import ( - BackboneConfig, - DetectorConfig, - HeadConfig, - ModelConfig, -) - - -def main( - project: Project, - splits_file: Path, - trainset_index: int, - train_fraction: float, - models_to_train: list[ModelConfig | tuple[DetectorConfig, ModelConfig]], - splits_to_train: tuple[int, ...] = (0, 1, 2), - eval_params: EvalParameters | None = None, -): - if eval_params is None: - eval_params = EvalParameters(snapshotindex="all", plotting=False) - - project.update_iteration_in_config() - for config in models_to_train: - if wandb.run is not None: # TODO: Finish wandb run in DLC - wandb.finish() - - if isinstance(config, tuple): - detector_config, model_config = config - assert isinstance(detector_config, DetectorConfig) - assert isinstance(model_config, ModelConfig) - else: - detector_config = None - model_config = config - assert isinstance(model_config, ModelConfig) - - run_name = "" - tags: tuple[str, ...] = () - if model_config.wandb_config is not None: - run_name = model_config.wandb_config.run_name - tags = model_config.wandb_config.tags - - print(100 * "-") - if detector_config is not None: - print(f"Detector config: {detector_config}") - print(f"Backbone config: {model_config.backbone_config}") - print(f"Head config: {model_config.head_config}") - print(f"Train Augmentation: {model_config.train_aug}") - print(f"Inference Augmentation: {model_config.inference_aug}") - - shuffle_indices = create_shuffles( - project, splits_file, trainset_index, model_config.net_type - ) - shuffles_to_train = [shuffle_indices[i] for i in splits_to_train] - print(f"training shuffles {shuffles_to_train}") - for split_idx, shuffle_idx in zip(splits_to_train, shuffles_to_train): - if wandb.run is not None: # TODO: Finish wandb run in DLC - wandb.finish() - - if detector_config is not None: - print(" DetectorParameters") - for k, v in asdict(detector_config).items(): - print(f" {k}: {v}") - - print(" ModelParameters") - for k, v in asdict(model_config).items(): - print(f" {k}: {v}") - - print(" Train kwargs") - for k, v in model_config.train_kwargs().items(): - print(f" {k}: {v}") - - if model_config.wandb_config is not None: - i = project.iteration - model_config.wandb_config.run_name = f"{run_name}-it{i}-shuf{shuffle_idx}" - model_config.wandb_config.tags = (*tags, f"split={split_idx}") - - run_dlc( - parameters=RunParameters( - shuffle=Shuffle( - project=project, - train_fraction=train_fraction, - index=shuffle_idx, - model_prefix="", - ), - train=True, - evaluate=True, - device="cuda:0", - train_params=model_config, - detector_train_params=detector_config, - eval_params=eval_params, - ) - ) - - -AUG_TRAIN = ImageAugmentations( - normalize=True, - covering=True, - gaussian_noise=12.75, - hist_eq=True, - motion_blur=True, - affine=AffineAugmentation( - p=0.5, - rotation=30, - scale=(1, 1), - translation=40, - ), - collate=BatchCollate( - min_scale=0.4, - max_scale=1.0, - min_short_side=256, - max_short_side=1152, - multiple_of=32, - ), -) -RESNET_BACKBONE = BackboneConfig( - model_name="resnet50_gn", - output_stride=16, - freeze_bn_stats=True, - freeze_bn_weights=False, -) -HRNET_BACKBONE = BackboneConfig( # output strides [4, 8, 16, 32] - model_name="hrnet_w32", - freeze_bn_stats=True, - freeze_bn_weights=False, -) -HRNET_BACKBONE_INTER = BackboneConfig( # output strides [4, 8, 16, 32] - model_name="hrnet_w32", - freeze_bn_stats=True, - freeze_bn_weights=False, - kwargs=dict(interpolate_branches=True), -) -HRNET_BACKBONE_INCRE = BackboneConfig( # output strides [4, 8, 16, 32] - model_name="hrnet_w32", - freeze_bn_stats=True, - freeze_bn_weights=False, - kwargs=dict(increased_channel_count=True), -) - -RESNET_OPTIMIZER = {"type": "AdamW", "params": {"lr": 1e-3}} -RESNET_SCHEDULER = { - "type": "LRListScheduler", - "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [90, 120]}, -} -DEFAULT_OPTIMIZER = {"type": "AdamW", "params": {"lr": 5e-4}} -DEFAULT_SCHEDULER = { - "type": "LRListScheduler", - "params": {"lr_list": [[1e-4], [1e-5]], "milestones": [90, 120]}, -} - - -if __name__ == "__main__": - # Project parameters - PROJECT_NAME = "fly" - PROJECT_BENCHMARKED = SA_DLC_BENCHMARKS[PROJECT_NAME] - SPLIT_FILE = SA_DLC_DATA_ROOT / "saDLC_benchmarking_splits.json" - CFG = PROJECT_BENCHMARKED.cfg - NUM_BPT = len(get_bodyparts(CFG)) - - # Train parameters - EPOCHS = 150 - SAVE_EPOCHS = 25 - RESNET_BATCH_SIZE = 8 - HRNET_BATCH_SIZE = 8 - - # logging params - WANDB_PROJECT = "dlc3-benchmark-dev" - BASE_TAGS = (f"project={PROJECT_NAME}", "server=m0") - GROUP_UID = "base" - - # resize openfield - if PROJECT_NAME == "openfield": - AUG_TRAIN.resize = dict(height=640, width=640, keep_ratio=True) - - model_configs = [ - ModelConfig( - net_type="resnet_50", - batch_size=RESNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - dataloader_workers=2, - dataloader_pin_memory=True, - train_aug=AUG_TRAIN, - inference_aug=None, - backbone_config=RESNET_BACKBONE, - head_config=HeadConfig.build_plateau_head( - c_in=2048, - c_out=NUM_BPT, - deconv=[(NUM_BPT, 3, 2)], - final_conv=False, - ), - optimizer_config=RESNET_OPTIMIZER, - scheduler_config=RESNET_SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-resnet50", - group=f"{PROJECT_NAME}-{GROUP_UID}-resnet50", - tags=(*BASE_TAGS, "arch=resnet50", "ndeconv=1"), - ), - ), - ModelConfig( - net_type="hrnet_w32", - batch_size=HRNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - dataloader_workers=2, - dataloader_pin_memory=True, - train_aug=AUG_TRAIN, - inference_aug=None, - backbone_config=HRNET_BACKBONE, - head_config=HeadConfig.build_plateau_head( - c_in=32, - c_out=NUM_BPT, - deconv=[(NUM_BPT, 3, 2)], - final_conv=False, - ), - optimizer_config=DEFAULT_OPTIMIZER, - scheduler_config=DEFAULT_SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32", - group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32", - tags=(*BASE_TAGS, "arch=hrnet32", "ndeconv=1"), - ), - ), - ModelConfig( - net_type="hrnet_w32", - batch_size=HRNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - dataloader_workers=2, - dataloader_pin_memory=True, - train_aug=AUG_TRAIN, - inference_aug=None, - backbone_config=HRNET_BACKBONE_INCRE, - head_config=HeadConfig.build_plateau_head( - c_in=128, - c_out=NUM_BPT, - deconv=[(NUM_BPT, 3, 2)], - final_conv=False, - ), - optimizer_config=DEFAULT_OPTIMIZER, - scheduler_config=DEFAULT_SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-incre", - group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-incre", - tags=(*BASE_TAGS, "arch=hrnet32-incre", "ndeconv=1"), - ), - ), - ModelConfig( - net_type="hrnet_w32", - batch_size=HRNET_BATCH_SIZE, - epochs=EPOCHS, - save_epochs=SAVE_EPOCHS, - dataloader_workers=2, - dataloader_pin_memory=True, - train_aug=AUG_TRAIN, - inference_aug=None, - backbone_config=HRNET_BACKBONE_INTER, - head_config=HeadConfig.build_plateau_head( - c_in=480, - c_out=NUM_BPT, - deconv=[(NUM_BPT, 3, 2)], - final_conv=False, - ), - optimizer_config=DEFAULT_OPTIMIZER, - scheduler_config=DEFAULT_SCHEDULER, - wandb_config=WandBConfig( - project=WANDB_PROJECT, - run_name=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-inter", - group=f"{PROJECT_NAME}-{GROUP_UID}-hrnet32-inter", - tags=(*BASE_TAGS, "arch=hrnet32-inter", "ndeconv=1"), - ), - ), - ] - main( - project=PROJECT_BENCHMARKED, - splits_file=SPLIT_FILE, - trainset_index=0, - train_fraction=0.8, - models_to_train=model_configs, - splits_to_train=(0, 1, 2), - ) diff --git a/benchmark/benchmark_train.py b/benchmark/benchmark_train.py deleted file mode 100644 index 4c90aa5239..0000000000 --- a/benchmark/benchmark_train.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Training models on DLC benchmark datasets - -In a first step, shuffles can be created for your projects (pass an empty list and no -shuffles are created). - -Then you can train models using RunParameters. I usually create the shuffles first, -modify the PyTorch configuration files to add a logger and modify the data augmentation -for whatever I'm doing, and then start my training runs. A logger can be added with: -``` -logger: - type: 'WandbLogger' - project_name: 'dlc3-ff5f2af-fish' - run_name: 'dekr-w32-shuffle3' -``` - -Which specifies to log the run to wandb, (including the project and with which name each -shuffle should be logged). - -For single animal projects, benchmark splits were created using the -`create_train_test_splits.py` file. This script creates a JSON file for DLC projects -specifying train/test indices, which can then be passed in the ShuffleCreationParameters -to create new shuffles with the splits. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -import deeplabcut -import deeplabcut.pose_estimation_pytorch.apis as api -import wandb - -from projects import MA_DLC_BENCHMARKS, SA_DLC_BENCHMARKS -from utils import Shuffle - - -@dataclass -class TrainParameters: - """Parameters to train models""" - - seed: int = 42 - batch_size: int = 1 - display_iters: int = 500 - epochs: int | None = None - save_epochs: int = 25 - max_snapshots: int = 5 - dataloader_workers: int | None = None - dataloader_pin_memory: bool | None = None - - def train_kwargs(self) -> dict: - kwargs = dict( - train_settings=dict( - batch_size=self.batch_size, - display_iters=self.display_iters, - seed=self.seed, - ), - ) - if self.epochs is not None: - kwargs["train_settings"]["epochs"] = self.epochs - if self.dataloader_workers is not None: - kwargs["train_settings"]["dataloader_workers"] = self.dataloader_workers - if self.dataloader_pin_memory is not None: - kwargs["train_settings"][ - "dataloader_pin_memory"] = self.dataloader_pin_memory - if self.save_epochs is not None: - runner_kwargs = kwargs.get("runner", {}) - runner_kwargs["snapshots"] = dict( - save_epochs=self.save_epochs, - max_snapshots=self.max_snapshots, - ) - kwargs["runner"] = runner_kwargs - - return kwargs - - -@dataclass -class EvalParameters: - """Parameters for evaluation""" - - snapshotindex: int | list[int] | str | None = (None,) - detector_snapshotindex: int | None = None - plotting: str | bool = False - show_errors: bool = True - - def eval_kwargs(self) -> dict: - return { - "snapshotindex": self.snapshotindex, - "detector_snapshot_index": self.detector_snapshotindex, - "plotting": self.plotting, - "show_errors": self.show_errors, - } - - -@dataclass -class VideoAnalysisParameters: - """Parameters to run video analysis""" - - videos: list[str] - videotype: str - snapshot_index: int = -1 - detector_snapshot_index: int = -1 - output_folder: str = "" - - -@dataclass -class RunParameters: - """Parameters on what to run for each shuffle""" - - shuffle: Shuffle - train: bool = False - evaluate: bool = False - analyze_videos: bool = False - track: bool = False - create_labeled_video: bool = False - device: str = "cuda:0" - train_params: TrainParameters | None = None - detector_train_params: TrainParameters | None = None - snapshot_path: Path | None = None - detector_path: Path | None = None - eval_params: EvalParameters | None = None - video_analysis_params: VideoAnalysisParameters | None = None - - def __post_init__(self): - if ( - self.analyze_videos is None or self.track or self.create_labeled_video - ) and self.video_analysis_params is None: - raise ValueError(f"Must specify video_analysis_params") - - -def run_dlc(parameters: RunParameters) -> None: - """Runs DeepLabCut 3.0 API methods - - Args: - parameters: the parameters specifying what to run, and which parameters to use - """ - if parameters.train: - train_params = parameters.train_params.train_kwargs() - if parameters.detector_train_params is not None: - train_params["detector"] = parameters.detector_train_params.train_kwargs() - if parameters.snapshot_path is not None: - train_params["snapshot_path"] = parameters.snapshot_path - if parameters.detector_path is not None: - train_params["detector_path"] = parameters.detector_path - - api.train_network( - str(parameters.shuffle.project.config_path()), - shuffle=parameters.shuffle.index, - trainingsetindex=parameters.shuffle.trainset_index, - transform=None, - modelprefix=parameters.shuffle.model_prefix, - device=parameters.device, - **train_params, - ) - - if parameters.evaluate: - api.evaluate_network( - config=str(parameters.shuffle.project.config_path()), - shuffles=[parameters.shuffle.index], - trainingsetindex=parameters.shuffle.trainset_index, - device=parameters.device, - transform=None, - modelprefix=parameters.shuffle.model_prefix, - **parameters.eval_params.eval_kwargs(), - ) - - if parameters.analyze_videos: - destfolder = ( - parameters.shuffle.project.path - / parameters.video_analysis_params.output_folder - ) - api.analyze_videos( - config=str(parameters.shuffle.project.config_path()), - videos=parameters.video_analysis_params.videos, - videotype=parameters.video_analysis_params.videotype, - trainingsetindex=parameters.shuffle.trainset_index, - shuffle=parameters.shuffle.index, - destfolder=str(destfolder), - snapshot_index=parameters.video_analysis_params.snapshot_index, - detector_snapshot_index=parameters.video_analysis_params.detector_snapshot_index, - device=parameters.device, - modelprefix=parameters.shuffle.model_prefix, - batchsize=parameters.train_params.batch_size, - transform=None, - overwrite=False, - auto_track=False, - ) - - if parameters.track: - destfolder = ( - parameters.shuffle.project.path - / parameters.video_analysis_params.output_folder - ) - api.convert_detections2tracklets( - config=str(parameters.shuffle.project.config_path()), - videos=[str(v) for v in parameters.video_analysis_params.videos], - videotype=".mp4", - trainingsetindex=parameters.shuffle.trainset_index, - shuffle=parameters.shuffle.index, - modelprefix=parameters.shuffle.model_prefix, - destfolder=str(destfolder), - track_method="box", - ) - deeplabcut.stitch_tracklets( - str(parameters.shuffle.project.config_path()), - videos=[str(v) for v in parameters.video_analysis_params.videos], - trainingsetindex=parameters.shuffle.trainset_index, - shuffle=parameters.shuffle.index, - destfolder=str(destfolder), - modelprefix=parameters.shuffle.model_prefix, - save_as_csv=True, - track_method="box", - ) - - if parameters.create_labeled_video: - destfolder = ( - parameters.shuffle.project.path - / parameters.video_analysis_params.output_folder - ) - deeplabcut.create_labeled_video( - config=str(parameters.shuffle.project.config_path()), - videos=[str(v) for v in parameters.video_analysis_params.videos], - videotype="mp4", - trainingsetindex=parameters.shuffle.trainset_index, - color_by="individual", # bodypart, individual - modelprefix=parameters.shuffle.model_prefix, - destfolder=str(destfolder), - track_method="box", - ) - - -def main(runs: list[RunParameters]) -> None: - """Runs benchmarking scripts for DeepLabCut - - Args: - runs: - """ - for run in runs: - run.shuffle.project.update_iteration_in_config() - - if wandb.run is not None: # TODO: Finish wandb run in DLC - wandb.finish() - - print(f"Running {run.shuffle}") - try: - run_dlc(run) - except Exception as err: - print(f"Failed to run {run}: {err}") - raise err - - -if __name__ == "__main__": - main( - runs=[ - RunParameters( - shuffle=Shuffle( - project=SA_DLC_BENCHMARKS["fly"], - train_fraction=0.8, - index=1, - model_prefix="", - ), - train=True, - evaluate=True, - analyze_videos=False, - track=False, - create_labeled_video=False, - device="cuda:0", - train_params=TrainParameters( - batch_size=8, - epochs=200, - save_epochs=25, - max_snapshots=5, - ), - detector_train_params=TrainParameters( - batch_size=8, - epochs=200, - save_epochs=25, - max_snapshots=5, - ), - eval_params=EvalParameters(snapshotindex="all", plotting=False), - ), - ] - ) diff --git a/benchmark/coco/README.md b/benchmark/coco/README.md deleted file mode 100644 index b636a6de1e..0000000000 --- a/benchmark/coco/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# Training DeepLabCut models on COCO Projects - -## Training and Evaluation Process - -There are three essential steps to follow to train a model - -1. Creating a `pytorch_config.yaml` model configuration file, which specifies the -architecture of the model, but also the optimizer, learning rate and data augmentations. -2. Training the network. For bottom up models, this means only training the pose model, -while for top-down models you can either just train the pose model (if you already have -a detector), or train a detector and then a pose model. -3. Evaluation. Once you have a trained model, you can evaluate all of the snapshots -you've saved. For top-down models, you can evaluate using ground-truth bounding boxes or -detector bounding boxes (by passing a detector snapshot as well). - -### Creating a Model Configuration File - -You can either copy an existing model configuration file and modify it to fit your -updated project (or run a new experiment), or you can create a default one for a given -model architecture using `make_config.py`. - -This creates a configuration file for single-animal pose estimation as a default, but -you can create multi-animal projects by passing the command-line argument -`--multi_animal`. - -This will create a `train` and a `test` folder in the `output` folder that you've -specified. The configuration file will be saved in the `train` folder, and an -`inference_cfg.yaml` file will be created in the `test` folder, which contains -parameters for tracking. - -You can log to Weights & Biases by adding a logger to your configuration: - -```yaml -logger: - type: 'WandbLogger' - project_name: 'dlc3-rodent' - run_name: 'test-tokenpose' -``` - -### Training a Model - -If you're training a top-down model and don't want to train a detector, simply pass -`--detector-epochs 0` as a command line parameter. - -### Evaluation - -For top-down models, running `evaluate.py` without given the path to a -`--detector-snapshot` will use ground-truth bounding boxes. If you specify a detector -snapshot, it will first compute bounding boxes in the images using the trained detector, -and then will use these bounding boxes for inference. - -## Training on Images of Variable Size - -If your images have different sizes (and you want to make sure all of them have the same -size when being given to the models), you can add a `resize` parameter to your -`pytorch_config.yaml` file: - -```yaml -data: - resize: - width: 1300 - height: 800 - keep_ratio: true -``` - -This will resize images using -`deeplabcut.pose_estimation_pytorch.data.transforms.DLCResize`, which resizes while -preserving the aspect ratio, and then pads the image to the correct size. If all of -your images have the same aspect ratio (and your target width/height), you can resize -using `keep_ratio: false`. - -## Example - -Creating the model configuration file - -```bash -python make_config.py \ - /home/niels/datasets/rodent \ - /home/niels/datasets/rodent/experiments/exp_1 \ - dekr_w32 \ - --train_file corrected_train.json -``` - -Then modify that configuration (such as the data augmentations) to have it match -whatever is required for your project. Once you're done and happy with your project -configuration, you can use it to train a model: - -```bash -python train.py \ - /home/niels/datasets/rodent \ - /home/niels/datasets/rodent/experiments/exp_4/train/pytorch_config.yaml \ - --detector-epochs 20 \ - --detector-save-epochs 5 \ - --epochs 50 \ - --save-epochs 10 \ - --train_file train.json \ - --test_file test.json \ - --device cuda:0 -``` - -Evaluating a trained model: - -```bash -python evaluate.py \ - /home/niels/datasets/rodent \ - /home/niels/datasets/rodent/experiments/exp_2/train/pytorch_config.yaml \ - /home/niels/datasets/rodent/experiments/exp_2/train/snapshot-10.pt \ - --detector_path /home/niels/datasets/rodent/experiments/exp_2/train/detector-snapshot-200.pt - --train_file train.json \ - --test_file test.json \ - --device cuda:0 -``` diff --git a/benchmark/coco/analyze_video.py b/benchmark/coco/analyze_video.py deleted file mode 100644 index 4db7e7a71b..0000000000 --- a/benchmark/coco/analyze_video.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Run video analysis""" - -from __future__ import annotations - -import argparse -import copy -from pathlib import Path - -import numpy as np -import torch - -from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import ( - create_df_from_prediction, - video_inference, - VideoIterator, -) -from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners -from deeplabcut.pose_estimation_pytorch.config import read_config_as_dict -from deeplabcut.pose_estimation_pytorch.task import Task -from deeplabcut.utils.make_labeled_video import _create_labeled_video - - -def main( - video_path: str | Path, - model_config: str, - snapshot_path: str, - detector_path: str | None, - num_animals: int = 1, -): - video_path = Path(video_path) - model_cfg = read_config_as_dict(model_config) - pose_task = Task(model_cfg["method"]) - pose_runner, detector_runner = get_inference_runners( - model_config=model_cfg, - snapshot_path=snapshot_path, - max_individuals=num_animals, - num_bodyparts=len(model_cfg["metadata"]["bodyparts"]), - num_unique_bodyparts=len(model_cfg["metadata"]["unique_bodyparts"]), - with_identity=model_cfg["metadata"].get("with_identity", False), - transform=None, - detector_path=detector_path, - detector_transform=None, - ) - predictions, video_metadata = video_inference( - video_path, - task=pose_task, - pose_runner=pose_runner, - detector_runner=detector_runner, - with_identity=False, - return_video_metadata=True, - ) - - pred_bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions]) - pred_unique_bodyparts = None - bbox = (0, video_metadata["resolution"][0], 0, video_metadata["resolution"][1]) - - cfg = copy.deepcopy(model_cfg) - cfg["individuals"] = [f"individual_{i}" for i in range(num_animals)] - cfg["bodyparts"] = cfg["metadata"]["bodyparts"] - cfg["uniquebodyparts"] = [] - cfg["multianimalbodyparts"] = cfg["metadata"]["bodyparts"] - - dlc_scorer = "" - if detector_path is not None: - dlc_scorer += Path(detector_path).stem - dlc_scorer += Path(snapshot_path).stem - - output_prefix = f"{video_path.stem}_{dlc_scorer}" - output_path = video_path.parent - output_h5 = output_path / (output_prefix + ".h5") - _ = create_df_from_prediction( - pred_bodyparts=pred_bodyparts, - pred_unique_bodyparts=pred_unique_bodyparts, - dlc_scorer=dlc_scorer, - cfg=cfg, - output_path=output_path, - output_prefix=output_prefix, - ) - _create_labeled_video( - str(video_path), - str(output_h5), - pcutoff=0.6, - fps=video_metadata["fps"], - bbox=bbox, - output_path=str(output_path / f"{output_prefix}_labeled.mp4"), - ) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("video_path") - parser.add_argument("model_config_path") - parser.add_argument("snapshot_path") - parser.add_argument("--detector_path", default=None) - parser.add_argument("--device", default=None) - parser.add_argument("--num_animals", type=int, default=1) - args = parser.parse_args() - main( - video_path=args.video_path, - model_config=args.model_config_path, - snapshot_path=args.snapshot_path, - detector_path=args.detector_path, - num_animals=args.num_animals, - ) diff --git a/benchmark/coco/evaluate.py b/benchmark/coco/evaluate.py deleted file mode 100644 index 93f98e1e38..0000000000 --- a/benchmark/coco/evaluate.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Evaluating COCO models""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import numpy as np -import torch -from deeplabcut.pose_estimation_pytorch import COCOLoader -from deeplabcut.pose_estimation_pytorch.apis.evaluate import evaluate -from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners -from deeplabcut.pose_estimation_pytorch.task import Task - - -def pycocotools_evaluation( - kpt_oks_sigmas: list[int], - ground_truth: dict, - predictions: list[dict], - annotation_type: str, -) -> None: - """Evaluation of models using Pycocotools - - Evaluates the predictions using OKS sigma 0.1, margin 0 and prints the results to - the console. - - Args: - kpt_oks_sigmas: the OKS sigma for each keypoint - ground_truth: the ground truth data, in COCO format - predictions: the predictions, in COCO format - annotation_type: {"bbox", "keypoints"} the annotation type to evaluate - """ - print(80 * "-") - print(f"Attempting `pycocotools` evaluation for {annotation_type}!") - try: - from pycocotools.coco import COCO - from pycocotools.cocoeval import COCOeval - - coco = COCO() - coco.dataset["annotations"] = ground_truth["annotations"] - coco.dataset["categories"] = ground_truth["categories"] - coco.dataset["images"] = ground_truth["images"] - coco.createIndex() - - coco_det = coco.loadRes(predictions) - coco_eval = COCOeval(coco, coco_det, iouType=annotation_type) - coco_eval.params.kpt_oks_sigmas = np.array(kpt_oks_sigmas) - - coco_eval.evaluate() - coco_eval.accumulate() - coco_eval.summarize() - - except Exception as err: - print(f"Could not evaluate with `pycocotools`: {err}") - finally: - print(80 * "-") - - -def main( - project_root: str, - train_file: str, - test_file: str, - pytorch_config_path: str, - device: str | None, - snapshot_path: str, - detector_path: str | None, - pcutoff: float, - oks_sigma: float, -): - loader = COCOLoader( - project_root=project_root, - model_config_path=pytorch_config_path, - train_json_filename=train_file, - test_json_filename=test_file, - ) - parameters = loader.get_dataset_parameters() - if device is not None: - loader.model_cfg["device"] = device - - pose_runner, detector_runner = get_inference_runners( - model_config=loader.model_cfg, - snapshot_path=snapshot_path, - max_individuals=parameters.max_num_animals, - num_bodyparts=parameters.num_joints, - num_unique_bodyparts=parameters.num_unique_bpts, - with_identity=False, - transform=None, - detector_path=detector_path, - detector_transform=None, - ) - - output_path = Path(pytorch_config_path).parent.parent / "results" - output_path.mkdir(exist_ok=True) - for mode in ["train", "test"]: - scores, predictions = evaluate( - pose_task=Task(loader.model_cfg["method"]), - pose_runner=pose_runner, - loader=loader, - mode=mode, - detector_runner=detector_runner, - pcutoff=pcutoff, - ) - coco_predictions = loader.predictions_to_coco(predictions, mode=mode) - model_name = Path(snapshot_path).stem - if detector_path is not None: - model_name += Path(detector_path).stem - predictions_file = output_path / f"{model_name}-{mode}-predictions.json" - with open(predictions_file, "w") as f: - json.dump(coco_predictions, f) - - annotation_types = ["keypoints"] - if detector_runner is not None: - annotation_types.append("bbox") - - ground_truth = loader.load_data(mode=mode) - for annotation_type in annotation_types: - kpt_oks_sigmas = oks_sigma * np.ones(parameters.num_joints) - pycocotools_evaluation( - ground_truth=ground_truth, - predictions=coco_predictions, - kpt_oks_sigmas=kpt_oks_sigmas, - annotation_type=annotation_type, - ) - - print(80 * "-") - print(f"{mode} results") - for k, v in scores.items(): - print(f" {k}: {v}") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("project_root") - parser.add_argument("pytorch_config_path") - parser.add_argument("snapshot_path") - parser.add_argument("--train_file", default="train.json") - parser.add_argument("--test_file", default="test.json") - parser.add_argument("--device", default=None) - parser.add_argument("--detector_path", default=None) - parser.add_argument("--pcutoff", type=float, default=0.6) - parser.add_argument("--oks_sigma", type=float, default=0.1) - args = parser.parse_args() - main( - args.project_root, - args.train_file, - args.test_file, - args.pytorch_config_path, - args.device, - args.snapshot_path, - args.detector_path, - args.pcutoff, - args.oks_sigma, - ) diff --git a/benchmark/coco/make_config.py b/benchmark/coco/make_config.py deleted file mode 100644 index 12bfe4a40f..0000000000 --- a/benchmark/coco/make_config.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Creates a base model configuration file to train a model on a COCO dataset - -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -import torch -import deeplabcut.utils.auxiliaryfunctions as af -from deeplabcut.generate_training_dataset import MakeInference_yaml -from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config -from deeplabcut.pose_estimation_pytorch.data import COCOLoader - - -def get_base_config( - project_path: str, - pose_config_path: str, - model_architecture: str, - bodyparts: list[str], - unique_bodyparts: list[str], - individuals: list[str], - multi_animal: bool, -) -> dict: - cfg = { - "project_path": project_path, - "multianimalproject": multi_animal, - "bodyparts": bodyparts, - "multianimalbodyparts": bodyparts, - "uniquebodyparts": unique_bodyparts, - "individuals": individuals, - } - - top_down = False - if model_architecture.startswith("top_down_"): - top_down = True - model_architecture = model_architecture[len("top_down_") :] - - return make_pytorch_pose_config( - project_config=cfg, - pose_config_path=pose_config_path, - net_type=model_architecture, - top_down=top_down, - ) - - -def make_inference_config( - dlc_path: str, - output_path: str, - bodyparts: list[str], - num_individuals: int, -): - default_config_path = Path(dlc_path) / "inference_cfg.yaml" - items2change = { - "minimalnumberofconnections": int(len(bodyparts) / 2), - "topktoretain": num_individuals, - "withid": False, # TODO: implement - } - MakeInference_yaml(items2change, output_path, default_config_path) - - -def main( - project_root: str, - train_file: str, - output: str, - model_arch: str, - multi_animal: bool, -): - output_path = Path(output) - if output_path.exists(): - raise RuntimeError( - f"The output path must not exist yet, as otherwise we would risk overwriting" - f" existing configurations ({output_path} exists)" - ) - - train_dict = COCOLoader.load_json(project_root, train_file) - num_individuals, bodyparts = COCOLoader.get_project_parameters(train_dict) - dlc_path = af.get_deeplabcut_path() - output_path.mkdir(parents=True) - train_dir = output_path / "train" - test_dir = output_path / "test" - train_dir.mkdir() - test_dir.mkdir() - - pose_config_path = str(train_dir / "pytorch_config.yaml") - pytorch_cfg = get_base_config( - project_path=project_root, - pose_config_path=pose_config_path, - model_architecture=model_arch, - bodyparts=bodyparts, - unique_bodyparts=[], - individuals=[f"individual{i}" for i in range(num_individuals)], - multi_animal=multi_animal, - ) - af.write_plainconfig(str(train_dir / "pytorch_config.yaml"), pytorch_cfg) - make_inference_config( - dlc_path, - str(test_dir / "inference_cfg.yaml"), - bodyparts, - num_individuals, - ) - print(f"Saved your model configuration in {output_path}") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("project_root") - parser.add_argument("output") - parser.add_argument("model_arch") - parser.add_argument("--train_file", default="train.json") - parser.add_argument("--multi_animal", action="store_true") - args = parser.parse_args() - main( - args.project_root, - args.train_file, - args.output, - args.model_arch, - args.multi_animal, - ) diff --git a/benchmark/coco/train.py b/benchmark/coco/train.py deleted file mode 100644 index 4c5ebb97d8..0000000000 --- a/benchmark/coco/train.py +++ /dev/null @@ -1,134 +0,0 @@ -"""File to train a model on a COCO dataset""" - -from __future__ import annotations - -import argparse -import copy -from pathlib import Path - -from deeplabcut.pose_estimation_pytorch import COCOLoader, utils -from deeplabcut.pose_estimation_pytorch.apis.train import train -from deeplabcut.pose_estimation_pytorch.runners.logger import setup_file_logging -from deeplabcut.pose_estimation_pytorch.task import Task -from collections import defaultdict - -def main( - project_root: str, - train_file: str, - test_file: str, - model_config_path: str, - device: str | None, - gpus: list[int] | None, - epochs: int | None, - save_epochs: int | None, - detector_epochs: int | None, - detector_save_epochs: int | None, - snapshot_path: str | None, - detector_path: str | None, - batch_size: int = 64, - dataloader_workers: int = 12, - detector_batch_size: int = 64, - detector_dataloader_workers: int = 12, -): - log_path = Path(model_config_path).parent / "log.txt" - setup_file_logging(log_path) - - loader = COCOLoader( - project_root=project_root, - model_config_path=model_config_path, - train_json_filename=train_file, - test_json_filename=test_file, - ) - utils.fix_seeds(loader.model_cfg["train_settings"]["seed"]) - - if epochs is None: - epochs = loader.model_cfg["train_settings"]["epochs"] - if save_epochs is None: - save_epochs = loader.model_cfg["runner"]["snapshots"]["save_epochs"] - - updates = dict( - runner=dict(snapshots=dict(save_epochs=save_epochs)), - train_settings=dict( - batch_size=batch_size, - dataloader_workers=dataloader_workers, - epochs=epochs, - ), - ) - - det_cfg = loader.model_cfg("detector") - if det_cfg is not None: - if detector_epochs is None: - detector_epochs = det_cfg["train_settings"]["epochs"] - if detector_save_epochs is None: - detector_save_epochs = det_cfg["runner"]["snapshots"]["save_epochs"] - updates_detector = dict( - runner=dict(snapshots=dict(save_epochs=detector_save_epochs)), - train_settings=dict( - batch_size=detector_batch_size, - dataloader_workers=detector_dataloader_workers, - ), - ) - updates["detector"] = updates_detector - - loader.update_model_cfg(updates) - - pose_task = Task(loader.model_cfg["method"]) - if pose_task == Task.TOP_DOWN: - logger_config = None - if loader.model_cfg.get("logger"): - logger_config = copy.deepcopy(loader.model_cfg["logger"]) - logger_config["run_name"] += "-detector" - - # skipping detector training if a detector_path is given - if args.detector_path is None and detector_epochs > 0: - train( - loader=loader, - run_config=loader.model_cfg["detector"], - task=Task.DETECT, - device=device, - gpus=gpus, - logger_config=logger_config, - snapshot_path=detector_path, - ) - - if epochs > 0: - train( - loader=loader, - run_config=loader.model_cfg, - task=pose_task, - device=device, - gpus=gpus, - logger_config=loader.model_cfg.get("logger"), - snapshot_path=snapshot_path, - ) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("project_root") - parser.add_argument("pytorch_config") - parser.add_argument("--train_file", default="train.json") - parser.add_argument("--test_file", default="test.json") - parser.add_argument("--device", default=None) - parser.add_argument("--gpus", default=None, nargs="+", type=int) - parser.add_argument("--epochs", type=int, default=None) - parser.add_argument("--save-epochs", type=int, default=None) - parser.add_argument("--detector-epochs", type=int, default=None) - parser.add_argument("--detector-save-epochs", type=int, default=None) - parser.add_argument("--snapshot_path", default=None) - parser.add_argument("--detector_path", default=None) - args = parser.parse_args() - main( - args.project_root, - args.train_file, - args.test_file, - args.pytorch_config, - args.device, - args.gpus, - args.epochs, - args.save_epochs, - args.detector_epochs, - args.detector_save_epochs, - args.snapshot_path, - args.detector_path, - ) diff --git a/benchmark/create_train_test_splits.py b/benchmark/create_train_test_splits.py deleted file mode 100644 index 46534d5885..0000000000 --- a/benchmark/create_train_test_splits.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Creates train/test splits for DeepLabCut Single Animal benchmarks""" - -from __future__ import annotations - -import json -from pathlib import Path - -import deeplabcut -import deeplabcut.utils.auxiliaryfunctions as af -import numpy as np -import torch - -from projects import SA_DLC_BENCHMARKS -from utils import Project - - -def create_splits( - seed: int, - num_samples: int, - train_fractions: list[float], - num_splits: int, -) -> dict[float, list[dict[str, list[int]]]]: - splits = {} - gen = np.random.default_rng(seed=seed) - for train_frac in train_fractions: - splits[train_frac] = [] - print(f"Percentage of samples used for training: {train_frac}") - for i in range(num_splits): - num_train_indices = int(np.floor(train_frac * num_samples)) - samples = gen.choice(num_samples, size=num_train_indices, replace=False) - train_indices = np.sort(samples).tolist() - test_indices = [i for i in range(num_samples) if i not in train_indices] - splits[train_frac].append( - { - "train": train_indices, - "test": test_indices, - } - ) - print(f" Split {i}:") - print(f" train: {train_indices}") - print(f" test: {test_indices}") - return splits - - -def main( - projects: list[Project], - seeds: list[int], - num_splits: int, - train_fractions: list[float], - output_file: Path, -) -> None: - """Creates train/test splits for DeepLabCut projects - - Args: - projects: projects for which to create train/test splits - seeds: random seed to use for each project (must be the same len as projects) - num_splits: the number of train/test splits to create for each project - train_fractions: the train fractions for which to create train/test splits - output_file: the file where the splits should be output - """ - assert len(projects) == len(seeds), "you must pass one seed for each project!" - - output_file = output_file.resolve() - splits_data = {} - for project, seed in zip(projects, seeds): - save_dir = output_file.parent / f"data-splits-{project.name}" - save_dir.mkdir(exist_ok=True) - - cfg = af.read_config(str(project.config_path())) - - # saves .h5 and .csv files containing the full dataframe used - df = deeplabcut.generate_training_dataset.merge_annotateddatasets(cfg, save_dir) - num_samples = len(df) - - splits_data[project.name] = create_splits( - seed=seed, - num_samples=num_samples, - train_fractions=train_fractions, - num_splits=num_splits, - ) - - for k, v in splits_data.items(): - print(f"Dataset: {k}") - for fraction, splits in v.items(): - print(f" Percentage of samples used for training: {fraction}") - for i, s in enumerate(splits): - print(f" Split {i}:") - print(f" train ({len(s['train'])}): {s['train']}") - print(f" test ({len(s['test'])}): {s['test']}") - print() - - with open(output_file, "w") as f: - json.dump(splits_data, f, indent=2) - - -if __name__ == "__main__": - main( - projects=[SA_DLC_BENCHMARKS["fly"], SA_DLC_BENCHMARKS["openfield"]], - seeds=[0, 1], - num_splits=3, - train_fractions=[0.8, 0.95], - output_file=Path("saDLC_benchmarking_splits.json"), - ) diff --git a/benchmark/lightning_pose_ood_evaluation.py b/benchmark/lightning_pose_ood_evaluation.py deleted file mode 100644 index 8f64060a31..0000000000 --- a/benchmark/lightning_pose_ood_evaluation.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Evaluate LightningPose OOD data""" - -from __future__ import annotations - -from pathlib import Path - -import numpy as np -import pandas as pd -from deeplabcut.pose_estimation_pytorch import DLCLoader, PoseDatasetParameters -from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners -from deeplabcut.pose_estimation_pytorch.data.utils import map_id_to_annotations -from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( - get_scores, - pair_predicted_individuals_with_gt, -) -from deeplabcut.pose_estimation_pytorch.task import Task -from tqdm import tqdm - -from benchmark_lightning_pose import LP_DLC_BENCHMARKS -from utils import Project, Shuffle - - -def load_ground_truth(gt_data, parameters: PoseDatasetParameters): - annotations = DLCLoader.filter_annotations( - gt_data["annotations"], task=Task.BOTTOM_UP - ) - img_to_ann_map = map_id_to_annotations(annotations) - - ground_truth_dict = {} - for image in gt_data["images"]: - image_path = image["file_name"] - individual_keypoints = { - annotations[i]["individual"]: annotations[i]["keypoints"] - for i in img_to_ann_map[image["id"]] - } - gt_array = np.empty((parameters.max_num_animals, parameters.num_joints, 3)) - gt_array.fill(np.nan) - - # Keep the shape of the ground truth - for idv_idx, idv in enumerate(parameters.individuals): - if idv in individual_keypoints: - keypoints = individual_keypoints[idv].reshape(parameters.num_joints, -1) - gt_array[idv_idx, :, :] = keypoints[:, :3] - - ground_truth_dict[image_path] = gt_array - - return ground_truth_dict - - -def evaluate_ood( - shuffle: Shuffle, - snapshot_indices: list[int] | None = None, -): - df_ood_path = shuffle.project.path / "CollectedData_new.csv" - df_ood = pd.read_csv( - df_ood_path, - index_col=0, - header=[0, 1, 2], - ) - df_ood = df_ood[~df_ood.index.duplicated(keep="first")] - images = [shuffle.project.path / Path(img) for img in df_ood.index] - - snapshots = shuffle.snapshots(detector=False) - if snapshot_indices is not None: - snapshots = [snapshots[i] for i in snapshot_indices] - - loader = DLCLoader( - shuffle.project.config_path(), - trainset_index=0, - shuffle=shuffle.index, - ) - parameters = loader.get_dataset_parameters() - - best_results = {"rmse": 1_000_000} - for snapshot in snapshots: - runner, detector_runner = get_inference_runners( - model_config=shuffle.pytorch_cfg, - snapshot_path=str(snapshot), - max_individuals=parameters.max_num_animals, - num_bodyparts=parameters.num_joints, - num_unique_bodyparts=parameters.num_unique_bpts, - with_identity=False, - transform=None, - detector_path=None, - detector_transform=None, - ) - image_paths = [str(i) for i in images] - print("Running pose prediction") - predictions = runner.inference(tqdm(image_paths)) - poses = { - image_path: image_predictions["bodyparts"][..., :3] - for image_path, image_predictions in zip(image_paths, predictions) - } - - params = loader.get_dataset_parameters() - gt_data = loader.to_coco(str(shuffle.project.path), df_ood, params) - annotations_with_bbox = DLCLoader._compute_bboxes( - gt_data["images"], gt_data["annotations"] - ) - gt_data["annotations"] = annotations_with_bbox - gt_keypoints = load_ground_truth(gt_data, loader.get_dataset_parameters()) - - if parameters.max_num_animals > 1: - poses = pair_predicted_individuals_with_gt(poses, gt_keypoints) - - results = get_scores( - poses, - gt_keypoints, - pcutoff=0.6, - unique_bodypart_poses=None, - unique_bodypart_gt=None, - ) - print(snapshot, results["rmse"]) - if results["rmse"] < best_results["rmse"]: - best_results = results - - return best_results - - -def main(project: Project, train_fraction: float, shuffle_indices: list[int]): - full_results = {"shuffles": []} - for idx in shuffle_indices: - shuffle_results = evaluate_ood( - shuffle=Shuffle(project=project, train_fraction=train_fraction, index=idx), - snapshot_indices=None, - ) - full_results["shuffles"].append(idx) - for k, v in shuffle_results.items(): - metric_list = full_results.get(k, []) - metric_list.append(v) - full_results[k] = metric_list - - print("Results:") - for k, v in full_results.items(): - print(f" {k}: {v}") - - print("mean", np.mean(full_results["rmse"])) - print("std", np.std(full_results["rmse_pcutoff"])) - - -if __name__ == "__main__": - main( - LP_DLC_BENCHMARKS["mirrorFish"], - train_fraction=0.81, - shuffle_indices=[36, 37, 38, 39, 40], - ) diff --git a/benchmark/lightning_pose_tf_eval.py b/benchmark/lightning_pose_tf_eval.py deleted file mode 100644 index 1e59fc2090..0000000000 --- a/benchmark/lightning_pose_tf_eval.py +++ /dev/null @@ -1,320 +0,0 @@ -"""LightningPose Evaluation as used by Matthew R. Whiteway - -Transmitted on January 3rd, 2024 -Forwarded on January 5th, 2024 -""" - -import argparse -import os -from pathlib import Path - -import deeplabcut.utils.auxiliaryfunctions as af -import numpy as np -import pandas as pd -import tensorflow as tf -from tqdm import tqdm -from deeplabcut.pose_estimation_tensorflow import pairwisedistances -from deeplabcut.pose_estimation_tensorflow.config import load_config -from deeplabcut.pose_estimation_tensorflow.core import predict -from deeplabcut.pose_estimation_tensorflow.datasets.utils import data_to_input -from deeplabcut.utils import conversioncode -from deeplabcut.utils.auxfun_videos import imread, imresize - - -DATA_DIR = "/home/niels/datasets/lightning-pose" -DISPLAY_ITERS = 500 -SAVE_ITERS = 5000 -MAX_ITERS = 50000 - - -def pixel_error(keypoints_true: np.ndarray, keypoints_pred: np.ndarray) -> np.ndarray: - """Root mean square error between true and predicted keypoints. - - Taken from https://github.com/danbider/lightning-pose/blob/main/lightning_pose/metrics.py - - Args: - keypoints_true: shape (samples, n_keypoints, 2) - keypoints_pred: shape (samples, n_keypoints, 2) - - Returns: - shape (samples, n_keypoints) - - """ - error = np.linalg.norm(keypoints_true - keypoints_pred, axis=2) - return error - - -def evaluate_network( - config, - csv_file, - resultsfilename, - shuffle=0, - trainingsetindex=0, - gputouse=None, - modelprefix="", - scale=1.0, -): - tf.compat.v1.reset_default_graph() - os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" # - # tf.logging.set_verbosity(tf.logging.WARN) - - # Read file path for pose_config file. >> pass it on - cfg = af.read_config(config) - if gputouse is not None: # gpu selectinon - os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse) - - if trainingsetindex < len(cfg["TrainingFraction"]) and trainingsetindex >= 0: - trainFraction = cfg["TrainingFraction"][int(trainingsetindex)] - else: - raise Exception( - "Please check the trainingsetindex! ", - trainingsetindex, - " should be an integer from 0 .. ", - int(len(cfg["TrainingFraction"]) - 1), - ) - - # Loading human annotatated data - data = pd.read_csv(csv_file, index_col=0, header=[0, 1, 2]) - df_index = data.index.copy() - - ################################################## - # Load and setup CNN part detector - ################################################## - modelfolder = os.path.join( - cfg["project_path"], - str(af.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)), - ) - - path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml" - - try: - dlc_cfg = load_config(str(path_test_config)) - except FileNotFoundError: - raise FileNotFoundError( - "It seems the model for shuffle %s and trainFraction %s does not exist." - % (shuffle, trainFraction) - ) - - # change batch size, if it was edited during analysis! - dlc_cfg["batch_size"] = 1 # in case this was edited for analysis. - - # Check which snapshots are available and sort them by # iterations - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(str(modelfolder), "train")) - if "index" in fn - ] - ) - try: # check if any where found? - Snapshots[0] - except IndexError: - raise FileNotFoundError( - "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so." - % (shuffle, trainFraction) - ) - - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] - snapindex = -1 - - conversioncode.guarantee_multiindex_rows(data) - ################################################## - # Compute predictions over images - ################################################## - # setting weights to corresponding snapshot. - dlc_cfg["init_weights"] = os.path.join( - str(modelfolder), "train", Snapshots[snapindex] - ) - # read how many training siterations that corresponds to. - trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1] - - # Name for deeplabcut net (based on its parameters) - DLCscorer, DLCscorerlegacy = af.get_scorer_name( - cfg, - shuffle, - trainFraction, - trainingsiterations, - modelprefix=modelprefix, - ) - print("Running ", DLCscorer, " with # of training iterations:", trainingsiterations) - - # Specifying state of model (snapshot / training state) - sess, inputs, outputs = predict.setup_pose_prediction(dlc_cfg) - Numimages = len(df_index) - PredicteData = np.zeros((Numimages, 3 * len(dlc_cfg["all_joints_names"]))) - print("Running evaluation ...") - for imageindex, imagename in tqdm(enumerate(df_index)): - image = imread( - os.path.join(cfg["project_path"], imagename), - mode="skimage", - ) - if scale != 1: - image = imresize(image, scale) - - image_batch = data_to_input(image) - # Compute prediction with the CNN - outputs_np = sess.run(outputs, feed_dict={inputs: image_batch}) - scmap, locref = predict.extract_cnn_output(outputs_np, dlc_cfg) - - # Extract maximum scoring location from the heatmap, assume 1 person - pose = predict.argmax_pose_predict(scmap, locref, dlc_cfg["stride"]) - PredicteData[imageindex, :] = pose.flatten() - # NOTE: thereby cfg_test['all_joints_names'] should be same order as bodyparts! - - sess.close() # closes the current tf session - - index = pd.MultiIndex.from_product( - [ - [DLCscorer], - dlc_cfg["all_joints_names"], - ["x", "y", "likelihood"], - ], - names=["scorer", "bodyparts", "coords"], - ) - - # rescale - PredicteData[:, 0::3] /= scale - PredicteData[:, 1::3] /= scale - - # Saving results - DataMachine = pd.DataFrame(PredicteData, columns=index, index=df_index) - # DataMachine.loc[:, ("set", "", "")] = "test" - DataMachine.to_csv(resultsfilename) - - tf.compat.v1.reset_default_graph() - - # compute metrics - conversioncode.guarantee_multiindex_rows(DataMachine) - DataCombined = pd.concat([data.T, DataMachine.T], axis=0, sort=False).T - - rmse, rmse_pcutoff = pairwisedistances( - DataCombined, - cfg["scorer"], - DLCscorer, - cfg["pcutoff"], - bodyparts=None, - ) - test_error = np.nanmean(rmse.values.flatten()) - test_error_pcutoff = np.nanmean(rmse_pcutoff.values.flatten()) - print(f"Test error {test_error:.2f}") - print(f"Test error pcutoff {test_error_pcutoff:.2f}") - - pred_data = DataMachine.drop(labels="likelihood", level=2, axis=1) - num_images = len(pred_data) - lp_pixel_error = pixel_error( - data.to_numpy().reshape((num_images, -1, 2)), - pred_data.to_numpy().reshape((num_images, -1, 2)), - ) - print(f"Test error LP {np.nanmean(lp_pixel_error)}") - return np.nanmean(lp_pixel_error) - - -def run_main(args): - batch_size = 8 - - if args.dataset == "mirror-mouse": - scorer = "rick" - date = "2022-12-02" - date_str = "Dec2" - global_scale = 0.64 - if args.train_frames == 75: - shuffle_list = [750, 751, 752, 753, 754] - trainingsetindex = 0 - trainingset = 49 - else: - shuffle_list = [10, 11, 12, 13, 14] - trainingsetindex = 1 - trainingset = 89 - elif args.dataset == "mirror-fish": - scorer = "rick" - date = "2023-10-26" - date_str = "Oct26" - global_scale = 0.7 - if args.train_frames == 75: - shuffle_list = [750, 751, 752, 753, 754] - trainingsetindex = 0 - trainingset = 81 - else: - shuffle_list = [10, 11, 12, 13, 14] - trainingsetindex = 1 - trainingset = 95 - elif args.dataset == "ibl-pupil": - scorer = "mic" - date = "2022-12-06" - date_str = "Dec6" - global_scale = 1.28 - if args.train_frames == 75: - shuffle_list = [750, 751, 752, 753, 754] - trainingsetindex = 0 - trainingset = 22 - else: - shuffle_list = [10, 11, 12, 13, 14] - trainingsetindex = 1 - trainingset = 89 - elif args.dataset == "ibl-paw": - scorer = "mic" - date = "2023-01-09" - date_str = "Jan9" - global_scale = 1.28 - if args.train_frames == 75: - shuffle_list = [750, 751, 752, 753, 754] - trainingsetindex = 0 - trainingset = 11 - else: - shuffle_list = [10, 11, 12, 13, 14] - trainingsetindex = 1 - trainingset = 89 - else: - raise NotImplementedError - - project_dir = os.path.join(DATA_DIR, "%s-%s-%s" % (args.dataset, scorer, date)) - config_path = os.path.join(project_dir, "config.yaml") - - shuffle_results = [] - for shuffle in shuffle_list: - model_folder = os.path.join( - project_dir, - "dlc-models", - "iteration-0", - "%s%s-trainset%ishuffle%i" - % ( - args.dataset, - date_str, - trainingset, - shuffle, - ), - ) - - # evaluate model on OOD data - print(f"Shuffle {shuffle}") - shuffle_results.append( - evaluate_network( - config_path, - csv_file=os.path.join(project_dir, "CollectedData_new.csv"), - resultsfilename=os.path.join(model_folder, "predictions_new.csv"), - shuffle=shuffle, - trainingsetindex=trainingsetindex, - gputouse=args.gpu_id, - scale=global_scale, - ) - ) - - print(f"Results on all shuffles") - print(f" Mean: {np.mean(shuffle_results):.2f}") - print(f" STD: {np.std(shuffle_results):.2f}") - - -if __name__ == "__main__": - """(dlc) python eval_lp_ood.py --dataset=mirror-fish --gpu_id=0 --train_frames=75""" - """(dlc) python eval_lp_ood.py --dataset=mirror-mouse --gpu_id=0 --train_frames=75""" - - parser = argparse.ArgumentParser() - - # base params - parser.add_argument("--dataset", type=str) - parser.add_argument("--gpu_id", default=0, type=int) - parser.add_argument("--train_frames", type=int) - - namespace, _ = parser.parse_known_args() - run_main(namespace) diff --git a/benchmark/madlc_evaluation.py b/benchmark/madlc_evaluation.py deleted file mode 100644 index ac72448eec..0000000000 --- a/benchmark/madlc_evaluation.py +++ /dev/null @@ -1,162 +0,0 @@ -from collections.abc import Iterable -from pathlib import Path - -import numpy as np -import pandas as pd - -from deeplabcut.benchmark.benchmarks import ( - FishBenchmark, - MarmosetBenchmark, - ParentingMouseBenchmark, - TriMouseBenchmark, -) - - -def check_bodyparts(gt_bodyparts: set[str], predicted_bodyparts: set[str]) -> None: - """Needed for the fish: dfin1 and dfin2 are not predicted""" - valid_bodyparts = set() - missing_bodyparts = set() - for bpt in gt_bodyparts: - if bpt in predicted_bodyparts: - valid_bodyparts.add(bpt) - else: - missing_bodyparts.add(bpt) - - extra_bodyparts = predicted_bodyparts - valid_bodyparts - if len(extra_bodyparts) > 0: - print( - "WARNING: Found bodyparts in predictions that are not in ground truth:" - f"{list(extra_bodyparts)}" - ) - if len(missing_bodyparts) > 0: - print( - f"WARNING: Some GT bodyparts have no predictions: {list(missing_bodyparts)}" - ) - - -def parse_dlc_df( - df: pd.DataFrame, - ground_truth_bodyparts: set[str], -) -> dict[str, list[dict]]: - scorers = set(df.columns.get_level_values(0)) - if len(scorers) > 1: - raise ValueError( - f"There should only be 1 scorer in the predictions DF. Found {scorers}" - ) - scorer = scorers.pop() - - individuals = set(df.columns.get_level_values(1)) - bodyparts = set(df.columns.get_level_values(2)) - check_bodyparts(ground_truth_bodyparts, bodyparts) - - data = {} - for row, df_image in df.iterrows(): - if isinstance(row, str): - image_path = row - elif isinstance(row, Iterable): - image_path = str(Path(*row)) - else: - raise ValueError(f"Cannot parse row {row}") - - data[image_path] = [] - df_all_individuals = df_image.loc[scorer] - for idv in individuals: - df_idv = df_all_individuals.loc[idv] - keypoints = {} - scores = [] - for bpt in ground_truth_bodyparts: - if bpt in df_idv.index.get_level_values(0): - df_bpt = df_idv.loc[bpt] - scores.append(df_bpt.likelihood) - keypoints[bpt] = (df_bpt.x, df_bpt.y) - else: - keypoints[bpt] = (np.nan, np.nan) - - if len(keypoints) > 0: - data[image_path].append( - { - "pose": keypoints, - "score": np.mean(scores), - } - ) - - return data - - -class DLC3Benchmark: - """A benchmark for DLC3 Models""" - - def __init__(self, models: dict[str, str]) -> None: - super().__init__() - self._names = list(models.keys()) - self.data = {} - for name, predictions in models.items(): - df_predictions = pd.read_hdf(predictions) - if not isinstance(df_predictions, pd.DataFrame): - raise ValueError( - f"Failed to parse {predictions} - not a dataframe: {df_predictions}" - ) - self.data[name] = parse_dlc_df(df_predictions, set(self.keypoints)) - - def names(self): - """An iterable of model names to evaluate.""" - return self._names - - def get_predictions(self, name: str): - return self.data[name] - - -class DLC3FishBenchmark(DLC3Benchmark, FishBenchmark): - code = "link/to/your/code.git" - - -class DLC3MarmosetBenchmark(DLC3Benchmark, MarmosetBenchmark): - code = "link/to/your/code.git" - - -class DLC3ParentingBenchmark(DLC3Benchmark, ParentingMouseBenchmark): - code = "link/to/your/code.git" - - -class DLC3TrimouseBenchmark(DLC3Benchmark, TriMouseBenchmark): - code = "link/to/your/code.git" - - -def name_to_snapshot_index(filename: str) -> int: - return int(Path(filename).stem.split("-")[-1]) - - -def main(output_dir: Path, test_hash: str): - experiments = [p for p in (output_dir / test_hash).iterdir() if p.is_dir()] - experiments = sorted(experiments, key=lambda s: int(s.stem.split("shuffle")[-1])) - for exp in experiments: - benchmark_name = exp.name.split("-")[0] - benchmark_factory = BENCHMARKS[benchmark_name] - - print(120 * "-") - print(f"Results for {exp}") - models = {p.name: p for p in exp.iterdir() if p.suffix == ".h5"} - - b = benchmark_factory(models=models) - for model in sorted(models.keys(), key=lambda k: name_to_snapshot_index(k)): - result = b.evaluate(model) - print( - f"{result.method_name}, {result.benchmark_name}: " - f"{result.mean_avg_precision:.4f} mAP, " - f"{result.root_mean_squared_error:.2f} RMSE" - ) - - -BENCHMARKS = { - "fishMay7": DLC3FishBenchmark, - "pupsMar24": DLC3ParentingBenchmark, - "trimiceJun22": DLC3TrimouseBenchmark, - "marmosetMay7": DLC3MarmosetBenchmark, -} - - -if __name__ == "__main__": - main( - output_dir=Path("outputs"), - test_hash="2023_12_07_fc2f00e2", - ) diff --git a/benchmark/madlc_test_inference.py b/benchmark/madlc_test_inference.py deleted file mode 100644 index 1ed0c744a9..0000000000 --- a/benchmark/madlc_test_inference.py +++ /dev/null @@ -1,213 +0,0 @@ -""" Benchmarking maDLC datasets - inference - -This script can be used to run inference on the test images of a DeepLabCut project. -""" - -from __future__ import annotations - -from pathlib import Path - -import numpy as np -import pandas as pd -from deeplabcut.pose_estimation_pytorch import PoseDatasetParameters -from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners -from deeplabcut.utils.visualization import make_labeled_images_from_dataframe -from ruamel.yaml import YAML -from tqdm import tqdm - -from projects import MA_DLC_BENCHMARKS -from utils import Project, Shuffle - - -def run_inference_on_all_images( - project: Project, - snapshot: Path, - save_as_csv: bool, - plot: bool, - detector_snapshot: Path | None = None, -) -> None: - pytorch_config_path = snapshot.parent / "pytorch_config.yaml" - with open(pytorch_config_path, "r") as file: - pytorch_config = YAML(typ="safe", pure=True).load(pytorch_config_path) - - parameters = PoseDatasetParameters( - bodyparts=pytorch_config["metadata"]["bodyparts"], - unique_bpts=pytorch_config["metadata"]["unique_bodyparts"], - individuals=pytorch_config["metadata"]["individuals"], - with_center_keypoints=pytorch_config.get("with_center_keypoints", False), - color_mode=pytorch_config.get("color_mode", "RGB"), - cropped_image_size=pytorch_config.get("output_size", (256, 256)), - ) - shuffle_name = snapshot.parent.parent.name - test_data_dir = project.root / "test-images" / project.name / "labeled-data" - video_folders = [p for p in test_data_dir.iterdir() if p.is_dir()] - images = [] - for video_folder in video_folders: - images += [ - p # f"labeled-data/{video_folder.name}/{p.name}" - for p in video_folder.iterdir() - if p.suffix == ".png" - ] - - runner, detector_runner = get_inference_runners( - model_config=pytorch_config, - snapshot_path=str(snapshot), - max_individuals=parameters.max_num_animals, - num_bodyparts=parameters.num_joints, - num_unique_bodyparts=parameters.num_unique_bpts, - with_identity=False, # TODO: implement - transform=None, - detector_path=str(detector_snapshot), - detector_transform=None, - ) - - pose_inputs = [str(i) for i in images] - if detector_runner is not None: - print("Running detection") - bbox_predictions = detector_runner.inference(images=tqdm(pose_inputs)) - pose_inputs = list(zip(pose_inputs, bbox_predictions)) - - print("Running pose prediction") - predictions = runner.inference(tqdm(pose_inputs)) - poses = np.array([p["bodyparts"] for p in predictions]) - poses = poses[..., :3] - - if detector_snapshot is None: - scorer = f"{shuffle_name}-{snapshot.stem}" - else: - scorer = f"{shuffle_name}-{detector_snapshot.stem}-{snapshot.stem}" - - output_path = ( - project.root - / "test-images" - / project.name - / "evaluation-results" - / f"iteration-{project.iteration}" - / shuffle_name - / "benchmark" - / f"{scorer}.h5" - ) - output_path.parent.mkdir(exist_ok=True, parents=True) - - index = pd.MultiIndex.from_tuples( - [(f"labeled-data", f"{i.parent.name}", f"{i.name}") for i in images], - names=["dir", "video", "image"], - ) - columns = pd.MultiIndex.from_product( - [ - [scorer], - project.cfg["individuals"], - project.cfg["multianimalbodyparts"], - ["x", "y", "likelihood"], - ], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - poses = poses.reshape(len(images), -1) - if parameters.num_unique_bpts > 0: - unique_columns = pd.MultiIndex.from_product( - [ - [scorer], - ["single"], - parameters.unique_bpts, - ["x", "y", "likelihood"], - ], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - columns = columns.append(unique_columns) - unique_poses = np.array([p["unique_bodyparts"] for p in predictions]) - unique_poses = unique_poses[..., :3] - unique_poses = unique_poses.reshape(len(images), -1) - poses = np.concatenate([poses, unique_poses], axis=1) - - df = pd.DataFrame(poses, index=index, columns=columns) - df.to_hdf(output_path, key="df_with_missing") - if save_as_csv: - df.to_csv(output_path.with_suffix(".csv")) - - if plot: - test_config_path = str( - project.root / "test-images" / project.name / "config.yaml" - ) - with open(test_config_path, "r") as file: - test_config = YAML(typ="safe", pure=True).load(file) - - image_output_folder = output_path.parent / "images" - image_output_folder.mkdir(exist_ok=True) - for video in video_folders: - index_filter = [v == video.name for v in df.index.get_level_values("video")] - col_filter = [ - c in ("x", "y") and bpt not in ("dfin1", "dfin2") - for c, bpt in zip( - df.columns.get_level_values("coords"), - df.columns.get_level_values("bodyparts"), - ) - ] - df_video = df.loc[index_filter, col_filter] - plot_output_folder = image_output_folder / video.name - make_labeled_images_from_dataframe( - df_video, - test_config, - destfolder=str(plot_output_folder), - scale=1.0, - dpi=200, - keypoint="+", - draw_skeleton=False, - color_by="bodypart", - ) - - -def main( - shuffle: Shuffle, - snapshot_indices: int | list[int] | None = None, - detector_snapshot_indices: int | list[int] | None = None, - save_as_csv: bool = False, - plot: bool = False, -) -> None: - """ - - Args: - shuffle: - snapshot_indices: - detector_snapshot_indices: - save_as_csv: - plot: - - Returns: - - """ - if isinstance(snapshot_indices, int): - snapshot_indices = [snapshot_indices] - if isinstance(detector_snapshot_indices, int): - detector_snapshot_indices = [detector_snapshot_indices] - - detectors = [None] - if shuffle.pytorch_cfg.get("method", "bu").lower() == "td": - detectors = shuffle.snapshots(detector=True) - if detector_snapshot_indices is not None: - detectors = [detectors[idx] for idx in detector_snapshot_indices] - print(f"Running inference with detectors: {[s.name for s in detectors]}") - - snapshots = shuffle.snapshots() - if snapshot_indices is not None: - snapshots = [snapshots[idx] for idx in snapshot_indices] - print(f"Running inference with snapshots: {[s.name for s in snapshots]}") - - for detector in detectors: - for snapshot in snapshots: - run_inference_on_all_images( - shuffle.project, snapshot, save_as_csv, plot, detector - ) - - -if __name__ == "__main__": - main( - shuffle=Shuffle( - project=MA_DLC_BENCHMARKS["trimouse"], - index=0, - train_fraction=0.95, - ), - snapshot_indices=None, - detector_snapshot_indices=-1, - save_as_csv=False, - plot=False, - ) diff --git a/benchmark/projects.py b/benchmark/projects.py deleted file mode 100644 index 143de3db2c..0000000000 --- a/benchmark/projects.py +++ /dev/null @@ -1,46 +0,0 @@ -"""DeepLabCut projects to benchmark""" - -from __future__ import annotations - -from pathlib import Path - -from utils import Project - -MA_DLC_DATA_ROOT = Path("/home/niels/datasets/ma_dlc") -SA_DLC_DATA_ROOT = Path("/home/niels/datasets/single_animal_dlc") - -MA_DLC_BENCHMARKS = { - "trimouse": Project( - root=MA_DLC_DATA_ROOT, - name="trimice-dlc-2021-06-22", - iteration=1, - ), - "fish": Project( - root=MA_DLC_DATA_ROOT, - name="fish-dlc-2021-05-07", - iteration=1, - ), - "parenting": Project( - root=MA_DLC_DATA_ROOT, - name="pups-dlc-2021-03-24", - iteration=1, - ), - "marmoset": Project( - root=MA_DLC_DATA_ROOT, - name="marmoset-dlc-2021-05-07", - iteration=1, - ), -} - -SA_DLC_BENCHMARKS = { - "fly": Project( - root=SA_DLC_DATA_ROOT, - name="Fly-Kevin-2019-03-16", - iteration=2, - ), - "openfield": Project( - root=SA_DLC_DATA_ROOT, - name="openfield-Pranav-2018-08-20", - iteration=2, - ), -} diff --git a/benchmark/utils.py b/benchmark/utils.py deleted file mode 100644 index 9ce68c6571..0000000000 --- a/benchmark/utils.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Util methods and classes for DeepLabCut Benchmarking""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path - -import pandas as pd - -import deeplabcut as dlc -import deeplabcut.pose_estimation_pytorch.apis.utils as api_utils -import deeplabcut.utils.auxiliaryfunctions as af -from deeplabcut.core.engine import Engine -from deeplabcut.pose_estimation_pytorch.task import Task - - -@dataclass -class Project: - """ - Attributes: - root: the path where the project folder is stored - name: the name of the project - iteration: the iteration of the project - """ - - root: Path - name: str - iteration: int - - def __post_init__(self) -> None: - self._cfg = None - - @property - def cfg(self) -> dict: - if self._cfg is None: - self._cfg = dlc.utils.auxiliaryfunctions.read_config(self.config_path()) - return self._cfg - - @property - def date(self) -> str: - return self.cfg["date"] - - @property - def path(self) -> Path: - return self.root / self.name - - @property - def shuffle_prefix(self) -> str: - return self.task + self.date - - @property - def task(self) -> str: - return self.cfg["Task"] - - def config_path(self) -> str: - return str(self.root / self.name / "config.yaml") - - def update_iteration_in_config(self) -> None: - dlc.auxiliaryfunctions.edit_config( - self.config_path(), - {"iteration": self.iteration}, - ) - - def get_shuffle_folder(self, model_prefix: str | None = None): - base = self.root / self.name - if model_prefix is not None: - base = base / model_prefix - return base / Engine.PYTORCH.model_folder_name / f"iteration-{self.iteration}" - - def get_shuffle_path( - self, shuffle_index: int, trainset_index: int, model_prefix: str | None = None - ) -> Path: - base_dir = self.get_shuffle_folder(model_prefix=model_prefix) - train_fraction = 100 * self.cfg["TrainingFraction"][trainset_index] - shuffle_name = ( - f"{self.shuffle_prefix}-trainset{train_fraction}shuffle{shuffle_index}" - ) - return base_dir / shuffle_name - - -@dataclass -class Shuffle: - project: Project - train_fraction: float - index: int - model_prefix: str | None = None - - def __post_init__(self): - self.model_prefix_ = self.model_prefix if self.model_prefix is not None else "" - self.model_folder = self.project.path / af.get_model_folder( - self.train_fraction, - self.index, - self.project.cfg, - engine=Engine.PYTORCH, - modelprefix=self.model_prefix_, - ) - self.trainset_folder = af.get_training_set_folder(self.project.cfg) - self._metadata = None - self._pytorch_cfg = None - - @property - def pytorch_cfg_path(self) -> Path: - return self.model_folder / "train" / "pytorch_config.yaml" - - @property - def pytorch_cfg(self) -> dict: - if self._pytorch_cfg is None: - self._pytorch_cfg = af.read_plainconfig(str(self.pytorch_cfg_path)) - - return self._pytorch_cfg - - @property - def test_indices(self): - self._lazy_load_metadata() - return self._metadata[2] - - @property - def train_indices(self): - self._lazy_load_metadata() - return self._metadata[1] - - @property - def trainset_index(self) -> int: - return self.project.cfg["TrainingFraction"].index(self.train_fraction) - - def snapshots(self, detector: bool = False) -> list[Path]: - task = Task(self.pytorch_cfg["method"]) - if detector: - task = Task.DETECT - return [ - s.path - for s in api_utils.get_model_snapshots( - index="all", - model_folder=self.model_folder / "train", - task=task, - ) - ] - - def scorer(self, index: int | None = None, epochs: int | None = None) -> str: - if (index is None and epochs is None) or ( - index is not None and epochs is not None - ): - raise ValueError( - f"Exactly one of (index, epochs) must be given: had {index}, {epochs}" - ) - - if index is None: - index = self.epochs_to_snapshot_index(epochs) - snapshot = api_utils.get_model_snapshots( - index=index, - model_folder=self.model_folder / "train", - task=Task(self.pytorch_cfg["method"]), - )[0] - dlc_scorer, _ = af.get_scorer_name( - self.project.cfg, - self.index, - self.train_fraction, - trainingsiterations=api_utils.get_scorer_uid(snapshot, None), - engine=Engine.PYTORCH, - modelprefix=self.model_prefix_, - ) - return dlc_scorer - - def ground_truth(self) -> pd.DataFrame: - path_gt = ( - self.project.path - / self.trainset_folder - / f"CollectedData_{self.project.cfg['scorer']}.h5" - ) - df_ground_truth = pd.read_hdf(path_gt) - if not isinstance(df_ground_truth, pd.DataFrame): - raise ValueError( - f"Ground truth data did not contain a dataframe: {df_ground_truth}" - ) - - return api_utils.ensure_multianimal_df_format(df_ground_truth) - - def predictions( - self, index: int | None = None, epochs: int | None = None - ) -> pd.DataFrame: - if (index is None and epochs is None) or ( - index is not None and epochs is not None - ): - raise ValueError( - f"Exactly one of (index, epochs) must be given: had {index}, {epochs}" - ) - - if index is None: - index = self.epochs_to_snapshot_index(epochs) - - path_eval = ( - self.project.path - / Engine.PYTORCH.results_folder_name - / f"iteration-{self.project.iteration}" - / self.model_folder.name - ) - scorer = self.scorer(index=index, epochs=epochs) - epochs = scorer.split("_")[-1] - path_predictions = path_eval / f"{scorer}-snapshot-{epochs}.h5" - df_predictions = pd.read_hdf(path_predictions) - if not isinstance(df_predictions, pd.DataFrame): - raise ValueError( - f"Predictions data did not contain a dataframe: {df_predictions}" - ) - - return df_predictions - - def epochs_to_snapshot_index(self, epochs: int) -> int: - paths = self.snapshots() - snapshot_epochs = [int(s.stem.split("-")[-1]) for s in paths] - try: - index = snapshot_epochs.index(epochs) - except ValueError: - raise ValueError( - f"Could not find a snapshot trained for {epochs} epochs in {self}." - f" Found the following snapshots: {[s.name for s in paths]}" - ) - - return index - - def _lazy_load_metadata(self) -> None: - if self._metadata is None: - self._metadata = _get_model_folder( - project_path=self.project.path, - project_config=self.project.cfg, - trainset_folder=str(self.trainset_folder), - train_fraction=self.train_fraction, - shuffle_index=self.index, - ) - - -def create_shuffles( - project: Project, - splits_file: Path, - trainset_index: int, - net_type: str, -) -> list[int]: - """Creates shuffles for a project using predefined train/test splits - - Creates train/test splits according to what is defined in a file (can be created - with `create_train_test_splits.py`). If there are already shuffles for this - iteration of the project, the index of the first shuffle created will be 1 more - than the current max (i.e., if shuffle1 and shuffle2 already exist, the first - shuffle created will be called ...-shuffle3). - - The splits file must have format: - { - "project_name": { - "train_fraction": [ - {"train": list[int], "test": list[int]} # image indices in the train and test set - } - } - - Example file: - { - "openfield-Pranav-2018-08-20": { - 0.8: [ - {"train": [0, 1, 3, 4], "test": [2]}, # split 1 - {"train": [0, 1, 2, 3], "test": [4]}, # split 2 - {"train": [0, 1, 2, 3], "test": [4]}, # split 3 - ] - }, - "Fly-Kevin-2019-03-16": { - 0.8: [ - {"train": [0, 1, 3, 4, 5, 6, 7, 8], "test": [2, 9]}, - {"train": [0, 1, 2, 3, 6, 7, 8, 9], "test": [4, 5]} - ] - 0.9: [ - {"train": [0, 1, 2, 3, 5, 6, 7, 8, 9], "test": [4]}, - ] - } - } - - Args: - project: the project to create shuffles for - splits_file: the splits containing the train and test indices - trainset_index: the index of the training fractions to create the shuffles with - net_type: the type of neural net to create the shuffles with - - Returns: - the shuffle indices created - """ - shuffle_folder = project.get_shuffle_folder(model_prefix=None) - shuffle_indices = [] - if shuffle_folder.exists(): - existing_shuffles = [ - p - for p in project.get_shuffle_folder(model_prefix=None).iterdir() - if p.is_dir() - ] - shuffle_indices = [int(s.name.split("shuffle")[1]) for s in existing_shuffles] - - if len(shuffle_indices) == 0: - next_index = 1 - elif len(shuffle_indices) == 1: - next_index = shuffle_indices[0] + 1 - else: - next_index = max(*shuffle_indices) + 1 - - train_fraction = project.cfg["TrainingFraction"][trainset_index] - with open(splits_file, "r") as f: - raw_data = json.load(f) - - splits = raw_data[project.name][str(train_fraction)] - train_indices = [s["train"] for s in splits] - test_indices = [s["test"] for s in splits] - shuffles_to_create = [i for i in range(next_index, next_index + len(train_indices))] - - print(f"Creating training datasets with indices {shuffles_to_create} and splits:") - for s in splits: - print(f" train=[{s['train'][:10]}...], test=[{s['test'][:10]}...]") - - dlc.create_training_dataset( - project.config_path(), - Shuffles=shuffles_to_create, - trainIndices=train_indices, - testIndices=test_indices, - net_type=net_type, - augmenter_type="imgaug", - engine=Engine.PYTORCH, - ) - return shuffles_to_create - - -def _get_model_folder( - project_path: Path, - project_config: dict, - trainset_folder: str, - train_fraction: float, - shuffle_index: int, -) -> tuple[dict, list[int], list[int]]: - _, metadata_filename = af.get_data_and_metadata_filenames( - trainset_folder, - train_fraction, - shuffle_index, - project_config, - ) - metadata = af.load_metadata(str(project_path / metadata_filename)) - return metadata[0], [int(i) for i in metadata[1]], [int(i) for i in metadata[2]] - - -@dataclass -class WandBConfig: - project: str - run_name: str - image_log_interval: int | None = None - save_code: bool = True - tags: tuple[str, ...] | None = None - group: str | None = None - - def data(self) -> dict: - return dict( - type="WandbLogger", - project_name=self.project, - run_name=self.run_name, - image_log_interval=self.image_log_interval, - save_code=self.save_code, - tags=self.tags, - group=self.group, - ) diff --git a/benchmark/utils_augmentation.py b/benchmark/utils_augmentation.py deleted file mode 100644 index a9e3213373..0000000000 --- a/benchmark/utils_augmentation.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class BatchCollate: - """Resize + scale images when batching""" - min_scale: float - max_scale: float - min_short_side: int = 256 - max_short_side: int = 1152 - multiple_of: int | None = None - max_ratio: float = 2.0 - to_square: bool = False - - def data(self) -> dict: - return { - "type": "ResizeFromDataSizeCollate", - "min_scale": self.min_scale, - "max_scale": self.max_scale, - "min_short_side": self.min_short_side, - "max_short_side": self.max_short_side, - "max_ratio": self.max_ratio, - "multiple_of": self.multiple_of, - "to_square": self.to_square, - } - - -@dataclass -class AutoPadding: - """Random crop around keypoints""" - pad_height_divisor: int - pad_width_divisor: int - border_mode: str = "constant" - - def data(self) -> dict: - return { - "pad_height_divisor": self.pad_height_divisor, - "pad_width_divisor": self.pad_width_divisor, - "border_mode": self.border_mode, - } - - -@dataclass -class AffineAugmentation: - """An affine image augmentation""" - - p: float = 0.5 - rotation: int = 0 - scale: tuple[float, float] = (1, 1) - translation: int = 0 - - def data(self) -> dict: - return { - "p": self.p, - "scaling": self.scale, - "rotation": self.rotation, - "translation": self.translation, - } - - -@dataclass -class CropSampling: - """Random crop around keypoints""" - width: int - height: int - max_shift: float = 0.4 - method: str = "uniform" # "uniform", "keypoints", "density", "hybrid" - - def __post_init__(self): - assert self.method in ("uniform", "keypoints", "density", "hybrid") - assert 0 <= self.max_shift <= 1 - - def data(self) -> dict: - return { - "width": self.width, - "height": self.height, - "max_shift": self.max_shift, - "method": self.method, - } - - -@dataclass -class ImageAugmentations: - """ - The default augmentation only normalizes images. - - Examples: - gaussian_noise: 12.75 - resize: {height: 800, width: 800, keep_ratio: true} - rotation: 30 - scale_jitter: (0.5, 1.25) - translation: 40 - """ - - normalize: bool = True - affine: AffineAugmentation | None = None - auto_padding: AutoPadding | None = None - covering: bool = False - gaussian_noise: float | bool = False - hist_eq: bool = False - motion_blur: bool = False - resize: dict | None = None - crop_sampling: CropSampling | None = None - collate: BatchCollate | None = None - - def data(self) -> dict: - augmentations = { - "normalize_images": self.normalize, - "covering": self.covering, - "gaussian_noise": self.gaussian_noise, - "hist_eq": self.hist_eq, - "motion_blur": self.motion_blur, - "auto_padding": False, - "affine": False, - "resize": False, - "crop_sampling": False, - "collate": False, - } - if self.auto_padding is not None: - augmentations["auto_padding"] = self.auto_padding.data() - if self.affine is not None: - augmentations["affine"] = self.affine.data() - if self.resize is not None: - augmentations["resize"] = self.resize - if self.crop_sampling is not None: - augmentations["crop_sampling"] = self.crop_sampling.data() - if self.collate is not None: - augmentations["collate"] = self.collate.data() - return augmentations diff --git a/benchmark/utils_models.py b/benchmark/utils_models.py deleted file mode 100644 index 1e7273b133..0000000000 --- a/benchmark/utils_models.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, asdict - -from benchmark_train import TrainParameters -from utils import WandBConfig -from utils_augmentation import ImageAugmentations - - -@dataclass -class BackboneConfig: - """ - Attributes: - model_name: the timm model name ("resnet50", "resnet50_gn", "hrnet_w18", ...) - output_stride: 8, 16 or 32 (HRNet only supports 32) - freeze_bn_weights: freeze batch norm weights - freeze_bn_stats: freeze batch norm stats - kwargs: any keyword-arguments for the backbone type that was selected, e.g. - HRNet: ``only_high_res: bool`` only use the high-resolution branch as the - image features (otherwise, in DEKR style all branches are interpolated - to the same shape and concatenated). - """ - - model_name: str = "resnet50" - output_stride: int | None = None - freeze_bn_weights: bool | None = None - freeze_bn_stats: bool | None = None - drop_path_rate: float | None = None - drop_block_rate: float | None = None - kwargs: dict | None = None - - def to_dict(self) -> dict: - config = asdict(self) - config.pop("kwargs") - for k in list(config.keys()): - if config[k] is None: - config.pop(k) - if self.kwargs is not None: - for k, v in self.kwargs.items(): - config[k] = v - return config - - -@dataclass -class HeadConfig: - plateau_targets: bool - heatmap_config: dict - locref_config: dict | None - - def to_dict(self) -> dict: - output_channels = self.heatmap_config["channels"][-1] - if self.heatmap_config.get("final_conv") is not None: - output_channels = self.heatmap_config["final_conv"]["out_channels"] - predictor = dict( - type="HeatmapPredictor", - location_refinement=self.locref_config is not None, - locref_std=7.2801, - ) - target_generator = dict( - type=( - "HeatmapPlateauGenerator" - if self.plateau_targets - else "HeatmapGaussianGenerator" - ), - num_heatmaps=output_channels, - pos_dist_thresh=17, - heatmap_mode="KEYPOINT", - generate_locref=self.locref_config is not None, - locref_std=7.2801, - ) - criterion = dict(heatmap=dict(type="WeightedBCECriterion", weight=1.0)) - if self.locref_config is not None: - criterion["locref"] = dict(type="WeightedHuberCriterion", weight=0.05) - - return dict( - type="HeatmapHead", - predictor=predictor, - target_generator=target_generator, - criterion=criterion, - heatmap_config=self.heatmap_config, - locref_config=self.locref_config, - ) - - @staticmethod - def build_plateau_head( - c_in: int, - c_out: int, - deconv: list[tuple[int, int, int]], # channel, kernel, stride - final_conv: bool = False, - ) -> HeadConfig: - heatmap = dict(channels=[c_in], kernel_size=[], strides=[], final_conv=None) - locref = dict(channels=[c_in], kernel_size=[], strides=[], final_conv=None) - for c, k, s in deconv: - for config in (heatmap, locref): - config["channels"].append(c) - config["kernel_size"].append(k) - config["strides"].append(s) - - if final_conv: - heatmap["final_conv"] = dict(out_channels=c_out, kernel_size=1) - locref["final_conv"] = dict(out_channels=2 * c_out, kernel_size=1) - else: - assert deconv[-1][0] == c_out - locref["channels"][-1] = 2 * c_out - - return HeadConfig( - plateau_targets=True, - heatmap_config=heatmap, - locref_config=locref, - ) - - -@dataclass -class DetectorConfig(TrainParameters): - train_aug: ImageAugmentations | None = None - inference_aug: ImageAugmentations | None = None - optimizer_config: dict | None = None - scheduler_config: dict | None = None - - def train_kwargs(self) -> dict: - kwargs = super().train_kwargs() - if self.train_aug is not None: - data = kwargs.get("data", {}) - data["train"] = self.train_aug.data() - kwargs["data"] = data - if self.inference_aug is not None: - data = kwargs.get("data", {}) - data["inference"] = self.inference_aug.data() - kwargs["data"] = data - if self.optimizer_config is not None: - kwargs["runner"]["optimizer"] = self.optimizer_config - if self.scheduler_config is not None: - kwargs["runner"]["scheduler"] = self.scheduler_config - return kwargs - - -@dataclass -class ModelConfig(TrainParameters): - net_type: str = "resnet_50" - train_aug: ImageAugmentations | None = None - inference_aug: ImageAugmentations | None = None - backbone_config: BackboneConfig | None = None - head_config: HeadConfig | None = None - optimizer_config: dict | None = None - scheduler_config: dict | None = None - wandb_config: WandBConfig | None = None - - def train_kwargs(self) -> dict: - kwargs = super().train_kwargs() - if self.train_aug is not None: - data = kwargs.get("data", {}) - data["train"] = self.train_aug.data() - kwargs["data"] = data - if self.inference_aug is not None: - data = kwargs.get("data", {}) - data["inference"] = self.inference_aug.data() - kwargs["data"] = data - if self.backbone_config is not None: - kwargs["model"] = dict(backbone=self.backbone_config.to_dict()) - if self.head_config is not None: - model_config = kwargs.get("model", {}) - model_config["heads"] = dict(bodypart=self.head_config.to_dict()) - kwargs["model"] = model_config - if self.wandb_config is not None: - kwargs["logger"] = self.wandb_config.data() - if self.optimizer_config is not None: - runner = kwargs.get("runner", {}) - runner["optimizer"] = self.optimizer_config - kwargs["runner"] = runner - if self.scheduler_config is not None: - runner = kwargs.get("runner", {}) - runner["scheduler"] = self.scheduler_config - kwargs["runner"] = runner - return kwargs diff --git a/benchmark_superanimal/memory_replay_example.py b/benchmark_superanimal/memory_replay_example.py deleted file mode 100644 index 58a1ac6eff..0000000000 --- a/benchmark_superanimal/memory_replay_example.py +++ /dev/null @@ -1,61 +0,0 @@ -from pathlib import Path - -import deeplabcut -from deeplabcut.core.engine import Engine -from deeplabcut.core.weight_init import WeightInitialization -from deeplabcut.modelzoo.utils import ( - create_conversion_table, - read_conversion_table_from_csv, -) -from deeplabcut.pose_estimation_pytorch.modelzoo.config import ( - create_config_from_modelzoo, - write_pytorch_config_for_memory_replay, -) -from deeplabcut.utils.pseudo_label import keypoint_matching - -dlc_proj_root = Path("/mnt/md0/shaokai/daniel3mouse") -config_path = str(dlc_proj_root / "config.yaml") -superanimal_name = "superanimal_topviewmouse" -model_name = "hrnetw32" -shuffle = 0 -max_individuals = 3 -device = "cuda" - -# keypoint matching before create training dataset -# keypoint matching creates pseudo prediction and a conversion table - -keypoint_matching( - config_path, - superanimal_name, - model_name, -) - - -# keypoint matching creates a memory_replay folder in the root. The conversion table can be read from there -conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" - -table = create_conversion_table( - config=config_path, - super_animal=superanimal_name, - project_to_super_animal=read_conversion_table_from_csv(conversion_table_path), -) - -weight_init = WeightInitialization( - dataset=superanimal_name, - conversion_array=table.to_array(), - with_decoder=True, - memory_replay=True, -) - - -deeplabcut.create_training_dataset( - config_path, - Shuffles=[shuffle], - net_type="top_down_hrnet_w32", - weight_init=weight_init, - engine=Engine.PYTORCH, - userfeedback=False, -) - -# passing pose_threshold controls the behavior of memory replay. We discard predictions that are lower than the threshold -deeplabcut.train_network(config_path, shuffle=shuffle, device=device, pose_threshold = 0.1) diff --git a/benchmark_superanimal/train.py b/benchmark_superanimal/train.py deleted file mode 100644 index 554c9347dc..0000000000 --- a/benchmark_superanimal/train.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Fine-tuning SuperAnimal models""" -from __future__ import annotations - -import pickle -from pathlib import Path - -import deeplabcut -import deeplabcut.utils.auxiliaryfunctions as af -from deeplabcut.core.engine import Engine -from deeplabcut.core.weight_init import WeightInitialization -from deeplabcut.generate_training_dataset import TrainingDatasetMetadata -from deeplabcut.modelzoo.utils import create_conversion_table -from deeplabcut.pose_estimation_pytorch import DLCLoader - - -def create_shuffles(config_path: Path, super_animal: str): - """ - Iteration 0: the three data splits given by Shaokai - Iteration 1: trained model on these data splits - Shuffle 1001: Split 1, 1% data - Shuffle 1005: Split 1, 5% data - Shuffle 1010: Split 1, 10% data - Shuffle 1050: Split 1, 50% data - Shuffle 1100: Split 1, 100% data - - Shuffle 2001: Split 2, 1% data - Shuffle 2005: Split 2, 5% data - Shuffle 2010: Split 2, 10% data - Shuffle 2050: Split 2, 50% data - Shuffle 2100: Split 2, 100% data - - Shuffle 3001: Split 3, 1% data - Shuffle 3005: Split 3, 5% data - Shuffle 3010: Split 3, 10% data - Shuffle 3050: Split 3, 50% data - Shuffle 3100: Split 3, 100% data - """ - project_path = config_path.parent - split_folder = ( - project_path / - "training-datasets" / - "iteration-0" / - "UnaugmentedDataSet_openfieldAug20" - ) - - data_splits = [1, 2, 3] - frac_train_data_used = [0.01, 0.05, 0.1, 0.5, 1] - - shuffles = [] - train_indices = [] - test_indices = [] - - for split in data_splits: - split_name = f"Documentation_data-openfield_95shuffle{split - 1}.pickle" - path_metadata = split_folder / split_name - with open(path_metadata, "rb") as f: - _, train_idx, test_idx, _ = pickle.load(f) - - num_train_images = len(train_idx) - for frac in frac_train_data_used: - shuffle_idx = (1000 * split) + int(100 * frac) - num_samples = int(frac * num_train_images) # as done by Shaokai - - shuffles.append(shuffle_idx) - train_indices.append(train_idx[:num_samples]) - test_indices.append(test_idx) - - cfg = af.read_config(config_path) - weight_init = WeightInitialization.build(cfg, super_animal, with_decoder=True) - deeplabcut.create_training_dataset( - str(config_path), - Shuffles=shuffles, - trainIndices=train_indices, - testIndices=test_indices, - userfeedback=False, - weight_init=weight_init, - engine=Engine.PYTORCH, - ) - - -def create_transfer_learning_shuffles( - config_path: Path, - net_type: str, - super_animal: str | None, -): - project_path = config_path.parent - split_folder = ( - project_path / - "training-datasets" / - "iteration-0" / - "UnaugmentedDataSet_openfieldAug20" - ) - - data_splits = [1, 2, 3] - frac_train_data_used = [0.01, 0.05, 0.1, 0.5, 1] - - weight_init = None - if super_animal is not None: - weight_init = WeightInitialization(dataset=super_animal, with_decoder=False) - - shuffles = [] - train_indices = [] - test_indices = [] - - for split in data_splits: - split_name = f"Documentation_data-openfield_95shuffle{split - 1}.pickle" - path_metadata = split_folder / split_name - with open(path_metadata, "rb") as f: - _, train_idx, test_idx, _ = pickle.load(f) - - num_train_images = len(train_idx) - for frac in frac_train_data_used: - if super_animal == "superanimal_topviewmouse": - shuffle_idx = 50_000 + (1000 * split) + int(100 * frac) - elif super_animal is None: - shuffle_idx = 90_000 + (1000 * split) + int(100 * frac) - else: - raise ValueError(f"Failed to generate shuffles for super_animal={super_animal}") - - num_samples = int(frac * num_train_images) # as done by Shaokai - shuffles.append(shuffle_idx) - train_indices.append(train_idx[:num_samples]) - test_indices.append(test_idx) - - deeplabcut.create_training_dataset( - str(config_path), - net_type=net_type, - Shuffles=shuffles, - trainIndices=train_indices, - testIndices=test_indices, - userfeedback=False, - weight_init=weight_init, - engine=Engine.PYTORCH, - ) - - -def update_cfg( - config_path: Path, - shuffle: int, - train_augmentations: dict, - optimizer: dict, - scheduler: dict, -) -> None: - loader = DLCLoader( - config=config_path, - shuffle=shuffle, - trainset_index=0, - modelprefix="", - ) - loader.model_cfg["data"]["train"] = None - loader.model_cfg["runner"]["optimizer"] = None - loader.model_cfg["runner"]["scheduler"] = None - loader.update_model_cfg({ - "data": {"train": train_augmentations}, - "runner": { - "optimizer": optimizer, - "scheduler": scheduler, - } - }) - - -def data_preparation( - config_path: Path, - super_animal: str, - run_build_conversion_table: bool, - run_create_shuffles: bool, -) -> None: - if run_build_conversion_table: - _ = create_conversion_table( - config=config_path, - super_animal=super_animal, - project_to_super_animal={ - "snout": "nose", - "leftear": "left_ear", - "rightear": "right_ear", - "tailbase": "tail_base", - }, - ) - - if run_create_shuffles: - create_shuffles(config_path, super_animal) - - -def main( - config_path: Path, - shuffle_index: int, - epochs: int, - train_augmentations: dict, - optimizer: dict, - scheduler: dict, - device: str | None = None, - batch_size: int = 32, - save_epochs: int = 20, - eval_interval: int = 5, -): - metadata = TrainingDatasetMetadata.load(config_path, load_splits=True) - shuffles = [s for s in metadata.shuffles if s.index == shuffle_index] - if len(shuffles) != 1: - raise ValueError( - "Found multiple shuffles with different train indices but the same index " - f"({shuffles}). To run this benchmark, there should only be one such " - "shuffle." - ) - - shuffle = shuffles[0] - print(f"Training shuffle: {shuffle.name}") - print(f" index: {shuffle.index}") - print(f" train fraction: {shuffle.train_fraction}") - print(f" train indices: {shuffle.split.train_indices}") - print(f" test indices: {shuffle.split.test_indices}") - print() - - # edit config to have the desired training fraction - af.edit_config(str(config_path), {"TrainingFraction": [shuffle.train_fraction]}) - - # information about shuffle - mode = shuffle.index // 10_000 - split = (shuffle.index - (10_000 * mode)) // 1000 - data_used = (shuffle.index - (10_000 * mode)) % 1000 - - project = "SuperAnimal-openfield-finetune-v2" - uid = "sa-finetune" - if mode == 5: - uid = "sa-transfer" - elif mode == 9: - uid = "in-transfer" - - # update the pose config to have the correct augmentation, optimizer and scheduler - update_cfg( - config_path=config_path, - shuffle=shuffle.index, - train_augmentations=train_augmentations, - optimizer=optimizer, - scheduler=scheduler, - ) - - # train the model - deeplabcut.train_network( - str(config_path), - shuffle=shuffle.index, - trainingsetindex=0, - device=device, - # edit the pytorch config - detector=dict(train_settings=dict(epochs=0)), - runner=dict( - eval_interval=eval_interval, - snapshots=dict( - max_snapshots=5, - save_epochs=save_epochs, - ), - ), - train_settings=dict(batch_size=batch_size, epochs=epochs), - logger=dict( - type="WandbLogger", - project_name=project, - run_name=f"openfield-{uid}-shuffle{shuffle.index}", - save_code=True, - tags=( - f"mode={uid}", - f"split={split}", - f"data_used={data_used}", - ) - ), - ) - - -if __name__ == "__main__": - DATA = Path("/home/niels/datasets/superanimal") - CONFIG_PATH = DATA / "openfield-Pranav-2018-08-20" / "config.yaml" - SUPER_ANIMAL = "superanimal_topviewmouse" - - FINETUNE_AUG = { - "affine": { - "p": 0.5, - "scaling": [1, 1], - "rotation": 90, - "translation": 0, - }, - "hflip": { - "p": 0.5, - "symmetries": [[1, 2]], - }, - "gaussian_noise": 12.75, - "normalize_images": True, - } - FINETUNE_OPTIM = { - "type": "AdamW", - "params": {"lr": 1e-05}, - } - FINETUNE_SCHEDULER = { - "type": "LRListScheduler", - "params": { - "lr_list": [[1e-06], [1e-07]], - "milestones": [450, 590], - }, - } - - PREP_DATA = False - PREP_TRANSFER_LEARNING_DATA = False - if PREP_DATA: - # ONLY RUN ONCE: prepare data (create shuffles, conversion table) - data_preparation( - config_path=CONFIG_PATH, - super_animal=SUPER_ANIMAL, - run_build_conversion_table=True, - run_create_shuffles=True, - ) - elif PREP_TRANSFER_LEARNING_DATA: - create_transfer_learning_shuffles( - CONFIG_PATH, "top_down_hrnet_w32", SUPER_ANIMAL - ) - create_transfer_learning_shuffles(CONFIG_PATH, "top_down_hrnet_w32", None) - else: - # train a shuffle - for idx in [51001, 52001, 53001, 51005, 52005, 53005]: - main( - config_path=CONFIG_PATH, - shuffle_index=idx, - epochs=600, - train_augmentations=FINETUNE_AUG, - optimizer=FINETUNE_OPTIM, - scheduler=FINETUNE_SCHEDULER, - batch_size=32, - device="cuda", - ) diff --git a/benchmark_superanimal/eval_zeroshot.py b/examples/SUPERANIMAL/eval_zeroshot.py similarity index 91% rename from benchmark_superanimal/eval_zeroshot.py rename to examples/SUPERANIMAL/eval_zeroshot.py index e585682673..1c7b3f5b3b 100644 --- a/benchmark_superanimal/eval_zeroshot.py +++ b/examples/SUPERANIMAL/eval_zeroshot.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """SuperAnimal model zero-shot evaluation""" from __future__ import annotations diff --git a/benchmark_superanimal/keypoint_space_conversion.py b/examples/SUPERANIMAL/keypoint_space_conversion.py similarity index 75% rename from benchmark_superanimal/keypoint_space_conversion.py rename to examples/SUPERANIMAL/keypoint_space_conversion.py index bcbf4b6213..ea3d7b4adb 100644 --- a/benchmark_superanimal/keypoint_space_conversion.py +++ b/examples/SUPERANIMAL/keypoint_space_conversion.py @@ -1,3 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# """Script to convert a dataset for its keypoint space to match the SuperAnimal space""" from pathlib import Path diff --git a/examples/SUPERANIMAL/memory_replay_example.py b/examples/SUPERANIMAL/memory_replay_example.py new file mode 100644 index 0000000000..265566c186 --- /dev/null +++ b/examples/SUPERANIMAL/memory_replay_example.py @@ -0,0 +1,75 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Script to fine-tune a SuperAnimal model with memory replay""" +from pathlib import Path + +import deeplabcut +from deeplabcut.core.engine import Engine +from deeplabcut.core.weight_init import WeightInitialization +from deeplabcut.modelzoo.utils import ( + create_conversion_table, + read_conversion_table_from_csv, +) +from deeplabcut.utils.pseudo_label import keypoint_matching + + +def main(dlc_proj_root: Path, super_animal_name: str): + config_path = str(dlc_proj_root / "config.yaml") + model_name = "hrnetw32" + shuffle = 0 + device = "cuda" + + # keypoint matching before create training dataset + # keypoint matching creates pseudo prediction and a conversion table + keypoint_matching( + config_path, + super_animal_name, + model_name, + ) + + # keypoint matching creates a memory_replay folder in the root. The conversion table + # can be read from there + conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" + + table = create_conversion_table( + config=config_path, + super_animal=super_animal_name, + project_to_super_animal=read_conversion_table_from_csv(conversion_table_path), + ) + + weight_init = WeightInitialization( + dataset=super_animal_name, + conversion_array=table.to_array(), + with_decoder=True, + memory_replay=True, + ) + + deeplabcut.create_training_dataset( + config_path, + Shuffles=[shuffle], + net_type="top_down_hrnet_w32", + weight_init=weight_init, + engine=Engine.PYTORCH, + userfeedback=False, + ) + + # passing pose_threshold controls the behavior of memory replay. We discard + # predictions that are lower than the threshold + deeplabcut.train_network( + config_path, shuffle=shuffle, device=device, pose_threshold=0.1 + ) + + +if __name__ == "__main__": + main( + dlc_proj_root=Path("/media/data/myproject"), + super_animal_name="superanimal_topviewmouse", + ) diff --git a/benchmark_superanimal/superanimal_image_inference.py b/examples/SUPERANIMAL/superanimal_image_inference.py similarity index 87% rename from benchmark_superanimal/superanimal_image_inference.py rename to examples/SUPERANIMAL/superanimal_image_inference.py index 79eb0f478b..ef02b5bdc6 100644 --- a/benchmark_superanimal/superanimal_image_inference.py +++ b/examples/SUPERANIMAL/superanimal_image_inference.py @@ -4,13 +4,10 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # -import glob - -import deeplabcut from deeplabcut.pose_estimation_pytorch.apis.analyze_images import ( superanimal_analyze_images, ) diff --git a/benchmark_superanimal/video_adapt_example.py b/examples/SUPERANIMAL/video_adapt_example.py similarity index 58% rename from benchmark_superanimal/video_adapt_example.py rename to examples/SUPERANIMAL/video_adapt_example.py index dd76bcff6c..856803016e 100644 --- a/benchmark_superanimal/video_adapt_example.py +++ b/examples/SUPERANIMAL/video_adapt_example.py @@ -1,3 +1,14 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Script to run video adaptation""" import deeplabcut.modelzoo.video_inference as modelzoo From f93c59d9f30bdf0799af652d8e3b55c769343e3b Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Fri, 14 Jun 2024 17:38:27 +0200 Subject: [PATCH 159/293] fixed openfield demo config --- examples/openfield-Pranav-2018-10-30/config.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/openfield-Pranav-2018-10-30/config.yaml b/examples/openfield-Pranav-2018-10-30/config.yaml index ddebb31ab1..330f02ddc4 100644 --- a/examples/openfield-Pranav-2018-10-30/config.yaml +++ b/examples/openfield-Pranav-2018-10-30/config.yaml @@ -2,12 +2,9 @@ Task: openfield scorer: Pranav date: Oct30 -multianimalproject: -identity: - # Project path (change when moving around) -project_path: +project_path: WILL BE AUTOMATICALLY UPDATED BY DEMO CODE # Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow) From 4286cbc87570d0073589871391bf4f4026cf5be0 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Fri, 14 Jun 2024 17:55:50 +0200 Subject: [PATCH 160/293] fixed broken tests --- ...ion.py => test_trainingset_manipulation.py} | 0 tests/test_inferenceutils.py | 18 +++++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) rename tests/generate_training_dataset/{test_trainingsetmanipulation.py => test_trainingset_manipulation.py} (100%) diff --git a/tests/generate_training_dataset/test_trainingsetmanipulation.py b/tests/generate_training_dataset/test_trainingset_manipulation.py similarity index 100% rename from tests/generate_training_dataset/test_trainingsetmanipulation.py rename to tests/generate_training_dataset/test_trainingset_manipulation.py diff --git a/tests/test_inferenceutils.py b/tests/test_inferenceutils.py index 124d1e064c..96fa511a08 100644 --- a/tests/test_inferenceutils.py +++ b/tests/test_inferenceutils.py @@ -61,17 +61,17 @@ def test_calc_object_keypoint_similarity(real_assemblies): def test_match_assemblies(real_assemblies): assemblies = real_assemblies[0] - matched, unmatched = inferenceutils.match_assemblies( + num_gt, matches = inferenceutils.match_assemblies( assemblies, assemblies[::-1], 0.01 ) - assert not unmatched - for ass1, ass2, oks in matched: - assert ass1 is ass2 - assert oks == 1 - - matched, unmatched = inferenceutils.match_assemblies([], assemblies, 0.01) - assert not matched - assert all(ass1 is ass2 for ass1, ass2 in zip(unmatched, assemblies)) + assert len(assemblies) == len(matches) + for m in matches: + assert m.prediction is m.ground_truth + assert m.oks == 1 + + num_gt, matches = inferenceutils.match_assemblies([], assemblies, 0.01) + assert len(matches) == 0 + assert num_gt == len(assemblies) def test_evaluate_assemblies(real_assemblies): From 82c27a587226df552d1daaa6b4760f8a4258b024 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Fri, 14 Jun 2024 18:15:40 +0200 Subject: [PATCH 161/293] update requirements.txt for CI/CD --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index c75a49e968..06f45deb72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # novel for pytorch DLC: albumentations<=1.4.3 einops +pycocotools timm wandb From 38c5cbac64ad20f7f2cb84743bf3d6c3ed88da50 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Fri, 14 Jun 2024 18:33:43 +0200 Subject: [PATCH 162/293] pin numpy version for CI/CD --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 06f45deb72..4535665b8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ imgaug>=0.4.0 numba>=0.54.0 matplotlib>=3.3 networkx>=2.6 -numpy>=1.18.5 +numpy>=1.18.5,<1.25.0 pandas>=1.0.1,!=1.5.0 Pillow>=7.1 pyyaml From 17f525931e4e72cfcaf660e4d47da449c95cacbf Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Fri, 14 Jun 2024 18:40:07 +0200 Subject: [PATCH 163/293] bounding box to list, unpin numpy --- requirements.txt | 2 +- tests/core/inferenceutils/test_map_computation.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4535665b8e..06f45deb72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ imgaug>=0.4.0 numba>=0.54.0 matplotlib>=3.3 networkx>=2.6 -numpy>=1.18.5,<1.25.0 +numpy>=1.18.5 pandas>=1.0.1,!=1.5.0 Pillow>=7.1 pyyaml diff --git a/tests/core/inferenceutils/test_map_computation.py b/tests/core/inferenceutils/test_map_computation.py index 2f96993d49..889fe3c361 100644 --- a/tests/core/inferenceutils/test_map_computation.py +++ b/tests/core/inferenceutils/test_map_computation.py @@ -347,7 +347,7 @@ def _to_coco_ground_truth( "image_id": id_, "category_id": 1, "area": area, - "bbox": bbox, + "bbox": bbox.tolist(), "keypoints": kpts.reshape(-1).tolist(), "iscrowd": 0, "num_keypoints": num_keypoints, @@ -397,7 +397,7 @@ def _to_coco_predictions( "image_id": img_id, "category_id": 1, "keypoints": kpts.reshape(-1).tolist(), - "bbox": bbox, + "bbox": bbox.tolist(), "area": area, "score": score, } From f69cea74ef5fb616d48d3daa1862c2c5c7213fc8 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 14 Jun 2024 20:41:51 -0400 Subject: [PATCH 164/293] Update config.yml --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 00da359495..a8cb967fab 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ jobs: build-and-test: working_directory: ~/circleci-demo-python-django docker: - - image: circleci/python:3.8 # primary container for the build job + - image: circleci/python:3.10 # primary container for the build job auth: username: mydockerhub-user password: $DOCKERHUB_PASSWORD # context / project UI env-var reference From 1d7fe9e42d0a03446ec23b5eed8b9d24566af1d1 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 14 Jun 2024 20:50:46 -0400 Subject: [PATCH 165/293] Update requirements.txt pin matplotlib<=3.8.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 06f45deb72..564d693224 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ intel-openmp imageio-ffmpeg imgaug>=0.4.0 numba>=0.54.0 -matplotlib>=3.3 +matplotlib>=3.3, <3.8.4 networkx>=2.6 numpy>=1.18.5 pandas>=1.0.1,!=1.5.0 From b59f22e3a9a510b0c1ec916a04b0f5c1f4fbd655 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 14 Jun 2024 21:00:50 -0400 Subject: [PATCH 166/293] Update python-package.yml --- .github/workflows/python-package.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d368ac0d62..04a4580456 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -61,5 +61,6 @@ jobs: - name: Run functional tests run: | + pip install git+https://github.com/${{ github.repository }}.git@${{ github.sha }} python examples/testscript.py python examples/testscript_multianimal.py From bd0bbc5970a47947b1faaa81685222e5d9622eb5 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Mon, 17 Jun 2024 09:35:46 +0200 Subject: [PATCH 167/293] set engine when creating training datasets in testscripts --- examples/testscript.py | 20 +++++++++++--------- examples/testscript_multianimal.py | 17 +++++++++++------ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/examples/testscript.py b/examples/testscript.py index 3e6c57e2d5..09ed733e9c 100644 --- a/examples/testscript.py +++ b/examples/testscript.py @@ -23,19 +23,18 @@ It produces nothing of interest scientifically. """ import os -import deeplabcut import platform -import scipy.io as sio -import subprocess +import random from pathlib import Path import numpy as np import pandas as pd +import scipy.io as sio +import deeplabcut +from deeplabcut.core.engine import Engine from deeplabcut.utils import auxiliaryfunctions -import random - USE_SHELVE = random.choice([True, False]) MODELS = ["resnet_50", "efficientnet-b0", "mobilenet_v2_0.35"] @@ -43,6 +42,7 @@ if __name__ == "__main__": task = "TEST" # Enter the name of your experiment Task scorer = "Alex" # Enter the name of the experimenter/labeler + engine = Engine.TF print("Imported DLC!") basepath = os.path.dirname(os.path.realpath(__file__)) @@ -133,7 +133,7 @@ print("CREATING TRAININGSET") deeplabcut.create_training_dataset( - path_config_file, net_type=NET, augmenter_type=augmenter_type + path_config_file, net_type=NET, augmenter_type=augmenter_type, engine=engine, ) # Check the training image paths are correctly stored as arrays of strings @@ -196,7 +196,7 @@ except: # if ffmpeg is broken/missing print("using alternative method") newvideo = os.path.join(cfg["project_path"], "videos", videoname + "short.mp4") - from moviepy.editor import VideoFileClip, VideoClip + from moviepy.editor import VideoClip, VideoFileClip clip = VideoFileClip(video[0]) clip.reader.initialize() @@ -283,7 +283,7 @@ def make_frame(t): print("CREATING TRAININGSET") deeplabcut.create_training_dataset( - path_config_file, net_type=NET, augmenter_type=augmenter_type2 + path_config_file, net_type=NET, augmenter_type=augmenter_type2, engine=engine ) cfg = deeplabcut.auxiliaryfunctions.read_config(path_config_file) @@ -324,7 +324,7 @@ def make_frame(t): newvideo2 = os.path.join( cfg["project_path"], "videos", videoname + "short2.mp4" ) - from moviepy.editor import VideoFileClip, VideoClip + from moviepy.editor import VideoClip, VideoFileClip clip = VideoFileClip(video[0]) clip.reader.initialize() @@ -381,6 +381,7 @@ def make_frame(t): Shuffles=[2], net_type=NET, augmenter_type=augmenter_type3, + engine=engine, ) posefile = os.path.join( @@ -430,6 +431,7 @@ def make_frame(t): Shuffles=[4, 5], trainIndices=[trainIndices, trainIndices], testIndices=[testIndices, testIndices], + engine=engine, ) print("ALL DONE!!! - default cases are functional.") diff --git a/examples/testscript_multianimal.py b/examples/testscript_multianimal.py index f1215d13e5..b2bd44542d 100644 --- a/examples/testscript_multianimal.py +++ b/examples/testscript_multianimal.py @@ -9,14 +9,17 @@ # Licensed under GNU Lesser General Public License v3.0 # import os -import deeplabcut +import pickle +import random +from pathlib import Path + import numpy as np import pandas as pd -import pickle + +import deeplabcut +from deeplabcut.core.engine import Engine from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions from deeplabcut.utils.auxfun_videos import VideoReader -import random -from pathlib import Path MODELS = ["dlcrnet_ms5", "dlcr101_ms5", "efficientnet-b0", "mobilenet_v2_0.35"] @@ -31,6 +34,7 @@ SCORER = "dlc_team" NUM_FRAMES = 5 TRAIN_SIZE = 0.8 + ENGINE = Engine.TF # NET = "dlcr101_ms5" NET = "dlcrnet_ms5" @@ -114,7 +118,7 @@ print("Creating train dataset...") deeplabcut.create_multianimaltraining_dataset( - config_path, net_type=NET, crop_size=(200, 200) + config_path, net_type=NET, crop_size=(200, 200), engine=ENGINE, ) print("Train dataset created.") @@ -291,7 +295,7 @@ deeplabcut.merge_datasets(config_path) # iteration + 1 print("CREATING TRAININGSET updated training set") - deeplabcut.create_training_dataset(config_path, net_type=NET) + deeplabcut.create_training_dataset(config_path, net_type=NET, engine=ENGINE) print("Training network...") deeplabcut.train_network(config_path, maxiters=N_ITER) @@ -330,6 +334,7 @@ Shuffles=[4, 5], trainIndices=[trainIndices, trainIndices], testIndices=[testIndices, testIndices], + engine=ENGINE, ) print("ALL DONE!!! - default multianimal cases are functional.") From 8a1f71d40e6d1c40716d3d96145164d8951bad60 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Mon, 17 Jun 2024 09:41:58 +0200 Subject: [PATCH 168/293] set engine in testscript_cli --- testscript_cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/testscript_cli.py b/testscript_cli.py index 7b74543cdc..d808678497 100644 --- a/testscript_cli.py +++ b/testscript_cli.py @@ -20,6 +20,7 @@ # install("tensorflow==1.13.1") import deeplabcut as dlc +from deeplabcut.core.engine import Engine from pathlib import Path import pandas as pd @@ -28,6 +29,8 @@ print("Imported DLC!") +engine = Engine.TF + basepath = os.path.dirname(os.path.abspath("testscript_cli.py")) videoname = "reachingvideo1" video = [ @@ -116,7 +119,7 @@ print("CREATING TRAININGSET") dlc.create_training_dataset( - path_config_file, net_type=net_type, augmenter_type=augmenter_type + path_config_file, net_type=net_type, augmenter_type=augmenter_type, engine=engine, ) posefile = os.path.join( From 40cba0c50b5132970a0c1a7fd67de6846cf61a37 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Mon, 17 Jun 2024 10:26:53 +0200 Subject: [PATCH 169/293] bug fix: parameter order --- deeplabcut/utils/auxiliaryfunctions.py | 6 +++--- examples/testscript_multianimal.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 634544e47a..b1f33df57e 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -552,8 +552,8 @@ def get_model_folder( trainFraction: float, shuffle: int, cfg: dict, - engine: Engine = Engine.TF, modelprefix: str = "", + engine: Engine = Engine.TF, ) -> Path: """ Args: @@ -561,9 +561,9 @@ def get_model_folder( for which to get the model folder shuffle: the index of the shuffle for which to get the model folder cfg: the project configuration + modelprefix: The name of the folder engine: The engine for which we want the model folder. Defaults to `tensorflow` for backwards compatibility with DeepLabCut 2.X - modelprefix: The name of the folder Returns: the relative path from the project root to the folder containing the model files @@ -670,9 +670,9 @@ def get_scorer_name( cfg: dict, shuffle: int, trainFraction: float, - engine: Engine | None = None, trainingsiterations: str | int = "unknown", modelprefix: str = "", + engine: Engine | None = None, ): """Extract the scorer/network name for a particular shuffle, training fraction, etc. If the engine is not specified, determines which to use from diff --git a/examples/testscript_multianimal.py b/examples/testscript_multianimal.py index b2bd44542d..a1c1003c16 100644 --- a/examples/testscript_multianimal.py +++ b/examples/testscript_multianimal.py @@ -138,7 +138,7 @@ print("Editing pose config...") model_folder = auxiliaryfunctions.get_model_folder( - TRAIN_SIZE, 1, cfg, cfg["project_path"] + TRAIN_SIZE, 1, cfg, engine=ENGINE, modelprefix=cfg["project_path"] ) pose_config_path = os.path.join(model_folder, "train", "pose_cfg.yaml") edits = { From 853b7fdf14bcdae90da215e80fac42150182b782 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 18 Jun 2024 13:04:38 +0200 Subject: [PATCH 170/293] Fix PySide6 AttributeError and uninitialized variable (#2622) * Fix AttributeErrors * Properly initialize DATA --- deeplabcut/gui/window.py | 4 ++-- deeplabcut/utils/frameselectiontools.py | 32 +++---------------------- 2 files changed, 5 insertions(+), 31 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 982d7efe7e..b096df1844 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -63,9 +63,9 @@ def _check_for_updates(silent=True): text=text, ) msg.setIcon(QtWidgets.QMessageBox.Information) - update_btn = msg.addButton("Update", msg.AcceptRole) + update_btn = msg.addButton("Update", QtWidgets.QMessageBox.AcceptRole) msg.setDefaultButton(update_btn) - _ = msg.addButton("Skip", msg.RejectRole) + _ = msg.addButton("Skip", QtWidgets.QMessageBox.RejectRole) msg.exec_() if msg.clickedButton() is update_btn: subprocess.check_call([sys.executable, "-m", *command]) diff --git a/deeplabcut/utils/frameselectiontools.py b/deeplabcut/utils/frameselectiontools.py index dd2201e40f..947d32d59e 100644 --- a/deeplabcut/utils/frameselectiontools.py +++ b/deeplabcut/utils/frameselectiontools.py @@ -262,7 +262,9 @@ def KmeansbasedFrameselectioncv2( if batchsize > nframes: batchsize = nframes // 2 - allocated = False + ny_ = np.round(ny * ratio).astype(int) + nx_ = np.round(nx * ratio).astype(int) + DATA = np.empty((nframes, ny_, nx_ * 3 if color else nx_)) if len(Index) >= numframes2pick: if ( np.mean(np.diff(Index)) > 1 @@ -282,13 +284,6 @@ def KmeansbasedFrameselectioncv2( interpolation=cv2.INTER_NEAREST, ) ) # color trafo not necessary; lack thereof improves speed. - if ( - not allocated - ): #'DATA' not in locals(): #allocate memory in first pass - DATA = np.empty( - (nframes, np.shape(image)[0], np.shape(image)[1] * 3) - ) - allocated = True DATA[counter, :, :] = np.hstack( [image[:, :, 0], image[:, :, 1], image[:, :, 2]] ) @@ -306,13 +301,6 @@ def KmeansbasedFrameselectioncv2( interpolation=cv2.INTER_NEAREST, ) ) # color trafo not necessary; lack thereof improves speed. - if ( - not allocated - ): #'DATA' not in locals(): #allocate memory in first pass - DATA = np.empty( - (nframes, np.shape(image)[0], np.shape(image)[1]) - ) - allocated = True DATA[counter, :, :] = np.mean(image, 2) else: print("Extracting and downsampling...", nframes, " frames from the video.") @@ -329,13 +317,6 @@ def KmeansbasedFrameselectioncv2( interpolation=cv2.INTER_NEAREST, ) ) # color trafo not necessary; lack thereof improves speed. - if ( - not allocated - ): #'DATA' not in locals(): #allocate memory in first pass - DATA = np.empty( - (nframes, np.shape(image)[0], np.shape(image)[1] * 3) - ) - allocated = True DATA[counter, :, :] = np.hstack( [image[:, :, 0], image[:, :, 1], image[:, :, 2]] ) @@ -352,13 +333,6 @@ def KmeansbasedFrameselectioncv2( interpolation=cv2.INTER_NEAREST, ) ) # color trafo not necessary; lack thereof improves speed. - if ( - not allocated - ): #'DATA' not in locals(): #allocate memory in first pass - DATA = np.empty( - (nframes, np.shape(image)[0], np.shape(image)[1]) - ) - allocated = True DATA[counter, :, :] = np.mean(image, 2) print("Kmeans clustering ... (this might take a while)") From 882f920e10f896bf2a526b69be40bc00c3253376 Mon Sep 17 00:00:00 2001 From: shaokaiye Date: Thu, 20 Jun 2024 09:29:04 +0200 Subject: [PATCH 171/293] Added notebook for superanimal pytorch --- .../COLAB/COLAB_Pytorch_SuperAnimal.ipynb | 5464 +++++++++++++++++ 1 file changed, 5464 insertions(+) create mode 100644 examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb diff --git a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb new file mode 100644 index 0000000000..2d9f708f70 --- /dev/null +++ b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb @@ -0,0 +1,5464 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "5SSZpZUu0Z4S" + }, + "source": [ + "# DeepLabCut Model Zoo: SuperAnimal models\n", + "\n", + "![alt text](https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1616492373700-PGOAC72IOB6AUE47VTJX/ke17ZwdGBToddI8pDm48kB8JrdUaZR-OSkKLqWQPp_YUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8N_N4V1vUb5AoIIIbLZhVYwL8IeDg6_3B-BRuF4nNrNcQkVuAT7tdErd0wQFEGFSnBqyW03PFN2MN6T6ry5cmXqqA9xITfsbVGDrg_goIDasRCalqV8R3606BuxERAtDaQ/modelzoo.png?format=1000w)\n", + "\n", + "http://modelzoo.deeplabcut.org\n", + "\n", + "You can use this notebook to analyze videos with pretrained networks from our model zoo - NO local installation of DeepLabCut is needed!\n", + "\n", + "- **What you need:** a video of your favorite dog, cat, human, etc: check the list of currently available models here: http://modelzoo.deeplabcut.org\n", + "\n", + "- **What to do:** (1) in the top right corner, click \"CONNECT\". Then, just hit run (play icon) on each cell below and follow the instructions!\n", + "\n", + "## **Please consider giving back and labeling a little data to help make each network even better!**\n", + "\n", + "We have a WebApp, so no need to install anything, just a few clicks! We'd really appreciate your help! 🙏\n", + " \n", + "https://contrib.deeplabcut.org/\n", + "\n", + "\n", + "- **Note, if you performance is less that you would like:** firstly check the labeled_video parameters (i.e. \"pcutoff\" that will set the video plotting) - see the end of this notebook.\n", + "- You can also use the model in your own projects locally. Please be sure to cite the papers for the model, i.e., [Ye et al. 2023](https://arxiv.org/abs/2203.07436) 🎉\n", + "\n", + "\n", + "\n", + "## **Let's get going: install DeepLabCut into COLAB:**\n", + "\n", + "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "0qb_Vh8F0Z4W" + }, + "source": [ + "### Here we are testing our private code on colab\n", + "### We put the code in google drive, mount it and use the module" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "collapsed": true, + "id": "Kx13wriE0Z4W", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "from google.colab import drive\n", + "import ipywidgets as widgets\n", + "from IPython.display import display\n", + "import shutil\n", + "from google.colab import files\n", + "import os\n", + "import zipfile\n", + "from pathlib import Path\n", + "import sys\n", + "import matplotlib.pyplot as plt\n", + "import matplotlib.image as mpimg" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 0 + }, + "collapsed": true, + "id": "wsSZF4Pk0kae", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "322ca9cf-d779-40fc-9e9c-b241be75131d" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Drive already mounted at /content/drive; to attempt to forcibly remount, call drive.mount(\"/content/drive\", force_remount=True).\n" + ] + } + ], + "source": [ + "drive.mount('/content/drive')" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 0 + }, + "collapsed": true, + "id": "AjET5cJE5UYM", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "b9b5b6d9-6b9c-4cfe-e3fa-9bbb03079fe7" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Requirement already satisfied: albumentations<=1.4.3 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 2)) (1.4.3)\n", + "Requirement already satisfied: einops in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 3)) (0.8.0)\n", + "Requirement already satisfied: timm in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 4)) (1.0.3)\n", + "Requirement already satisfied: wandb in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (0.17.1)\n", + "Requirement already satisfied: dlclibrary in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 8)) (0.0.6)\n", + "Requirement already satisfied: ipython in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (7.34.0)\n", + "Requirement already satisfied: filterpy in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 10)) (1.4.5)\n", + "Requirement already satisfied: ruamel.yaml>=0.15.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 11)) (0.18.6)\n", + "Requirement already satisfied: intel-openmp in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 12)) (2024.1.2)\n", + "Requirement already satisfied: imageio-ffmpeg in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 13)) (0.5.1)\n", + "Requirement already satisfied: imgaug>=0.4.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (0.4.0)\n", + "Requirement already satisfied: numba>=0.54.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 15)) (0.59.1)\n", + "Requirement already satisfied: matplotlib>=3.3 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (3.7.1)\n", + "Requirement already satisfied: networkx>=2.6 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 17)) (3.3)\n", + "Requirement already satisfied: numpy>=1.18.5 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 18)) (1.25.2)\n", + "Requirement already satisfied: pandas!=1.5.0,>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 19)) (2.0.3)\n", + "Requirement already satisfied: Pillow>=7.1 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 20)) (10.3.0)\n", + "Requirement already satisfied: pyyaml in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 21)) (6.0.1)\n", + "Requirement already satisfied: scikit-image>=0.17 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 22)) (0.23.2)\n", + "Requirement already satisfied: scikit-learn>=1.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 23)) (1.5.0)\n", + "Requirement already satisfied: scipy<1.11.0,>=1.4 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 24)) (1.10.1)\n", + "Requirement already satisfied: statsmodels>=0.11 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 25)) (0.14.2)\n", + "Requirement already satisfied: tensorflow<2.13.0,>=2.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.11.1)\n", + "Requirement already satisfied: tables==3.8.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (3.8.0)\n", + "Requirement already satisfied: tensorpack>=0.11 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (0.11)\n", + "Requirement already satisfied: tf_slim>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 29)) (1.1.0)\n", + "Requirement already satisfied: torch>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (2.3.0+cpu)\n", + "Requirement already satisfied: torchvision in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 31)) (0.18.0+cpu)\n", + "Requirement already satisfied: tqdm in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 32)) (4.66.4)\n", + "Requirement already satisfied: cython>=0.29.21 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (3.0.10)\n", + "Requirement already satisfied: numexpr>=2.6.2 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (2.10.0)\n", + "Requirement already satisfied: blosc2~=2.0.0 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (2.0.0)\n", + "Requirement already satisfied: packaging in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (24.1)\n", + "Requirement already satisfied: py-cpuinfo in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (9.0.0)\n", + "Requirement already satisfied: typing-extensions>=4.9.0 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 2)) (4.12.2)\n", + "Requirement already satisfied: opencv-python-headless>=4.9.0 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 2)) (4.10.0.82)\n", + "Requirement already satisfied: huggingface_hub in /usr/local/lib/python3.10/dist-packages (from timm->-r /content/drive/My Drive/DLCdev/requirements.txt (line 4)) (0.23.3)\n", + "Requirement already satisfied: safetensors in /usr/local/lib/python3.10/dist-packages (from timm->-r /content/drive/My Drive/DLCdev/requirements.txt (line 4)) (0.4.3)\n", + "Requirement already satisfied: click!=8.0.0,>=7.1 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (8.1.7)\n", + "Requirement already satisfied: docker-pycreds>=0.4.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (0.4.0)\n", + "Requirement already satisfied: gitpython!=3.1.29,>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (3.1.43)\n", + "Requirement already satisfied: platformdirs in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (4.2.2)\n", + "Requirement already satisfied: protobuf!=4.21.0,<6,>=3.19.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (3.19.6)\n", + "Requirement already satisfied: psutil>=5.0.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (5.9.5)\n", + "Requirement already satisfied: requests<3,>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (2.31.0)\n", + "Requirement already satisfied: sentry-sdk>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (2.5.1)\n", + "Requirement already satisfied: setproctitle in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (1.3.3)\n", + "Requirement already satisfied: setuptools in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (67.7.2)\n", + "Requirement already satisfied: jedi>=0.16 in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.19.1)\n", + "Requirement already satisfied: decorator in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (5.1.1)\n", + "Requirement already satisfied: pickleshare in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.7.5)\n", + "Requirement already satisfied: traitlets>=4.2 in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (5.7.1)\n", + "Requirement already satisfied: prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (3.0.47)\n", + "Requirement already satisfied: pygments in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (2.18.0)\n", + "Requirement already satisfied: backcall in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.2.0)\n", + "Requirement already satisfied: matplotlib-inline in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.1.7)\n", + "Requirement already satisfied: pexpect>4.3 in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (4.9.0)\n", + "Requirement already satisfied: ruamel.yaml.clib>=0.2.7 in /usr/local/lib/python3.10/dist-packages (from ruamel.yaml>=0.15.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 11)) (0.2.8)\n", + "Requirement already satisfied: six in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (1.16.0)\n", + "Requirement already satisfied: opencv-python in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (4.10.0.82)\n", + "Requirement already satisfied: imageio in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (2.34.1)\n", + "Requirement already satisfied: Shapely in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (2.0.4)\n", + "Requirement already satisfied: llvmlite<0.43,>=0.42.0dev0 in /usr/local/lib/python3.10/dist-packages (from numba>=0.54.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 15)) (0.42.0)\n", + "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (1.2.1)\n", + "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (0.12.1)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (4.53.0)\n", + "Requirement already satisfied: kiwisolver>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (1.4.5)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (3.1.2)\n", + "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (2.9.0.post0)\n", + "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->-r /content/drive/My Drive/DLCdev/requirements.txt (line 19)) (2024.1)\n", + "Requirement already satisfied: tzdata>=2022.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->-r /content/drive/My Drive/DLCdev/requirements.txt (line 19)) (2024.1)\n", + "Requirement already satisfied: tifffile>=2022.8.12 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->-r /content/drive/My Drive/DLCdev/requirements.txt (line 22)) (2024.5.22)\n", + "Requirement already satisfied: lazy-loader>=0.4 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->-r /content/drive/My Drive/DLCdev/requirements.txt (line 22)) (0.4)\n", + "Requirement already satisfied: joblib>=1.2.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 23)) (1.4.2)\n", + "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 23)) (3.5.0)\n", + "Requirement already satisfied: patsy>=0.5.6 in /usr/local/lib/python3.10/dist-packages (from statsmodels>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 25)) (0.5.6)\n", + "Requirement already satisfied: absl-py>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.4.0)\n", + "Requirement already satisfied: astunparse>=1.6.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.6.3)\n", + "Requirement already satisfied: flatbuffers>=2.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (24.3.25)\n", + "Requirement already satisfied: gast<=0.4.0,>=0.2.1 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.4.0)\n", + "Requirement already satisfied: google-pasta>=0.1.1 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.2.0)\n", + "Requirement already satisfied: grpcio<2.0,>=1.24.3 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.64.1)\n", + "Requirement already satisfied: h5py>=2.9.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.11.0)\n", + "Requirement already satisfied: keras<2.12,>=2.11.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.11.0)\n", + "Requirement already satisfied: libclang>=13.0.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (18.1.1)\n", + "Requirement already satisfied: opt-einsum>=2.3.2 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.3.0)\n", + "Requirement already satisfied: tensorboard<2.12,>=2.11 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.11.2)\n", + "Requirement already satisfied: tensorflow-estimator<2.12,>=2.11.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.11.0)\n", + "Requirement already satisfied: termcolor>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.4.0)\n", + "Requirement already satisfied: wrapt>=1.11.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.14.1)\n", + "Requirement already satisfied: tensorflow-io-gcs-filesystem>=0.23.1 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.37.0)\n", + "Requirement already satisfied: tabulate>=0.7.7 in /usr/local/lib/python3.10/dist-packages (from tensorpack>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (0.9.0)\n", + "Requirement already satisfied: msgpack>=0.5.2 in /usr/local/lib/python3.10/dist-packages (from tensorpack>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (1.0.8)\n", + "Requirement already satisfied: msgpack-numpy>=0.4.4.2 in /usr/local/lib/python3.10/dist-packages (from tensorpack>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (0.4.8)\n", + "Requirement already satisfied: pyzmq>=16 in /usr/local/lib/python3.10/dist-packages (from tensorpack>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (24.0.1)\n", + "Requirement already satisfied: filelock in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (3.14.0)\n", + "Requirement already satisfied: sympy in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (1.12.1)\n", + "Requirement already satisfied: jinja2 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (3.1.4)\n", + "Requirement already satisfied: fsspec in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (2024.6.0)\n", + "Requirement already satisfied: wheel<1.0,>=0.23.0 in /usr/local/lib/python3.10/dist-packages (from astunparse>=1.6.0->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.43.0)\n", + "Requirement already satisfied: gitdb<5,>=4.0.1 in /usr/local/lib/python3.10/dist-packages (from gitpython!=3.1.29,>=1.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (4.0.11)\n", + "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /usr/local/lib/python3.10/dist-packages (from jedi>=0.16->ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.8.4)\n", + "Requirement already satisfied: ptyprocess>=0.5 in /usr/local/lib/python3.10/dist-packages (from pexpect>4.3->ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.7.0)\n", + "Requirement already satisfied: wcwidth in /usr/local/lib/python3.10/dist-packages (from prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0->ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.2.13)\n", + "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests<3,>=2.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (3.3.2)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.10/dist-packages (from requests<3,>=2.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (3.7)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests<3,>=2.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (2.0.7)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.10/dist-packages (from requests<3,>=2.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (2024.6.2)\n", + "Requirement already satisfied: google-auth<3,>=1.6.3 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.27.0)\n", + "Requirement already satisfied: google-auth-oauthlib<0.5,>=0.4.1 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.4.6)\n", + "Requirement already satisfied: markdown>=2.6.8 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.6)\n", + "Requirement already satisfied: tensorboard-data-server<0.7.0,>=0.6.0 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.6.1)\n", + "Requirement already satisfied: tensorboard-plugin-wit>=1.6.0 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.8.1)\n", + "Requirement already satisfied: werkzeug>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.0.3)\n", + "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.10/dist-packages (from jinja2->torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (2.1.5)\n", + "Requirement already satisfied: mpmath<1.4.0,>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from sympy->torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (1.3.0)\n", + "Requirement already satisfied: smmap<6,>=3.0.1 in /usr/local/lib/python3.10/dist-packages (from gitdb<5,>=4.0.1->gitpython!=3.1.29,>=1.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (5.0.1)\n", + "Requirement already satisfied: cachetools<6.0,>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from google-auth<3,>=1.6.3->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (5.3.3)\n", + "Requirement already satisfied: pyasn1-modules>=0.2.1 in /usr/local/lib/python3.10/dist-packages (from google-auth<3,>=1.6.3->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.4.0)\n", + "Requirement already satisfied: rsa<5,>=3.1.4 in /usr/local/lib/python3.10/dist-packages (from google-auth<3,>=1.6.3->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (4.9)\n", + "Requirement already satisfied: requests-oauthlib>=0.7.0 in /usr/local/lib/python3.10/dist-packages (from google-auth-oauthlib<0.5,>=0.4.1->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.0.0)\n", + "Requirement already satisfied: pyasn1<0.7.0,>=0.4.6 in /usr/local/lib/python3.10/dist-packages (from pyasn1-modules>=0.2.1->google-auth<3,>=1.6.3->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.6.0)\n", + "Requirement already satisfied: oauthlib>=3.0.0 in /usr/local/lib/python3.10/dist-packages (from requests-oauthlib>=0.7.0->google-auth-oauthlib<0.5,>=0.4.1->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.2.2)\n" + ] + } + ], + "source": [ + "!pip install deeplabcut==3.0.0rc1" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "5h0vq6E50Z4W" + }, + "source": [ + "### PLEASE, click \"restart runtime\" from the output above before proceeding!" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 0 + }, + "collapsed": true, + "id": "LvnlIvQm0Z4X", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "61bd7b86-525f-4eab-8441-d0bfd927b6e9" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loading DLC 3.0.0rc1...\n", + "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n" + ] + } + ], + "source": [ + "import deeplabcut\n", + "from deeplabcut.pose_estimation_pytorch.apis.analyze_images import (\n", + " superanimal_analyze_images,\n", + ")\n", + "from deeplabcut.core.weight_init import WeightInitialization\n", + "from deeplabcut.core.engine import Engine\n", + "from deeplabcut.modelzoo.video_inference import video_inference_superanimal\n", + "from deeplabcut.utils.pseudo_label import keypoint_matching\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "UeXjmtu40Z4X" + }, + "source": [ + "## Zero-shot Image Inference & Video Inference\n", + "SuperAnimal models are foundation animal pose models. They can be used for zero-shot predictions without further training on the data.\n", + "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another dest folder." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Zero-shot image inference" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Upload the images you want to predict" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 535 + }, + "collapsed": true, + "id": "c4yfTj7r0Z4Y", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "1d4f71b5-fd2f-4215-ac3a-5d92011fc25d" + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " Upload widget is only available when the cell has been executed in the\n", + " current browser session. Please rerun this cell to enable.\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saving img41.png to img41.png\n", + "Saving img53.png to img53.png\n", + "Uploaded files have been moved to: /content/uploaded_images\n", + "Contents of the new folder:\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from google.colab import files\n", + "import os\n", + "import shutil\n", + "from pathlib import Path\n", + "\n", + "# Step 1: Upload files\n", + "uploaded = files.upload()\n", + "\n", + "# Step 2: Define upload directory\n", + "upload_directory = Path('/content/')\n", + "\n", + "# Create a new folder for uploaded files\n", + "in_image_folder = upload_directory / 'uploaded_images'\n", + "out_image_folder = upload_directory / 'processed_images'\n", + "os.makedirs(in_image_folder, exist_ok=True)\n", + "\n", + "# Step 3: Save and move files to the new folder\n", + "uploaded_files = []\n", + "for filename, content in uploaded.items():\n", + " # Save the file to the upload directory\n", + " file_path = os.path.join(upload_directory, filename)\n", + " uploaded_files.append(filename)\n", + " with open(file_path, 'wb') as f:\n", + " f.write(content)\n", + "\n", + " # Move the file to the new folder\n", + " if not os.path.exists(os.path.join(in_image_folder, file_path.split(os.sep)[-1])):\n", + " shutil.move(file_path, in_image_folder)\n", + "\n", + "# List contents of the new folder\n", + "print(f\"Uploaded files have been moved to: {in_image_folder}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Display the images you uploaded" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 812 + }, + "collapsed": true, + "id": "Ybm7pvPKNKR-", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "da0e87d9-5b91-4545-cdba-09976f5d0b01" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Contents of the new folder:\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "print(\"The images you uploaded:\")\n", + "for f in uploaded_files:\n", + " img = mpimg.imread(os.path.join(in_image_folder, f))\n", + " plt.imshow(img)\n", + " plt.axis('off') # Hide the axes for better view\n", + " plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Jashzdjb0Z4Y" + }, + "source": [ + "#### Selecting a superanimal name and corresponding model architecture\n", + "\n", + "Check https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/ModelZoo.md to learn more about superanimals\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "collapsed": true, + "id": "uH9LXig90Z4Y", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "superanimal_name = 'superanimal_topviewmouse' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", + "model_name = 'hrnetw32'\n", + "max_individuals = 3 " + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "2OGXW7WmHRR1" + }, + "source": [ + "#### Image inference API predicts images in in_image_folder and outputs predicted images into out_image_folder" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 6654 + }, + "collapsed": true, + "id": "OmJtVmHq0Z4Y", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "2362df9a-3aa7-42cb-ec81-4f8381e9168f" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'left_ear', 'right_ear', 'left_ear_tip', 'right_ear_tip', 'left_eye', 'right_eye', 'neck', 'mid_back', 'mouse_center', 'mid_backend', 'mid_backend2', 'mid_backend3', 'tail_base', 'tail1', 'tail2', 'tail3', 'tail4', 'tail5', 'left_shoulder', 'left_midside', 'left_hip', 'right_shoulder', 'right_midside', 'right_hip', 'tail_end', 'head_midpoint']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: None\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 27\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 27]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'left_ear', 'right_ear', 'left_ear_tip', 'right_ear_tip', 'left_eye', 'right_eye', 'neck', 'mid_back', 'mouse_center', 'mid_backend', 'mid_backend2', 'mid_backend3', 'tail_base', 'tail1', 'tail2', 'tail3', 'tail4', 'tail5', 'left_shoulder', 'left_midside', 'left_hip', 'right_shoulder', 'right_midside', 'right_hip', 'tail_end', 'head_midpoint']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 2/2 [00:31<00:00, 15.81s/it]\n", + "100%|██████████| 2/2 [00:04<00:00, 2.41s/it]\n" + ] + }, + { + "data": { + "text/plain": [ + "{'/content/uploaded_images/img41.png': {'bodyparts': array([[[6.0902344e+02, 1.0450781e+02, 9.4288880e-01],\n", + " [5.9838281e+02, 1.1160156e+02, 9.0683377e-01],\n", + " [6.1611719e+02, 1.2224219e+02, 9.4751132e-01],\n", + " [5.9838281e+02, 1.1160156e+02, 4.9548081e-01],\n", + " [6.1257031e+02, 1.2224219e+02, 4.3269035e-01],\n", + " [6.0547656e+02, 1.0805469e+02, 4.9392006e-01],\n", + " [6.1257031e+02, 1.1160156e+02, 4.8222214e-01],\n", + " [6.0547656e+02, 1.4352344e+02, 8.8368005e-01],\n", + " [6.0547656e+02, 1.5771094e+02, 9.0701991e-01],\n", + " [6.0192969e+02, 1.7544531e+02, 9.0866005e-01],\n", + " [5.9128906e+02, 1.8963281e+02, 9.0004200e-01],\n", + " [5.9128906e+02, 1.8963281e+02, 6.1854482e-01],\n", + " [5.8774219e+02, 2.0382031e+02, 2.9714042e-01],\n", + " [5.8419531e+02, 2.0736719e+02, 9.3075049e-01],\n", + " [5.7710156e+02, 2.3928906e+02, 9.1254854e-01],\n", + " [5.5582031e+02, 2.7121094e+02, 9.0838885e-01],\n", + " [5.5582031e+02, 2.6766406e+02, 6.6969872e-01],\n", + " [5.6291406e+02, 2.5347656e+02, 1.1113699e-01],\n", + " [5.5227344e+02, 2.7475781e+02, 1.3679989e-01],\n", + " [5.9483594e+02, 1.5061719e+02, 1.8572396e-01],\n", + " [5.9128906e+02, 1.6835156e+02, 3.1358978e-01],\n", + " [5.8064844e+02, 1.7189844e+02, 1.5388538e-01],\n", + " [6.1611719e+02, 1.5061719e+02, 1.8709363e-01],\n", + " [6.1611719e+02, 1.7544531e+02, 4.5999062e-01],\n", + " [6.0547656e+02, 1.9317969e+02, 1.1289987e-01],\n", + " [5.3099219e+02, 2.9603906e+02, 9.0115750e-01],\n", + " [6.0547656e+02, 1.0805469e+02, 3.7565941e-01]],\n", + " \n", + " [[4.1302344e+02, 1.5013281e+02, 8.9839083e-01],\n", + " [3.9211719e+02, 1.5013281e+02, 9.1423309e-01],\n", + " [4.0605469e+02, 1.6755469e+02, 9.1672188e-01],\n", + " [3.8863281e+02, 1.5013281e+02, 5.6915891e-01],\n", + " [4.0953906e+02, 1.7103906e+02, 4.4163048e-01],\n", + " [4.0257031e+02, 1.5361719e+02, 3.6568445e-01],\n", + " [4.1302344e+02, 1.5710156e+02, 4.1134340e-01],\n", + " [3.8166406e+02, 1.8149219e+02, 8.5191375e-01],\n", + " [3.6772656e+02, 1.9891406e+02, 8.3902079e-01],\n", + " [3.5727344e+02, 2.1633594e+02, 8.9918709e-01],\n", + " [3.5030469e+02, 2.3375781e+02, 9.3533570e-01],\n", + " [3.5030469e+02, 2.3375781e+02, 7.4649817e-01],\n", + " [3.4682031e+02, 2.4421094e+02, 2.7727538e-01],\n", + " [3.4333594e+02, 2.5117969e+02, 8.5074162e-01],\n", + " [3.2939844e+02, 2.8253906e+02, 9.0284985e-01],\n", + " [3.1197656e+02, 3.2783594e+02, 8.1207591e-01],\n", + " [3.1197656e+02, 3.2435156e+02, 5.6623256e-01],\n", + " [3.1894531e+02, 2.9996094e+02, 5.3559665e-02],\n", + " [3.0849219e+02, 3.3132031e+02, 2.0988575e-01],\n", + " [3.6424219e+02, 1.7800781e+02, 1.8389371e-01],\n", + " [3.2591406e+02, 2.9299219e+02, 2.0259061e-01],\n", + " [3.3636719e+02, 2.1285156e+02, 2.1805577e-01],\n", + " [3.8863281e+02, 1.9542969e+02, 1.5399683e-01],\n", + " [3.7817969e+02, 2.1633594e+02, 2.2692600e-01],\n", + " [3.8514844e+02, 2.3027344e+02, 2.1960309e-01],\n", + " [2.9803906e+02, 3.4525781e+02, 3.5328293e-01],\n", + " [4.0953906e+02, 1.5361719e+02, 3.3797303e-01]],\n", + " \n", + " [[3.2492188e+02, 3.6645312e+02, 9.2720670e-01],\n", + " [3.0064062e+02, 3.8379688e+02, 8.9140952e-01],\n", + " [3.2145312e+02, 3.9767188e+02, 9.2880279e-01],\n", + " [3.0064062e+02, 3.8379688e+02, 4.9237543e-01],\n", + " [3.2492188e+02, 3.9767188e+02, 4.2291871e-01],\n", + " [3.1451562e+02, 3.7685938e+02, 2.9230788e-01],\n", + " [3.2839062e+02, 3.8032812e+02, 2.6238552e-01],\n", + " [2.9023438e+02, 4.1154688e+02, 8.8332975e-01],\n", + " [2.7635938e+02, 4.2195312e+02, 9.5248973e-01],\n", + " [2.6248438e+02, 4.2889062e+02, 8.9019632e-01],\n", + " [2.4514062e+02, 4.3582812e+02, 9.0826660e-01],\n", + " [2.4514062e+02, 4.3582812e+02, 5.9481096e-01],\n", + " [2.3820312e+02, 4.4276562e+02, 3.9131784e-01],\n", + " [2.3473438e+02, 4.4276562e+02, 9.0042192e-01],\n", + " [2.0004688e+02, 4.5664062e+02, 8.7398684e-01],\n", + " [1.6535938e+02, 4.4970312e+02, 8.6447608e-01],\n", + " [1.6535938e+02, 4.4970312e+02, 6.3416219e-01],\n", + " [1.8270312e+02, 4.6010938e+02, 9.7222522e-02],\n", + " [1.5842188e+02, 4.4623438e+02, 2.6928642e-01],\n", + " [2.7982812e+02, 4.0114062e+02, 2.2257748e-01],\n", + " [2.6248438e+02, 4.1501562e+02, 2.7101427e-01],\n", + " [2.5207812e+02, 4.1848438e+02, 1.7106722e-01],\n", + " [2.9370312e+02, 4.2542188e+02, 1.4119300e-01],\n", + " [2.6942188e+02, 4.3582812e+02, 2.7226987e-01],\n", + " [2.5901562e+02, 4.4970312e+02, 3.3760059e-01],\n", + " [1.4107812e+02, 4.3582812e+02, 9.7957635e-01],\n", + " [3.2145312e+02, 3.7685938e+02, 2.3908456e-01]]], dtype=float32),\n", + " 'bboxes': array([[509.58276 , 85.064926, 128.88495 , 227.21083 ],\n", + " [288.62692 , 124.81932 , 148.07205 , 222.38081 ],\n", + " [122.01376 , 342.69934 , 222.54361 , 131.66571 ]], dtype=float32),\n", + " 'bbox_scores': array([0.9999999, 0.999997 , 0.9999958], dtype=float32)},\n", + " '/content/uploaded_images/img53.png': {'bodyparts': array([[[4.72656250e+02, 1.33656250e+02, 9.16062176e-01],\n", + " [4.55468750e+02, 1.33656250e+02, 9.10584450e-01],\n", + " [4.65781250e+02, 1.50843750e+02, 9.20696080e-01],\n", + " [4.52031250e+02, 1.33656250e+02, 5.48837364e-01],\n", + " [4.62343750e+02, 1.50843750e+02, 4.87998635e-01],\n", + " [4.65781250e+02, 1.37093750e+02, 4.98770237e-01],\n", + " [4.69218750e+02, 1.43968750e+02, 4.21647400e-01],\n", + " [4.31406250e+02, 1.57718750e+02, 9.20776665e-01],\n", + " [4.14218750e+02, 1.68031250e+02, 8.32261205e-01],\n", + " [4.00468750e+02, 1.78343750e+02, 8.56697619e-01],\n", + " [3.83281250e+02, 1.85218750e+02, 8.27098489e-01],\n", + " [3.79843750e+02, 1.88656250e+02, 5.89772344e-01],\n", + " [3.69531250e+02, 1.92093750e+02, 3.87809008e-01],\n", + " [3.66093750e+02, 1.98968750e+02, 7.55185008e-01],\n", + " [3.45468750e+02, 2.26468750e+02, 9.04548585e-01],\n", + " [3.21406250e+02, 2.67718750e+02, 8.51889312e-01],\n", + " [3.24843750e+02, 2.67718750e+02, 5.88238776e-01],\n", + " [3.28281250e+02, 2.50531250e+02, 5.10663651e-02],\n", + " [3.17968750e+02, 2.78031250e+02, 1.99461669e-01],\n", + " [4.17656250e+02, 1.47406250e+02, 1.98352650e-01],\n", + " [4.00468750e+02, 1.61156250e+02, 3.82132381e-01],\n", + " [3.83281250e+02, 1.57718750e+02, 3.83855522e-01],\n", + " [4.27968750e+02, 1.68031250e+02, 1.11350164e-01],\n", + " [4.14218750e+02, 1.85218750e+02, 3.06811243e-01],\n", + " [4.00468750e+02, 2.05843750e+02, 2.46663406e-01],\n", + " [2.97343750e+02, 2.95218750e+02, 9.00896847e-01],\n", + " [4.65781250e+02, 1.37093750e+02, 3.99487644e-01]],\n", + " \n", + " [[6.10117188e+02, 1.09257812e+02, 9.50750053e-01],\n", + " [5.99382812e+02, 1.12835938e+02, 8.89275432e-01],\n", + " [6.13695312e+02, 1.23570312e+02, 9.25537527e-01],\n", + " [5.95804688e+02, 1.09257812e+02, 5.22700429e-01],\n", + " [6.13695312e+02, 1.23570312e+02, 4.27219480e-01],\n", + " [6.06539062e+02, 1.09257812e+02, 5.17836511e-01],\n", + " [6.13695312e+02, 1.16414062e+02, 5.11843443e-01],\n", + " [5.99382812e+02, 1.41460938e+02, 8.64772022e-01],\n", + " [5.99382812e+02, 1.59351562e+02, 8.87489796e-01],\n", + " [5.95804688e+02, 1.77242188e+02, 9.03314292e-01],\n", + " [5.88648438e+02, 1.91554688e+02, 8.98596525e-01],\n", + " [5.88648438e+02, 1.91554688e+02, 6.42297685e-01],\n", + " [5.85070312e+02, 2.05867188e+02, 2.90820181e-01],\n", + " [5.81492188e+02, 2.09445312e+02, 9.32401717e-01],\n", + " [5.70757812e+02, 2.41648438e+02, 9.19849038e-01],\n", + " [5.49289062e+02, 2.73851562e+02, 8.86915267e-01],\n", + " [5.49289062e+02, 2.70273438e+02, 6.51529133e-01],\n", + " [5.60023438e+02, 2.55960938e+02, 7.66883641e-02],\n", + " [5.42132812e+02, 2.77429688e+02, 1.29956439e-01],\n", + " [5.92226562e+02, 1.52195312e+02, 1.47577330e-01],\n", + " [5.81492188e+02, 1.70085938e+02, 2.71813959e-01],\n", + " [5.67179688e+02, 1.77242188e+02, 3.45247984e-01],\n", + " [6.13695312e+02, 1.52195312e+02, 1.65641069e-01],\n", + " [6.06539062e+02, 1.77242188e+02, 4.92357016e-01],\n", + " [6.06539062e+02, 1.87976562e+02, 1.71018571e-01],\n", + " [5.27820312e+02, 2.98898438e+02, 8.94161284e-01],\n", + " [6.10117188e+02, 1.12835938e+02, 3.94761950e-01]],\n", + " \n", + " [[3.06601562e+02, 3.29710938e+02, 9.13135529e-01],\n", + " [2.90570312e+02, 3.56429688e+02, 8.61805081e-01],\n", + " [3.17289062e+02, 3.61773438e+02, 8.98008704e-01],\n", + " [2.90570312e+02, 3.59101562e+02, 4.81671065e-01],\n", + " [3.19960938e+02, 3.64445312e+02, 3.54580611e-01],\n", + " [3.01257812e+02, 3.45742188e+02, 2.27038369e-01],\n", + " [3.11945312e+02, 3.40398438e+02, 2.37113133e-01],\n", + " [2.98585938e+02, 3.88492188e+02, 8.50156963e-01],\n", + " [2.90570312e+02, 4.01851562e+02, 8.79369020e-01],\n", + " [2.79882812e+02, 4.15210938e+02, 8.32222164e-01],\n", + " [2.74539062e+02, 4.25898438e+02, 7.87312865e-01],\n", + " [2.71867188e+02, 4.28570312e+02, 5.75566053e-01],\n", + " [2.69195312e+02, 4.36585938e+02, 1.90131292e-01],\n", + " [2.66523438e+02, 4.41929688e+02, 9.00796175e-01],\n", + " [2.47820312e+02, 4.60632812e+02, 8.32954049e-01],\n", + " [2.26445312e+02, 4.68648438e+02, 4.07222718e-01],\n", + " [2.26445312e+02, 4.68648438e+02, 6.00789249e-01],\n", + " [2.29117188e+02, 4.65976562e+02, 1.31227106e-01],\n", + " [2.18429688e+02, 4.73992188e+02, 2.13691890e-01],\n", + " [2.87898438e+02, 3.88492188e+02, 1.71175405e-01],\n", + " [2.77210938e+02, 4.01851562e+02, 1.45578876e-01],\n", + " [2.58507812e+02, 4.09867188e+02, 2.74997532e-01],\n", + " [3.03929688e+02, 3.96507812e+02, 1.03413269e-01],\n", + " [2.98585938e+02, 4.12539062e+02, 3.89558613e-01],\n", + " [2.98585938e+02, 4.28570312e+02, 6.41631544e-01],\n", + " [1.99726562e+02, 4.73992188e+02, 2.66729474e-01],\n", + " [3.06601562e+02, 3.40398438e+02, 2.24778220e-01]]], dtype=float32),\n", + " 'bboxes': array([[275.63156 , 112.20724 , 219.51932 , 197.95145 ],\n", + " [504.64838 , 86.445526, 128.49219 , 228.65271 ],\n", + " [174.0281 , 307.49316 , 161.32147 , 171.05508 ]], dtype=float32),\n", + " 'bbox_scores': array([0.9999994 , 0.9999994 , 0.99999714], dtype=float32)}}" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiQAAAGiCAYAAADX8t0oAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOy9ebxsV1Ev/t09nj7zvedOGW5CmELCEBBCiAwGiIAgT5Sn4kNFH4IgUQZHECf0yRN9ygdBeQ4PREVxREGNMvwwgGEKc0IiIYGEJHe+Z+rT896/P86tfaurq2qt3efchHPt+nz60917r6HWWrWqvlVr7bWTLMsyTGhCE5rQhCY0oQndh1S6rxmY0IQmNKEJTWhCE5oAkglNaEITmtCEJnSf0wSQTGhCE5rQhCY0ofucJoBkQhOa0IQmNKEJ3ec0ASQTmtCEJjShCU3oPqcJIJnQhCY0oQlNaEL3OU0AyYQmNKEJTWhCE7rPaQJIJjShCU1oQhOa0H1OE0AyoQlNaEITmtCE7nOaAJIJTWhCE5rQhCZ0n9N9Ckje8pa34H73ux+mpqZwxRVX4BOf+MR9yc6EJjShCU1oQhO6j+g+AyTvete78KpXvQq/9Eu/hE9/+tO47LLL8PSnPx1Hjhy5r1ia0IQmNKEJTWhC9xEl99XL9a644gpcfvnlePOb3wwASNMUBw8exI//+I/j537u5+4LliY0oQlNaEITmtB9RJX7otJut4sbbrgBr371q/NrpVIJV199Na6//vqR9J1OB51OJ/+fpilOnDiBpaUlJElyr/A8oQlNaEITmtCEilOWZVhbW8O5556LUslemLlPAMmxY8cwGAywf//+oev79+/HzTffPJL+9a9/PX7lV37l3mJvQhOa0IQmNKEJbTPdeeedOP/888379wkgKUqvfvWr8apXvSr/v7KyggsuuABPetKTUKvVkCRJ/gEw8u1RlmWgVatSqYRqtYqpqSmUy+X8fgxRXRz98folL7GRHSsfb69HHv8eT7H8atf5NV6/VX5svfx3qVQaGjt+vWjfajxuV+Rt3HJku87UyqosN03TsfJaY16Uby09XYspS6ZJkmQkvxz3ccrNsswdW6tMr66i/Rbbt16fjnudz71x2jpOuiJ5OW9blSmvPk8utHs0v9I0RZqmOX9pmmIwGAxdy7IMg8FgKB+wKb9kZ0i+kyTJy6SyZNtj9V2sDiyVSkjTNMoWDQYDfPazn8Xc3Jyb7j4BJHv27EG5XMbhw4eHrh8+fBgHDhwYSV+v11Gv10euE3Dgg6N9c9KUkQQk1WoVlUolH2RLaLVBlICkiCGPSVMUkIxTh7xeFIxI48QnqcV/DCiRgEQznkX6RTOi2wlItDYVUYDjgJKiIPVMA5Jxyopptwc8qA0kC/RfjjG/tpVxkX3u8b9d4KcIyAjxNO71kNGX+fgYeWXFtDXmHjAqz0XlrEidseCIgxAOPMrl8hCQyLIM5XJ5JC13wDVAMhgMhuojOhOARAYDQhQsM6qUbaZarYZHP/rR+MAHPpBfS9MUH/jAB3DllVdGlyOR4lbJ8gJIILhQaOljjLhGUsBktMe6tlXSDH8sH951LQ0wLMDbQR5QKvIZN19s28epQ6aNzRvbb1beUqlkfrZr3DyeJH8yjdUGft+TP9mecdoU6nPrWuy4yXtS8ReVe69PirQzVKbWBzE8W98xvIbaIfcreOWOKw+xJOWXxpXLo/yOHbcYcLgT6D5bsnnVq16FF7zgBXjMYx6Dxz72sXjjG9+IZrOJH/7hHy5UjidAFtoOCV3Ia/QmzzcqhRTCvU1ycsak0/JtB//f6GN3b1GS2B4s3ae+0sA45Q+Vo1FMFJL/t6Iz8j/3KL2yrXbF8Crrk+XFGn+rfC+9l8+KIvFvrVwtqlGkf2L4tu7FysGZNLrbXRfvO4rscrnkvwmI8KUQLlOWM7zTgQjRfQZIvvd7vxdHjx7FL/7iL+LQoUN45CMfiWuvvXZko2sR0oTdmpTawHI0KiesnJSaQrDQvsdf0faMQx663srkiwEVtNejSP7YdmshyZg+jzUSWyWNl3GVRmzemHZth+KSc4HL2DjlFwUzMbIsSStXGtoiOiTEl7dc4c27omAmpm7rmiStTqkzY9pjpbHGYFyZ3E6AFBrTrYJs3oeajbHmlFeu/M0/O5Hu002t11xzDa655pptKcsyaLEeDwlAuVweEgwuQJ6nFVIc1iT3IhdbjWqMY+RjFX3I+FtKdatAgANJbRnBA19eGgtkjkMhoBVrcD1PfDv40wDAOMZhq8Cb0zgRTNnHVntiwEYsFZlPMUAgxqDH3guVpwEMmc8znEXujUPeeJ5Jujfq8hw3AHlkhNLyj9zPqI3hVsFIrA48Uw7djnjKJkQxxse7TgNorZXTf3lPAylyQIsaxnH4D5FXj+epxdRbxFONASaxBjwWsMUaoSLjZpURSzFjZyn67STLoGwFvBYtQ5anbUKM4W8cXjRPVFPw0lDFAkXZv0UNXkgGvD6OWZLx5kYI0PA6LDCvAbFxAa8VpYopT9Nxlm6J0RfjzkueT9sGwPe78CUb/tHqC4HMWL40IHRv01kBSIqSnHTU+Tw6QsQFXwMfnvGLMd5nmmIN7Jms35rQVt+F7mv/vbyhNNtp7EPyUcSYFXniJVSeRkWWSLTyx/Xei5AsS45ZLDiJXSooOk9CdfElyxBQCxm+os7DmSTeJotijFqMrMQCjtCSi1auJs9bjezERkC1tNRnBE4IZNA1Djp4Xno6Z6fTWQ1ILGVhKbFSqZSfP8Lz0zXvSYOQIQwBnVj+Y+oqWo7Gl5ZG27Fu/ZdeVIgXi7eQR1fEWw7VVSR/LOCR6bQ9NSFPddyIQ4i2WnaMh+2NnczDedLq4iDNkjUtnwReUjZDRjUEBjSjZsl8iEcvnZUvdG+rwFuWpUVGYuVIS7ddvBYBoh5/ofKLjH1ofDz9RTaJR0ssUKLp7Z0IUM5aQBJrhPmAbsUzt+oJCVyozO0wuEXKshSGlT5U5rhgi//2jhoOlRtSUhYAC/XxVsLOWwFl41ARD3Fc2RoHiGhkbYKWXmIRspR/CJCNEwHyHAxerienseMyDu+SR8vj9sgCI5YhtmSf1z0OuIo1/JIs2dyqAdfaEGoX1Sv1Hr8nz7aivSQ8gqqBxO1q171JZwUgKRrS5cQHMXTWQkxZ2ncov8VrETAS207+P8ZYFAEDseQBP8lXkUke6yFp97fLcPKyPHkoEtbdSh7rmlZmTDoP0Hv/Y3mm8feiSNb4x3i3Mv04Rk0aCS2dJ588HO/Vx8P1IXn37hfplxie6H8oGhTDiwc2LNkMOVFFDDAHRR6v40ZsQrpL08PcJskPMKx7+CPEFq9F+iZW32v9th10VgASTrEKllMISMhr43iBIWVvgZFxPVatjhgqatCsNEWAnaVsYsfDqmO7wr4ej9rkj1HQ4/JxX5TleeKWMi1Sdwy4jJlr4wBSmX+7vOcY/mPHJcbrlTIZC9q1SIdWP8/v9REvIxYwWXWE5pkVXRkXnGj8hSI4si9CYNory0tvARftuuTLGj+r3iJ2c7tByVkHSMalcU5uHNdQUhoZcovJPy5fluEs6uV7dcW0Xfttvf/HKjPkPcfyI8vwxr/odY8Xz8DzNEWpqPzG5inqDW2XQeBleP1HdWjeHfEeCvlLsu7FjF3R8mLzeJFgmc8L4VskN1JqfMQuCViGMKRzYqNURfKOQ7LvPIC0FZ2dJP4ypJTpLDu9VMNt1mAwyHnmaUJ9LttQBIyMky9EZy0giVXOvEP5RyqxcZR9UX613166rdy3ED2fiFutK5Rm3P48k+MwDp0JfmLAbuxTON9o/VWUtgJwpGHxDLlVb6xR94zUOEsh9F966pSez1MNREiD6tVv8T8uEA2l85YtYo3ouGT1J/+vpQtFcSwQE4qYyGvaOBBwSdMUpVIpf18NXU+S0++ykTLCy4wBwOOCxu2gsxaQAFvbpHdvkmX8x0WtMfesZaJxogTavVBaXk+M8aVr2+0FefXx6+MoaE7bGQHbrrJjDf1WAEEsH2dSyWnjbL0teqvebqicrRgEz3jK/6ElHav+2AhIiLY6T2IBo5WuKJDaqvxpeoL3ZwyfVv9ruofLrwROBFooD4+axOpai7x2bIduO6sBSSxxlHlv1xszEc4UsIo1QrFAJBbIFAE9oT6K5dOjmGiQpK0q3G80ivGkv1F417xpTkVAhpXX8nI1T3lc3rX6NKOkeeLAcITM88TP9Lhtp2zEGHGNeNpxZJiflGrlsaIkMeUTX6FxJqDBl2WIL7JTEmBQnfLMG8lL7JED9yWdtYBEE2qt83nYU3rtW1E28rcsWwu3hqIGMXUXyRdqYwipx9Q5Tj+OQ9tVx3aUcyYmvWdcvtGUylZpqwYuBmSE6pGGUQIUi19vLLR6Qx6r5YyQ8ZJlyzwhcCPL1HSm1FGx+bR5EBpXWV+IigAYzRbEyIKszyorxF8orcUnfWjJhohHQSQ44f85wNF4GMfWnClgs6MBCRdab6Jo1yUV6VAvrKaBEssoy0mnTS4LBIzDtyQrb6gOC3hp5RTlz2r3vUXjeplbva+RNCTjlheaB99ogGa7x9wy+la/SMdByxdTh5fGAk2xFAOmtHSxoKAoX5Z8FjX4nIetAlPJl+z/0DiSftbmiaf/raiaJldW2ZYe8uRVq4ODFW1cPbti9b12fStRQ047GpBsF1nvsDnTpCmlohEO7/9W6ExFCjzvjNereXnbaaiKeCwhspQx538c439vgbF7G/TFUCj0zakoeLXASAgMew5DbJ1FabvlfrvrigU3MoITm39csiJGRfMRFdXLms6SfIScOf5bghgLUBD4oPNJLJkORbzuKzprAImGxD1PgdN2ePIW8bI1FDwuAIn1yLYCcIqShd5Dio5PDg+VF4mC8fq9KFQo/zihyVDULrac/6p0byrEkLdnUWguWjJn8RB7z4p2eKBM5o+pS/PuteiBxous0+Nb40HyEwN6ZFr+HTMPZXu4boiJrMTck99WBEXjke7zPSSh8ZJvB5bl0lM5Xn0WjeMwxNCOBiT8MSfPs9Z+E0lBkcaK7mkDL8uNpRjB3QoVASIxgKpoOVq/acDEihxYRttTttaEtqIX1uNxHvE1XErvnWyovbmWK7kQOLGeBOH1y2uyv0PpixjfmLTjgIkiHnOoLzzFHkMWqC6S3uNvq2QZfk2utlK3pg+1+7yOcfSa1h5uSENtsea8l88DZJa+KDrOnhzF6jG5Z0Tywr/lia2Ulz9xQ2npRXz0+LDMG+McWu3cqqzvaEACjAcIKB9gG81700ujurXf3rVQWVY7ihwHH4OUrT4LAY5QPTGKVTPAWj45wUJ9oLVZe2yODiTidWntlgo2BpB4cujJp1RYFhUx/rGgZKuAxLpmyZh2DLvkW+aL8bpjePSubzWtRyH+LdnyPOqYOnm+0D0POMn/nqMY22eaV6+NO3BaZri+10CQJYOha7F5LD3PwSVvhwa0+FzX7llP1vD34RTta17OdtOOBiS8U7mQyckW6nAa/JBwWYaO/nuCppUTAzxiwYllkEMUQvWh8nifeQolxoBa3gngHwImwZxmuKwJK3nWytJ48gCWrEcCFdkn0qhS2VbI1SPZBslXCBh6162xjsnr3df4jeGBp7PmlDeGsTQOsNuKDohN642BFcHQ7lvGnN+Taem3nG+h9nhAhQMGKtPTydIYa7qft4XSaJs8ZbstkFzEWdTAgGWbtHuyLzSHSBtb2VaNX7rnRWGtvg+NyVaA944GJL1eL9+QSiS9wxBI0LxJTYhCxjrmvqYIPJ68dDFK3BIm+QhYLJAJeTXc4Hpt8JC9xX+MofOUoQQDchzImyiXy65Rp/TWMeW8bOnZ9Pv9PFxqtSsGIHp88fbIMqRCK2JINIoB+LGyZRkAzlsITGnpQnXHGtCiSjYWsMWUGwPqY3n0DFrI8FN6a0zldUtHaTxoeZMkyU8ktcoL6Xmr72INZxE508jru5h7Uldy3i2dx6+Xy+Wg/Mt5avEoy4+dE0Xmzo4GJGmaDgmsPBhGi5popBkQfn0nUIwC1LxLy3hxgY8FP3Jn97i8xyoyntcaM162dpCUBCOlUgm1Wg3AaXnS5Ed7xwT/Tcaf5LPf7+cf4oX3lSZ748pfCExpPMt7IXAXQ9R/vK/4fLXKtMCs7BNLWRfhN7avQqSBr9AYanuMZB5yIEKAm9cbw6eUvRjaiuc7DsUYUp5Wk18LAKZpinK5PJKG6wWtj0hX8PfIFO1Hqz0hcCjngTWGci5o85zu0V4Tq857e8zPCkBCwkUGhQSKrtPE15SX58nFCghPr6Utalg8EOHxo+XXAIiccHJiaW0ipK0ZNConTVP0ej13eUVOIA3phxS5pXjkQVFWGRyEyPqkkQu9Kp6XKZUW54n6j9J4ERatnRqFxkO2TetXSseBQgyojLlPckBRoRDI0Qx0TBti+LT4lWV7IN0irtwtvj2eNHnW8skN1R5Z+kqbm97YaDzGzAUtXaxxk3NJ4zHWUZInsMbkC+lXnk5LbwFqXpd3TbsfskEWaNV0BLd58riL2LafKaCy4wEJ9yy5AeADxa/xKAoXTm2yWxOrKFkAIYZCPGiKWiJnqYS44tTQdJIkqFQq+bVyuYyZmZkc3HEvI03TfCmiVCqh1+uh1+u5hkC7JsdIAw2Ul5ZVKJ/mOcj+kX2m5SNAS8ZZKjHNcGjtkfUTv6VSCZVKJcrbDRlvL73FT4xxpN8akLXSaySdACIZxdTq1ICVRaG5xWVVq4uMNAfoREUdCdlHEnRywMOvyUiuZxSsPuNgQzuVcxyjEwIpof9FAAill30k9bNHPJ92FLwEmzFkbQrVxtaaX9qYW3xbusECFvQtn8ixypJ5uG6LlQtZHq9vq7SjAYk0ttJLpk63Dj6jSWwtNYS8AykUmsIBMPRmxqKDpimo2Le8Ulo5KagvyuVyDjw4WEuSBPV6Pa93ZmYGS0tLaDQaaDabQwa73++j3W7nv7V9EhppwEPyZ4ETyk/fFiDxnqTRFIjnlRQxjLKNvE1FDF7IIGiKW0tr1ekZ86L/JVkv8pL8e+WFgJsGnLx0FujxeCvCq7wnl6f4PS7X9D8ERrS6uCMlP54RjCVZRixY4v+9FxluFcxovGr6gOtoLZ0WXeX5YpyIEF/efW5rLF0i57Gc/9arBDjxerT+iJXBGIdpnP7a8YBEKgA5SJSGPHipCCgNf3yTkzdp6LeG8EN8y3K0/1pa/i2VLOdZI2p3pVJBpVJBuVxGrVYbWu6idPS71+uh3++j2+0iTVMsLy8PvQAqyzY3a9LvSqWCRqNh8qLV4W1Mtjw87X4IYBShraJ9TZETAI7lbbsAiUfa/RDgjSmT1ufHyV+EQnOTvkNARJYh57JWRqgcrUxg2EHh9y3PmKf12sK/5RKcN088wCodLi2dJMuB8vSb9T+mPo1vihjEvlBOGmPvzbhadMTiPTQvPbBj2RYtj5RXiyeZpghYKkrjzJMdD0jo2xpQrlxIQOk3GWGKkhAoGUeJh9KPi/ylEtDaKtGuRL48DY8Y0WSlfThaH5ZKJbTbbRw5cmSIB75myYWfgI7kXfLJ/8vfPK8EKh5AGZe8MdE2r8rJbsmfVBgaeNTSaqQdkBQDSEI0jlyG5shWx4MoBuBbILSohyfrsYCATKN5rVod/DpfctTue3xJnmhOkBHm13nZ1v4cYHSJR1smleVa+kWmDclCrBG3wEwRkBSqg1/jfWmNqeTDq9/jVZMTTado810DNhrg4HbQkimvTk/OZfoY4GnRjgck0jDyZ8z5YHCUniRJ7tVXKpUhkBLTcdYk8oAH50WL0vB0seDCSsOXP7ggyjVD+u8tbUivTmuTdd0CJF5e4l+m98r1DH0R8gwS/eaGSQMJGq9a+pBBjOFtq4DEMkpF854pKgKWNIPA83vzMtT3Mr32O0SWoYkxZFZeGaKndDziaAFhz3vleWS/aaCM6vQMq9VGkju5p0vrB4s0HSmXsmLLKzovY6lImywgq13XAHgsiPcASoh/Dax6Ttd/CUBCyzCa0bCMFj9MLcsy9Ho91Xjw8iRpqFSSnCByqYKWSeQk5MpETlLtMUpZlzX5efnUBg1lj0McZHEFo/EghV/yoSF9fk+7Ln/zMi1Dq0U+Qkqap5Pg1QIksu0aeQbTSk/f446ZVtY4+UJkgYQidRSpywLXmmEFMBINkHk0xRsDArV74zzhEtsHUgfw8ycsQOKVbRkYDYjz+cDBAd+/pkUcJMDX2gKED0XUAAnnT7YxBADlmMuD2kLgIgZQ0H+p6yxwEAsaYoBICARr5AEeTc7kd6zDs6MBCU0E7vFrxgzAEADp9/s5KMiyzT0QdPYELeXwMvl6OF2jpQm+N4XuS4VF1zgI0bwkKtsSlpAC9PJpPPG+0ZA2JwvkUVlyD4513oTkyQN1Wl2hNLHpvMnLy9GAyzjnrViKKWaiavIiy/TAUVEeY5VHSPlpG3m9Ppf/x/UqtfwWHxa4kPlDBs3yILVrHtjdKnkOSagNEmRbaeSc4A8G8HzaHJN9ahk2ec0jrd6QA+BRCER7/7U9KxrosOqKqZ/SaCAhBpRoY2LJuscnT2uBEQ5YY2hHAxI5wb1nzXlkhIiABIEEeuKEb37VIhZc2fI8vG5L6CSil+m1/LGesOZpUNtDqFgTrBgla133DIfVHg/wxCrr0GQP5ZV9aB2m5gHHInzGlqFt+pV8bYWKeONF0kgPuAggkddjQGAIpI1j/GPzaLJnzQ0tIsDr8RyIohTLt2ZIZBquR7g+9YCQ1Qf8cWtKJw2XlG3tsXFNLrhO3g7SHquVpPFP/GkgTQPJHoCX4+Q5kTE6gTvwRfJJsg55tHjzaMcDEsuIWUaPg5BqtYp6vY56vZ5HPDgI4R9eJ687yzIzjcav5Nv6reWL8eiL3ON9ItNaCsniLZZkiFxTuB5Ys0imoXHxlKR8HJwDErpmjetWAEmskbH6X+b3lh1iyVPuId6KlBkLSLRlgRC4KaIUpXPC/1v1hAyhvG4ZKI0XjXfNyHplxF6XBk2bexp/ci5qfHo8a+kkuNeOOrdedSGXh8cB5V6eWDAsdY5XtpzToTQeP+NQTL4QKPHAvZRj7Wwcj3Y0IPGAgOZFctTMjV2pVEK1Ws2fuqGoR4ynohk1SZ5ilEsbljLwyos1RFY6/hiiJoycBwmeuFGUfSrTy3wSFHhjGSqTviUflpenfculN5mP7mlvBrXyEMmlP41kn8YsexW9X1SRxRoY+h8y/ltRwBqIkMd/E3+0aV2Ww42ZBQT4bw8MeZuaZR7JiwVkNL60fDKPBRAsniwnyHPwOFmy5+k6yqcBHKtsaeRlOq6nrejEOGTp3FCZRTaHFwUsHk8eXx6AkHV58uOVZc2RWOeLaMcDEk3J846QZBkv7b4FamRdWpgxRJagybwxT/7E8Or9tkAFBwIaAAnlofvW+S+87NCTRxoPXhp+nxsrnkcDQN65BTJc7ClpDZBYCjjGIGtkGQ/rvpZmXIVtlWUp2XHayO9p+3b4PJdzKLTPxwMh2m/tWgiQWKBHpqff2snTsizZLu6BcvBj1SOjQN7mbA1MyX7m/e/NDZlH5pVkgQwNEGtOlFWuxo9WllWmVp8lh1r5Mo8HQCwKpdHG08ujjaHHk5RLax5w0PlfApDQJtFYkuic7x+J6TQNQWqApAg/1jXP8HoAwAJcWhRHM/QaCNFAgwUsLOCitUvWEYpOxAJIayy19lvjEYPyiwAST75CXolFGj9csVp1xVzT6pFKOxbceIbby2cpVssYWPVKQMoNnWdIrd9Utnef6wUNIBQBJLwtsgz5KgduJLS28XZraWS/STAkj08ATh9IabXRamtMPwOjRjHGuMUaQT63PVmy8gGjMhiSZwskaDpHS8MNvUfamGjt4B8NdGr8eTIqdVAR/bajAYllnEJ55BIFDQRNPO/UViDsZVoGU1vWkEsWllGV+1mkwee/tTTSsGt1Sn5lH9FyFi/HMta8vCRJRk6ClfxqezI0UObVI/n10nhlaYo5BvBoCkXet7wQrzyLLEDlKSyu0GK9JwlE+JyJ4dmaN0QWoLeUcSx5r17X1vpjnIuQUQnls4AQp8FgkB9WyPOH6uRpYvbHyHZrURiZVos8Ea88LQcpmiHjdRFAlLo3ND+LyFqsU2Hl80CC59hsJ2kgroiekfe969q3NU8ocsw3/ob61aKzCpDQNSstMDyQcg8KTUoJBPi3BBbyGk+r/daiAtY+Cr7JlrdXM4wyHf+2oiBaebK/ZPkUkdI2/Fqk9bVWv1aWxacFOGhieLx4Co9PHFkON8iaFxECJID90jmNigKSGC8y5I3G8ENlx67Ze0ZcGy/PKFghfC9PLHnjpvHGFX9sPmsDOee7UqnkoMSq16tDAhDJKzAatbE+Mq0VGZJgQ+YhklEjCVxku73lJ6u9XkQuNPetuc3/h2yMdT12zmt6GMDQfqmQzFm6yJJfPm48HfWHJlPkaGpl8XltOS8anbWARDMmwOnn6Mm4ktfP325rLVHI697eCM14avklMLHK4W3Q2qkZbn7d2l/hgRBZBgc9GoDS+JT/ZX6vXnkvtI8kRuhjlIJWN69TU7Sax81JGqSivIS8MpkmRllZvIb4sMYr1ivT6tdAnWbE5T4eIs8YeOMh80tQ7/UPV9QxwNza2KrVwaORMm0MILE8XO2pB8ub5Xm8smUbeR4LHNE1CUjSNB0BYlqd2p4ZuVQly5U8h8aW95Mn90Se/tKWo606LeJlWeCM0ml74eieBUS0saY8ctWA60TKry0BFtFxwA4HJESWUZWGiyu4arWK6elpNBqNoZfNSZAhwYQ08PLQtBCC5kDI2/dBdca23QIbHPBo/SWvWXs+pHGQ14lioiYaePL6TAMjoXyh+r170kh64EebdOOAHo+sia3VGzLSRZUEL49HD7nRiDHeGj+yn+k3v66NgaaAPeWrtUmOpcaL9l8rw4pMavzwdlv7QmQZ0pCHxlUDLTKyERozD3hoe2MoHfUFf+O3BXj4shQ3iFp/eACLG1VtaYqXr12j9DL6ZkWGrDIsWaXfXI9RGXwc+G9Nh1Iaqz8lSX60iKYG7GQ/efXQOBOQJB49O+jRjgYkNMD0WwIIus7/9/v9HIDUajVMTU2hXq/nIEEzfrx8Xq92nwuc5JX4sPZfcIATMprab9kXMXlkfssIh755vVr9nmdiKXJvPLX0MRSTR0ujAToZrqR7HkjxjEYMP57yiS1vHLKetKBroX6NBS0yPf/N+9aTL1mOBWAsEKJRyJv2ZJinkd/aPasNQLGXvmn8yfwWuKEyNQDEdZQ00pxPq8/I8FE53tNFIUDCPxogod+hKI8WGdCiPVx2OIixlsAsEKrxwuW6yPjGzgfJu6aTY/SSpQM4kIoF9pJ2NCDhh5kBo6BB87DI6Far1aEPf8ke9/I1I2+hvxgD6gGdWE9LK1fjUePL+m+VI8uKCT3KfFLhen0p/8cu7cj6YtLF8q/lkSBBm+CWDFoeiAU8igANz7BthTzAIxVpqBxKZ42rNAxafq08SRIkchr3yTjOO33HRDJjKDTfvUdgOW/yt5xT0phqZdA9D1jI5TNZt1W+Boq0MQ8BEmAUKFugQ4vmaIBK66MQINGMvBet0cCUbJv1xIpH2jzU2mgBN8rv1Sl5tXQ15ymWf2CHAxL52K8EHfyaFCQAORCh6AiPktDHijqEjJ0GjGLBgvbt1SXBk0yjlcUNplefBfAs3iyjSvc8QGOBN40/q01W2SHSNlZ6Y6x5L1r9sg2837n36RlyDwxY6SUfGoWASyywkXJBxA0WN06hMdPk1Eqr5QlRUcAmeeDjSN9a+2SbZJ4iFDNWvM6YOjywUZQ/2Qcx9XsA1IrIyf/a3AiBG26QtXxaOdo9C1CkaYp+v6/m4/nl3g1r3w0v1wIv2vhL0oCflQ4Ynb+aztP+F9VXnHY0IJHKngyeZWz5bwIzcv8IlREywHJAtHo5Xx7osKIw/FsKg+RT48njQdZn1a+VO47BJ7IUlQfkvDRFKaZdnEevT0Nlh8BbaF+PpozHNWgahRSHBbyIl1DZvAwNZHjzNARUYkgDERpv4yhPyb8np0SWMZCGbRySbY0ZW8kjjwJoYCVmzGPa4M0pyk9zIxTJ8o6at3jRAAlvuxcFsMAOT5Om6dBLXC3iG3rlHhjtqSHtySW594a3hf+WUR5LRrlNot/aGFiyvRXdDOxwQCI3UGqAghNtxONP2dBH22RKFDLkvH6Z3jPyIQDggaHQEydAXMjba68EBx7F3teiV1paaTRi6hlHCXvpQiTTxTxxBIwqMGksreiLNO6hvghdlwrYSq9FKULROK1O2caioDdGnmPG36u7CDCI5Z+3NzbSEcN/TFqvTIsfC8DF8ueBCCkL8pon4xY/8r8X+QGGdZCXToIO/tuTZbIl1WrVLJuXo4GGEKiw8gGj0SV+Tgyvm64RvxIgFaFxAYikHQ1IyDDz/5q3Ir0ZvrFU2zdC+bT6iCzvyMqvGXfP8MtrchIUBQlaequvNP6tCWi1U1N0IdAl64tR4JIsReP18VbIM4ghw2OBEi9dEV5CSlfrX8/oFzGmMcAkVJ4Gzrw8lgHxSOt3r99i+0LjKybCYC1taTxr/TkOL5YsWmAgBNosedLmv+RHpqdP7PKmVa8F5njUwOsTr41Sj8oohJbGeqJHe3SW88l/U3oqi48NlaMdUEf3aFmJIjm8j2Lnz3an2/GAJGRctPv0jD9fpompK3TdUsIWAIm55hlSabStSbSV9hEVBQZa2UUUeAio0O9QuUVAUFEKTVyumDTFIhUl520rY+mRpjgto6EZ6lhDpN2zxssbb9l32wkiPcAheaPrXPmPQx5Q1+axBAI8z1ZAl8aXls/jQUtL4EHKVsz4evdLpdOngMozf7R+sU7plXzKa1RmaB54RPdlFFHaIF42X/LX6tSuEZiQgASIOw+m3+/nKwStVgu9Xk8FUhofMuKyXbSjAQmRhUa5oPGJQYCE3vDLn9QZt36LHy6csYCE8+nVU/S6jGwUaXPR/tHar/FiGZlY4FFUWZwpCgGTUBpNqdB/YHOtPLQ2XpS0/g8ZHI00MKXlszxx+m3V4YWPx+2LmDHReCLDEWOkZfkxdZLhLTqnz4RxkPVZDpAcf0v3FiUrysH1Oze0PF9Mv3CQI9ssowryfgx5zoU2jrI+6zf/Xy6Xh8580crTAAmBPNq6kGUZOp1OfgCa57x58hnqixDteEAyjgFKkmTkiRoiTWlaZYSETIIRD3yEyohpUyhfqP4YZB/ip0i0RoIOqeRI8GX90rMpEjak/EUBmSwnhqz2e/Vyz0OOg3xcMsaYWkpdUqzijenzIsCZl2eVGWPIx+EzxJul5L0020F8nLX5wUkCgpBnrRlCqzwrjzaHubzJ/7LcWAMd4t0jTwfxcjVHiX+HyrF404BaLM9Uv+xnrS8IWFht4CCEg2n6T3XSsRdUtsdzkiQmmNvqfNjRgERbbonpVAIk/IVvwGg4naeX+eVH3pe/PYGNEXitDTEGRqazvr16tHpJuLXn5TVFyvnSlI8EI5Yi0LwGzfhY13jZIcVr1SuVzbjAhpfNvTrAP6XXM7ahcQ0ZpPuCYpR3jMcr5UyWLe95Huh2UQxIoHQWebzFAIoYKpLPAxpEMU+QeWVbeWKMvKaDtLq5HvD0YuxYbcXBkTxrzoSUVw7+tLJ4PlkuOWX03iT+YEesvMpoodcHsbK1owEJ4HtdXh7+uC8wasC3yg8HS9aBZ9bk0yI38ndIYEPkgSAZktMmZyiMHtOHRT0IzShpCjHGqIWUZMirtJRUSFFa1+Uasseb9j9EVIfHU4wXbpVdhA8PXFjj4o0bpSnCc2wajbeteu+h8qnsUF9tB2mGTpIF7sZpcyyo8PJqTlKIV688SwfzezGyFHOtCFmOHP2O2asj0xFR++joC+6gy9dCWHPL0nmx81qjHQ1IpNGUk0UOYGx5MaCE0miDIu9pZYYG1eKLSCpFSyBDbdcEXStPKi0LyVsTly+TbEW5xigZzqPGf6xxizGEHoXyWB4lKUK5tKSNQYyi9MBlrLzEKJ0QWTLDr8U8MmrxIGU2BHpkWzUPNEQh2dDGbxyQEWtgrf9F8nI+NfCl9c04c0TTWSE+KL3U1Z6+8srW+Odznq7FnMWxHaBUkjduoWUTaoc8UZfuUV7tQFBJVr9uh17gtOMBiRRMCyBoAu2lKcqH9luSht7lRNaAVAh0hIxMiDQgIu95nlEItFgoflwj741TaELFkBaK1MqQ7YiVg1Aariw8bzIGNIfub4fMc/KMbcgQhwDiuGQpzRhZkmQdQOXl0crVwFAMPxIkWOk9ABHTz5THAiSh39p/fs0DI1xv8E+SjC4ThHSJ1lf8yRO+j0Iab69/ijiU20Fy7LX7kkeLL03++MMdsQDP4zXmmkZnDSDR/tM1jna1D1AsihKTzjPysaTVwycnpRkHhBTlIaaecSZnTHmx5WsAtQivMfJA16w18iJtt5QHKUbtuGZvHMbxWnl5lsxa1zS5DjkCWt3avVgaZ27Jeq3fGvETREPjoQFWPn+LKvkz9aglJytCsV1l07kZ3vykPrIOAqMPvYdM45HK7vV66Ha7Q28WpvuVSiUHOXKssuz0I7Va2WdC34bAmZUnxJ90fqW8U3raQ2K9MykWNBe5LmlHAxJOlvGi39KI8xBVSKHIvBLMWDxoUQL6raW32hVSCOMYRMsgW56HNPZevbGG3SovVGdM1MD65nlDMqORHPsYw0wU601LRWG1zconQavWNg1ccGUU6gsP7Fieo2f4x1FyWrleXgtkWbKq8Ui/Yzf0yblA1zTlfqbBxn1FvF2DwQDdbhfAaT1snZTN+3cwGKDX6+WAZjAYoNFoAIB6bEOWZeh2u9jY2ECn00Gv1xvig05SrdfrqNfrOS+8fAIylI8folmr1c7YSxU9x0DKS6zMeHrKm18agNF45jxthXY0ILE6M2Qo5RHzWodr5XlGTBMq775ldGOBAk8fC0Ksdml8xpxaGxJkSTEv1vPAjtZPMaFbq189ku2LBTJeHRY48iIb1juSuJIksjxPS760ekNP9/B+sPZ7WCCAl2HNtSIk26KV5wEGy2O00lvzkIf5PYBF/U/jZAGgUHuttpwpKjJWHsCSssPHjLeHn4XBD/iiKMdgMMBgMECr1UK/30e9Xs8BAsnkYDBAp9PJAYncqJkkCfr9PgDkj7xSVKTX6+V1UN1EHEBVq9Xcjmxn/4eAvuXYeCCX39PkWOqNkA47U/K2owEJUayRocHwNvFYylyG9aRy187uiClXllnkfTrat9beohQy3jHGnYybnBxF+AkZ/5gJZN2zlGxM+bH1auVqeb03aYaid1xBaKc9esAxZGgsQEakgRcramBFTbw28nq1fLwN8mVsPEpUxNDLOmPISh+SDSsiJY0Hv+adUcJ50SI448y/7TY8SbK5VML/8wiH1hbZJqLBYIBSqZQffc5PZ03TFN1uF91uNz8inddJ5fb7/RwAUR7+pl7JA4Ei4pu/bX47yAJxRcbBA9k8TZFyz4QsaHRWABJOIWNBk1qLAMREITSgod0vaiD59VhA4KWRvGh5NAUVUlhaP2iIvShQkgYrdEqt1ldFjHFRIzXOeFppNENv5ZHtCimG2L0tmrL1ytbmiDb+Mf0q67PqLaIsY/MWGffQiZQhZR8zf63rVvRGHo6n8SONb5GTbrdieGLyacch8PyWXNKHwAtFNej4Bnnol3ZkupRXWp7hYMOKMvA8SbIZXen3+zm4OpMG2xojD8R55WjzT57pZQGjEG21H3Y0ILEMraW8Y7wtrw4LeFhgxFI81jHqHpjRDKRWD588WlkxhtUCCx5gkn0b6gNtUnht9b49/mMBVqgcr6wYQKLxFYrQaHkkxZQh74VAkBxvObbW2PF81sY4r77YNnlgQzNoVh7NAdEUsafsY0BJTN0eMNCAmyULciw8nReqU0sT27eyjZZOCoFgHh2pVCpI0xS1Wm2kHO3pJ8ojN7RSebVaDTMzM0NLLzzKIvudgxza8Jqm6ch7aWIoVmdYfSLnqTUeGnCV16nt1nvd+JjHtHHctgFnKSAJ5ZEdb+0stgACXbOWcWKATUxdIcOylfsyrSQufN7pfbGK/kyT7LdYg1+kvDNFUvnHAK1QWbH1agY3FsRpSnA7+ykEnqhez0jGKNCQNygBk+TRyjeOrIXK0Iy7dUCWTOdFVULXOMWAEYtfDSBJOeS8ct41nWvxlSQJqtUqGo0GyuVyDiAoT7lcxszMDBqNhmqYOfH9J5w/uQ9mq2TJYcw8iCEL/HMwRu/G2e62xdJZBUjomvwvlaWGBiXKtcABpfMmhsVnkfT8W/NYPQVpAR6rHo0sL0F+h9LLujQPTFMssX3qtcWTjSLGIiRjofxF0sj6Yr0vjU+pULgMF30jq0ZFjL4sz8sTI5chivXkQmAkpjxN0Rfho2h5mkHhdfH80rjTb22O8rwx/IXSy9NONd5CbZfn8cTIHJVdLpdRq9WGjCwt+ZTL5TwywsvjkRgCHbxM4k/OS0uPxfRfiKwx0nSsB0wtPjggkXYxtiyrfO23RzsakHDyjIYcfH5MbsxeBQkQihp3WU6ojBBwKdJWK68nWDL8yfNKxSbJmyhavZaQS0VbtC8sT9MaA8sr1gx6LMWCEamw+T25WdMrh8tYKL1WN2DLTxFFNA5AscrReCvqoXv8abIpjUyskfbqkWmtOVQU3GjgxAMpsp6Y8j1HRPIgy5Nt0earNdfJ8MvxCDkxvM10LDrtDyGdr723hTub9DQPbVrl+0UADJ2Bwp+a2grFlBEDLrS+0NIDyI+KDzmXdN3T5bLsceisACSawFuKhJBgtVodEjDLm7QAScjAyfTyesjAepNcprUMusaX9E6KCA/1kXxcWmtDUSRdVIjlpAgBvhjAuJ08Fk3L+5WIKwDt6Svu7Ratf1wDU6RNXl5PRmI8sliw4IFZyVdIpr284xCfvx44iI0KWPmK0lbbZZW33eV69MflP8Z6eX3L5bg8J/Q1vgEuShky/mfr5bAy0n3Dm3yL5B0ipV+yTgZ8KszXjgYk0rBawAQYXiuTH9qcpJVvlWfxYd2LMWgeoNnKZLa83nGRbChqoHm2GqiTFAvoND5i0m+HF7OdJOWriNELRTI0AxUCwSFeY7y3cUBbEbDC82necgyY0eqyPO+YvFsl2Q/jgooYfmJ59qJGRUg+/WLpVAnKttKvSZJgHetYS9bGLuN0YVsv4ozRdvO2vU8wj0U7GpAQWZ6xnHwcGHAPyzPUWpoiEQ6rfD4JPSWoTdYzRZaRo2t8PVdrU1EeNbBGoNEDMEUMh8enZ6xjjTTnKea+VacGoK1yrXpiQuRA3HJODLCxjD8fT8lTTDTGart0LmR+Lbwc0y4L4MSWYVEMkLb6hd+PBWwhIC6/Q+RFbWJJi0BpIHq7wIikJEswi9ltK+8bhYJRCi+fkUc+Bh1Vv0JDkRFk6NV70fydFYBEI2705XX+DYTXer0TRovyxOvkE3EcD00rl8qW96TRl3VvN1mKaNw2asTH1xtP2e4i5dN3CLCEDItGHBTzeqw+8sC2xqcGDDTFv1VwZ4Gpe5uKjG8RoGfJltan/MDF2Lp4GRbQCAFNKUtbHYPtHMv7Mio5i1m8In2F6tRpzp6nS+l/DBiUDq+mDzXSrlvXpCzKDcD81FqrLvr0+32srKxgeXkZrVZrRCblgYMeSdn/9JM/jR7iQMmOBiQcLNB//k2/tf+xSjj0NI1WnqaQLEMT4sPzUmK84RjyvJhYxeTdtzzaWPI8zZjx9ECQ57Fn2ei+DoufGM+frlteuhVF4ddDoMSq29qorJWrlWO1uQgIKGKkNRoHTBbxvD1QIMsmxcz7VZ7aGQv+tjK3ZDpNz8hxipnTHu9F+Y2Rke1KY+WLTZck/kFysXycKSePy6g2RhJEeABCAmn+sAedXguEdQcntW8yIHZ5aUcDEiJNcXoCQfd45ENDsZ6x865ZnpVnpKSX7BnBcT0OrphCnlhRIyjrkTxrZVnCTIcNxU5qXq7XrhieJd+xYEpT+EUAg2bci46zx6f2tI5X/lYUqtYXVF/I2+f5irY/Jn3IU5WK3ZMrOVZ0lLk3/hbojOXfopBnH8ob44UXrSvGm9bq1/oj1EcxfRgz72KWMzWKdUhieJRyxT/yiRj5BmPeDxwwe+/b4bJeROda17diq3Y0INHCo5y86xKM8PRFgIilWIvmDV2X3rTGt0ae962BJM/DsvLEkGWMJMVMCg/4Fa1bGs8i4ynLiAHBGm/Wfa/ckOxYSp4Uk3UsuqW05b1YDzFWQY0LJEPkleF5expwt4xOCKxIPjSjZxlvbd56dYUAl6SQ0xCS7dD4jqsvtLq9ueIBWW+cYl6M54GyoiDYuy/1NAcV9JvevcPT8/wcEEswI3kHkD/eLG2KJ/saYCLa6osGdzQgsUgzOly4OSDR0tO1kJdugRmLLKEoMmE9lC/r8Or1PEELlFC6cYyrx3MsaX2m8RPqo3EVZCzFlK95Qrwt9PSXfElaLKi1eOJjq0VLino3nsfqAVnP0I9TH9UJ6FGgGENpGXAPuPB66Z43H2MMuFZ/rFGLMSJElmxxHrVTrDl5AADYXMaSXnzM2IfkqgjFgl3tWow8xNYZk5aPIwERiooMBoP8TcRZdvpMFRpDsml0/D19OOCg32TbaCMrHRhnARKLT60tW9Gv9rvGDbruuuvw7Gc/G+eeey6SJMG73/3uEYZ+8Rd/Eeeccw4ajQauvvpqfPnLXx5Kc+LECTz/+c/H/Pw8FhcX8cIXvhDr61t/ZhzQgQFHktaeEH5NOzCNt4//jjXCMfdjDZmWlvOioWKN//uCPN7ovtcXEgR6ypD3lQVS+eT0AIsEdRoQDV2XsiVBL/0n5SDfSK3VYbXX6jPJW5G+svpEglWNv1gwGOMFx+TX+JPlWoaqCKCW6S2vVNZp9bWWPgaMWB6tZ9g1+dc+RR2zrYDmcQBHkTqstByAWRQzFlqe2PtSdih6wX8TKKGX+9Fvfp+/Z4dATb/fR6fTyd+AzN+ETPlkHilDUs40Wd+qfSkMSJrNJi677DK85S1vUe+/4Q1vwJve9Ca89a1vxcc//nHMzMzg6U9/Otrtdp7m+c9/Pm688Ua8733vw3vf+15cd911ePGLXzxWA8Yx9NYBVJaBojShzvaMXixvmpGIJRJcS7As3orU4T1xFGqrLMcykhxlW4qRX9Pq9hQu/0+/NVAigWnspPMMu9VXXv0eKNHKCvElyx2XQgBGAyOeMbDKt9LFzn1PNmLzyDTyFRI8vQVIYpyXmPotQ+AZh9gx0OaUN8YxFAtmYtLHtCGWrDnlydy4dXEKOWUabzIPAZBer4derzcCMgisdDoddDodtFottFottNvt/Dfd4/nko79SrjhwGQeghajwks23fdu34du+7dvUe1mW4Y1vfCNe+9rX4ju+4zsAAO94xzuwf/9+vPvd78bznvc8fOlLX8K1116LT37yk3jMYx4DAPjd3/1dPPOZz8Rv/dZv4dxzz43mJTQpNCWkIWDeuTGCFhsVCfHFy5GRAW9SWJ6XJiD8bZQWae32JrxWnhVBCJHVZqrHMmxeeTKNHHetrRoIonbJ8da+NZmIBQlees47VxI8recFc9JACH+njSaXnLbquYYUcJHytb6PqYPShOqTfRGqn5Y2YmTf27+jRfysqKzkTwMiFiiSZclxijH4McB8KwbL4mmrRjBUrgcmpezI/ou1IUX51Zxo/lgv54Pe0cOXeCgNn+v0RA3SBLWTD0CjOY1ucieWs08jzU4fhy8/lr6je3wjbVHa1j0kt99+Ow4dOoSrr746v7awsIArrrgC119/PZ73vOfh+uuvx+LiYg5GAODqq69GqVTCxz/+cXznd37nSLmE5IhWV1fH4k8qC+rYXq+HjY0NAJvrb/V6feRoeaIiAhdr6DU+5W/N2+HXNUXlIf8idcv722VkNNAggaGm4CXIKgJULB6tdntANcZ7CvW5nPQhedHAiFWHpzSor7X+tcZNtouDmVBbLWPCwZbX/hgnwAMPcv5YoI7ARag8+e3JEB/j2LZY/MeAjtD12PtbIa3PqU4LeHG+ttuoW0AvBtBqfS7/W/fGbQfPz4Euf22H9en3+3kZFPWgexTdoDT7jj4ZF9zyI6i39+V1379xG2688A04Mf8ZAMPnmHAgpLWPz4lxnlgqDmEcOnToEABg//79Q9f379+f3zt06BD27ds3dL9SqWD37t15Gkmvf/3rsbCwkH8OHjwYxQ8fAIso9EWD1O12sbGxkYe0tLc90n+tPo0spe/9t67FUMiYhIi31TqHw7pmfay8XrtjDbvGTxHSjL70Hq0PLX/k3sYp8tbdtf6wlod4ep4uFlxq4EryIBWarFPj01pO0vorxJ8HPq22WOVYshZTl3Y/Zs+ZJe+UX+YJtUfjiS8RFY0IeW3U2mr1U5HxkjxYdcaMmyzP2+MXopBsxsquRxo4kfNLyyPnfKjPyL5xwCSXcWj5hpZlyPleXV1F48tX4EGfew1q7b1DvMy0LsTlN78Zi6uPHHrrsdwK4G0P8Nrq0Y54yubVr341XvWqV+X/V1dXg6CEOkN2lpx4PA0f6F6vl6ev1WpB4YxB9ZYxDinbmIHl6caZSLHGQONfm0hyr4lm3GU52jWNNGVUpM2yHstr4/xqdUiDwycjlyUizbPQ+sPiw4uSWe3XPEP5ckSPL/mb82IZdY93/jv0pJvGv0eSr1CEw+s32X5Nwcp65G9Lbrx2aXNKa1/I+YkxCNZYUf28Puutu0XHh6iIjFt94s1ZebK5m9bgR+Ytqle99EX6zQI4HBjQt9ycyveaEDBptVpoDBr4joUl7H3UK9HFLL5y5DvRuuubNvlGGcAAl3z1FfjQxd+nAmDuqGttjelXjbYVkBw4cAAAcPjwYZxzzjn59cOHD+ORj3xknubIkSND+fr9Pk6cOJHnl1Sv11Gv18fmy1MmwOl1dHoLMF8D0wTZUzDjgA6rTF6uVZdWhhSgEP+S36IegqfY+H9POCWfso1SIcQYHFl+TL5QmUWUpOcNAaMH88m8IaAhlbcECdJjoTRyD4MFCC0eLGAk+8Frh6XINGMXMghaP8h8RRWjVLiyrljZ88AM/50kiQoMNf69exZ58mqll6CE35MOXGxbOd/yWmwbYtrFTwa15Cek2z0HIMSnp7e161r5vC+5Uy0BCT0pQ9ckQCFA0mw2sb6+jucsPRj/6/AnUR38CgalMpAB1eRN+I9HXo33fentyDpzSFDGYvsSTK9fiJX6rcH20jeB16I2hGhbl2wuuugiHDhwAB/4wAfya6urq/j4xz+OK6+8EgBw5ZVXYnl5GTfccEOe5oMf/CDSNMUVV1yxnewMUZadPklRPmXBw+2UplKpoFKpjB0a5OQZfOJBSxtr2Hi5/Ldcb7R40wRHlmUZOq09npILGTCZhk/AUBkWyYkcS7I9mhGVysEznFvtI483ft0iajspKUqvPb2k3ePtleDH4pF/W080yXo1j1C7HkNef2ugjOqxZEbqA+tlZLEyGtMmq/1a26x5uRWyZAPAyPKJlmY76tZIk9uR9iZ6G7x8sXzHyD4Qt3zB78tlGLkHhB7t5csz9ERNt9sdeoxXOnZP2vUA/NbdH0Vt0EUJGappH9Vscz/JFcc/iKde8sIhvuqdvSMRFy0Co10nfotQ4QjJ+vo6br31NGK6/fbb8dnPfha7d+/GBRdcgFe84hX4tV/7NTzoQQ/CRRddhF/4hV/Aueeei+c85zkAgEsuuQTPeMYz8KIXvQhvfetb0ev1cM011+B5z3teoSdsQqQhcH5WP30qlUoefaHOIzBSdDIXVQAcSVvKMZRPq58oxmho+a2JaRkQLZ1Wp6e4vXcGeZ5aUfIAgyzTU1CaEeC/qQ+2aqz4GGpjC5w2ktzQkwcbOtiK8xrDj5XX2ugmDXiWZahUKqaC5kfca/0X05+y3+QcC0VUeJqi89K7p8lGEVmw8oRAsNdnWv96UQ6t7ySYlO2LKUvjN9QmPj/5mMdGr0Jt4+RFqDwaJ3pFbfAAAD3WK5+kAYY3ipfT+2G29GMY7LoUC0slvPJr34oBSihjVCeUsxRPOPaveN3Pfhw3770EB24rY+Pzi0iP+PpDbgLnEdiiOrowIPnUpz6FJz/5yfl/2tvxghe8AG9/+9vxMz/zM2g2m3jxi1+M5eVlPOEJT8C1116LqampPM+f//mf45prrsFTn/pUlEolPPe5z8Wb3vSmoqwEDQX9T5Jk6LApGSGp1+tDSpzS8vy8bO3bUuqxk0qSN6mKTjgtjSYwWZaNeK+xoESmiVGaWh28Lz1Q47VVKiuL/xiyytEe3fQUrcWLBjQ0gyDbrSlFXp5n2Hl6ns6icaJKsq7YMooYXQkEPYMyLkmDS/2ktccyvFY6bki1MkIAh/MXyq+BLous+x441ACIvGaBFPk7xGPRiJkFcrR5YYFXqy8tPorMGSs/N/AciFjAhPdLkiRoZE8Fln4WnXICJMBCejcefvzzLi/9UhlPPvQv+MRDL8ba0gDZ416Lxr/vx+y//4GqV7djBYFTYUBy1VVXuZ2dJAle97rX4XWve52ZZvfu3XjnO99ZtGqzviLXgc0ICL2Zs1QqoVqt5ojUeqqEvr1d81b9ltGWyi6kgOSk2IpxtQxbyJhrn5jIRQz40PgbR+CLjoWXT0vPH5nV8hepg5PlhYXy8fxcfi0DqSllDky8tmnlWulk2nGOELeMWCgt5yUmr+RZAw2y/NDYhICpBJ9FDb4sS/sfSqPd06Jq3lhZ/Wy1xwLdVh6tTEvOYsBWUXmQFBqDcUjyRe2m5RC5TENPz/CTVmkep2mKSmkWya6fweAUGAGAWtYM8pGihJnuZrqsvJmx9S0vQuWem1G/5d+D80DO96L6e0c8ZWORpzT5NfkhQMLTVyqVoVcux9YTw4NF3mCFjJp1OJkHMCivZZg88ox10fIk8JCAREsXKsfjIzRG4wAWviFVU0Qx4MTjKZQ3pJhlOdaJtvxayMO1+BoHHI9LXn9rUaQY0MPvF/HMY0Gi5FPuO4mpk/Ja4xFThgSVHGxp7YvlR5Ydw792X8tr3Q+CU+d99568Sv3pgXsLmGnG2iMNoJGDzKMhHJTITaxku6jumex/olcbthHLpXPRqkyh0W9LFnKqpj38594HDl9M+2hd+XzUbv7QiAyGDgMsCtK2N95yH5A0Zp6BI9IESabXlHbMOx1ieS6aVzO6ngEPgQTPeFsGi/pA28RWhC8vfaisEFF679j12Lr4mIdkSivTuufxJNOF3jMi01v1aueXaPPAOx03xC8wusmTK036b3m9sky6x5W/511bfeEZNc0IS4Ni1anVEwN6+G/vmG6ZNhRJkfms/B5Z/Hj1crL0qCdPMr/k2QNK1IceFYlmSD69Vyxo1yyQYt2nNLwOb/zke2es/ilVHwWkw3X1khn84/2eh35SVtueIsF6bQZ/94hnD98oVdA/+Ehk8OfDOPImaUdHSCRpG+g40aBbnWVNHp5XprXyauksobbyWvyFrvF7XLl6hob/1tJoHnYRsGAZf8vQhQ4/CoEbbsS0vJJ/roC41xfTVg8IxKb10ll5pBen5ZcnofK8siz6aDKsRR+0cizex1VQkgetXt4PnE9+nRstmWarFCrHuj9On8SClNg6JG/ytORQ+TF88rGQ3zyt1h5PX29VpiRZfG4XhUC4Zuw54KLffBmHR1GI0jRFVtX5ftee1+CxR/8dB1fvQCU7fQJrPykjQYYf++//Bxu1aY37EV6lfGjzLwe4kabirAAklvJOkmToDH9g+NFDnt4ybFq53n/POIRATaiN1oSWpKWRBtzjX7bFeyzUaqvVBipPlsXBh3wVtmaE6L71CKnHk5be6pdQm0Jt5VR0PVW2P+bIZq0Mqfz5fNDK4aDM8j5jQJjmEHhyG0MhQ2WBIQ1U8TI1IyTzhyhGdjxwEurTcTxQLTpglRnqF3mtSN2xtJX6eH7rnvY7VI4FUDxQ7pHmDPDN5xKg0X0eFeH7R/g5JJS3nX0CpT33G6l7o7SEn7zk3/DcE7+J59z+55jpbe4X+fD9H4fffMrL8R8XKUdvpANU7vgssjQFmI7UQOW4fUJ0VgASzVOUwqMZZ+kNWwajiLHT7sUAHCvvOEqd18v7pgiY0NKFQBuvM6ZcC0hY96SSCB3AY42bNvayHAvxa+Vb0QMtrRwbr6+kt6gpSC9vaCw0HqlPSfHJza6yjJChD6XR0kue5Nzl79aIBTd8fst+lwbHkxtP0YaMlTUeFigIRSdCyl/OGYtCvIbmgUdeWgl2LcMf4jtUj9bHXj9roEge2a+VG6rfSq+Bf3k8Oy3VcABCT9jQf0qXZRm+1vwNPODAf0daSYDS8BzZKO3Gn+55Pf7wxMOwvPwb2KhWcOT5fwDUGnoDSmVM/cefum3UQOQ4tOP3kAC20SSSwkjnkfAzSaRRsj5WPfy/VqbHX2zbiigCzbjHGG/ZDg3IhPon1Gdaehm50tJr/Rq7x4ITr6toeZ7RHXcsQjIRAi4hkv1g1SkNk1WH551rcqDVXeQ0R60Mjz/ZRq2dnAeLV2prqC6tP2Qkipdl8Rp73SvDm3OxpPWDlI2itB0yHKNrRuqALysx46T1g1Wedr0IIJRREQId9G4aesSXIiT8YDR6X02320W7s4x7bno5SoNsc7WFWKB9JXfdhBu//lO4u3kMzeZxLPzFK5D0uwAHiad+n/+xr+K8OxZQSupDfMrfcq/YOHRWREi0SWjdK5fLqFQqqFar+aO//NRKq3z+2/KILWG1eAvdp7pCeSyiNN7GLItvz4h6eSwvVytLOzqd/lunempRDVmHply8vggZUM8ohZR/lmW5nFl5OJ/ykCGrHdp4WrJief+htnCSfFn9Iq/L6IZ23eLV41EDDRbv1nWSL1L+kn8rz7jKllNsGZoshwBhDIjSeLB0mwcmLCAmy7N41cqwePKIy9Q4kQxPd23F4+dkzWlJcn8IARH+sjz52C9FSvhG18Nr/4DVT30RF53zOtT3XA6Uy8iax3D463+IO078/lAbGydOYNfn2lg9fw7ri31kCTDVrGDxaAUXffUSzM//Di6YuQdfPPGDaPW/MnL0wXb10Y4GJCEgYuXhJ7Va76zQ8snfmgGL4SVk2GSUwBrs2D0JHp8WfxYQ8dJbYEFLKzetyt8SjFhASLvvARKrPR7A4mVqBpauaS8UtMrV+kUDDh4QCcmVJTfySZiYcvh1+UJBzQBYfaaRBmK0NFSWDJ8XNXzSsGvXi5QTIimPoXxWWD+Ul8sGpeVjoYELry2WDI4DIKQ8FMlv1WuBxyRJgAFM0nRTiGePn5h+kTIn88jj4flx8HyJhj/2K5dw+B4TKrvZ/TK++LXvQ/ZV++DALKlg5oo/RCnZhz33VLDnnipjHDj0IKB6Y4r62l48bPef4YajT0GWtQuB31g6KwCJZwA58WUawPesPIXNr8UYGuu+9T8GNMSQZcRjygwBMKAYIOLf8hFUaWQ5YLGAAwcBVrs8QGABExmpkEpckzMJRCQf1jUpb1r7isiClFkNFGhepAaE5P9QO7iitZSult9S4iFQ4KWR6UP1a6BE1hXLn0cxoMLzoiXPFp/a2Id4Do2HB5Q9AKH9DvES4pGXEQMGND61ORDKG1tXiA/+za/LQ9AoMmK9MI+/r8YCmrJejffGec9Aefp8g2EAGXDynBTnrldQK+3D3qn/hsOtd6nlxgA9j3Y8ILHegSKNH4D8bb4yT7lcHjmh0kJ/Xl0xxsNKa50AK3mx6rHIAiUWyJCGUPutGXOeT+bhbaQxk22WyzfyeP/YPraUntZuCag0AOD1fUy9FnlyEeNFa/2gGTNZHn+TtVUu/2iKLQakyOshgxRSbDKfHE9t7VoDV5IPbf5rTxZpY+0ZOO36OPNWa7OsO5Sfp7eiDBZY5WVpc0G+QkEDsVa5VIZ1b1wKgVlNLvjjzhrf4/BoyYeUJT42crkmy7Kh/xKMyKdvqIwYWaM0U+c+BVnaR1Iy4EACrO/KkJ3ajLI09a05INGiRJ4jE6IdD0gswyqpVCoNbWLleUgg5alzfDnHMiAaL6F0WvqQcdOUYIgsLzu0p4TzIfnVwIQmgBYAsECNFjGRY8W/NV41YxOKkljlyDRaFMQDL5ZBCLVlXKPlkVSsEqRoPMsnUrxHjnk9dJ/PIb7EotUVerTYU+wyMmMBB8uwyHyeMtXGNyaSQyT5jh0/DaxpT31Y6b06LbAd4wBlWTYytlZ/Wu21gKrHZ1HydLc3Tl55IfA5Tn6qm0CI3CNC+0QoasLBSShCYgFRAECpDiSBaHdC+UooJVMjt6msrb7bZkcDEqKQYaH/lpBTWs1jluVrv0NGSv6PBTMATMUTqwg1IyyBBi9TggIOJGSZIcOqgS5rX4iMEHlPKnkep3Y4XuwYaP3F02sA1YsgxEa1ZF/EvLI7ZMx4u+UjzpoR92Scg4sfvv6HcbxzPMifxdN20XaWlZeJDNj+YofKT7C90YCt0nxpHq/d99ro9BogtIAMpbHuaWk4jQMMQvNC8sPBVGzUKRQZi+kXSfy+3CMiP9qhaFYEzGoDp+7yTWic960wDy/LgFoLSJAgzfpY791oll10PCTtaECiGdZYI0nGLibEFQIksb8lzyF+rfJCXpzVdmA0OuKVYz2Kq/0O7eXgvNFTJ9LgW/WGxpTqsMBLrJIKAczQScBWuTJPSGa18LFGnB/5Vl9rnxTPawEmbSypvOOd4zjaOeryNaGdT0WMKc8TWubQwIw3Rz3ng6eResBqi5xr40Y6PIckhng6eeYIlSuBhwVOKG0MKNH6c/3Wv8DCQ3/CHYfFQ6d0MUo4tPEXI2WGxjGWdjQg0cjqFDJU9GI9DkgonwxpW50cCziKggYvTygNN2ZE3KhJMCbzSUPpnaPitYGXz/tUS2cBSusezxsSfm/MvLBwkTK19ngh8CJgtOjE9trr5ZFyL/m1yiyhhKX6UiEeednbQdsacaHQyBaKPNMRlu2k5cFy3mYPQEjyQv8aGEkSPeLnRUvk/PR4inE+Yh0SWY7lFGg8FuFT6zOrPRyYaFER+ZHkLQ3Rd3/jEI5/4uewdMUbkKWD03tJTmWdOZlg/nAKJGXctvqraA++arYzFD0K0Y4GJCHUrKWXh6LR+ptmaDVvVqvbMjT020LR4xoiWZ636TMW+GjpYiIjcuJqkQoJimQ5GgCx7mnt99pWtI81RaOl15Qff4yc59PkKMbzKwpKJH9aOFp6qPQ7to1ES/Ul/ONV/zhUpuahkUK1eJS/5TWp4LTDlyxFLF/oZ9XL70tPUzMQMg/1pfeysxDxMrUIlSQPRMhrWnt/8q6fxMnByaE88rcmK6E6ZDprDDTerLLlPZlfOiiWvtVIiyTGGlI+pzVZ1fiU96QcaTIk95PIqIh8jJ+XHUvN2/4Kg+adWLj0pagfeBKSpIRKq4vdh6cwfwhY7X4cX1//Ayx3P6y20ZorRXXYjgYkRLGNTpLRvQk8PE4dSIYltNs+tNExVii9dljXYusM5aVrnDd5RojGD08jlVGapkPvo6FXY0uSdWp10Hjw8kMKKwZwSEAglZnnuWj/PZL9WzR/kTqILECi5eNktV9LOy5v2nV+0FJIkWoROK+N4/Aoy5KAg/QDyZsEKSEwSN/0mx+HTxuBLT757xigrgHjmH7QfsuyrDq1/tPqscAOz2PJoQUiitgDKocvYVpnU4VkOEZ2vTklwS5/yoYOPdOWazQKARTe/8RD+/D16B79OMrlKsrVOsrJAEeSOtKsjyzrRwOMcYAI0Y4HJEWVvDaJudGz0mnXNHBh3dfQcwzvXpti6vXySUAQMuYyj7xu/feQM9UpJwiNCefJU75yclkTX05ib/JwXrQyNZIbSC3abkDCy5VeFqAbBE2xS48tts5YMKHl1X7La8SXtslb885k3hjyogD8aQYAI4DEK0caGu2USzkG1onC3l4ti/cYEKKR5NEqX5ahgbKQEyHzWmCN6wqrnSEjrfUf5zGmzXJcPEDq1cl543nkI70clITAdywosvR4mvaR9DMk5TJQ6pj5zwTtaEAiO1ZOYO3lTUmS5Ms11oTyrsnrseAiVolYpIEIj59QeRZPfHLxSSPBAl3TyqQJVQR4aelkfRZJ3rQ83iTUlIul/L28nJcYfreTNCUVUtxSEXuKzhtLz+CMA1ZiDAO/rhmUEE9auXSP9wel0wyBBCSaLGiRVlmWtqxBeorkkJevPfWmgbKQEbTI6n+rX63+LApONR5lPXx8LDBiGVrpCIXaZgGlGADEj5HQeJFt1ngl+dHOHgkBxJi6tPZJHiweNdL6qCjtaEAC6JEMfk+SttTgle1dCxlcyyhaJ3vG8GApQEtBaRMuVJcGDIhvzXPTJjCPOPGnangd9OEbjOm/dgYJr8/i0esj4osbALov3zejlav1VSxZii6UZ5w6KB9XiJpylApVgk/5uyhvxA+No6VIed1eWTHpvLxWfskn7wcNjCTJcBRKKmBvjmiGRip/4oX2uvF5onnyvA3bRSHDa0UDLDBRlGTZ8p41nyxZ3U4nYNxyvP6QsiKfrLH2i3C58ABhjFPAx9lzTmJpnPw7GpB4ex0kcQPIjZUcXC7kMYDEUvKcR4ufEFK26vbq5ODBEkDLw7F44CChXC67k5sDDH5N8s7Tad/WUz5EnmGjMqx2k0HhvEmFoPFfdLw0ChnHcckC39IzlEpckwX+4flDss6vWyBVU5oW+Al5pVSefOSZ5/H6WwO6vA8soygNQ5adPk2T8hKYkMqdGxp+LDgHjMR3pVJBqVRCpVLJ+SK5pnuSYmRKG0cv0uB53iGvPJYfqxzPMHoGWF635rPWRu+aVlesvGu/ZRt5frlsowF66URodYScC0lSXsc97GwcELijAYlloLX70vBwJcs7zjI+skyeVpZnGXgPCGylzdpvrb1eG7RJK9PErF9ba98WqLDGiNfJ04fK9EAMTyOjNhZw4781pVGEtH7ZTlASKlNTEHIMJPjwonlkcHlZ2h4diz8aXwkSNSDCedA8ZP6bg00PuIaIZISDMg2M0DW+x0Sec8R5I6+XAInl3Xa73fyYAuqrSqWCNE2H3lheVOeEgFmojyQY0dLJZapxQcq4cy2GQkCM/vP0PE3s3NVea0D/JSCR16z9YFL2tSiJJRMWyTnkgRFPhoqONacdDUiKEE1m8izkEehSEWvo3DLKdC9krItc8yikgLQyLYDADY8GPDSgxf9r6F7et+qlurS6NR5lObIsS4HxsbRO45WHjPG8mnHmbdM8Ldk3kvdY764Iaf2kybFlLKw+D4E7z+iHABAvQ5ZVBIxw0GABGVmmxvNgMMiNfJZl+X4zfmQ3lSP7NMtOR0qkPEngwt/SqpXHgU21uvn2VYq6VKtV1Ov1XJ45aJHt9doauqal8Qw2bwOPXsl0oTr5fON9ac0xumaBBEt/ePqiCFl9os09Wb7sIw2QhDayhsCRBdz7S+cgPXAhklYT5du+iBJG+ZTzyio31BextKMBiWX4iLhS4EsO3OjxDtcOFePlcuPJ04UmlvxtGSlJIeXJ81hnhsTm52BAK8v7lr959EH2n5VfLtd4T/1IIEOkjQXx4L0vxeovbSJa/FhgTCpkCfY8b35c8gAB8cB5kvm8MkPXKGrCl1EscKCVF+oDy5CEwItlzGQ5MupDY0mggN4nwtslNxsSMKG8/OmINE2H3ksil2r4h8AKX6apVCqYmppCmqaYmpoa2vsUAiWyD+R1Lb22lERAyHvsVCtLM2ChPN7Tj3JcPVBSpF7Or9WfUrasIyK86Ij8731kuRaI57xbNDhwIVo/9Fr0H/7403lOHMb0374Z9ev+fognrp9Cey9D8zKGzhpAopGcAGQs+WYx6nD57Lk10Fp93rKCzK+RtRcmFhVLg+lNIo8fDgy0+qy6tfq1s0O0dBogkWFor52W8ZW/tU2rsm9kmeSdSS9M1qFd987V4MokBB7GJakwLcCubXb15I4TV4wyvWy/14favVB/yPZJmZfGhOrgyzCy7UmSmJtMtTV8ui4fCQaGN/DK47/5a+W5J0wghMAKlUnfFB2ZmZnB7Ows5ubmUK1WUavV0Gg0RuSOt8ujkBG3dBof/5j01GexJMu39GkIZBadVxx0WUBXpiXishwib57FPN7r8aXdB4DBvoNY/5W/RNaYGeZl1z40X/SrSGfmMX3tO/J8mr3bLsdJox0NSADdUyfiwsmXbAiQhMrUroXOK/Emg0TzmhGM4YWoVCoNeWIaP3TNm6hSqVvlUJ2WgFoRFlm/5FdbsglFfHiERDsdVbbdu8/bwPmVkTTN6GlleZNYlqEpdYv3EHHjL8GIBpr5dSmbXqidSC53Sl7onvZUE08v5VgDBPI37xs5JyU/1n9t47fsN7kJVXvtu4yO8PL4ExJZlqHT6aDb7eb7QDqdDnq9Xg5Sut3u0KmclL/T6SDLMtRqNczNzWHXrl3o9/uYnZ3NIylTU1OoVquuLgl5sbFOkMyn9Z3UL/KaRdoc0cgCKqFyrfZo12LKlnNYAyVeu60+57bBsxHefJHU/u6f2AQjZWH6T5Xd+p5XYOoj/4jS+vIIHyEqCvw02tGARBNYfk0+SSGXA/g9qwx5zQpbxexEtoBOCIx4EyRUrzWppbKQSyQhUGO1RXvCRrZT9iXPKx/5BfTzZOS3NQk9747/1gyWvOallWXJyIQ1xhwQWEpJtsNKI3m1yrEMkzZW0vOX6b3yuGzJ/pCkLZfKtmgGjQMunlZ7osfjX7vOoxM8gqG98EwCE96HPCJy7NgxrK+vo1wuo16vo9PpoN1uo9ls5r+pTOKDb4CtVCpYXV3FxsYGOp0Odu/ejdnZWfR6vbzdWtvHNdrW3NPSakCY92eReosYQE8GY8vS8oQiDt4800gDxloeqbO8T2hecUqnZtC74umjYIRTqYzOlc/E9PuHX6An90VJJ3S7aEcDEmDU05LX6DeFZ8mbkMaMe8RePV7nF530lpGLzU9lSGMfM7m9NCGgV1R5aACCePf2lfD8GrDQ0vCyNQAVaieR5eFJ/rXfvAyrL/h9jXeNQqDE87RkOqvMGJBnlWXd9/qxSJ8myWg0TDOCWhs8z51Ibmbm0Y1yuWy+eZXvDyEAQxGOjRaw0Z5DOmhisLaB3vo+pNkAa/0jWFtbw8bGBtrtNmq1GgaDATqdTl4m5yPLMjQGe1HJ9mF97SRarbvR6XTyTa+lUgmtVmtoj5wWOdLIWnIOefhW//P8IaOu8RWjDz0nLR/XgisLofZooCfUHyM8Gf/pmgXAaTytvpFzQ6N0YckHIwCQDpAunZPz4slDDAgqSjsakMQaTvrNoyNeZKGIgQ1FDYik4vXyxIATTSC8RzRlXqufJA/a8ovkX3q0nqditV97ukbLR/9J8QKjR0vLvNZvrQ9C/FMa3nbJX0ipj0MhJW0ZDFmvpvA0OfKMyWahp+vwAA7nRduzYil9i+8QCOHt8oiPm6wvTdOhp2qoL/hGVBkR4f97vR5OLme4++TT0OxeDlKztSawdGcJ+09kWJv9D6w13oiN9pfyTa4yIkNGZrH9EFy+8mqc13kSEmzK/KHpj+L29A/R2rOBXq+HSqWCdrs9NC8qlcpQG0Py48m1JVehMZL6wao7xJPGYwxZ80YDwkV4ipE3a+574E3uHbGcVim7nLdKOcFTv/kCPOdb74/9e6Zx+NgG3v2+r+BfP7sCZCmQOFH1pIRk9QQesmcKP/yovbj83Bn0M+D/++o6/vzGZRzdGAxFPD17FjMPJe1oQFKUaMMk7SPxEKVl2DRD6uX1JhA3wJbhDBlHCUJiQ2gWCOHXtf0FFmiQJJWT9lSMxkuon+g/KWz5KK5sk1auB6yoTE2BekpL9pH8rZGXP9b7kG3TNuJpZWv7OjgflM96miLB6X4q8minZQC0PpBUBChqpOWndnIAIB/LpWu0t0NGSQhI0GejXcNtR1+EQboI4PRete40cM9DUuz7SoKFw1fiYc3L8PGp56GT3jK0JAScjugudR6BZx35a5RQzcEIAOzbuAJ7b7wcX1n8BXSXVvP9cQSk+FOFErhZY0K/Y6IE9JuDV29si5ClC7255NUfBbCVOqwytb6x5qwFHLgTxcGI/IT4kzTdqOAtv3wVvumh+zAYpCiXS7jo/Hk8/tHn4vk3HcH333gdli95gh0pSRK8dOXf8foXPARpBlRKm/U9dM8UXvSoJbzwvXfi+rs2zL4qChgljXcE2zcI0YSIMZKUjoc0uUcSAxi8iTwu/1p9MWkkcpZ9EWqLxb/sI7nhVCtL8hDi0wIE/KPlo/8c4Hh96/FYhBe6RgqeP6XF+4qno2+tLlmu9t/Kp+Xh9fJH22UZ2uZhb95obbDSef3vjQu/Bvggzhsfj3ibZVvIEHAAwn8TCKF9IGTw6Xqv10O73R7anHrX0SeNgJFNRja/jlyUIa2UUM4auLjzms2yKrMYXPLfgctfivQh34l+aQrJYICXNL8dT5l7I54497vYW/lSXlQJFSRZGed/9sfQ7XTzDbecV22jrTVW3jcnaQStckN6Rqs/5vq4Oleby1upS849WQf/7+k2Ij5OciO0Vrc1Dj/3o4/BZQ/ZAwAol0tD3w+/eA/+19Q/AWkKpAOMUJbhKf/+FvzGlQsoJUkORgCgXEpQLyf4o28/iN2NytD82U46KyIkmnBoxJU1MBye9ybUOHVZaTwvja5LRC8FVyuX543hw2qPZwR5Go1/otCTSLJuzdDwsZEGn59xIcvlh1rxycIjB9oYynsx42w9qk3fVlg4NIaURvYbATGNtLK0+r12ad5ezKZpOZ9k+VKBFvVUJe/eo9haW7R6uQGQe0K0Dy3jyOhIr9dDq9XKf/f6CZY3HoMRMDLUOGB1b4Zd91SwlH4zKo/9aaSP/X6gVAbSAUqlMh77kGfiLf/4P3Fg189ikFWQIMW3Lvwy/rP1NPztiT9CJ1tACWVMr1+I9WP3B2YPD7WLe9mq8RVdxueTlNMQaZFDLULqlefNBVke3QvtmbDq0Nqq8RCKVnLZ55uZLbIiTfIpLR4d0QClpVd2L9bxrCffLwcgksrlEr790Yt4w+tegjue/2vI9py7CU5KJaDXRf1f/xQ/0Xkf+osLqJRH21EuJZhKgO+5ZBH/9zPHh+TEeiqyKGDZ8YAk1ksir1Z7CkROWJ5GPlVjeRFelEUqzxAgkUiakxcWlNe0dNZ9iwfqAyuvpiy0PuPeqWwDB4myTo0v7fAqmhT8nAleHh8fi395zTKonrxpCkPWH6tIrTHSZEDbwBhSBlooW/Z3mqZDIC8vH8NGq4hi5jxS3tABcdZYWKQZHQKzfD7GgBG5XMOvtVotNJvN05GU3gIy1AINB3pTmz+/8ug+sit/+DQ+KJdw/xNfxp/94w+gNuhuXkr6edYHTn0A/2PP9+JtR/8FhCqq6/uRlI6qczQIrLVLBti3nCm6p+UNgRtr/tG31g4iaykmND/poy1berKn3dfaa6WXtkC7J8GkxoPlSDzykr2oGGCEqFwu4Yra3Vh55dPQf+jjMDhwPyStdVQ/ex3KG6v4lpdfpoIRohKAbz5/Gv/3M8fdeixeQ7SjAYlUnkSaN8aNIb/PT5bUyvUmtvWt8Ul1ZFmmGtTQb2qLBTQ0gOSVaYUsZX+G/su81tkdWnrigwMWjUcOcqhtUjFoCN2rm/YQyScrQgaR01Y8Pl6fRdROKxKjeY1anRY44f3E17R5HSOPuSfD92VZ2p4CL73FE/+v7VGRACUUDZL5pXdKERBa8qDlGIqO8A2tlK7dbmNtbQ2rq6unN6Nm/aiF8FIf6FcyfPmxvZF7L77h91Ed9FDORoFrKRngwvr1qDzq/eh8/mqUBwlQ77ht5f0yQtlwGj52sk/l4ZE8j0XSGdPIkl2KhEo5tqJ2Rb1xTUdp8hMTRZE8UNkymhfSLTH7RqzySrYqGaJSKUGSZai2TqLanQe6TaDX3qwrkDdJEjh4JeerKBAhOisACf9P31KI5PoxPS5HikQz0NZjcxoQikHl8r/ncVjt4HzINvM8IbDkgYoQxQqbBuxk/tBGV23/Av9PHjxd18ZRUzoUyq5UKrmx0XgNtddTyCEDHOpH674VadP4ksbaUsBU7pCCM6KDPD2/TjzxKIQlsxLscPDpeaHaybIeT7Is7hjwCIjcH8JPS+VP0vR6PXQ6HaytrWF5eRlra2toNpu5ISmXyyjPfxYoPxxIjGWbEjB3PMGR+/UxkMGULMN/u+XdqGTKGv8p6pXKuODCv8avP/tx+I7fSZDe71ZUlZfsAfr8ybIM3iOxUk40ebP6d7gpcbrLuy758shzJqTujgEZVnRDK1t+y3I1AK3JJmBHULX28zI+f8txDNIMZQeZpGmGz7UWgd99A5IHXHy6nOY68K4/xse+/hF8y4XzZpRkkGb4+N2tIBAdl3Y8IOHf9FubTPwJG74jndJoSjCk9EO8SJ6kF28peg/oSI8jxoCGQIGXXgM2IYPgKRSe39v8qW261JQaX+6x2qPxwCNm/KkrDnQsmdAMMW9/aFw1RWwpRUme58Zlw+sHrWy+bCLzUn+laYqEfKjsNJCQfUqHesn6uDLW6pHXNCMi57VnBDSFTx/5mK4GQuRekU6nky/RHDt2DCdOnECr1cof2QU25XEab0Nt128DWYpEPmKZAbPHgfpGgn59tO2VtI+pQXvkOqdymmKhtYrDFw3wvpffgyeupiiXayNHGkRHSUSaEJCQ//n4aAepaQbeqi/E77igRPJKbdCcTk12QxE3XrcmvyFQT2Sd+Kv1rSz7yPEWPnj9nXjy485Xl276gxQf+vxJHHrNW4HqMBJOZmaB//ly/MHfdvHU8i0jeQEgzTIMsgzv+tLySN3bRTv+KRvNUEplSh8CJNr+BDKKWiQl5kkJWZeWRnor2u8YMCHrlLzzNNpTIBp/Vt9q/ST7RebjXqj8aIDD63urH7UP3yck+0G2gYgD1Vqthmq1mr/anb9mQD55JJeTLPnz+lK2Te6l8WTNkgWe12q79YSNbJtVn6ybyqO5JfsrJHMx1y35j0nLiQMS+TSNPHGVrtPTM+vr6zh58iQOHTqEQ4cOYXl5Gc1mE+12OwcqzWYTqyc+huaRVyNL1zcrTdM8IjF7LMP+W4EB2rhn+Y9G+OuXqzg8vc/t87RUwu1LFyIrA7c/aD/a9fmROcPbK8d4XAptcJYUox95WktuuT7xyg7xQmWFIhixshRbp5WOy6IEyjKdzKM50QDwq2/+JG6/cxVpliFNKeKSIc0y3H7nKl7XfzKSahWJ8eqU/+85r8Jv3LAMAOilp8vvpxkGKXDNv96DY63Rpbvtoh0dISGSgmV5YWR4POWmIX8qU97X6ubfXtiT8xkyOJw3KazahtMY5R26LkGMtp+D94v8bxlHWZ9mtPh7hqz80pBL4vetftbGid8L7VvR2k/XpDKSXqv0/rW+tsg6o0D2kayHp5H7AazHDE3lnGCobXzZTM4Znk6OhSYTmqLlm2apXdrctPLzA8c40KCPBkYoKtLtdtFsNnH8+HEsLy/j5MmTWF9fH9rkKpeBWq1rcfzIBzA9/xRU6/dHMkgwcxRotlN8EXfh68l70b1rDZXlq4D585CUTsvwnz/iBXj5x/+PuocEACrpAH/6mO/dbHNSwuHZC7G79ZUhGZPjMCL3AVti9SMvz9NvMjog08syQ7xY/Hi8xrQlthxtXnkREKscCSi43MhN05Zjx8uX9ayud/H9P/mvePZTLsJ3Pf0B2Lc0jSPHW/i7f70V7/n4UbTe/iubT9U49NsL34zr//LdeOE37ds8GC3N8IGvNvGnNy7j9uXTe55CkdhxaEcDklhDTkZO88I1o6Ll52RNLJlWKlwPLWvAxkrjvV8iJj//bYEELaoCwLzOhVPboKoZIJ6WRx80I8jzWcbca68WSvX6JUQa8KL+4fXx+9IwePx6RH3En2jRQupeXRJsWTyYcoLRyAt/703Rdlpt18Bi6JrmedJ1uYFVe5EdgZVWq5W/Z2Z1dRVHjx7F2tpa/pgvPype1rlZXxcrx96DJElOHwefnOIj20zXf//Po/Kd/+/UAZqboORtj3oRnvnl9+CBJ748tJckRYISMrzhyT+Br+y5/9BoWJ6zpqssYKn1+zigxAIjJg8RJNsRAiCha5ocefzLei1etD7h17ksyjNv6LeUV0ueLWp3Bvjrf7kVf/XPXx7m4bwLg2AEaQrs3oOPfb2JT9z9tdxu0pN2Gnk6uCjt6CUbopgnLKSxCy3FaIYzpGS1DWT8vgVQrI2dGmlG3Cpf1mOVZbXB6gNpaOVHAxmy78vKJjxrHGS9/GCyULs5D7R3yFpq8hSZ1i7twyewtUQXarc1fjF9Y/Ev28h/W1EUb07wNKRcyUBrdRPFLB9KPizeNZnz+oSDEXnGCAcj5KkmSYJOp4PV1VUcO3YMJ0+ezPeMyCUebjRkmVpIPjc6X/8E+n/z/cju/lTOZ7M6jf/x+J/H3y5dhI3KVH79zsXz8OPf+b/x61e/6nSHZhn2Ne8cMnBavSNycSpEIoGl7DcN3PB7lv6J0YGefFlp5e+tkCbPsnxvOdZqa6gfJCjhb3vWDkULAZBYylZO6Aeicd7LJeDk8REdKR+X94DRVsbmrIiQaMIcm4fCyJKkF06/edhZEjfuPCRuGQXN09MmmwxJWxNJK9/qA55W2w/BBdLysqx65bkVISUkDYrsb9lGrd2hvuDXKpWKajiLeHHWPU0RWYolVEaobhmNkWFkfs8jLUISE+LW8haZh9Z1y1MtUhZvh1yGkftGtDNHKEKyurqKEydO5BtYueGXACNJTi+FcT1h6Zic18OfR+kffwTVxfMxtfs8NNJ11Aer+MOlXfjVZ/88dk3vRbvawK177o+MOxDZAAeX/xOL2QbKp/Y7UZuprUmSDO3pIX60vuKP2caOvzUeoSiJnOcx4+tFxryIjlaepWND7fHqlmXxfNKI02+5NMiXEnn0TQICjXdpl7g8AgDW15B97DrgiicisY6OzwB86Fq1D+4N2vGARPN2S6XS0OSSAkgerCfEXJlok8d7Xtyb9DHGJlaRB8OxEWUAw+d9cHAg+ZEAQ/at9l8aSAmCqH4LpMh+k16ZNMJSQUilwNsWE7Llp8LK/vOAjHbPUoKxsiH51Mji1btmKVyNV773gNLwN2jLvR5a3XyM+Fh4oXKeV/YDyQl/D4y1OZUf+y7fU8OXa9rtNlZWVrC6uoqTJ0/mh59Re/navzQU3ICQDEmDxNtRLpdRr9exUOtiZrCBOXw76unjUOrWMP/J2/CxZyxhY9c5yHfGZimABAvtY7jqnmuHIn+8fnqUndrIo4QaxYLRcT12CSZiwSvVOS4PWj1cDj2HVMunATdL53C55DIgz73hcsnTWPNI/qffU9USzllqoNUZ4NBJ5Wmtd7wZ+KbHAUgAbWPrX/0/JCePIUtGnUEP+G8X7XhAMk56C6hYZWtGVlOa1oTRJoHHm9fGmEkcC0o0pRACGIB/Ngj/8P4JLV/IsrzlL86LZ0y1sjnQ1PbiyPo875YDHM0DssgCV1oaXpd2TypF3karzhgixevxxPvQa7csy2uzFuGRdfLrWj/J5RL5/hnubXKwQJGPbrebb17d2NhAt9uN2mjI26AZC6tvyuUypqamMF+/DHs6b0CSzQCbiyloLD8QT/rrBLc+9H24+yF70Z/Zg+neGh584jO4ZOVGNCoJypXqSGSTGz2SU4pcWoCEePRkRGubnOtFZI3mnHbomleGBrwtB9MDtrx+LZ8277RN55qe0GRZk0e5dMj/S+fXGp/56Qpe+ZwH43ufdBAzU5vje+PXVvDGd/8n/vmTd5/ugztvB37mhcDLfwlg55CguQb85R8Df/cOwLEJ3vWQDouhHQ1IgDhvlb7lvguexgMXsvOtZZtYgOCBGqtuXpfmhcYOvpyI8roWHfEmtJUuFkhpwMQyWNY176kUTUmEFKUEMN41a3NoqNyi94uMr+dNagqbNqRSe6iPVOVOIZJk9J0yWn18mYB/y5NhuVHyPEGZR85LCTakote8TwlI2u02Tp48mZ/CSoCEGwgeOZNtDxl0Kf+VSgW16gL2dH8DCaaH3uoLlJAkwIO/+DSc/9XXYm7f1zE/P496vY5qtZovxxDIkECeGzTqYwk0NT3igXALcFn/NQAu73lL4TydLF/Ku1aHVk5RgF6kPE0eZNROLtNIEM0/Mr90fOYaFfzNa67EA86ZGTp/5CEH5/GHL78cP/8nn8fb33f7aaZv/RLw488DHvAQJOdfCGw0gc9/CkmvOwRGZDstgBbTP7G04wGJJM3Y8VAlhTalZ6sZRLouN35qk1LyEDOxNH4B3cDyMmUoLU1HX6DlkWX0+ZNIvO0S/VubiOXv2HQ8TWhjouQ5BgRqCtIbI68sKs967FYDKHISa8pTa6dWLi9TKkEuH95/yYvkj6c3Hy/G6cez+fKEFi3idYRkXHqDXMY1vnk92jKN5nHyj3zqptVqYXl5GXfddVd+xggHJBLEaP0qx8iTUZpfi+VnIsFcDvjSJMPJc1IsH0jz01yn1n8a07U/Q73+VVSrVVSrVXPvFedRLjtq8ykEWq30FlCX8inTyt+arIR40cB3KL22lB6jB0L6xLpHQFd7S7RcqtEACb9WToCrHryAcxZqOLnRw7/ddBwb3Qwveeb98cBzZkZeqkcntv7K9z8M//SJu3F0pTPcqK/cjOwrN59uQ4RelW2z0hYFIkQ7GpAU8RqTZPjpDH4dOD15uVL1FImHsOVAaajSQpuW8ZXKjX5zMOIZbo1/rTxLgUpwIfnlRkJ7RCxUpwZASNmmaYpqtTrUD7JfPaMv+8/qa6uvtDHWAIJst5VP41+L3Fn8WHzxJRTJh8YXN6iWbGlnvPD6qCy+j0TySuV73pWcM3Jeanl4PqnAvZficcDCPwRGaKmm1WqNLPNIkGkBEvrI/rPm6DQux+YekQRZkuGuhwzQmh8utz0zg1uTH0WSvAcXlj89NG+08okHdS8WRueLJq9SVuWeH2sOWm3W5qk35prsSrnivy29rTlHVpmUxrMBIZLzlCIiJEtWZEQ+Rk6A5mkXz+NXnnUBlmaqSNMMpVKCZmeA337/HXj+txw03/BLPHzPEw/ize/58tA1D6Bulcbtux0NSGKIC4b2tl8Zdg491aENnpyYnsGLMYZePqkQY+qQfFvgIIY/UoKAvZaqTX6tXy0wovELnH4RIpHmhVuASrY7lmKNqcY7N0qa4beiBOPwpSlQy4uV/y0gE2q3tm6u5edzxAKU2ryifNbSpAQFmlephbxlmk6ng42NDRw/fhz33HMPjh8/nhsLbUOq/Ja/JYWcm1O9CZyKjizvTzfBiMxy6v+X02fh3OQ2VJJ1dd5oc1uCl2Sk8DBp4yb/h6INIfnmMmIBJCuaEqpTm5+x9Xp8aNc5j0mS5JtXu90u0jTNI28SKHN5JzBy1QPn8KbvPn3uTOlU5GOmXsYvPOsiDEqA9/7wNM1w/wOzQ9ckf1o7uK73aCvATdJZAUg0ZSaf4uBLNvzxOKkkQ0CBbxoD9DdgUtker1p9IT4s42gpChnO1Yw8kTwd1aqDl2kZVO39MhpZyzN8SY3SET8aKJFjIDf2aYZU8myR5QlKGfD6mfPHeeN97gFM3gcaH1p+D5xoysiSO69vJP9WuXLsJMAIGQAZjSLyAIf1kd5ov9/PX5Z3/PjxPDrS6XSG9p1IKuIYWMSNVzv7IqaTxwNIsHzAMy+bdNfgm3Bx7aP5ZlUN1PNroU3inF9LljQAqW18tkAyz+/NFxlt00BvDNjW2sb/eyDeq8cqk4jei0VpCHgQKKGN0/Lt0dpj6Gma4me/9TwgOw1EJJXWO0gbNfd1v+vtPkoLC6g/85moPuIRQJahd8MN6Fx7LdBqjfTL3qkyvu/+c7hsdw2DDPjI0R7ec3cb7UGcnRuXdjQg0RQqMIr+pBG1HmfSDLacRLR5zNqAF8Ozd03yItuiTU7LmMhNvNrk1/pAAiPOgzyQjBPli1l+oPzaAWfaurisQ/aNvOf1TRGja/GuRdWsdDGGPwYUSxnQFGYICGt9pHn8Gq/afYqueP0r83r9r8k4yYK3wU+CDrlkoz3RQNGRVquFlZUVrKys5GF1SitBoORT8ivHxpMPSp+mKY733o3dtRciRRm9KTN5Tuvp3iEwoi1F0/ySDoTV/54xlm2V/cLL8PYscRCvHY4IjC6vSNDjgW3ZDn7d0ltWG3lemUbWzX/LMefyK9+fJOVVfi7eV8cD9jRG+B3iHUDW7iKZrqv3q5US/mV1AUvvfjdQrxOzqF91FWZe8hKs/uRPov/FL+bpn3n+NN7yuL2olE6VDeCZ507hJy+ZxQuuP4mbV/tq/8j+HIfOGkBC/7XfPC1NTBIaUmraOisnLmwWuuZCyAGLh7RlG7jSsIyU5o1ak4zfs5ZNrPQyjexDzif95opRGmN+TYuKEI+kYHm/W+2jb8tT8/pRK8u6J5Ucj6xZGz+LgFU6rE0bD0vBarJoeY5eWziw0ACL9tvKq/Gp5bW8Wcujln2hARGp3LkBoDNIZJRkfX0d99xzD06cOIG1tTVM1zM8+1sO4qEPXMAgzfDJzx/BP33oa1hd7w7xZI1tyDhq7U7TFL30GI6VfwVLg1/ejL87AcYEGSql/tDjuzRnCBzyeSRBiTa/veiCZqj5Mh8vJ9QHVI50lrRyKK9cGvSAQ2jOcX0k+Ywxolq9moPAy+fvT+ORkm63iyzL8kPQtA3Yu6bDJjrNMmCQYZBm+UZWov4gxcdvW8etr/w1oFZDIsHx9DTmf+d3cPJ7vgdYXsZDF2t465V7UUqAkuiPxSrwjit34cnvP4bmIH6prAjtaEBCFDLyZCj5G1xp4nKDKAVTCleWZej3+6hWq6riD6FpmS4U7ZDCLvNpBp+XrwEJi2fPg+XXrYiLBs5CIExGriQ44Xm9qIsVhdD6wCLvnmc8ZR2hsi1jkKYpKpWKCm6Id76BWT45AYz2keXNSt45SPRkK8tG9zVQPusFh7xsDlrk/PCAiGV4OCCR7wWRURH67nQ2nzRot9v5vpHjx49jdXUVj7x4Fq/50UtRKW8+aptlwOO/6QBe/LxL8fJf+wg+96VjQUCi9XFMuizLsJF8BJXZn8DM6mvRXNgLWPKOMs6fum3oqcFqtTpi7Cmi681/iyxDa81xmZfapc076SBoYDoEwjVeR3RlNppfLrlr5Xt9ofFs8ZZlWb48Q7JKMkhP2HDZlfKcpinuXu6M8COplCR48z/fhh/89gdhYaaKbj9FKQEq5RI+/MWj+Mneo4FKZRSMAJtv/Z2awtR3fAfaf/IneNHF88gwCkYAoFJKsKsGfMfBKfzF19pR8l0UlOxoQCKF3QsV0n3P2FkTVobiNBRsAQ9PAWtt0cCULE8DLZJ3HibUgI7WPxrF8KvdIwOlnTeh9bfVdupzaZB5vTJSwd88y/NbBs8ab8m3pki50pXG0upLWRf1F187twxfKIJhyX8sKLH49ZSPNXZaeUVkj+rl48hPvfT2iVjvdKGjupeXl3Hs2DEcO3YMq6urWJoHfv4lD0W5lORr9cRao17Bm177BDznx/4FJ5aV0y+J1yRBUm8g7bSG5MID+/SfomOV6UNYyt6KJn4eyBIgEfudkGK2fALnTN2OJCkNOVoEQLRlUAtU8P+xAIrnIRBdJK/kwQMlHiCV5Vk88DnLl4p4XaTXrWinp+usa6VSCd1uN39aS8qplGGSUT5XbzvWxqfvWMNl58+ORD+AzQ2ry60+3vivt+NN/3Y7vu3yc/CQ8+ew0RngX2+4BzfdsYrdf/NTKDuH4aFUQv2JT0TnHe/AM86bQdXZiwIATz1Qx1/eMQqUNL30XwqQcJId4HVE7OThk4I+EtCQIMd0vGdINMAgJ5+l0LWJFVMnUch4asAhtGnVM4weGJF8aX3hATKNX69tkjf+WwJPrc+0NmnprWt03VKEsj7JV0iZa+3jSzOakdKAF+dh88Zon1lGQdbB22sZHo1vyzDRR9u0ypU9gZT19fUckKyurqLT6eDbr7oISaJvHCyXE0xNVfCcqy/C//ubL43c759zEZrP/GF0rngGUK0hWT2Bxof+BtPXvgNJa13tDzm3CVhMTU1hoXYU9cGf4CuV70eKKpJTz1BkKGOufAyP3/VulEujS7B8mUbbuB2z0Zzz6P22ALMFbrW2FwElGi+xQCpGP1vzWCtD+23xTmO6sbExtDmb5FGCE/5NZf3KP38Nf/UjlyLDZpSCKE03I5avefet6PY3ZeTvPvr10f44dWSC13bU60iSBLWAiJSSBPXSqG4wyy1IOxqQSCMWI3Q8nwQXnvEKbQ7lZAEIbXJZxpnXxz1xmZeQuNUv1lNAMWSBJKssfl2G5yVfXjmyTHo6Qz4JZLVLls+N1lb6wapHuy7HJAYEWwDCSsdBgGbgrXolkAvVI38DyN8Sy2VYPj6sGS0N6Fl1cOKgQ/LL72mP/dKHzho5efIkjh49ivX1dbRaLXS7XTzusqWhUy4llRLgiZefOwJIeg94BE7+9P8FKlXg1AvLsvnd2HjWC9F5zNXY9es/hFJzdaQ82dZqtYparZbv+dhbvgX7K2/A0eRR2MC5KJcGOKd+O85p3IVKpYwkGX6rNM/L92Px+qwIiZbGGyet77U0IYNlUaxMUp1yY7XGRyxZT3TJsjwgz+9VKhU0Gg3Mzc2h0+mM6AUtsicjJp+/ewPf80c34ZeeeSG+6YK5PO/tx1v4tX/5Kv7tphMqGCLq33QTSrt3IzGiJFm/j8FNNyFJEnxhuYtv2l0fAj6c+mmGL64Mv5hUc5Bk38TSjgYkRF7DSVnS8coU2pSG3EPhUul76Ym0DaSecaA88gkTy2jwvQQx+zWsvrH40fpG+7bAl0UWEPR4oeUMTdF4fHLlIp8EkUDNGhsi774WAZBgKJYscCvvyz7jMhCKthSpf+Q+RuWLz6UkGX6ZGCfPO7bqlgbPUtyh5Rt6vLfZbOZP1GxsbOQnZVYqvmuYJAlqVXEYXlLCyo+9AajURl9UVi5jsP8CrH/PKzD/tteNtJPkmuZ7rVbLP3QkfK3Sx0XVz6Jc/kIONsrlSq4fSKdRPv5G33H0gBfdsu5TfityEdKrFsWCGSk7HnDi6TQ+Q3qUp43pF6JqtYq5uTmsrKyoThIwerCf1Bufu6uJ7/yDG3HRUh3nLdRwbL2HG+9pjtSv9Xfrb/8W9auuMvlLKhV0/v7vkSQJ3nbrOh77OPsxr1IC/NXXO+YYcx7GcQDjY3jfoMQVYchj5R95X36HJrD23L+lbDWgoZUpy9XKkvc4aAHC3oFWvyxP2+DrtVV7lFqrS1NIoX4mAyfThfpIbpKVciJ58qJVRUnjLRaUSEWl9bsWrdNkx+obeS9mHGRZ9J8/ckr3+fzS2hGaO1o7LMBBoW/+GCV/uoaesCEwsry8jOXlZbRarRyMpGmKm249if7ABnL9forP33x86Fr34Y9HuvuA/tZUAChX0L7y25FNz7nzplKpoF6v5++nkbpKO2eEv8OGvw6DyuVjVGSctY8nu3KeWbKnla/VH+JHS+/pPCu/Nket9sf0kVYOEfE3PT2NqampHHDyDbZW9E+27ytHW7ju1pUcjFB9fB7Kfuh96lPY+NM/3SyL7enLTr29euOtb0X65S8jSRK89+stvOurm2UPeJQl3fz9upta+HorK9RXReisiJB4FKOogdGzDjhZXq70wD1v1uJNuyaFXHu8TivbUh6eUMinNkLRi9h6tccCJe/akzNyCYArbs1zsMCY5FvbW8MfzeZ9EEMa31qarUZHNMVmpdfuWf3jPbZp1aVdo+tyfVxS7NKMTMPr4B/r/AZ51kiv10Or1cLq6iqOHDmCI0eOYG1tbeg9Imma4m+uvR1PePQBk59SOcHfXPuVoTb2Dz4YGPTzpRqVqjWk+y9A+WtfUg0fBxWnoyDloeiJdGjI+FAebR55xiHLNvceWKQtPWj3eV9IvRCSAY1PTVal3GnGPxSFtPjnvHtzle5Zc4rnt8oplUqYnp7G7OxsvkzIzyGRoESzQbGk6euN3/99DL70JUw973moPPShAIDeZz6DzrvehcEnPjGU/tWfW8OnV4EfuLCOS+fLGGTAR4/38Sdf6+GTJ/u5DG7XMhmnswKQeAZfGjcrLb/PwYX81urUBNoy5h5o0DxHrX38urWJNSTA3NBn2ej+DKk8rfolf7wsqdS09snlKQuQAKfBk7YkIJWiB5xke2VZljL3FJ+mBGR0wDLokn+tTBnB8SgGCIVC0zxNDElZlGDPM25W/dp/ysP3hVAUhEdF6GmaVquF48eP4/Dhw/m+kU6nMwRG0jTFJz5/BG//u1vwQ991MQaDNH83SH+QolIu4bf+6DP4yh0rOU9JkiDpdYCY8ej3RuSegAQtuZDnTGCjVquZgIT6ulKp5N4258vq16FrytCGljussmI29ltgg3/LOajJTCgaXFRmYw2npevov7VkSmlKpRLq9Trm5ubQbDZzGeTRXx7542TZIO2aZwv7H/4w1j/8YbMPSC6r1SquPQZce6wDZBmS0uhLaKnNltOpRcxj6KwAJEDYEGugxBs8aewkSWNDZcvHXEP88v8hYxALbmRaa2JbvITq1Pi27mmCrCkvuSHSM0wShPEJqwEDWa/sB8sD4/1rGVSPYoxrKI1WbywPRRU2b7s8pM0DTNJ4yLmojVVI5uSYaMovy7KhNXf+TYCk3W6j2WzixIkT2NjYUN+qSnl+/5034gu3HMf3ffsD8YiLl5BmwCe/cBh/9g//iU994cgIn7XPXQd830+Z7UCWonTiMCr33DakJ3iEo1KpYHp6Ol+u4UswfF7xKAjlJ+Mh021FZnn/a8aWyuVpPDBi3dd0Rqk0fGxDCKjIaxYf3n3LbmjOpdUmGc2xeCyVSpidncXs7Cw2NjbQbrdV0BlyfKz+mH/6E3Duy/4H6uftQdYb4OSHP427f+OPMDh20iyDjwPJE+1HsvrF6wtA3z8ZSzsakFiGUEujhTWL1KEpSPoNnDaoIYAQ8iRkeiK57yWUnpMlRF5ZmqKwJjvv3xiAJK/xCZFl/lHsxI+1cVPbI2R5EXzyW4afKzOvLVr/SEXl5fcUuiwvBDCtcjQeYkBcTLmyT/lSoMezVY8nS7If+BjypZx2u50/3ttsNoeWaeRbVamcD3/qHlz3ybtVnuT/6pE7Ub/hA+g88ip9H0lSwtw//TFKGH2vFIGKarWaA5J6vZ4bA5oP8qRVvneER0eo72Uf0//Y8bQopDNkWk5FvHheV4wj5QFhjaSsclDK01hgjvMYeoJRgpNyuYx6vY7FxcU8QkLySGOq8eP1FdVx///3v7D0+IchyzaDdlkGHPhvT8S+Zz0et/zor2H9w58a0bdam0Lt9HRZrG3zaEdvavU2xwHDxpJ7JnJ9Vluu8NZx+VIC/5brunypQQ665Fnul+D5ZZoibfZ+yzyyDk2Jan0q+dLap5E2Nt66uNYX1L+UX5tcMq9WljbeRdviXbfGzfsUrSNEofbI8dPySNI8SU9OrXZaRkf7rz1dw0EGgZGVlRUcP348f0eN3DciAc04NP/Hv4jazZ/c/NPvAelgc18JgNn3/CEa1/39SDtJvqrVKmZmZjA3N4fZ2VlMTU3lSzWafuD5+NM42qZXT44tipFh71te4/OT88Wvafk1fRTDp9eumD6wIuF8nwfxHmMTpP5Oks3HgOfm5jA/P4/Z2VnU6/WhF7/KKH5M2w781Aux9PiHnfqP/DtJgFK5hAe/9bUoT0+pc2+qlCDBcJ/ScmCShKNb1hwel866CEmSjCK6JBn1Nggda16F9juEmGUaGkxt4tFmIF4255OXpbWZf/M3S8p+0eri92XbvYkrFYScbFafAPYR03yCh4yTxou33KLxofHgGULrekgmrJCz5s3FUij8ztssPSwZrdDyaWVLhVTEQ+ZRKh4lkTIvFZ4V9ZCb/WREhDaw0smY6+vrWFtbw+rqav5EjXx7ryeTVrskldobWPw/L0Xvwd+E9hXPQDYzj/LRuzD9kX9A5ejXTWcgSRLU63Xs2rULc3NzaDQaqNfrI9EQaejoOhkv4iskU0UjaLy9WlRCfofKleMu6/DmFK+D/7bGMWY+cr54xJXPEb70l2VZfqaIjEp50T25BEP6eGFhIQfGpVIJzWYzB9MSLGt9zmn/9z09j4yM9j9QqZaw/2XPx+HffjsAYLqc4EfOqeEH9lexv1ZCL83wLyf6+L+HB7htcPrkX95/PPKpLZ1uBxgBdjggAXyUzv/LHeyArihD9UjyDIVlYOXRxZRGAgQ54S2DoS1xUFquyKRBlPxZXguR5nXJ/FJJaMbQi6jItlpARdYt/8ekJfIABBk9DhrpIw2/pTBkW7j8hYBnCIhQGg2wyf/WOTpW/bKNlvxpfMtHELUXB1pgh/pd7vXQDjyTj/x2u11sbGzg5MmTOH78OJrNpnrYFB9juaxUlBIAtf/8NOpf/syw/CUJ7r+rjgfsqqM1AD53tIs0KedgpNFoYH5+HjMzMznA4G/v5ZE7/l9GES09Y+mRQm2LzOfJUwyg1UAEvxYDqr25xMdezmMLmBLAJUBC5czMzAzNYT4GnG8ORqRuq9VqWFhYyN9nI4E31SX3R8l+Ku/Zhdqs/pbf020B5p/0GBz5nT/BTAl416XTuHSmlC+PVEsJnrm7gmfsruDHvgZ8ISnny+fA6YcIJBi0eJLjUoR2NCCxFL+Wjn9IUKxohGe45KSX+UM8SQXCoyjSUGuKMrbNVtstD0VTbjK9XBbR6idBlgaQC7jkidrIAVkM0KN84wi+R7zdWhRGUzj8nlRCHDDSOBN5h5iFABSRVNZcxnk5sl1b7TfZP1IuiC/uWcUaOAkGtQPQJEDhYOTEiRNYWVlBu90eesRSjievj39bbY2lB+yq4deffA4ed95Mfm25PcAffnEd77q1i+npaczPz2N+fh5TU1NDQF+euEp9Wa1W8+Ua3rfefBwXhPC8NHYySuGByu3igfLzOefNGYskwKUlCc2Z4zzzs254f0xPT+d8evqZX+P9VSqVMDU1hbm5uaH9TFwv8ifGrD62Tl8d4aG8KVuvPFjHpTMllAWflVKCQZbh/1yQ4LuPN4BqDa1WK4/gyP0y1jySfVhUx+xoQELEPfYQCucGhV+nb+kNW+V4CiwGJJCBlkZEAywh8tLJdmi/Jb9WBCR0TUZYOAjR+JLjRsIvy5Z9IsuRfbCdAIUb99D6rpQb+U1lWEBWRpU8w8nLlf9jvRatv+Q1zkussdbmhwXwZBoOOrS28+iJ9hI92ijYbDZHznvgIIbXxXkYl3gfXjBfxd/994swK14MsjhVxk8/ZgHn7wGuXduH+fl59fFe4PRc4tfpADWKkPB6NbnkbZJ97LVDygCXa66vYhy5ov2q6XFLvxV1TMjAV0+924U2kUq9xkFDpVLJ+0y+QZrmsnSsvH6R+nJ6ejqPwpBs9vv9EUDOQQsvp3fPEfS7A5SrZXjNb37+P1FPgOftrYyAEaJykmChDDx7Xx3vb0+h0+nkbdMAMHfAtot2PCDRhIlICrVmUGTnlkol9WTQUN2yrFA++bHKs0CUx4v02D2eLCNmCaDMq3kYlvcm2+RtWJO8a/xbeYtSyEhzD9GahNKDsOSM71sqwkORSc+VqqYctciJRtL71dLxyIdVD/9vgREibRmL950EFvwxXlLgGxsb+VM18jFfDmr4RzOE2dI+DJ7yLGT7z0PSXENy3b+idOvoy/VkP77yin2YrZXM94F874XADV8pIRWbUuUGVQ5EqtVq/iSOnEOSeD/HOEyavMj+4Gnkda1+rT7rugWktOsW8PLmCpdRDcRIfUnXKZJCciUNswayPT2lAbxKpYKpqSm02+18iajb7Y6UAdgHNx7/t09g37OuVPNk2WZ9R/7P23GwXsJcJQDaADygMsCHWOTF6lverljZCNGOBySS+GTlnSfXYikt/03LEdJTB4aFnoeePfQrJ7PGHwk73yilgYOtDDblt4SGTxDqE76cZRk2Xq5GmvLg+TRA4uUtQh5PRchT0tyg8Xo9ICjTy/uhiIQmb3IsLe/SAxeWfFjpZR7rEUi+rGWt/3v9ydtEZcjTWPm7atbW1nDixIlcwcuISGykYPDcF2DwP36UNPrmOWL/7fuQfuI6lH/rtUi6w69fpzFvVBI8+8ELJhgBgAzAE5e6+GivMrRB1Toini/VECCxAPFQPQbYCoFCa05LvWWBzpiyZBrOSwgoxyzbyHlC/ciP2Zf1WeCO9HOWZXleD/B5fMh6aOmmXq9jMBig2+2i3W4PlS/3lUj6+qt/GzOP+H3MXrAHWQZQlcTa117/dgyWV9GuhXVfAmBQKg/ZI+JdG1t6z1iozbFU6LHf17/+9bj88ssxNzeHffv24TnPeQ5uueWWoTTtdhsve9nLsLS0hNnZWTz3uc/F4cOHh9LccccdeNaznoXp6Wns27cPP/3TP43+qXP1i5JluCzly4Wdfzhpj19pv7kBl14NT2eBEc5fEaNO+bT2ank5n5xHHh7WlmnkUwHaR6vHu0+8a30i+8VrfwxpBm6cdNQXVlpPwWv8x/BklaeVKfvWIkvRe9c1Yy7rlfzEeoxaORJ807Usy4Y2r9Jvut7r9bCxsYG1tTVsbGyg0+moh6ZZfcivD57yLAy+/6VAqbR5vkilsvkBkD368Ri87OdNed8zU0Ot7MtrBmBPHUMvxaM9Dbx/JBjRNoO79SiyEGNAQ2WHQIO85xkoLW1Ip8t0mr6R+UqlUg7o+OPSVtokSfJ+p3zT09OYnp7Ol3xCusDqR14H/eaAk+9voeU5OTeGqN/Hzc94Mb7+tn9Cd7WFNM0w6GdYvfFruPmHfgkn//w9yLIMh7oZbt4YDL2jRlIZwBfqe1xei9iFolQoQvLv//7veNnLXobLL78c/X4fr3nNa/C0pz0NN910E2ZmNjdvvfKVr8Q//dM/4a//+q+xsLCAa665Bt/1Xd+Fj370owA2d+w+61nPwoEDB/Af//EfuOeee/CDP/iDqFar+PVf//XCDSCKMdo89EZ5uEdLwsURrOcpFlUMWloJXrR2xQi5bA+VzY2pVoYsj3sAMd6K5I+3xfOyOE/Wo8tbJcuzjyGLf28svP/cyEiyljwIEIQo5K1xuZDRFCsNfcfsIfHkm7dFA2Y8n5yXHExQOJuOiedHxvf7fWxsbODYsWM4duwYWq1WDlxi+m3oWpJg8D0vBNJ0E5BIKpeRPelpyN75VpSOHhrhe62XYZBmKDsRkgTABk6fP8FfTkjzj3v0BEo02QmB4aF6GbiT13n+GNAiZUW7Z+XjOk/7jgHrnixpwJOXze/LvXyyfDoJl5ZutLotnjhvWp9ww16v10+9dboyFJ2m6AxvhwqAsgz3/O8/wD3/+w9ckPfmu/t48wP1p3IGSPDV+i7c09iN0vrRIb0sQT1vk+zfmD6xqBAgufbaa4f+v/3tb8e+fftwww034ElPehJWVlbwx3/8x3jnO9+JpzzlKQCAt73tbbjkkkvwsY99DI973OPwb//2b7jpppvw/ve/H/v378cjH/lI/Oqv/ip+9md/Fr/8y7+MWq0WzY9UctoASOOndVjsjvXQdUmaAFs8j0taebzdFmLlvykEam1m1erzBJDK0R4ZlZtWtd/faBQCgeOAVK38cdqvRZT4f4tfDQTxzbtaedr5A16kiK7JeShDvBL48r0itDzDNxTyjYWDwQCtVgvNZhMrKytYW1vLH6X0Hqu2KDt4EbD/3ECiDOljn4jkvX81ItfNXob3fbWJq+83Yy7blAB8ob8HpcrpZRpt3wi954bOJ5Fja4ERyxHwgILMJ8GrrCMGsHskdbRVd4h/Ll9W/VZbOGkeP5WnHdMQAv9aW3lEhUf/KELCIyHcZiXJ5tIIrSLwshoX7scFP/h0LD3xEUhKCU587Cbc+Wfvw/rNd6j8/fPJAV7/9R5+9rwKslNvWMwAVBLgrvoi/mz/Y1DpD/PmOQ6a/Sw65zhtaQ/JysoKAGD37t0AgBtuuAG9Xg9XX311nuYhD3kILrjgAlx//fV43OMeh+uvvx4Pf/jDsX///jzN05/+dLz0pS/FjTfeiEc96lEj9XQ6HXQ6p9dsV1dXAYx2jEbUYRx5AvaBNlYZ2mTggiHza8Kr8Wt5NDGDq/HL1zo95UO/qU/kBPD49srm96XB0kBJt9sdCkd7bStK2ppwEeL9EAIl8vdW+ZfhYM/r40AhpHiJpPxzvr3IlsajR9IYUF9qYFUqbAlKsuz00zWc6FRWOpGVP6UQih6NUG0qnCZN83Scd5K3N3/6JL7l4DQSYCRSkmXAxzp7sFaeQfVUaJ7PG4qO0BIBvXRP9n/I8Go0pMeoqMSOjsWA2+G22ftKPEARw3uoTRZP8rrFo9RxvBw5V2L0gSbznLgTyDc0azbC2h+07+mX4xFv+gkgSVCqbOrxxgX7cf7znoqbfv6PcNe7PjhUP33+5HiC67pVfPdSCQ+cqQKNGdyy60LcvfsCdHo9JIP2iE6TTgPvF6k7aH4Tn0VobI2dpile8YpX4PGPfzwe9rDNY2sPHTqEWq2GxcXFobT79+/HoUOH8jQcjNB9uqfR61//eiwsLOSfgwcPAtBDkprilGtxPK2WVwqoJKlEtLy8DG3NjQuglUe7Lnnn31y4tXI5Ktd29HsKL9bAUllaf3HeqA7v8cWtGHVrbK20Vl+H0nrlx5QneZXXZKhUS+PJrtWXmtGQisvbdKyVNW6/aWn40zQUESFAQk8i0Kmsy8vLOH78ONbX1/N0oceGTT4PfX3zCHiPKhUkd9w2Ynjo+7bVFD/yvuO4c32Yh16W4EOtffiXzoUjr0fQ+p+Oh4+VoZh5k6fB6W9L71B6TQ5C8hi67pUXAkCa3GjprP+8DGveS3Cs1e+1y7MJXFfzjzw+nl/jUZosyzB13h5c9qYfR7mcoFRhm2QrZSSlBJf+rx/B3MMuGuGlXC5jamoKzcY8/r56Hv5k8WH4h/2Pwm2zB5AlyVDEzupzr+9kpK+oDh87QvKyl70MX/ziF/GRj3xk3CKi6dWvfjVe9apX5f9XV1dzUKKRNL7SCMcaCFKMMQqB56V8RFLR0EcLj3PeuWLVECr9lptUvfZIoMP3i8i02kST9UjDJt8LpL1fhpcrFXNRAfZoO8ui8qxQcpF6Y428lBWtDCmrPHwaKtu6Tt5byCuUsmrVJdMCeoSSyzsHI9pBaJ1OB81mMwcjJ0+eRKfTUcGbxpu1vyRZX0Xpw+9D+qSnAWVFPQ4GwPIJJJ++Pm+XnIPlchm3Nsv40Y8nuPLCOdx/oYqsOoXbst1IK1OmI0C/i7zTSbbTkq1QFIHLmRa5ijHy1AZen7aZOFSOJ08aT5K/GK+c55V8yHucJ1mPp9s0ncrv0djS/hS+iZauUWSQL6nvvfxBeMxv/wgWGr1TZQHtrIKNtHp6GSZNccEPPh03v/oPczmio+937dqFvXv3YmFhAYuLi8FzbaQ95Se3SlsmqSgoGQuQXHPNNXjve9+L6667Dueff35+/cCBA+h2u1heXh6Kkhw+fBgHDhzI03ziE58YKo+ewqE0kuj5e488g83fjKktTXiTOGSEtHt8MLlhkahRCrn2bfHGlZhsl2wDpdNAWpIkQwJm9Y1EzFo93rIL/82f6S8C9u4rspQgv68pwVBoN6ZOueGXK3rZf/IgunHD4doGPwI8ckwlKPHqIqDjgR765k/I8OPhaXPrxsYGTpw4gaNHj+ZLNdZZI7w9MX1Receb0b30MmDP/mFQMugDgwGqv/OLKGWnHRUORsiozM/PY3FxEfcki1juNVDJKqjVqqgxp0iLRshNrB4o4MQjLOMsfWigwZIf/l8z1LwMLY8kuYlefkv+JI8euLDaaLWVLzdbbaH0Wlp+XwOMWnn06CwdfDc9PY2NjQ0MBoN8/pOOP+9pj8Tjf+8lQInPQ2AKfdTKAywPppBhcwln6QkPR7lcziNtjUYDu3fvxv79+7GwsIB6vY6pqeGX7skotjYeWt9bur4oFVqyybIM11xzDf7+7/8eH/zgB3HRRRcN3X/0ox+NarWKD3zgA/m1W265BXfccQeuvHLz4JYrr7wSX/jCF3DkyJE8zfve9z7Mz8/j0ksvHbshxB8n3lkyDCWNsvwA+hMwEixIoMEHlV+XL/az6pSkTWyrDq1suVRF/RQKX1vgxAIRvH9jIwZy0n+jAxNOctxC48jTxJRLv/l1QH8PkKTQZk7rgDCvLsmP5Fl+W3LLz93g8sLBg9zZL09l7Xa7WF9fx7Fjx3DPPfdgeXl56OV52qO+nkxKfpOVk6j9zAtR/qe/AjbWNy8OBih97EOo/uwLUbrps+r8JYPC3+BLG1J5OJ5/aE7x0Dx/3xb1S8y483YWmU9SBjydoBlZT9asOWLJiPz2ZEpLE0Na/UXmZshu8LTSAdVshkzPl+mG5Gu6jit+64cBte+AEjJMl3pD1yqVCqanp7Fnzx6cf/75OP/883NAQq8s0PjS3pWktS20ZcFylC0qFCF52ctehne+8534h3/4B8zNzeV7PhYWFtBoNLCwsIAXvvCFeNWrXoXdu3djfn4eP/7jP44rr7wSj3vc4wAAT3va03DppZfiB37gB/CGN7wBhw4dwmtf+1q87GUvC0ZBPKIOkJNK8+ypo7T3M8jypJG1liskL9Y3H2AP0fN7xKtVljUh+XXNe+IHvFn187pkufw3X+P0DCr/7y0znW0Uqyx5ejluVt96ETyNuDxJ4hGFkKHhdXl1cl75Edjyww864+CEzhmhzat333037rzzTmxsbGBjY0N9qsbiKdSmfN6traDy9t9F+R1vAaZngXYLyam9JdQerowJRNRqNczOzubvqOGHnllOCb9PT9bQ+SQylG61LdROc54jG9EJcty8yIL22wN/1j1NrxYpKyR/WtRFlmfl5aQBd95PWp8NyZUyjjyd1Iv0v1Kp4ODVj0BldsrkOTkVKWmiirSfYuUTt2BmZgZ79uzBvn37sGfPHkxPTw+9C4lHzrkscxnV+OTt4zaS0sUcV6BRIUDy+7//+wCAq666auj62972NvzQD/0QAOB3fud3UCqV8NznPhedTgdPf/rT8Xu/93t52nK5jPe+97146UtfiiuvvBIzMzN4wQtegNe97nVjNYCTp2glEIhVVJrwWgKtGQ1PKcaGViXSlGBJGiwLoFiTwrsv+bD6w5vY1r3QMtBOIU/hbqWsmOucLG/V4kkbb23vk5XXUq4hPng0RIvQcHDCzxvZ2NjAysoKDh8+jM76Cp5yMMPTnriApUYJR5sD/P0XV/CeG1exoZ+8PUQWkBr5n6bA+urQffrm81C2ncrh4XYZGZH5+ZJPRTyBI/tZAlXZv/zbGociJA1TSFcQcYOngYuQo+OBXU/+rHos3WzpQfk7BPY1vrR7sl5aNicAW61Wc1khWZh/wAFk/QGSqm22kwQoI0NWKmH9Hz+BgwcP4pxzzsH8/DwajUZ+qJvsEymPMoIj+4Dn5WMs0xaVtUKAJKbwqakpvOUtb8Fb3vIWM82FF16If/7nfy5SdZCoAy0epTe+VcPh8UGkDTonLgRSQEMGnpcrFb22TyYGocv0IQ+Fkyw/VK41ab+RwUmo7V6acUhThl7d3j0ZjZAkQa2MNGyFf609fN8Ar4svu9B+kXa7jVarhdXVVRw5cgTd1WP4rafUcP7CKS8vSbDYKOFnrtqD5z5sHj/y13fixPrpdoV480CJN2dleUlyeg9It9sdWq4F9Cfn+DW+fyQmekiAh7fHM7Dyd4ikEY4xxlY51vVYR8YCGxqvMWXFyEWRsiUg0kC+1I1yLpI88OU92txaLpeBnnFYn6A0A06+5Z+wv13G3gsuwK5du4aicFIGJc9SLrX5oMmG7JNx6Kx6l40nrKQYrDfQah0Y6lRtgmpCpn1T2phJGUKbvBxN6AE9FGvVFeJFU8ZSoGU9FlA5G8jziKx09yZpoLKIEgmBnqJGiurhEU0OTvi+EYqOtNttrK2tYWVlBS99VAnnzZdQYvzS74uWanjNU/bhp99zV7BdAJDs3o1k715kKyvIDh2KAiJclpfqJRycqaCZlnAkO715vtfr5ZGRRqOhrsuT4ueeMEVHeIQkRFtxrhKMLktr7Zf1Wc6KB1xil2d53da+NIsnq7wYWfcMbKhOKz3XuzIinCTDT3FyPcqBKaVvffJrKP2I3YdZlqG70sH6r78bu090sOv884eWaEjetE2r/DcH0nwjuiUfmj3w+sSjswaQkCKzwIYlOF7HynSesgqBIQ/waAPtgSYJQLz2hQCSVn5RQbImvLzGI1hZtvk6b/mY8FaEeUKnyfKYNYUrAZX09sYlPp5WmdJb5Ke08j0kzWYTJ0+exEzWxJXnV035qJQSXP3gOeydqeDQ6kBNAwDJhRei9uIXo/zYx+ZlDW66Cd0/+iMMPvvZkTZwRZ0kCc6bLuPnLpnF0w7U8sPPvtLM8PYjKT7Hwu9kVPj+EeoLvoGQwAg3RLxfOHlRYOpDy0h4ZM1h6cxY+k7KVcxcHgf4WOWEon8hp1PjV+a1QIsHcOi+BAGWM5skp98CnGUZKpUK0jRF91gbJz58C3Z984OQlEtqXa13fAIHejVM719Eo9EYeS2B5EcDJJpd0dqo9Y1sa1H9cVbvKOQTXwpE6DySkJH10LRUuBpoCBlcqagtPjQwIsvXwm0eoLHazr1YTnxDofa+A1k352srBu8bkay+vbcAFu9T65unC42Bx3uRyIlVlwQiWTb8Er1+v492u43l5WWsra3hotl+sC/LpQQPO2fzJFXNSUnudz9M/e7vovyYxwyVVbr4Ykz95m+ifMUVaj8QndMo4W+fsAvfysAIAFw0DfzqRT08ZbY1lIdvTuXXOSCpVqtDL36TcmP1n9x8KA2ENfdl2yRP1tIz/bfGXtZpjVURHSh/e9diywr1Q4iPov1J1/gRDVxfyk+SbEZJZmZmsLi4mD9GfvTNH8LJj90KAEj7A6S9PrI0Q9YfoPmOT2HXPX0sLi5iZmZmaOmP72Oi33KjtTwsMzRG9/lTNt+IFGo03eeP1HmHnWlgQl6n3/wRQ7kBKCa8pU1sbwJbxl1ODlk3LVOFBMwql4carQkb8mZCYEwDLBMqRiTrmqzSff67SDRKG1sN/IR+c1khfrlS4wei8TNHms0m1tfX0a32AITfd2XxAADVa64B6nUk8hXr5TKyNEX9p34Krec9b/OIeAxH9pIkwU89ZBaL1WTkXTWlJEGWAS/Z28SnDy0gYY/yaoaJvul9NfRkjZzH1C98LOQ3GTePeN9b92U6qTe4vtPmfFGZ4nnG4VuWYekgDTRwnjXdqZGMenjpOFDk6SXPHJTTaxLoRFV6aivLsnxO3PO/r8WxCz+OuW9+IMozdeDoBqpfPIY9M4uYmZtTDwC19i/xPrNAVGhvpuZ8aPsYY+isACT827pHHUueinwfhiRNiCxBl3VbQhuL8rXJo01IXrf8LRVJzKTX1jh52VZbgdOgJeRd8XKLKK//ysQVV6ivQn0bUqgcGGg8ePVqHrqXVgMkPFpCG1vX1tawurqKZrOJTzc7GKTT7tt0+4MMn7u7PVIfACQHDqB82WV2O0olJEtLKF9+OQYf/3h+nXicLQPPPLduvjgvSYA6gCfNdfExLA7tA5AgjJZq6MkaLWor55fWpzJ64gGEWPmROkeLehSVra2QB0pi3hQu82q8xugjq2zZVxLEArrx5mXwKCE9adNoNFCr1fI9VQRU+net4cRffBIzMzM4cOAAFhb2YGZmxjwZmzvMmgPvATbtv+ZIb4c+39GAZNyGS+PKy/EOlbI8B00BhBC9TK+l8RS7FKoYxRNj0DQeYr0da6JpdceCs28k8ozydvMuZSo2kkb3SD7kptGidXvEZcQ718T7L+WLypKHoK2urqLX6+FYb4AP3LqBpz5QByWDNMN7blrFclt/sgHnBt7kCyBLUyTnnKPeO6dRRtUBQwAwALC/MkAyGN6sKCNDSXI6QkKbWTmgD83X0HzTolNFiOsf2Q6tfjmOUifJfCFdpZWjRWUs3RPbxpiyQvNN27fDy5P6mgNT/uJIKodAKu0D4U+fEcidn5/H7t27sW/fPkxNjb4UksuZZvO8cdRsT8imxdi2EO1oQKJRzKQj70QqCeD0MoxWlmZUNeRt8SRDeFrasggjh6IyIR4lD9o1WZ4HQrzrVr4iPE1oe0hTPBoglIDRMiKxdXqek5VHenAckNBm1larlb847w3XncQ58xU8/EAdgzRDuZTk35+9u43f/NBRtR8AAOvr4XaUSkCzqcrzSi9igyWAFspD+wWA0T1ttJTMj4rnQFIDAbLvinilVlrrKRsLFGh6T+2HgHG38ltAR0Y1vCigbF8IPIUoNA9kVFj2l6X/+BiT3HNAQkCj1+shy7LcbtE7aXbt2pW/EZrLCa83JiIi2yj7WYIUzebxCNC4un1HAxJrsC2B0AwvzwPYj6bJyWCBFi5gsvzQRAy1Vf622qD9l/xr/aHxyq/JJR0rRFukfRNQEkchw05pOGmKRJYnjYtMG1OvVh6/zucL542+6aOd4kpKutvt5nma3RQvffdhXHX/aXz7Q2awZ6aMw2s9/MONa/jQrevoDU7v/UjT4d/Zbbchu+ce4MAB2yB2u0g/9rGRPkmSBEe7GW440cMjd1VQNvKXAHyqv4Ckqr9Zm/jh+9q0ORcT1ZIAgfe/5vh4883To/J/rBGPBbYWAOJleXlG2kHsJb4TpfHqtSOmfjnvZH2aQae9I3SPNjnTEzY0D4BNp7Ver2N+fh7z8/P5WSUSvGp6Xqs7pAus9mryKWWwKO1oQMJJCn7RDtFCbtLIWghd3ueC4YEQKbS0XETX5Jt+Q3szND61ttB/a9MV/+2F83heuWaqGTarH3Y6nak2SQUdqqeIzFtGKsYQjFOHvCYVGz93BNiUp36/j263m/PIFeAgBT5w6wbe/+VmDlw2v0fX7YHTcylNUwze/nZUXv1qs0/773pXHiHR+P+dm9fxJ1cuIs2yobNQgM1Dqf6/1gyWSw1MsVNX5aZ3mn9875XWb1qExAL/vAzytGPBpNZOuqcBTE9O5FhZuihksGMAmdWeBPZcKQIurPTynjY+GkAn4mCb3sNET5bJ1wXQZtder4ck2Vzmo70l0t4Aow8hSJ3N+ZOOApdTXq4WAfL6bNwoyVkDSGSneeli7lkeC7/noUPv0SlroLTwrla2VVbMRKd6Qmnpt/RktfI4f5JXrZ6zGZxshbZi/ENkybKXTotieCBDlmHJqzS4sg4KS9MTNt1uN48i8M3okideLo9gyjZlH/4w+lNTKL/kJcimpoDBYPMEzDTF4K/+CoM/+zNXNj92vIeXfWoFv3HZPBZqCXpphnICZAD++UQFfz3Yi917p9FoNEbeZ6P1tzQSHGBp/SwND5G3aZKXQZ64LItfsxwuSqu9YsACTTH/LbIcpliQFlu/FiHwyAJbWh0cNFC0gz9FRh8CkXIZj+73ej2USqX8bdKNRmNE7/K6NRAi+dJ0vAQy8pF17rxbzoxm92LorAAkckJQJ3oCTOmKGAEtQiEnMb9HXhBdk6QZf20pyBMmbaJpikwTSj5RtHsSeHgAjP+32iv5n9AmSQPtyUpRhUl5+VhraXhaqoMbnhggwvMD9iOBUq74XGm1Wjh27BgAYGNjA+vr6+h0OkNP3lBe/uH3tLNweJuyLEP2/vdj8OEPA9/8zcC+fcDKyub/lRUgAPqTJMEHDvfw+A+exNPOmcL956poZ2V8ZLWMTmMW5503jampKTQajfxtv/LpB+JFbmLl7eHX6Ddvm1zuiTGqZAx5me12O3/zqxwnC2zKiGgowqz9Dxn1ovpZnTeIf9LQ44XI4jk2H5dV/noEigzSvhGSHQD5+5zSNM2fvqF9I5YdIJ5kZC7UZmtO8/tcN1hjNK5+39GARDOmwPBAWB2jCRZ1rgUiPA+AE391eFGkaHkEXp3adYtX6+kc4pWEnvJ5AEe7JoX0bAIgZ6INVrRAkxtL1mUaCSA1MMKve3xJAydliZcr08p8lgKjJ00oGtLv99HpdLCysoK1tTV0Oh3zMX2u4GXEREvLZTrtdJB98IMj0QgPEPJx6WcJ/u3IALXlMqamalhaWsLeXbtwcT3Fw9ZuR3Wjgnvm9+PorvOGyqFoiXRaPP45UOl2u3lfViqVfK+BZxykd87LbZ5angq9bV0DklsxRlLGrbbzeiXQ1MrR8hcBNrxOntcC01o+noaXwz/8vB0ONvh4EmDh+0to75HWbukUevqX5mSsXQtRCNjE0I4GJJK4QpRAg8hC/Bp5CN5T2J5AyDK5JxLyGDSyAIYkrpQ8vqwJr31rRIDO2vQ7IZ28/tbShigGbGjpLOCile95nhoo4Ydp8broer/fR7PZRKvVQqvVQrvdRqfTQa/Xy716Hj2QIERGRiTvmpHRHle2ABXxyfd9UHh9dnYW91ucxSvSr+ABx1ZwSvpROpzhSGMX/uWSp2N1esH0aHm7LMqyDL1eD61WC4PBIK8/SUYjwpozwY0gv95ut/OnOjS58WSJ6z/NsI1rmKRe1cqK1ZcxwEnWJXU4d7j4fW9fH5dLbbxp/wiVSZtV6cReAEOAhOqgZUC+lOLZIK2PtLSeg89/c8fCGudxQCBwlgESjayOp2tap8koQWwd1jVNURAffDLThiYPANFvTbFpxkTuZeHoXD6W6LWXl6GF4qWAakBpAk6KkdVnsWBD+6+R9AK1+1Y++paK2lNWMn+lUsmNZLvdRrvdxvr6OlqtVq6wJdjgryoATr++gMiKxvBrHGB47ebt54/s0nr+7OwszlnajdcOvoI9g81j40/FPAAAe1rLeO4X/hF/+ZjvRbfcGFqqITBhRUVkn2rjZOkw7U3A2t4UeQSCR548aYBPK9Ny5qzyZDl8/GJI6lypO6UT6805+WhrKJ8kDoTpyZo0TfN319BSDLWNnjAjmzQ1NYXp6c0lQevoCmkXNHCq6WnuIHA512wK8ePJ4Tj6fkcDEkuJyA/vYJlPM+jajnhZr+blxJBWLpVXq9WGFIY8IIlPZNkmACMKSAMtkhfp4ViT0fIYQu3bDiH9r0SWbPL/mlGylL78La9J+Q/xpJFl0DVwxIELzTcCHfQysV6vh/X1dWxsbJiPAnuREdk/5f1L2P3KH8Ts5ZciqZTRPXQCJ9/+D2j960eHnjSzIhRyDtGSS7VaxfT0NPbv349vm+1j38qG+mxHCRmmey089J6b8JkLHz1SDoES3i7OP79GIIg8ZHozsOSXf3MvnR4j5WmmpqbyUz5jxlHrH41i8mlOolaG50xa88S7pv2P0flaHguQaIA5y7J8Twh/9QidysrfZSSfrkmSJE/Hl+ikbtbACJ9vMk3MKwd4mzhpe0nGiYwQ7WhAAuiIVw4I3zi2HSQNrWYYPAHWiJQLPebIy5LlkQLjyjQEzmI9ZVlvLMm8/HsSHYmncftLKmoJYmU6+T8kHzGgBBhVbrJ8UrT0oSUZAiHtdjtfsqGlCQ4+ZBtD/Dae9Bgc/M2XIymRPAKVi/Zj5nUvxsqzr8Lhl/1aXqbcy2G1kULrjUYDi4uLWFxcxGM3bkIGmA+bJsjwkMO34HMXXT7yMj2pn/iZE7I99MQOja8M3fO0st/J2aKlGbo+OzubX7MeF9bKlDqJLwdrYEGTaQ8EanV7+tWqI3RPA3BWGssZ9a5xoEljy79Jn9PyHwFDHh3hYISiI8Do5mIiuQ/QaifJhPzPbYc2Fp4D5PVjiHY0IOHCL/dGyDREMajdQ948v3xUTpJnWDRQw4VdExTuJXGh8cCRbDuvxyJpzGKNnQX6JmAkjqSnsxXiRkuOm/TExyUuB5o8WMaDe3/tdhtra2tYW1vD8vIyNjY20G6387MZKL1UrCH5zbIMSWMK57/hJ5CUEvDk9Hvh8gej/ePPx8rv/vkQvzziwuumiEa9XsfCwgJ2796NXbt2bb5ddb3nvjo9ATDV7+Qv0puensb09PTIO2xo3muRIOKHn1NBnrKcrx6gko/91+v1KBAb0nGWIdL0kGfsZD45HvRtAQdPrjWdHkrjkWyD7AN+n0AGX66h6AjJATmaBFj4fp9arTYkMzLaSPXIJ7pkBFOml49waxTq063qEqLtCxvcByQFIYSOaXBCAucJqUSOvEwLVVplciJh5em5kpDrzhxxy3RcCLWP1g7igfPD0/P/3s5sbYJafTKh06T1k+aFaIDTMtY8v/TCYxVIjPHn3/JRVq0M+k9P10xNTSFNU7RarfxEVr5hVHu8lStUKadEu17yvShXSrDYzzJg13c9WQXWcr4RGKE9I/v27cOBAwewZ88eNBoNLE/NY+AcxpUiwer0Qh4Zobf7yicmLKNO/cb7L2afm6WvOPEo1Ggf6Zs9+XhLkKDpGI1/q13yPh+DGH2r6d6ifWSlt9LG8MEPQqNlyiRJ8jnAH+MdDAb5U2c0BrR/hJ9rI+vmkXN+9o2nJ7QnUjVZtGQy2B8F1P5ZESGRv7V0pOCs9e4Q8UGRAye9BDlBeVrt2xpwjlx5PdLztQwY513zskh4+UFJvD5C6tuFfic0SlJGYshKr8mdpihD9Y0bPZH55G9pZADkTwwAm08UNJvNocOiyIDTOQzasoBV/8w3PwJZdjoiIilJgNpMDdn8DLDaHCmXGz8Kl8/NzWFhYQGLi4uYnp5GtVpFpVLB5/ddjItX7jT7poQMX7no0UNghG8ql3Vr/agZbu6gSN55v48jZ6P9NQoA+T0CkpxvDxxoutHiUe5V0NoTAh2SJ09vxvSVBz54GdyRoxOJKVJCj/rydxklSZLPhd6gj9lvOgczD9iN2cYMGidrqDVruW2QTzRqNtGyQ5pNo7TSXmh9sh0yJemsASShPSLc45HrlpqR5xT6T2Xwb5leggctrxQAnl9OQg/o8HKsg60obCg9WY03q/0aCJuAlzNPNK6anACjb6yWSwBc6chyPUNogQ2Nh5AccA+uXq/nmyrpJWKcX/40TLlcRqfTyc/S4EZqtP2RrpmjWMlgTE9PY9euXdizZw927dqFubk51Gq1nKc7dx3ELUsX4cHHbx+pNUWCY3svxJEHX46pag0zMzOo1+tDS7JavZo+0AwOH19phDSQYndDOIrL0/H/GkjwnMSY+rz0XC74NU+/er9jefN0H7/PjToHI/yQMxo7OneER5vSNEVycBoHX/AYVObryPopklKClVKC9pEBlj7SATY2I+pyKc5z0rX2h5xObkf4NTnWFkBMCoRIdjQgIQoJviQLGcprmiH20ofq9FA6Bx0ar/K35q2EvGCaxDwvbWLzPBnZH1qIb0L3LoWUKR9/7fFP/q3l075DIEMqNhmlkfLNnYkkSbC4uIiDBw8O3U+S04/F0pJJkiTodDpDdWibaTc+/5+YuWi/yW+WAYNOH4Pjy3k9vB+SZHOPBu0XWVpawq5du/LICAdVSZLg3y5+Kpbv+hwuu+vzmOpv8tcrV3HbAy7HVx79bZiZmUW5XB45FZW3Q7YdwNATOPIe5ZXjZTlXvN9l/ZqnrOlCOZ6eLrTqsshzbDTdqAEuiweZ1qvL0+v8urYMyr8lEOGAhMA4LcOQPKVpit5cgl3/8xFIKqciaJXT9XT2lHD0qinsvXYDSTbKo7aU5zmblpzIMqk9sgzPZhWlswKQEFnevKdMNXBg3edpPK/AyuuV63mWFu+WIHjgioReyy8nGleIVIa2P8CiCWA5c8S9Fs8r9ORKixZyip07msIvSlmWoVarYXFxEe12e+jgL3qbaaVSQafTyesc8ibFHE6SBMfe+A7s+W9PAJDAEsUT135sxEGhR2NrtRp2796NvXv3YteuXVhYWMgfuSQQQoCEwu2fe+CV+NKDr8TSxknUKhW0ls5FZWYOMzMz+avkPbBA9/keMGuZyjOYPIIQApMaGOHfGsixwIcV1eH1c760erQ8suyYfpD3ubzyp8I0PezZAt6/lg6m9vGXRtIpxPwgNP6SPKo3TVNkj1lEUk7yp8OGqJSgtztB57wKpr5+etOrtn9Ltl+2TcqG52hy58A6YsJz6GPorAIkwGjny2OlAeT7JmS+0MTQJmwI7XtRlhAY8srhfMYgVE15cD6IFxI4K/3kSZrtpXEmrVTMMiphKW7tumWEJG8asCEvj4ifn2C1kRtIqcTokdT5+XkkSZI/VTA/P4/Z2VksLy+j1Wqh0Wig0WhgdXUVzWYzX36USn3+4vNQuuVmzD38/kgzoJtW0MtKyLJNgNK8/TCOvO73Rjax0r6Q2dlZ7NmzB/v27cPCwsLQe2l4egIoFMGp1Wro7lpCUq9jpl5HkiR5maR3pOEivvm+Lf6klHV0viTLKHi6ihsX/jgpv0dlaL+LyrClt2SZ1tMh9FuTtRFKRvuEG23Zt5pu1khurOXOKe3JI9vT6/XQ6XTyR9ypbfTElQR63W4X5YfvQ1J2tiGkGVoXbAISuflatlfrX2qjdl3msZznGNtVlM46QMLJQmreoIXK8bzRmDK2Shaa1YxTKL9FliDLPjsTAjmhUdJArRYVkGF97Sh1+h0C0xbxsZaRMg4sNEMR8tbpxEqKJszNzWF9fR3r6+toNpvY2NjAxsYG1tbWsLKygvX1dRw7dgy33Xbb0N6Tcq2Ki3/jpdhz9aOR9gcolTb7oV5O0R0kOH5sgON/8wGceMs7N0H4KUBBj1/Oz89jamoKS0tLWFpaGoqM0L4r+lA+Ahy0PwRAvveEDJDVJ1rfSAPgOTfyOtdXWr2Wc8M9f8mbp/eKRCtkfksWpGxboFvLlyQJaNsC7V+QulIDN9ROObesqI9cuuZjSHnovUx06vBgMEClUsH09DRmZmZGHtve2NjA3XffjV21CwKdB6TV03zIaI2nm7mtkGMQa8vkZlpqfyygs+isASRaB4SE2At5a1S0g2MQKPcUZT7rv7xnrUdrAEJTdJwsECfzajQBJsVpXAWuAWp+XwMiFmlRlhCf3DvUlJplQD0eKG+j0UCWZfljjvPz8/mbf9fX1/PDoWZmZpBlGU6ePIk0TfOTXQ++/Luw9JRHAQBKldP7QgCgigHw0Y/i5O/9RR7VIEBBT9EsLi5iZmYGS0tLebSGiD9ySdER2gdAm3NpiSlJkvwJIt7HMY6DBvxovxfvL6+PJcDgm5q9+jSHwzNans4Itc0DVFqZdN8CM7lMYrT/ZPma0dba6fWX5EWCEgIj9IJI2sRqbWxutVpoNpuYPbaB6p5pWz9kQG0dI9GRWD65E6OBs5HqCtgKujeOw3PWAJIYkoPBBYfvk+DfWn5ehkexxkYONg9VhiY5F0R+AqacTDxEWSTkxo2Oxu8EhNz7JNfgLRpHIXh55Fh7T7aFDGdMHXwTK5UhnxQi4EAh8fLMFA58z7cgsZYWyyUc+I5vxtfe+LdIl5v5aalTU1PYvXs3FhcXsbS0hMFgMPRIMj8Lhc5H4UCG3kNCRoZHiwC40SpOmo6hssrl8tBhcTy9Bx5iHCO6rwFdmUbjt4hujPWeZfulR68Z0pAui7mugTONdxmVkKCTDv6j6AgtB5KsUHSEdPZgMMgPBVz+4O3Y+90PhfmASinB3O2peiYL1c/5l4DFsm/cQY7pSytKNy7taEBioUItHRcWeTJdUU9uK0Y4lFd7jp97OVxoiLQXiXGFGAIPdF07gpoEU9ZfpE0TKk5FJ7hUhvw7FK2IqUtTUHJe8fK8ORbrgQHDG/UIoJC89/t9zM/PY25uLt8cOPWoB6A0VfPbUi5j6QkPR+tDX8Ti4mK+J2VpaQmLi4uYn59Ht9vF1NTUUH08okKHWVF0JEk297zQ0ze8vdxB4O2UY6D1ozQs/ITOmDGMicpIo0p7g4rIoKcDLONX1KmzoikxQMsr0+NHc8RCoCxNU3S73aEXRGZZhnq9jtnZWczMzOSPjNNZJIPBAK1WC6urq+j3+1i77k7suvJCVM+fBfjG1iwDkgSLNw5QW0cOvLVHfuW3124C0aG9SjKfBRDHpR0NSIgsgZSevBQuuk6TkHs2WlmaorfSFuGbyrFOQCUkDQy/xVOL9Gj94fFooX2ZXxPECRC570mOvTRAXEmEQElI+RfhSTO6vGyLNB64vNFmQAD5XhP6rlarKFWrKCNFJUmRAehlZWSKmzkzO4uFAwewd+/efI8IvUW1Uqmg0WggSZL8XTNZluUAhD+uSRERui55J2fCOuTQ6j8tykE6wurHULRF5vdIylURionISGPGr8u80ujxOrijNE6ERNqBItESzit/cIJOY6XXIGRZlu8vIlnlSy0EYJaXl9Fut1EqldCoTqH0V1/HzLfdH60H15FWN+usrgOLN/cx+7XhCBx/ZD3Udt5WDjw1B8MDNCTbln4ZR4fsaECiIUJ5X/vWBkA7aEqW602g2I7n6bSNSLIOzdDEehaxoEHrRw148Akg803ozJMFiLV3fXjywmU+lDbW+9Teu6IZ1Zg2xnjyFKFoNBqYnZ3d/L5gD+73om/BfKXNygPaWRnNtAYe/546vI6FpSXMzs7mL5fjb9BNktNLM3SPlncIkPBj7bXwPSftyT6PuNHg41K0HE5FwIj3X6MQANXAEc9XhCzHMia9d0+WGzMXqE9J/glc8Ncg0OZmAiO0D4nK7PV6WF1dxcrKCvr9PqrVKubn57F3cQ9231JB6XagNw2gnyFZGwDC0ZbLNhr/mu0jIsBMTrnWR1LXS2dH66dYW8VpxwMS/m2l4em0DpQnHGp5Q52roUtO2nXa5KS9ejymTK0OTZlwwyOfU6f7/B4pY85HrHBPaOtkGXR5jUjuKdGAgcwvAYS2MVrzTC1eNd6LRkdC5UtHgvZvTE9Po7FvEZf83HNQnp8S9QFTGKBU6mAt3Tzxsnfz17HYAhZObV6dn58f2h9SYU/dUJi90Wjkc5XSeOv3GggsOp8lGJHt5/0p66R6NLmJAXwhsKqNe8ihk1EMCbq0PB6/seTpbu26F23hvPK5xN9TQ/tG+v0+yuUyZmdnh16KV6okaOxbR22hiywFjn61i6NHj6DZ3Hx9wczMTH72TbVaBTKgtJ4hy4CMLdkBo48wa9+cb6tNWntjgR+B5O2wAzsakABxoATA0AFGlqdgKRJZX6yn5/HFFZOmqDRlZEVnZH1anpAitECHlm5C9z55UbNQPk2+POAcO8YxMqV5+d4c4tdDbyWmSMXc3BzmHnUpkoWGenZDkgD1JEWrP0BneQNTf/kpLJ56KR5tMCSlTmCkVts84p0MCZ2m2el08ogJ5zemz7zw9jC//uOwGsjQdIGmE0IH4VnXQrIWM7ZWdNXztrU6ND0Zk0/+tq7FpOEREQ5GWq0WNjY28pOEp6amckBbq9UwtbuHPY84jqSSAafYnjsI7H3oHrzvbRvoNcv5qcD0tBn1Dz9QjchaquHjQX0kn+iR/Ulp5F7CkGzLOb4VR3XHAxLAR4ZE1NFWBETmixFKwPc2QtEOS0looMibdJa3ogmg9FB4nfK69J41z2ZC9y5ZANMz8FJRcM9O5i8ytlpERovMjCMrliHkcsrfg1O6YhFwDpLKsgzVOw9h8OaPYHd9BvOL8/l5IUmSDEVI6Hj3mZkZLCwsDOkOWqqxjtKm8rT+1KIMVnSAl2/pDS1iwsu2ohsxFBNF0a6HIiXaKc9kbDWjagFZWV+ofZ5u1/rTyiMBAn9hHm1kpcfP6bwR2ntUm8mw55HHkZQyJAn4CiJmFip4xosuxEf+dBV79uzB3NxcfpAeyYFm56xTsy1QwK/TUg31H3fa6T9fIvT62Br/otGtswKQALqQyQ9XmHLiW8KnratpSF3eiz00RioaS4C0tLGDzSe2BjwsMGKVpX1PaPspNLFDXrYsQ+bxxm6cdLG8WWBDA0zcCyVwTNfo6O1evw9MV836iKY7A+ye351vgOWeID91tVar5XtLZmdnh/iQSzXEp0Z83nF9YEWtLEOuvfxMztnYiJc8/TSWuA6V1+XvWN3Bf2sHbck6JUiTZYV4J9IcNQ34hOSeeOGHn9GZIxRho/1J5XIZsweXkSSnwIigUjlBY66MSx+3D/XWUv64u9wQTbzLpXev7ZxnusadTLkxmAMSWQYvl9sUy6YVpR0NSKSBta4RSeMuO5WucyWiRSc0pM7L1Q4g0oTeys8V3VYGlysfDXzIeqWitdIXAUMTKk4WWOUGO3aDolaWJtPjRjNCxjCWtOhKlmW550lv95WKslqtol6robTRRzptq7MkzTCb1bD33HPzMrl3CGzOl6mpqfxtvtVqNT8/gu5rbbPmudV+69TWUP/IurSTcj3gulXnIdZIx5KUzRidLfPG6CItwivzaQ6ZZswlPyRLdIJwt9vN5YiWamiT9NTeDSQuhkiw9/4V9G5rjPSFPNuGvi1Qr7WPbJqUV5JH67UhQxyK/tluO7CjAQlgG1hg+BQ7L3/onheZkIo0lmetbm1icq9Qpo3hndrPy7YQbxFeJ3TvkQZuPVDM82lkKZLtACOaIoydGxJw0bo8fyEZV8ikYEulEhZuaeLkZfPDZzZwKpdw/qESphYWsLGxgX6/n88tClXTJlZa7+cbuy1FHxNJ0sCedII048fLlc6F5mjQt7dHTtMlMWNk6Ywi+kHbQ6LVK3VUTFTGSkv1anPF04NyfPh9Lp/9fj/fN0KP+NJyX6PR2NyUeqqMpOSD0CQBKrUEKdtaoI09fTSAzL+1PrSeoqE5RfOiiDPsAcmidmNHAxLPiMp72mOJshxrklsKiMr16veQpBQe+m+9gVJODLmcpCknj0IKxVKak+jI9lGoLy3joY2bJ9ucaF2a3/fkwAIqfD7xujUeNRm22kmeJyl84lfKK1eku29ax8mL6sjmaqMbW7MMe+5KsXujikE9yYEOHVhFT87UajU0Gg3U6/Whd4zI5RbZT9qhgZahjXFgQmPMr3HHQ+oTS+94R4zLekIUcq54Ot4uTceG9BaPEHj3PT45LzHpNN1P49fv99FsNvPICAA0Gg0sLCxgfn5+6JThwWCAXrOK2nzXjJJkKYB2Y4QHyR/n3zqw0gN7mgzJa7EyqvFZ1LHhtKMBSYj44Ml1R0soNW+U3+PfXp2yTFm2FAK6Tuvl3DvT6pC8aW2WaaQyH0dwpEKZ0JkjS8lYAFrLw0lTUpYMxIBPT1l7aSze+F4NCoXLN7FK2SPwkrT6mH3HzVj71vOAh+zZdDcBlPoZzr8DuPiOGjBdRafTyXlcX1/PQ9UUHaH9JVYfywhDDOiSZcVEKDTj7fWdRtq+jHHIy2t55pbBkmMoQcZ2RW9i7lt6VNP59Jvkky/TtNttJEmSb4Km99Tw4xwAYPVrDey9rGvzWQLSE3uHHuPX+k3T71LG5OnAWvv4dbmMqJ0GrMm62o6IMbPorAIksZOu6CTnXlJIwVqeiQVKLL40ZWSBC55XW6Liwq0dosP5mwCNbyzSdtfTb81TjAHNFnkg1iIrKuNFBK10HIz0+330er183pGSpnVu3k6KppTWe6j/xc1Idk9j+oH7sG/3HuzbqGOmcuoU1fJmetoXQk8U0KFnFB2xDJLci8DnlTXv5Xz2+kcSByUx3r8EMR4w8MZBK1ujkEfuOUOa7rN0qweYeVqPV0/XSj7lmHEgQvLWarVyMAIgf0s0vcaAl0kyfew/O+hPdXHgwdWherNsEz+nR85B0p0FkuF6pdzRXPDmP+9PCUqtBy6oPN5O2X8eeLb6O0kSIFId7WhAIoVK+63d49es0CInT7laURc5YTyQItPza9ZGOi2PN3G5Eg8pGUtpTOjeJT7+oTErAkCkhxRj9Kx6tUeHqQ5N7kMyy18QKZWr5gWmaTqy/DLdK2P/iQoO1KaGTlNNks3lGnoyAsDQQWe0VMNBfcgYhuac7A+LLBCk6aKQQZfLN3JZ2XKkthKJkGDCAiDaPU9uyPBK3aWVZ7XP0v38t/XYMZVHHw5G1tfX0ev1MDU1hbm5ufwVBPS2Z36Ka6fTwfHjJ3DLXx/FhZdN4dInLGF216b5TTfqwIlzgbUlkOXmY6k95WnpZ96f9NuK6lnE20s8WOVb+celHQ1IiDzPwQIpXl5N+RUlCVQsvqyJFssfL89D/fxJgRij4/XphLaPNMDIr3uGkZNUIrK8Ik91xMg89xgt/qTsy0fsJa9Upnf2Bv2mtL1eD51OB71eD0my+dTN7OxsfsolfyKhXC7nT9eQAeIRQ34MvKw31CcxkSJubDWPU45fTF2a88V/a30dQyEgqaWn8mN0lleXxr80hHyJJ3SOkyxL04FWtIHL5WAwQLPZxMmTJ7G8vJw/UUOH89FyH+czTVOsr6/j2LFjWFlZQa/Xx1duaGL1jmk86CEXYffuJUxVZ1TQISMXkrwIiUfy4DO+R4uXIfteixBqY8fBJABkseERnCWAhJPlLVqK3ytHu2ZNEA1IWCfj0T0KiRFPljKR/GjCayFpjSzQ4xm/CRD5xidNOViRPC2NRjFRNc1r5fktr4orLh4ZIeUvDbgsF0B+/gOlr9frWFhYyJdeONAAMPLEDs0dem9NLGDX+sjqGxmJAobP1KB7GqC0jDsv3zLoPA2vf7ucDc/wW3yTnuK6Sjt/RJNj6VySwbeeHOJp6bccX2suSFmj99OsrKxgbW0t3+c3MzODXbt2YWZmJn+ihtozSAfoNNaRHFzHrlIJtRO7cOQzFZSbdRw4cABLi/sxPTWt9i3vK82WWLJBfRJLWgSF1xOyCR6Qya8V8Ol3NCCxhAewPZsYT0eSVCIhBSDzxOw/sTyskAfNf0vl5IEcKUyUXlPIEzBy31FM35PCIq8/RrZC3m+MfGvGx5uTWh3SA+Xfsh5Zd5Zl+SOX9F4ofhQ89Qsty9BeE94f/D6d0qrpDjmvYvpI6zP+W1tKkVEna0lMghtNx1g8eAe5hSj2YDUP9Eq9xjdx0jUpUxqQLcK7JkMhvvkm636/j42NDTSbTfR6vfw03127dmFhYWHkVQL9tI/BQ0+gcU4f9UEVSamKXekMLnj8Xmx8OcXsHedidnbWtF18bC2drAF9D6h4dlKS5VTwfN48GNdm7GhAAowur3jemVSARTxDDQ1q3qesU3szJ+dBqxMYfa02Jzm5yEuQE10rV9bHBVsqf0u4J7R9FAIGXr6QDHl1xaQJKX25V0F6sbHghNrBoyO8Pg1oA8jPgOj1enk6egswPbbL949w4MPL5WeRWO21QJ4FyDzSjHWWZUOPY3MjJHWNtUlRls8BqidnISdFOjJeW2V/WPc0mSgiezxNyEGMcbA03c73KLVaLayvr6PdbqNUKmF2dhYLCwv5vhHpDKQPWkHpQA9AglL5VN+dEq/pB5ZQm+mhfLSc72WSvEnZpGuh5SltLEOvN5B9oPW99Xix1n/a/1ja8YAkljSw4nWaBQS0AZEG3LpPg+q9PpwLIU2KkNfmHSPsgRGNR36f6tdOcJ3QfUshIKB5RBZgsJQT/6Z0nLiMavIZyz9/Hwg/PZWeqNGo1+vlBoLaRUe+12q1/Bo/GJAeI+Zr5jR35GZWjzTjHGp7EY+UGyMOQEJlSL4s42FFCzTAoDkp45AcC9kOzRmzTrzW+PBAiQaoQjJPstjtdtFsNrG6uopWq4Uk2Xy8d/fu3Zibm8vPGqFHfNM0Rb/URXKwY8tSArT3n8Tcsf1D0SF5Giv1BedZW+qSfSmBpXReqY2WPbPkRpal2SJV/ySIXrbZ8YDEQ7+W4gBOK0JPCclJGbN5SlP8vAz6rRkNTVF4O6w9b0PyJz0Aqz6tLeOi3QltjbTJLxV6pVIJvuLAUi7WCZa8Tm8O0XXLYGhgRcogBySk0AEMvZlbcybIULTb7Xy5ptFoYGZmJo+O8LlNYfder5cDEv50DZ09ogFvy6iH7vP+jwUG2vym/Wah9NROWac0UF5bLN5C7SM+PZI6T+pGeV07R0W757XD40vyL2WSIiNra2toNpsYDAZoNBpYXFwcAiNEaZqi3W5jY/4E5kOvmSmn6M1uoLY6m4MR2R+e7aB2xTiW/JrcdmABOU+fFKGidmNHA5KQspT3+ODwJRtrQHh+z1O0yuB1SgVDPFgggeezSOPRm5SaMuP5rUkgaQJOto9i+jKkBLgXza9Zcq2BAs94hWTQMmihSCA/MZUDBTkHOK8ELOhFZvS4b6VSyd8dQksvEpB0Oh10u92hOugFfbVabYS/cZSv5XXL/pJg35ubliHn5EVR+DhbY2Xxa7VRK8u6ZvEsSeooAmEWv/KeNWZaOssgk13o9XrY2NjAysoK1tfX0e12Ua/XMTs7i7m5OdTr9aHoxmAwQKfTwfr6OpqVNexKp1HtD5AmCbqVCqDZjESPQvH/2mP/2ny1SBtbTbbofoy9OZO0owEJkdXBVtqQoefkeZiyXGvyaYIA6AhXAw6x9XMql8uuQdDyaZ6oRNMTunfIM2zcOPP/IcVkGSbLy5LGR+PJqttSmDxyQkCEHw/P6+fpeSRlfX0dJ0+exNraGvr9/ubZI9PTmJ+fz8EIf4SXIir0eDC1p1qt5ntO6LySEBjwiAyIdF60MmKBhlaHJC8CI+u0rlE52njJejWgqPFkOTyaAdT6nT+JqOXXKOREWePLI2gAsLGxgeXl5VzGSL7m5+dz0Et5CJA0m020OyfxoAPrOO/IOoiTbrmM47OzWGucfqImA4B+Ve0b+dHaIueznIcxQIXazqMm1BcSMG+FEsTnPysACWALIu/Y2DwyP1/X1oyzJwTaxOVlSKXvTTjPqEheLDDieTFaPTzPBJScebKiGvw3V6IxHq3lUco6PG+0KM8eKJfvqclPWmVPFPB5wZdy+PtD6Oh3erMqea38BNYkSfLXw9MZJBRR4e+uqdVqQ3uxLPnXyPIwQ46J9tsyMp5jpDk8su4QgJC8eAbJkympCyVP2vKJ5JeXIc+u0foudnw8QAScjr61222srq6i2WwiyzI0Gg3Mzc1hcXFxaMM0B8nNZhMnl+/Bld/SRGM6GTLC1cEA56ysoJymWJ6ZRQagl5TRq6aYYu3k31rfau3iusAiKTuyfL630bNhVr/yvtS+i9COBiQh4033NMUdQtqcYj2YUOd7E4aH5jQvpogXpXlAfBKGULTm9WoKWmvTBLBsD2kghF+P8WA0T9Ab25CMhcCSVr9UlHwDK50JQt4wByRWe+nckY2NDXQ6nXzJZXZ2dmgfCAETAjOdTmdo02y1WsXU1BSmp6eHwIjVR7JfvIhErJ6Q81q21dNf9xaFQGrIudHSePm1sqQBlbqniAG3dCFweq9Os9nMN7DOzs5ieno6f3svlxH6rK+v45577sGDLl1FY7qCknjjNP3bu7aG1akGeuUKmpUadmFY78uHByyQJv/T/OHXQzKsgTJvP6Umq961cR3YHQ1IYogLTsjT4MQRMGAfn8vrieHDq0+WFVKAFjDg+TWBiPUoLN40pT0BIttDZMRD3nHR5RoPfGrKQ6b3yucRRC9Plp1+7wwt1RARgJBnUvA+6Pf7WFlZwfHjx9FsNpGmaQ5G6MhuXh7xw/epUDt5JEXOdW5wipBljEPKWd7nJ8lKUKnVyfn1AIRluDXgWHQ+W/VqdVnOjFWu5t1r90LlSEBCRHLZarWwurqKjY0NZFmWHwnPX5ZHwJX2PDWbTZw4cQInl4/jAQ9ujIARSdOdLu5cmAOyEuZaM0PLhBZQo3GNAQvyP48u8rJkft5HXh9SGWeKdjwgiQkNWeAhhP41z5QGzXqyQSuPlxGqU16TiFOWIe9pZVqgxOOXlLbkxeN9Akq2RhZ4tsbWk78i84HKtABwTB18fkiPnsu/9a4aqZSlTKdpitXVVZw4cSJ/3Xu1WsXc3Bzm5+dRr9cBYOQReb5xli/X0CFoHJBo/Mo+Gqe/rbzWmMr+iDUAvD/lI8OSlxjeY+dzUSPGdYynmyRQs/SQ5Zhp/Gm/6emYlZUVrKys5MuA8/PzQxul+Vkj9ATOiRMncOLECVSrA1QqgcgYgFKaIUOCPccWUUtqSEr6E0ZSHrS+0/pGtp/2VFm6IlYWeN2ha7ycouBlxwMSwN7DQcpRbtIJhUC5MpTlWRNIGyiPTyuN18ZYw5Bl2ZBilm3S8su2WUcsj8P7hOKIFB03RCSvvL+tUzktQCHva/k0xRQLajSZ0OqSgERLy+cnpSuVSmh12zicrOBko41W2gGw+YZV8l65vPKn2Hhd0mDLOeJFMTivFljzFLPWTiL+CKv2OKtWtjXWUlZkGbEOUVGSBpLq44bJcpisMjRn09I/Wv0xlGWbG57X1tawtraGTqeTPz4+MzOD6enp/GV5wOmntZaXl3H8+HEcOXIEnU4HM7N1ZBngVZ8A6JdKmFuewTmH9qnA04peyGt8nL02yz7UbAKl49+y/ntL3+9oQBIScg4q5JHRMchNGgaJSDXQovEYusfLlIq6CMLkilfzxnjbpWBKobU8GE1JSF4nYGU8SpJk6E2h0nBTGs0oekCZyxf9lqeVeh6mRd54S0NEc5CWaui+BL4j52gkwK1LJ3DbxcsY1MoAzsF0bz/6nz2O6c90MD8/n79DhMrjUQ96coKWh6g+eUy8xjv9toy/5gho9+R9LR8fKzkeIaAjywnl88CqV4dGnqMD2ODZqlvTQ1b6GCeQSDvngxyAjY0NrK+v50s19Xo93zNCG1hp/xG9z+bo0aM4duwY2u02arUadi3uxfrqALPz6yYoSQBM/f/tvXm4pFdVL/x7a64zd5+ek+4MJCEJSRgSSJrBgQRyMSJIrqI3IirqhRtUxM8BJxQvhk+9zhiHi+gnKIoaEAiEMAWBkEAgkAFC5k7Sw+lzTp+h6tRc+/vj9HrPqlVr7b3rdCfNaWs9Tz1V9b773Xvttfde67fWHt7Zs7CpshXZAd49ZtVHLUOJoMi0UodLECj1hq8c+ZvXYz0AcUMDEknSA6JrpOBjhcPz0RA/zSP63glhDXDrni+9L43kWSoky3jxQ+FIPjEHG1k8xxjIIfWTlKnsV7JdeJtaSl4DLbzfaetPJNC28h20PnSt3W6nJ6XyaIDGKz3jnMM9u2bx+HQFfOdgks8gf/EWtM9ymPrGZmQ6vVEPOuGVFtA2m0202+20LNphQ8bGqp/GU0zdfc+F2o2+5aFgFtiRhsU6Lj6mXlbUweIzJm+uX6SekXpaPicPRdP4kCePWryS3ubX+dt76YV5o6OjmJqaSk/8pXagyMji4iIOHjyI+fl5dDodbNu2DZs3b8bu3buBdgLgS0d5kXIBOs1dGF3ZjiRjv8GXy8XStzH9TMqNxoQEH1Q/q4+GwEUMgByENjQg0RpGKm/qTPzNutb8akjhasAmhPw15KjdkwNGgglfBELziqxOy/OmPLX3ZkggF1JC8tTbISiJJ83Y+AACGVnapscXg9J5GpxIcXOjzfu/BV6PR72cW4uMcDBCfFGf0Q4qXBxp4PEtFT3zTILWVILDpzSx8/FSD7ghWdBOHiqbAxKSEzeIsYtzOf+WUdDGn++a/M/rEQNoNB3g0xMaSYcppi/E9he+IBRAz2/KR/PkQ2Xwvk3/rTxIR5GMVlZWcOTIEVQqlfTNvZs3b8bU1FT6fhrqR81mM52iOXLkCJxzmJ6exu7duzE1NYXJyUmgm0Nt8WKUJr6OJGnCuQQ4emZ6p3EqmisXIJvt3d4r68jlH4oEyciiZm+0vsCf12Snre+S5T8Z+gI4CQCJNHyWp8XRtm/BlyTNA6HBZB0+NkgkQ5KmFGMNuwQV0huR+VmATBoIoH+XkdZJhxRPHPRxj9YyWvw6nQpJ52pQO9J5HNr2Qf7h3lIIuNLzko+YenFngL65Vwz0OxBESZLg8c3LSLqA8wTu9m+rYdcT5b7rxDNN1Xa73Z7zSWjKxuepavUfhLQ2tABLDACy0mtpfIbIovWMYUv3avqBIlLcIPvylDrL0jmyn1vlE480hqrVKlZWVpAkSTp2JicnUSwW03IJjBw5ciQFI5lMBtu2bcOuXbuwZcsWFAqF1AnotLZgZf67kS/OIslUgCSHbnMHnFvdgSPHogSBWv21e9JRlc/4dHeIJCCh8kLtJUHKegDLhgYkRJawSBiksKV3FjL6suNo4UEeWpMdg+ej8SXvaV6i1ZliOibnSVNSdE+TSwilW3Iakk3S6JCxJGUmd25JI0bX6KCver2OZrOZRgBocWcmk0nP5AD00Hiovdbj+WjOgLWIVYsGyjyq+ZYXjCABGsX+d7zwfKXjkc1m0+kakpFmFNZTX6qbLwJhPRdDXI9ZukbyQiT1lEWW3tIAk09n8v8hoOKc6wPRdE2WLR3EEPCSep76A03VVKvV9JyRyclJlEqlHoe13W5jeXkZ8/PzWFpaSsHInj17sGnTJpRKpT59mclk0G3v7OGBgxFerxCA8rWtr/7EfwiQyuPv6Tl+cjKRr18fa98m2tCAhHuY/Bp984aRitgaTJpgeeNrK/a1fORvn1dA/HCD5Jv+8A1qzjdFb/jJghaa5fKTBsMaLJJ8KHpI/URt3Wq10sWs0oBqz9TrddRqtVRhNhqru07I6+Phcd6WvH/JMnxeTYzHTr/5WgH+wjxNwcmypILNNgF0HeA52yHX1j1EPjfOy5YREjm9JfmTnnWIYtJph1hpsuB8WOPVx7vk61jIpxutMrVnJfkAsvZfc8SsPOV/apt6vZ6+nyabzWJqagqbN29GsVjssRmtVgvVahVzc3M4cuQIAGBqago7d+7Epk2b0rNJOB8aIAP0SKAPBFvt7ZOl1O1yHaX1jOxfIR2k5XGs/YtoQwMSTtLYap0yRtFaiJ4UnCzDAg0+vqSx50BC5sHnPTl/vsiF9DooH8m37PS+wU1prQE3pDBJWdGUH4FRrW219s3lciiVSqhUVtdXUMh4ZGQkXYxHzwP9R0LLfH1jJQSOpHdEyowDEQImsr/x9HwXXJKsLjic3J/g8LSnfzlg2+E1D1WetuqLzmjb2gfxAKWHqpHMU1uoyNOFFDtPN4jxPxaDEXJgYkjbUKBNJ1t6yPof6scatVotLC0toVKpwDmH8fFxjI+Pp0fC82MiGo0G5ufnMTc3h0ZjdUfXrl27sG3bNoyMjKS8+NrC5wCHSKuP1rZaubHtFeuAcOCi8XS8QMmGByQ+Y8rTyDURfJGrfN5qdLkIjt/zdR7L2/HVxerEWp4ar1oePn7k/1D+8veQ/KTJk79DQuuHUvE659LFd7RThPoSLdKknQH82diFyVaZMR4uH2McjND7avgaLp4fNwA8z1arheKhFsY3LaEw7rBSLqFWKLKCV6Mjuw6UV6MpR8cmffN1K7J+WmRElq+N6xCIs2RjkTXWZNma4+DTe+sxDqGxLMFQCDjFXKf8QuvVQnUJOYbU1s1mE5VKBUtLS2g0GigUCti8eTNGR0dTsFir1VLnc25uDgcOHMDy8jLGx8exe/du7Nq1C+VyWe0fUucS+LXqLZ/3yWq9aUK6XNoczcl4KvX8hgckRCFvRZ5vMCi6loZdU2DS4HPPTFtBzufvrE7AO7hU3jx6w9PFDGAqP5SG8yCN5RCQHBvFgFrNO8xmsyiVSiiVSgDsMwlk3yIFKfOVClIbFxY4lkaVftN0EkVItDK18ii/Qu1xPNt9Dld+Y2E1HYAHt2zD584+F/Nj4xip53DufRMoNrNARj/PhE5npXz5tmBtHp/zZfGpgQJJ8p5vPIYUv6bXQmBEy9vHB9c1PrKMvgWmfXzSc1r9KHoigYqmf7het8qhPkBvia7X6yiVSpiensbY2Fj6ygFa+NxoNDA3N4cnnngC9XodW7ZswZ49e7Br1650nRbxL6dF+PjyyVX2I+moHgsQsGyKxUuoHFmvQcHuIHRSABIOAHzKmVPsHJmmXGTjWmBE7qO3kD/3LKWC4kZELnq06hYiuVaF8ySJb9Oz0nAeLOU+BC/9xPuFFiXRvBcCsdrctaaAZH+Ra6D4szx96AwI+RwRf56+NU9R87jpfrm+D6eufATIMR4BnDl7GKfNz+NrO74XmZUt6Ha6cIn+Yj55CBsHI7lcznv+RaiOUr78Oc2wcLnEgBOND83IWGnlczFGZJDxKfOzwIeWTuNPpvGBHaucUBn0Bt96vZ6+coBOYQXWAGylUsHy8jIOHTqEWq2GyclJ7N69Gzt37kzXmGjylA5kKDri41mzK5JCepj/ltcsUGKNZ57myQQjwEkASEICszw/wA9gfGXRq9Kp02lgxOedyN/WCngrKkPl82d5R4vp0FyB8wEkjaCMjmiAzKqb5YUPaY0G8U65Vyift8AIpxhlwsFIzLM+I8NBr+Y5yro555AA2LHwcSSu/3CpDBySbgfnztyGb41elfYpDkiA3jcK8+gMn9KxFphLz9VyRvg1CUJ8QC1WllaaEKjhfFv94VjGoaxrKB399umTkKHzATBNB0nqdldPWKWF4PSWZ74TzbnVI+RbC/M4/Y7/xOUPfB2jnRaqW3ZibvRK1E47rS/qIY24BCJaVFzWW5ObJUeZxron+7CPuB3RZPlUgBBOGxqQyLlpC2DEeCeUTiPeKNq5I/JZC/Fyb1Ez6pryo3ppUQ0LfWv/JZDQDAMvUyoNKQt+jwM0TSZWPkNaI82L4f3O6pvyQCRfX7dkrs3hx4wXrQ7yEypfKtdS/VEUulXA6B4JHMbaB5BvL6CTjKcgg/cxvphVRir521WtOljE5cJBmwZKYuShyYGn06aZLT59EQPNibCejSHLm+ZklScNuczHMoxa/jIfqy6tVgv1eh3dbnf1mPdNmzA6Opqex9PpdFCv19F5/BG87P1/gfGV5dW8AEyuLOPU93wT83fdhgd/7BeQHN0NZ4ETDkQsOQ3iPGrtLMv2ARALMMf0/1jweTwpPjzwbUhywEriHYK8JZ/nQd/aYLKMN/9oq/r5qZGSXy29jzfrnlRU2i4HS6nJe5Y8pXxizszQ5PRfmWLkDOj9TiohTr5xoBkx2WbSq5MKy8pbW5BK6a2t9j7lliQJcs15xPSSQmexh1+rfF4/mqqxojUx7RI6TTTUBjK/kHPge1arQ4zROR4GhmQhp8tieLX6tO/5mGclIABW5VitVlGv15HJZDAxMYGJiYme1wa0221Ulpfx/P94N0ZrFSRYw8MZt6qbN33tNuz6xA1qnfjYIcDLI3HadKXkV+uX2seSi3Ut5tmYNYhPFSjZ0BESQA/5yd8ckNAWSCuvmPJCZfFrPKLCeYnxMqxyNf41ZCuvyWctZM3roSlKIs1LlL9lOf/VSRppy3hpHhRRkvTvTAiBSIsPiyfJ3yBjg4NseWS4VnY3U8NK4RE08zNI3CyS5WBR6CDvBSSSXwIkdP5IqB5W20gQLtOHABd/TuZNaWR7aPWyDLhMo5Wl7TAKjU0feODlaAtRfY4dtSE9Q+1J/cfqO4MAMZqqKZfLmJycTNeNEL+1Wg3jD38TW4/M2PWHw7bPfBgHX/oqZHL5nrKsj+Rbtr8EAxRplqTpce26rDfPSzv8Uuah/Y+hQexYiAaKkFx//fW46KKLUpS5d+9efPSjH03v1+t1XHvttenq5auvvhqHDh3qyWPfvn246qqrMDIygm3btuEXf/EX0zdxHitp3hrQ79UTad6Otv6D3+dlaR++zZAPUsmjnIaxDIrW6XyoWePbuibvaQOFl9tnSISsCGzRPX4wVmw0aCOSz5uVBozLwGfgpLGTJJWKfFaCRXnf+sj2scYUz1uu2ZDGRCpm/qlnD2Ju8tOolR9EJ7+MI5tz6HqMowPQSEawnEyr44CPLy5T2iadz+fVA6Nk+2ny4tc1OWjfgG48udMgy5XGnz+n/bZI25GnUeieXGumyd3i1wfCiEgGXCa+/LU8rXFCOqlYLGLTpk09W3ZpJ1iz2cT0vge8/Q4A8tUljMzs7+nX2pokTQY8eiJBC984IMeLzI/XS6bRIiw8eiPXDcq0lC8/M8hnZ2QdOa8x/VOjgQDJqaeeine84x2444478OUvfxkvfvGL8YpXvAL33HMPAODnf/7n8aEPfQjvf//7ccstt2D//v141atelT7f6XRw1VVXodls4gtf+AL+/u//Hn/3d3+H3/zN31wX85zkIOfkAxmW4HzekfwkSdKznVAqPNnxLOXH66GVK/mVBsn6WKSlsxS09qycFiKe+DUOvHi6k5UsmUpZcZn4nhkETHBQQQaZ56EBYH7Nmta0+oE8b4Q+1pkjnLdut4umq2Jx7MsAummMvJPNYv+2zea0TQLgicJzkM3lzWkCCUqAXkCigWvZfr7rKS+ibClTK52PpHKXRkTuuOPlhsasVY/QM7wOPmNm/ebPa0R11PjggNfShdzYyz7h3OqOtLGxMZTL5TQ65NzqKxjo1NZOuw1YC5c4r0frxbeOh+rP+ZJ2QkvD8/eBAp8dIblIYGKBXSuCySnUjy3HaRAaaMrm5S9/ec//t7/97bj++uvxxS9+Eaeeeire9a534R//8R/x4he/GADw7ne/G+eddx6++MUv4rLLLsPHP/5x3HvvvfjEJz6B7du341nPehZ+53d+B7/8y7+M3/qt30pPmByELM+DU8i78VHsYCU+eDhUDhzJH1+E60PBMSAqphNRB5aLFvl2SeKLnvXtPycjRnnQNQ0YWh7MyUqWrLj3kcmsvWJeykemkwBYRllkRMBaVyLHiwZEO51OuuDPqg8vgx9CRoZDU54yglgfeRQcjBA9vmMaiXPYNTO/mhYZJOjCIYvHS8/FfP4cr9cmgV6SJD1rBgbth1rdtfHgey4mvfT8+TO8zXn+WhuFyhnUKbAACL8ny5UyjjFWsv7yt/Y8N7bU1jLfYrGYnmCcyWRS0Ewvqex0Olg4/Rxk7vqcWRYAtEfH0dq1p0fHx9ZTjgnLsZTPy7FPxPsF//DrcspGG5O8HAkKpf2SvGp1lL8HpXWvIel0Onj/+9+ParWKvXv34o477kCr1cIVV1yRpjn33HOxZ88e3Hrrrbjssstw66234sILL8T27dvTNFdeeSXe8IY34J577sGzn/1staxGo5G+rwMAlpaW0t8xhs45l76uPdaLINI6iBbWDhE33vI677C8PjED0/LoOO+yPnxnhq8szWvhSl6enSENqNYuvnsbkaSX2mq1+haoccDAr8l2423BgYUFWqSikt4jPcsNis+A8XsS8PTVG/riaQ5QJa8yctDKz+pOaZLgsV1bcXDrJmyeq6C8sAutzBjmsqehjUKfxyd55hEbjX+1PkobWGnl9Vh9YgFAXq5GVruF0q/XKMQSN3oSEBNxfeEDkZxnSz9IOcj+ZT0zMTGRghEO4Km/FgoF1J5+Eaq3n4qRw/uRKPrcJQnmvvvlQL5gAgutT0oQHAImPtvjq6sc4xKkWMBR5qn1HXl9UKA9CA0MSO666y7s3bsX9XodY2NjuOGGG3D++efjzjvvRKFQwNTUVE/67du34+DBgwCAgwcP9oARuk/3LLruuuvw27/920HeuFGX1+nUyBDi5opCNqbWuXx5cWNkhZd9g0oqIam8Qnxoys6njImskLNzrscboQEudxWRouIKQCL5k4m4rLLZbGqkuSfF9/vTMxZYkGCGDKxzrmeLK6WxvBMenvYpN+1bI5mHnLKRc8+8DG1HTkiNtfI5HNqyBaXGc9MoTJL0hre1+jSbTTQaDTSbzT4Zc96suhKYs9LEyEpL60tvjfUYQEQ8a8/HPLte4gaK9z+tbay24vpB8u3rt5ZO1n6PjIz0RfySZPV1C6Ojo+mayIf+56/i6X/6G8gvzgNu9Uwcl8kg6XaxfNGlmP3e/6Gu09D6vOUMhgy4BiC4PKy6hwCtXJdC40JbzqD1Lek0h/iXv2NpYEDy9Kc/HXfeeScWFxfxr//6r3jta1+LW265ZdBsBqK3vOUtePOb35z+X1pawu7du3vSyIHP/8u1DPwZH9KUpBkPywOIMboaGNEa3TdQYxtcDno5h6l1Rg1M8c7GOyqloXA/TyPXQpxsURLebjL0SREDmhMOeRkkM1Kg0oAS4NE8LW0MUD6yXflzsg6ybtpvWQ5Po20j1PppvrUZndyCHiUB4ByQaUz2Pa/Nh8vpIz49xNtFTi1KeWj/tTpY7RfrBPjK1ca+1rahMmJ48JFl6KTBsQwYTxMymhb/UhdaW7atvJIkSbd7U7/gi0iLxSJyuRyKxSJaIyP4xq/9KbZ84RPY9KXPILdSQWP7KZj/ju/B8nOejySbS/PUFrHGGmAfyPKNwdDuMEorZU76mctGWzhL+l7TDxo/sq9rUz6D6viBAUmhUMBZZ50FALj44ovxpS99CX/yJ3+CV7/61Wg2m1hYWOiJkhw6dAg7duwAAOzYsQO33357T360C4fSaFQsFlEsFvuu80Ea6uya12ENJh9RZ9TADU9D6fibVuUAk0qVrmmKyqqjZiTkPcmnVNRSRpInKbtQZ6a02k4PAija9raNSFKJyPaV881ciWttKvMisuatZV+ia7w9NGBAbR8CvrI8SRZYkPe1cVaq70G9/PAq8lCKSBIgX9mtji2LD76glStfaouQkZZ19hkYq/34vVggoKXTjAOXp6ZTQnmGytecJJku1hnSDDavB/0mncD/yzxkvtoYscqWDhL/z4+DB4DOyBgOXfFKHH7pq3r0s08OWp2kfLT7krTnLZASej4kIwlU6PnQrjpKp+kJbawPCoSP2Sp0u100Gg1cfPHFyOfz+OQnP5neu++++7Bv3z7s3bsXALB3717cddddmJlZ2+998803Y2JiAueff/66ypfC0HYS8DTWfR9Cl/+1uULOC+fH50FYwMjHv+RLPusbQNpHKgJtK7TGn1zESFvofNuZQ17SRiUfQJC7JKz24aQpLt5fpNGPyddSDPw5axqEp1vLUAc+MvJmyQIACsk4ygsXrmbHpu273aP1P3waso2pHuNrbVtce7Z/az/tsKEzSKTBsIw85c+/Nblash1UGfPnrJ2BMf3neJKmf0KgjfPHt8ZK4nXQtqVq+tbX13kf08rhZVBUhHa9yPLks7IsK43kS9OBUk4WeNDqZ+kUKfcY0vjl+tziw6L19ndOA0VI3vKWt+BlL3sZ9uzZg+XlZfzjP/4jPvOZz+Cmm27C5OQkXve61+HNb34zNm/ejImJCfzMz/wM9u7di8suuwwA8NKXvhTnn38+XvOa1+D3fu/3cPDgQfz6r/86rr32WjUCcjxJM/jy+qCCl6BCotkYpCmVucZvLE8hpUl50gDUFB1XyLH1oDTaOglZn5MRkMSSlK8M+2p9kpNmMHnevmdle2vPAmth15iF2nzBbQyg0a5lKlvQmj0PldyDKE3XgASozmZRqO7GVHEX2tl2DxDTtkPyenEwTDzl8/k+MALYYEKCSspT7k57MkkCJC4DCVg4+do5RFpftO5rstfSyLVkUk9pvy3Hi6fTQIcPqBDxbbchsGlt6w2BVYvnkE6VsvCBGGlnJA+WvpVp5djh5wjFEgd8lKfW1jE0ECCZmZnBj/7oj+LAgQOYnJzERRddhJtuugkveclLAAB/9Ed/hEwmg6uvvhqNRgNXXnkl/uIv/iJ9PpvN4sMf/jDe8IY3YO/evRgdHcVrX/tavO1tbxuI6UFJQ/iaseQHOvHrljIlgR8LmgwZIY18wMPqmPy+7NBa3lwZS9AkB5UceDQlI+ukAcGTCaBIT8g3IKXC0WSlPaPJ3iprEOMk8+DlWHlo0yKWwuX9gX+3220sz7bxwAMtHD58BLlcDmeeeSbOPntXz0JE6nscVMj6Ul+lBezAmgHib/gNyScEpGW7xfTlQca31hdijA/99pUVM95kP7B+y2sSSPiAgVWulXdIV/nK1fLS1rH5yuDkm27W6hyrr336IIYvqwyeXjq9sl9pay01IObT4yHd56OBAMm73vUu7/1SqYR3vvOdeOc732mmOe2003DjjTcOUqxJUin70vAtitrz2jegdz7NW6FjfzUvKgQ6ZKf1eUeSD+7N8s6lHU7l6+RanTnftCpbU4baPQtlrxc5f7uRZgS4Jw30R0S0Qc7JB/R4WRzwUXmSBwt8Wn1AAxB8m3q664TtjZG7akJtKnmhl5rNz89jaWkJSZJgcnIS09PTPWcSUd5W6J/4b7fb6REBrVYrlY0Wlpc8UR5aG2ljJ2ZMUZ78O9Yox5QTAiah9NY9328NsPryt3QG/14PX/JaTB4yKqIZWGtM+vp4jHPnq5cvP403zcnU8g/JVwO5GiDxUWh8DEob+l02mtHW7jvX/y4bfk8+wzsDP7MD6PX0eCelMgh9a4pTW7hF6SW/oXrRtzXXLMuVikN6m1Ip+4CVlC/JSIb7NSDFZbrRQIlvgEnAG1oRr3l3vjIsA8P7nwW4tX7E203yZJXb7XZBeMTBNpJaWfK3c6tntiwuLmJxcRGdTgf5fB5btmzB1NRUH3ixQuy8j/J1TTzaKQ8rlMpe0wfUT33ykPUOpQnd48QXzvuUewwo4ml9QJjK1UCIBLnyHs9fy3s9BsoC8jH11PKRUzDrJa2e2tlOMZH2Y+VDUghc+Z4hnSDPl9JIlmMBo0FpwwMS67pUpHzRpZaODzSfQvVNz1Bjxih2bWEsBw4WIpfKXdZB+/aBNSsC5DNmvP5Snpoi50BMgi4frbeD+/I9nkpB1oHLO0b5Wn3OMm5aW0gwYuWjGQZutLlSoTbk9SDAvcaYXRfubXGScllZWcHCwgKq1SoAYHR0FFNTU+n7RrTIn6b4tLHO66KtmdIo1G4hT9d6fj2GOAQm6VtG5TReQuBfAyD8ula+ddaI1jYSWIQMmASJvD9aelHWQ7vH6zOIHpBt7JML8e1bTzVIedp/WbbVV2JlpOlkX5/RgLy8N2ifJzopAQmRRH785Ea+gCdJVveqa4Lkg5/ypM7GIwHSS7MaXkZcLKXPz6Gw+LHOVdHqIOuhvfFTyk0jyyjw8oHeE23lgCEELheX8fJ9FNPZn2yvhPcjYPUV5nQapNxlJA1iyFBb26J5njy6YR3i5ZOT7EfAWt/m1yh/S8lYoErKST7fbrextLSEI0eOoNFoIJPJYGpqCqOjo+nYktEmWW/e3/l4lvXRwL/Gn5SzFgGw5OpTwj4j4TNwMm8trYw2cpmEDJkkDcBqsvF53rJva1vLQySdNG2RvMar/A0As/VZXHnTlUdvEhO+wvnP4xzBTVjZyVoZbi3s2MeDSbIOyjPEv4Pz1sXBAW5V1q1WC+3xNjoj8a8X8NkpAKjn60BDPqXThgck2iDVBhE/TpoOl+Lv1ZADSRplqYz4IJEKm5/zwY2GTGdFWDgPsr6UTlOkITlJ9OucS0EapQt5LURySofkoC165fWTdXHO9bwOPKYuWt4hpW7lEwtYNIPFvTZ6aRutJdJAYIyc6X6r1eoxxBoA5dEMDq75ff7bOdezNkQaEWkIZLntdrtnDYmPf/6cNiddr9exsLCA5eXldLpmcnIS5XK5Z+rFOgiLeKZx1Wq10Gw20Wq1eqYQtQWtvnEtybcWyroWAwRletmfeP+hOsqoAaBPDVr9S0Y+5PVQv+T5+xauc5J9MkQaj1JvDRId6aKLmfqMem9IBmVwHA4EWR9taEBCJEGJNMDaoJEGWlsE6itPM1D8f6fTSQ2tppSld8cjC0Sa92PVh3iPBSjSKFkD3ZKd5jXzfClPHw9y3vVY1pbEGAMreiTTWPe131yBciPJSS40DZUlDZEs24qMaPXj8vR5+9I4UvtwUMHXkPjqIMeeNtYqlQoWFhbQbDbhnEOxWMT4+DgKhUIfeJeGSPY9in7Klxfm8/l0yy+Paso1Wz4QLmXvSxMLhmV669v3jDTc2rOacQ8Zc6lLY+vAr1ll+UCcxq/Fqy86S/lNF6e9vJuUPMmREX4NIkICPR2l4XzFOAb9bBj9nEVIms1m39KG9VBfv2g5NCLCJCcFIBmUfGBFu6YNAm5MrVXJlIbuk8cnwRMfgBKASAMvjQr/aIuotNCrc6veLs/XN+cpedS8IKD/WOeYNQB8iqvb7fZMVVnGQgIDLbrFv7V6hICIVp4Gdnk9YgwKL0vmyxdF837FASuXvwbCfeXHAFZZR86bJWefjDSDRdGRSqWCbreLQqGA6elpjIyM9OQVmm7h5QH9b6kuFApp5Mq3xsKa8ooh3s9jydefNNBL5Wj8WfpAW8AZOy6s6IfMJ9TPtbJCxAGoVqZ1XdbjPd/xnr58NP3K+xpPI/O2zj3RSCtXgkmfPrXqx+vpI00/WGCP2rvVamFmZgZf+cpX8K1vfQtLS0totVrp+T3SOZG6WdpC6Uy1Wi18DB/z8g1scEDCBw+R1YDaIJED2Le/XObFlYBljCRIsLxJzpccBCGPQ4bW+f56Xr5VJpFvsa6mJGX96TmZv2aQiC+6x0PzEtxZCxIteVoKy6f4fZEFbXGnVC5a/UIk85OLMSWPGsmpO01G9C0VhtWm8kPRB8tr4nn7DC0vf2FxAfMLc6hWq3DOYXR0FNu3b0exWOwDt7GLA0l+VL9sNotisdgTIZFys2Qlr9EzFvn6u3Wdy1crX8rSl7evHHkvRj9KncbbQpbhM6ghmXLyAQ7N8IWiWlZUyBqfctxZESjtGU3PamVb5fF0MU4DzyNUjnZPOoxA7xuytal1jTdNV8X2f4s2NCDh5BtsgH5mAjUKPccXYsaWSaQ9yweHhtQ1wyYVuPSAeN7yIxeSyvpZsuIyivF+LOOsnV/Cy+NptfUmPI0mI4t8sucRJpkXla1NffCdJxoosWhQMELf3KOwpq5CUQJtDMjrlrLR8qIPnwqx6iP7ghZ16KCN2bF9WNp1EJsv62Kqsw1L99eRPDKGsbGxHqXMx2QIkBCPPIpDC6Z5lEXyG0OhdOtRxBoAsK7H5BtjNGNI6h5AP62UA6mQweX9zydL2fZaX9DScr618rXfWtma4Y0BB74xqslGA15aXWP7J5EGorSySbcB6HNeJRCV1/g9rW2lDhgEXBGdFIBEGnXtHh9EnLiiDg0y7Tmg/9Xycj2KvC6JG6CQQdHqFTIU8qjtmLxlnhqokgCGy5i/ZVYDVfS8FQWSYEbjy4p+yAESUjAaQAB6z4LwydtqG+uebAMtwqYZuhD/vutaHTSjKO9LJRSqiwVKOmjj8R13oVlaSdNnsgkmzykB53SAB5twK+W+cRU6AZbCza1WK11HYilJ3yJMrY9KmVkUSjcIWNHytJ7z9UlZL023hPjQDJzVL61ntXys/iSdxZBxjzV23LnRyKqLphu1e8cqV/58bCTOV668z+WtnTOj6diYfq/xp9nQQfLY8IDEUqpSiWlKmNLxdQxJ0ntugWZINQFrCpP/5p6G5qVaikyi/FgErRlnSylxQGQNNG1wSlmQMZAy8CkTuciQ5y3vy2iHNSCld67JWzNA/Dc3xtrzfGrBMgza8dSyDJ9RkbKQoEpOlWmKXzNUsk4WXzQuYoyjL4rknMPc1L5VMJKgZ2ldkkkAByyf8QTK35hAgkyP4rT6DuVPYWZajNdut1Mgw09n1XjSZErTh7HrSmLuSwdFGyPyO9Q3NVkAep/jMrDk6QMQMp2UmXxOXpOAwKcPtDaPNbxafbRvKWfJl+w3IbKcyVDdNAdRe4b6o8/ISx0g71nl0zNyylxelzLS+qDUk+sBNhsekAC2saR7RKG543a73bMFVROihWCl4feBFspbpuF5yzUtstPLTkr3+HVZhtZZaRuo7EDW9l251kQqWau8kLfD5aIBnRgPSauzTO9rf/ktwQYR70fWNl8Z6tZ4shSiVKhav5LAidKGlGJISXCFFDNVI3nQyui6DpYmZ2BuXkgAl+ugPrWEkYVNPXWxHAuKjNTr9b51LrlcTn2ZnuSNytHqY8lHprXSy7bygUQrD02pU14hwyPz8QESTpoHrZFl8C2KNUhyLGi8SDlbstB0KP+2pmp9+Wr8+QBJiNcYuRBfmq7gZJ0ObclH04UWP5ZN48/ysrTxEUMbGpCQYpJC0FAgpdUWAvJPp9PpO2paK1f+1xS9VNQyvVT2mrKUClMiZauDWwqJy4QDGF/n1Ay/NQh9wEQzuFp6vn7Del5ra2u9TOx/adw5sNOeoW8ZYZLpLGPE7+dyuZ4jmyVphokTl9kgCkCrvxXt0BQbn+q0eG1lG3DZwEFLDmiXakiSzSYY4d8EQmi6hiIkJMt8Pp++w0aSDyRK/rX6DyJfmYf22wId2n2NpHdMv3leUgfI5zk/IZAh+7nlDIbyDMnR4iOGLx+Q4fLgvMn3HfnqAdgvlPTZIx//Uqa+/750GsWATGmTJMD1gZgQWIqlDQ9I5OADdEDS6XRS5VUqldLnJUqm1cbWQUo+pa8pHa2BecNbizG73W663cqqn/Vf5is9glhlZ625kcpP8m6BIkmaMrbmOPl9mYf13+ItBOIsmWjAiNrJNwhlvrzf8lCpdmiZJgeNLw30aP3LAhs0RrQ+YS0slQpM1jeVVbeDyZUVlJur5xDUCgUsl8ro8jGWAN2kVyY8L15/mkqiMV2v19FsNtHpdFAoFFAoFNIIiQS3GvnaPeY5LV2oL1l9wnef5MDJB8BDHjuRNqYsr5r/9hk5S0/GgJAY4ynHogQjoSk/Ih5RlkBEm66QYzcGbPgAXkg+NC55Xlo/Ce0QDbWptZsuBlxobbBeOikAiba4kog6GHlT3AvVDCfQuyOHl6WVLY0oT0feowwdWspF40MaZAt0WaCETztonhKFuOlcEn5fKhW5Kpvz4VOg5LlyJcEHGi9P232jyd3X3pIPWQ+6Jq9rC7K03/xbO/5eptP6HK8PyYa3gZRziDSDJusrjZ/8yGiHBAeSrOmcnj6bzKFY/By2LbXS++P1OrYsL2P/pk2oFYqrzwBoZXKm8tbGBoESUqKZTCbd5st32fA8LIWulSHrpJHVrpo8tLQ+o621KQFgyZPWV2IMu7wny7OAhvW8dV3bOabpkUH5tfIMPc/T+AC3r3ypK61y5DVrXIf6pXU2jO/MmRDxPkYLwy0QrdHxACGcTgpAAvSHlbSBzKMhPDRN1ygfGvQSXGjl0j1+zLcc4PzdLZax157lgEIqf56PBjoovbb+QnoCrVYLPuIGm/K3Ikj0n3v8vF24nHidrXfrWMCEy0NTdryN+Y4fzosP9HAZafPQHEjw5zRgJtuG58XBgE+xyDwsoBYyZhYI8fWvHh6S3jI59YPTGpLSfwJo9y0fyTiHU+aP4JGtW9HKZtFGBknSb2hlOZxvinhSHyMgQh9tW7nMBwi/Cyik3C2FLGWuPRPq59p1DVhZfPD+EgscQt62xasvrQYcNL0UcxaU5FmCAs2h1J7R2tcaqxpJua4H9GnXtN8W0PIBsFC/lbpEe/1EzPMc2EkdMyhtaEBikTQCdI3ft3YPxBgsXgYNLGvnjFyoqhlNX6fjytJqYG1qJbR9jBtcWVeuGHjn4uFNuW5CG5h8WkpTTHzQ88iIJnvKg9LwMyd4Wsm3rIOUY8zg8ylzXne5BkQafi5zn0fma2srf5IRv6eBaAk8KFTLZaktptZkINu9D2xlHwLQhia6o09gaqWKgxNTqGRL2N7O9ZQhjbbkmdaNEBihqRoeIdFkpxl33+Jd+bzGE5eFxbdMy/uFr2ytXK28GMDhSxe6tx7SDLwFDIBe587izcej1P0WYAiBAgnMfDxbfIYAj/WM/L0enRWTVvbD0CL2EFn2dBDanoFqkQAAkK9JREFU8IAkBqFaysNKKw2d5Vn5ypDX+5S1YhysfH0DiYxwSCnJZ31GVm57lMqP8qGQuEwjn5NykIoU6DWmPDrF+dC22FqeoiVXTcZSvlyRaTtoZP1kH5TGnz8jgaDWHjK95J33UV6WTKPJQ6bliih0qq9zDj2v3RBl9fGa3a+CkVTuAEYaDSzlSgASTM6Pm8aXX+Pz3QQC8/l8un4kn8+r034SLPA+5ht7lpKVfcDqdxyg8zrQPWk4fc6ErAMH+/y3bMtBvF7resigW3nF6qRB89OiJBoYsdqRp+f3tEX1cixa8tDK0ZzWUN19IMTSYZpO9+l5nq/Vh2PI0o3roQ0PSIB+o0nEByr/xOTH3ztjdSienpOMGnCjoXnIFsjQ6sjTa55pqCNowEFLo/EnIx2a0eXXLV55tIOXx7fQWlNCUrlI480XgGmyiyGt/tpCL2nU+JSLNsB9kRErVKoZe1lv4s1nWH0gyaq3LMciTeG3221k8m3zGaLEAUgSbDmwCflWDl30n0vDee50OqjX62g0GumbfSk6ks1m0901VFdflMQCGuuJcljGw6y3YkBlub7+Yo35kAGy6uJbH6al95Gmg0N5xPAsx4hcb8bz0XSVlLkW1dZ4s3jneko6a5qDouUhybILWttY9Ysl0sG+6Xcf+fRqKIpk0UkBSIh8A4m8Ku0lYb78tM4sFbpURtJQcWNJnYB3KGlc5CCL5ddXD56/vM6JQIG2O0EaTg0cWGd3UN5EPuOrGVYChLTzSOYvp3QkINTIij5I8EQfWW/6rQEY2U8kv1of4flwwCbz1PLwKT2tHAliLIWmlesrg9I2Gg1kUEZ5tIrEWBbQBVDNl7D98WlsmdnUk6cWaaKF6RyQOOd6tvnK9g8BNF+drP90zeeghJwOrld8Y4Gn1+756iCfiYlUWHlbIE0zhrL9QnXkfV4aWu0ZS89bY0ACLlmHWAoBNZmvFZ2Rz1uRFis6y0nbTaT9DvUxHnWUemhQigG1Fm1oQDJop5ILWzUwIfOVitanzDQDzp8lo+rzQrmikwtlKZ21KlzLk/+XvFkDmp9ay+9pOzC0AacBC8pLW2gowYO1sE5GIUievE05Lzy9Vd+Q/GT+mrys5zV5y/4j/2sLdKmeGviQfUbybxkaufgsSVbP7+BASAM0MfJqNBpYXl5GfXYcZ5w7az6TAVA8dC62NFfBiEOvgpRyIUBCihNAzwJW7u1Zp//6jJZVJ/mb8yfztki7P4iytvKm9tKmf2OMY0wZ2j3LwGmg/cmiGB3II0+y/S0dHgOeYsqXz3IeBtHfVt+JAQ2yzS0wRFvoY9eQWGNDljUoMNnQgAToN+aArTgHETRX2pK0aQrunfO8LMNBRoZ+y7wkMJD3rXpZnoy8ZikmfjgQN1YcLGiDXPKj7S6gtNxwaM9KPuXz0rBzPrWFnIMogtA5J9Z1ra6+5wF9wTL9ph1KnF+tP8aCEQJWUq7OuT4gwusglaklC+oXnU4HzWYTCwsLOHhwGY12Bude0IVzAD1Ov5srp8O1tgLon/Lj/NM6F/5GUuKH+ivvU3yMSCMp28nqf5Y8Q8bMR9qY0XiT48sqh9eX72iTvEud5HOI1mNwQ/nHkhynIX0tHQ75rUUmZPvLesQ4ezy9ZeCt9DHXYp6VvA7Ch0ZkbwaJxvtkuV5eNjQg0RSEJSSu1Hxp5T25JiHUqX0DUnrylJ9USrwcnj7G+wgpXH4Ql+x82kFAsr6Sb2l4SWZSwdIz5M3Jo+95HjJCwsGG3D3BDxPT6itlrilcWScLFPE6afOufMeE1a9oSsx3Kqsknj/nI8awWKCQ7sut6Fb5ff0K/WtSOp0OqtUqDhw4gIMHD6JarWL53jzQHcUZZ7dQLC+v1qM9iVbtDLSbO1T+5W8au3JnDZ07wiMkmnG0DJwPXFgyiE2vPQ+E5/s52JCL63m7SzDC62gZFaudff1dM7pa3WLSybJjZRIqj9dd9iHNUFtj3Df9EQM0NKBp1f14kgVGQuVJ51jaR56fBu6I1jMeLDqpAAkZBM1jJAPMtzbJwevzpIh8u0pkOsqTPEdurOUaDR/A4TRIR9Du+65xg8rrQGnkgOMKUE7FWPWShllTZHzdDVdW1Laa4bJkIQ2SFuGhdBqf0jBquyOk4ZAy0OQs+5jGh1R0EsT52lJ+y6kguejPyo/GjEa8DjRVMzc3h0OHDmFxcRG5XA7T09PIJttQPTKNZoXADwe+fgBNPPATWekk5Xw+33cQmtaOXCdowCSGQo6IJhv+rFU3H8kt7NRvfNtRNSdC1kMjbSr5WAyoT29pAGQQMKKlD/UhSfJ5uYHBV24MoNSAsY8Hn06LATmDtJUsi9tHaTufLBBl0YYHJPQtP3SdGw0JRjREDfQrEN4o2lw+N5YamtT45VEAbT6fypX5aGsMQvKRv/k138DmPNBHHrWsARRNVpJ/Xld5/oh1nXuNnF/Z3pr8ZBptjQoHYhwUEd/aAmXZFyzZWTLmBlOuf5B50T2tzrJMXo4WmeN5WkTlaGuK4Nbad/S+r2HLJz6I8QfuQdc5PDS9C188/QIsnXYONm3ahMnJSeTz+aN5Hn1YyEJbp0Nl0zRQs9lM57k5ICEwwqNnsu2kbGRZ2n8p01gwo+Ur20XqGJ/x4ell/XzGitJJneJLOwgwCOmhkOH2gRaLqEzNWVoPSYdH5mm1t68fDALsQrZiveRrY4vkOH+qwQhwEgCSmMENrJ1CRwoOCL/9lxsoq2zKhwaJdqCY5EVGImSZ2tyolp6T9JoGXZxkKQdZDx6F0gaxVXef1yHTkyy5ouCAiP/XEL38cB5khEADDRTF4nWVhox44f+53Hj9LGMErAEMqlfsKZmyfTSZavKWQCtkZGV0xAkwsfWmf8WpH/z/0M1kkDnaFuccfBjnHXgIt+W/D8vPeAZKpZJXsWv9hcYqLWLl0ZEkSXoOQOPRkWP1nIk08KI9H6N7ZD0t3nzgVeo5CUZ8wEQDJfK+5a1LOcQCjJAxiwXGmnMYGgPymRgeQrzIMi3Q4TPmWt+wNgbEtKlFMY4GT0fjjTtdXF8+lcBkwwMS7beWzjnXt+2XGzRNWWgDnzqQjJRw4l6stXDVd86Gz7hJJSQNrFYX7XmrbG6kZTTjmluuwVxjbu3hBEjMd8pH0tE8UkPn/PnGKJsTgexPBDk4HmyIS7/6Y5W47CPycXCYra3umpmtz+JlN16OXGUJ+C7rib9C67Z/gsvGqRlfn9UAc6w3N12cxt9e9rfR5VrXBiUtj5gpN81g0NjUplZIx/BpYvqWkadYz/l4RFF8JPmJATCWAxRDvqiF1pe0NrLSWBSSucaTDwxqfBxLe3DbwXfZ8HtDQDIAWV6LlZaiIyR0OT8e8k40T0IqC2CtEeXx5jwdN/78PpXJXzwnT06VJPfYS6JyQhEhCZo4AAOAucYcZuozHikP6b8KddHFofYRoBRI2D4ChM9He9LJMg4x+kMCdQ0oSUNC4IC/rEyLVFiOgixfPhvDP68zD8drO8m0SIRWLy19KPISY9C0uvmiDpax1kjL1wKFMflqdZI2wucsh6Ipsc6W1Q4WiPHl22630Wg0vGXxz5NFGxqQAP2GlEjzIjgg4crFErD0ViSo8G3bJCXA+ePlEOCgY641Xui3tntDU2o+b0OrryzPGmiSMshgS2lLv8CUyAZ53wOj7GOJviTo8/hPZNREjUAcY3RpkOjIoJEUMx/nMFufXT1NFRnsaGSROP82QZfJojU+2ZOH9lsri77VPnpUfr4+P9eYQxf6luiQQbFIjkM5/cqJHABr+lRzDiTw0PSCxbvGK+fDWpwseSH9JiMuXHaDgCMfb4NGEdZLMZEVX3/k0/IyT8sGaWXHlDcI2OIbCnx9NwRGaH0Wlc/Bq3XOzZNBGxqQyAFsRTCA3pPoqBF9gEQabmocOcjluRc8UkLPa2sNON90DoTV4No2Pg3o8I4kFZu2gNbKk+dFvzltKW3Bx176sT5QpD3DZcAXHWp80m+Ss9w5IfO3oktShtR2ctFjCO3LnUNE2qDXVqhr1+WWZZ/MZISN3+e7xnzGQfY5jSwwLMdPt9vFlTddOXCUzGoXk5+jAMqr9I5O7cUAibnGHF752VcOxHOIniyFfCz5DmKMjrSOqPel08Xz5WM2lmfrXI+Q/vGRT3/xevh4s8Zz7FjSnuM0aD18URuZp4wUaXxboIZfl3qEb/mVOpjrmieTNjwgoW9fR+Iggr9ILAaQSCUtAYg1OK1jv6Uypg7hG+i+QSXDhNKz0kBKzMJGzpP29k3NePFyOGrnsqBzL6TXxnngW4LpnuUVWtuntfrw6SffFBjnh/ch32CUhwpJ5SL7pwU+ZZ+WfVuCBP6cTK958xpJHnk+ckozzQtd7C/GHKLUAZr2aa1PBXXRxeHG4RPKw7czcfChkWbs6Dp9a7ubZKSFP8t/a9vmLT60/1LPW9e0Z63oj69sn16WTlZIb2jjWntGghHLWeRj2Fem1Avam8qtdnsyacMDEl/H8wEN7SwIK28LUFjP8g4hf0u+iJdYRE3fMiqhgRNtoMswci6XU3mgfCR44fnIOkveeFp+TS4MlltZqVwpOzlAQrufNF609pDl863NBMi0fGW5MYpVA0pShjJPDShozxOAoPv03h+tn0rDoeUnn200Gthc2IxOt4NOu4Ok08Fos2bWFQAaYxPowvb8fGOP9+8eXsV0lwZYkyRJp2wyyGC6OO3lM0Q+BX+86HiW4fPeeZpN+U1pGj4uQlERPkbl87586L4GarR8fK9+sJ7T+rX1PJ/y8BEvR3PQZDorcjEo+Z71gS3tnjZGOCjxTS3K5QlP1njY0ICEk2wETXAhkEHX5W/5jA9Fa4ibkzUQZT5Ww/MORP+loZbPy0gIL1s7F4T/plNFeXRBAhgNgPmUWOx8pAaSpAy73dW3vfL/Ur58ukryo5UvtzZbU2aavH0k28M3p098aHWyflP+mgy0eobqwqNYnU4HKysr+P2zfx9PPPEEDh8+jHw5j0u6s3jxp/8VSdchcd3VpSpJgm4ujy/999fjwO6z0Gg00qlSvtNNK4veg9NsNpHL5TAyMoJyuYxCoZC+0ZdP5dFUHJ1JQmuyMpkMfuj2H8JscxabC5vx/r3v76u31f78HpelHD+8jegZC7hbBl+OVbmd3qcLJGmn7lL/5UCV8qepYglquQy0/5qhl/z5AFGM3LVnLV1JJKOgvP7WaxFCERSZnu4NMhVlUQxQ8QELqy0GJb6cgcYn5UcfLQL8ZICSDQ9INGWqKQYAqiLkeWj5+sqMua8NBpnWZ5A1BcWf5QrFt5VYTlNReg2saLLRgJEEO0QWr/y+pUjkPZmOnucKxdpaTd+afKwBpZWvtZFUBHytCBlKnyLWeOb/iW8JNjS58Px8Xo7VBtL4ychIt7t6Cuvi4iIOHz6M5eVllMtlbNu2Da2tz8SXnvdC7Lz905h48F44ALNnnItHL9qL5UIZrVYrHXc8XzKSrVYrPWuEFF8mk8HIyAjy+TwKhUIqS/5KAgId2ngggMrJWoujyZP/t/qCBlS1fhLSLb78BzV4HMDLqUwiyznhz/nyD52TMyhpbeEDJfKaZZxpDFpy5M9qOkzmSfmEAID2fxCQIOsVSjMICOH9SoJQ7ijwqKzmDPnAiNa3BwEuGx6QAHEV5i/marfbyOVy61qoMwgYAfROo0VJNLQbY1z4cxy8aMpS5idRf0gJy/xiBg3/zYGBBAnW+gvrN7AWYdDOX5CkyURrE03pxPQPTUFSvUJ5+treN8BlBEcDkbKNNSDCeeLAinhoNps4cuQIDh48iJWVFeTzeUxPT2P79u0YHR1Fyzk8dPn3o/Ud35uCjGaziVaj0TPueISErtVqNdTr9fTtvfl8HsVisQeItFqtPhlR3XO5XE9Yn/qEDKtr06KWYuVy0mRK+Vnkk38McdBl8azpGQ4YLJ0jy+B5hoxriGKe1ww7vx4yyJbeCZXtq59PNhKU0DXf2pgYGgRI+J6LyUfTTfLDX1rJnwsB6uNNGx6Q+BQK/eYGjDw2moqQIdfjyQsf6JrHTqR5b9bcv6yTvB5SQpJPDWhIRaWl00jzcrT8eeTHes46GlrudOL3fe0YUyfLq6S0lgx83qVUwFLZaQqGvn1AI7ZdQs9S2fLAQFJSFBk5dOgQVlZWUCqVsGnTJkxPT2NkZAQA0i2DpNR41IPABx97tM2w0WigVqthZWUFrVYLmUwG5XJZbWO6zsdtkiRpmaVSKS0jm82iWCz2yMby7jVZaHK1gEms3GPShYCqBkZ80YQQD3LcaPnxcjXyOQG+64PmweUjAU1MHWLL9OlG+azUN77+Yzl/PudLSzMoWQCDTwfTeJXTevSM5hiH/q+XNjwgIQoZQ2pgHiGhNORVaaHeQcrXOhov2+JXhvrlfWlMtby0OvvQc4yilHOyPiXNPRxr6ohfk89K0GFFTng5Uh4xisiqd2jxnORF8qSR755PllYbW9e1cn2GVpM/RSgIMNOakcXFRczOzqbTNNPT05icnEShUACA9Dh3CUgoIsIVH1d+BESWl5dRr9fR7a6+ybnZbKJaraaLrak/bd++PX2RHoEaqgM9x7cqlsvlPjkRiNH6J49kWZ5kDGnA15dOu2YZEaufWwBcGnKtDBprIUARE5WIvcbL5v9D6Ym0aezQ2Lfy1MYGv6/lHQsIrfQW6NHK5zwcq8Msy6DF+vKUVl6mfMbKi/Ps628h2tCARBojiSx5Go4GtZ0JpIh5foN0gBCKjEHi2py41Xn5f18nX29YkL6taSPthWu8A1tneGgdXUaIrNX7lgfC85SeRahNZB143WnAan2L56NFuGQantaK7MjyQwYt1M4xeRBPPFJC7445cuQIZmZmUKvVMDExgZ07d6aRCmB11006PXMUhND4IsDAF8xRVKTVaqHRaGBpaSmNjvC+IF9cmMvl0pfzEdDh0U1a6MrfbeOTgzZ1SGmkodO+Y8hqU06haBz1v1AekixvnOevgS6uf3zG3ooQSP58QMlHMWmAwV40qtF6daN0oEJ6WZPPoGXLfGJkqP3mz3Knj8aVlf8guuhY6KQAJD4vlCtd2i0gF7fSfZ+hiOWHyIqQhBraMvA+wxxjdGQZ2mCR/PrKVN8Ay56l6IoctDISQfkAa22Qz+dVWWpGhbcff1GdJherHTTgp7WTlicfxLy+1pknWj4xyoWvfOfXZTr69gFkKw/nViOIFLmYmZlBvV7H+Pg4du3alUYdaPzwEx7pjbx8XRblR2/qbTabqNfr6VQNf2EeX3tCz2cyGRSLRXS7XczNzcE5h/HxcTSbzR4ASnLP5/PpYthms+mVFdVDk7VPtiGSfTVmrYmv3PVQSH9Zfc/SFfK3trg89IwF+qUOCW3BPZYoQaxR52NUi8RI+R6PyEUMSaBzLGVL20KOiEzDdQ/nI6Zvracvb2hAAujhMF8jcWBCz0shkkI/Xp2fH/4lB6g0snLni1ZPjW8rnSSSEZ8WCT2vyUHKR6ahMzDkda7MNOIDgLYxWh4d/Se5Udh/PQNB6wMkJyvSIo0Ipdf4lNdCAJXX17eWhNLKvH2gw8dTs9lEpVLB/Pw8Go1GOkWTzWbTo6WJJ7lVUEYuZD352CIF2Gg0UlBCoIXGJ5VJ0zeUR7vdTvtvNptFoVBIIygciGhjy6JBDLNmGLT0Vp/RgLzWJ3yAUjOSkkJHxVsRDI0HSydZRl7Tdb6+bkVhfF67dc1Xj5Ah18aPlqcGSrT2ku20HkdXlk/lccdrPSCO8uP2UD5j9UF5TdOHvmcs2tCARFN4VoPzbYZ8URz36OSzg3Yc3ojafniep/ZNabSQs5xm8vEn68AVQiyI4Z1MUxgcbEjQoIVSNQUny+UDlsvQZ5yBtagLPxpeq7tVR16mfI5HXjQgJBUc1d3Xzhoo0LxPrSx5XeuzvjbW+ODrPAhg5PN5bN26FWNjYykgIP5o6pMDEr5eROYrnwWQTgs1Go2eqZ9arbZafmMzTqv8d0y2z0Anv4Kl3V/ASvFQuquG+kqhUIBzvYfXcV6orjTGtUOtfIbZR77xJ/uH1Q7yd2iMaoZVa3+fkeeknSgty7GmX335yvvaWCE+Yw4m42X7HDYL9Eh+qE/ws1ssMCJ1EM+DPrxfhUDKekmCPam3Bo2c0LN8DGtlUvtpz/vyDqXR6KQBJPzwLqsxZHSEXwPQF7IalPjg1hpQ2yamGTpSApTOWsdB6eXvmE6gHdlOfFvv3gmVS3WUYEXWnZ7hKF9b4ManhbSBZoEkqfB8MtHAg7YmRN6zlJZP9r5BLe/JNtHWOmjKiN8PkTTetO4jm81ifHwc5XI5LZcMupz65NMzsk9w8EJrRWjXzvLyMiqVClZWVtKD01qtFur1Op6+8FpcPPcWAAlw9OV4mZn/gYVHv4z9L/hjFMbW3o1EIJSiJrQwXY6ZRqORbvWXwHU9XmtM3/KNR59hD41p67cvf81Qa04cjSGuH2LK8uUvwQjvL/I8mZDjYvXt2D7P85ZlWgAyZHgHBQKx/HGS+pTGqwU6Q+1PROOTT5VSep8TJ+2VxruVxkcbGpAQ8cGkde4kSUwkHGNIYsmajuB5a5ES+aHrcvrB6mRygFnGUrvne0+NZmgsXoj49AlvB20XA7AGJCWfVn14Gq7cqAzr3TYa/1rkSBp+mdYaZFrkwipf5i3vUX7037cOwceTlib0bJIkKRDhxkTultH6rBbVIRDSbDaxvLyMxcVFLCwsYGlpCdVqtS/Ssmf5e/Dcud9gHK71z8nZZwNffCNmXvynAHqP/OegDeidriDlTTxqyj2GYo2j77mYaEJs1GsQ0vIguclorgXIY+Ql+4JWF0ly3Fqysowk528QYEJl0/Syj7R6hdJZ9yRp+krThVKmFmiLrT93RGj916B5xNJ/CUCiNYq19kMqVG4I+fPHyg/lpfFAHr+MpGgAiYfJfMCAdyJfJEXyqYU9eVm8PpZnrxlVGengMuHHfdM1TVnLqInlpWkA0Ln+BaC+waVFXKQM+cJbTclqz/i8Xf5bKiCSqy9MbhmXkOLXypV1oSiDXAsixw/Q+7JKkju/R0CEoiC0hbhSqaBarfatPXFdh4uOvBEOHSRQplaQxdShy7C4/O9oTx0Igld+jyIovD7cCMZGlFJePOk1HgYxEjIPrc9rJPVOqP9zWVgGlO5p08haOj5uOV+UTtM7vD14Og2cWHpwPWAE6D9UUdZN/tciOLJug9Kg/cUCKzF9Uqbh9jAGaHL+NB2i/ZY2z0cbGpBoZCkNUphcCQL9LyfTKFb5aANJy0sDUfQ8H5x8kPvy5MpYprOUNnVgbeGbNvgtgyd5IuNknaciOyYpOm3tBNCrMDQeOK9yG6cmA1/0iqeR5cQYe37P5yFp7SSBjgRBsUAz9N8CkhbfHIRw0CHXkdA9ut9qtVCtVlGv1zE3N4elpSXMz89jeXm559A0DnZGmqdgqnWOv45JB2OPXYyFqQ8HZcHr12630/UmgwIQnwfK87PGCs9rkGv02zedp/FC1wcxkD7dwnWUZvhllEVGQy0QIiMw2pRrDH88P1lOLPkAiY80Qy35CQFD+XyorpYTx+/JPmLJlJ9Bcjydcvk7lk4aQKI1umwYAiRaGFrSegd0SOFJY60pMYreaIspOX/WQlcNoWoykREG32Dy1de6b0UfeLm0sEzryHyfvKYwSBFqR4fzcn0KQfPEQnXW8vLJQ5ZhtanMg6ezjOIg7WOVy0lOych7cv0IByi0a4ZvHV5eXk4PVqtWq+kCVr6QnHjLdksmX6xmyHRL3lN1Y8ka4z4wYf3Wpvp85OtzvL1D/epYyQeKtDFrHWbmqzPd4+f6hMrSjKgEMfScvM91bKhukih/rZ25PtXGJM9jEPIBXl6+5IHf16beQkT6Uq4fOVY6lnw2NCCxBpNE5vI+KVC+1kHmJRFniA+p7H1oXTOwGnL1GR3NuMQYUnldLkDV+IoxnCHFrrWHVLzyN+fNpwCIrDlQn/ejGV3tOatuofykYZERHJ9R0kCMpaysSJj0bK32pA+NDQ5M+H8NkBDIp8Vx1WoVy8vLOHLkCJaWlrC4uIharWYugqUyljL70EYdOdjAJHE5tKae6FmYavWJJEmAo7foLcA+UOojC2jIadLYMai1tS+9xjOPCA5CPnmF+pgECzyN1Qdl3nIs+5w3i0Jtz/nx2QmrTKmL6JoW5bFIk4lVpg+ExJJ0UkNE9eO7bI4XKFkvbWhAwgXIG1VGDvgAsObFtbxj57240iaSoTTe0D70rg0APkh4p9MUYYxy1LwKznNM+FIDVTJfWTcrH86XvGcBQy6TXC7Xt0OK8xHypnk5vE+FtiNaRopk4QN3MSBPe4YMkUwn+5QGfDg/VE8+Zvh6EAlA+H3+ojy6TtGRlZWVdGpmYWEBlUoFlUolbR8+TvrGYVLF/aPvx9OrP4yMopocuugWqqjuuSNdYRLrjcp0MX2cX+f9IuZZH0l9EzJGWt8NGR8L6GrpQoZV5if5tgy11selgzEIWWBS05FambIe2pjj31YdLWdTe05e09rRV1fJ/3pk5nPMpZNwPIDRsdCGBiQcTEilLBUyByQSyFh5y7fJyvuWlxNC52TM5YIqbiS4UeQGdb2L3rRObSkOOZWj1UHu4Zf5hQaivCcjNZQXTV9ZvJBi9g0c2Y68r3CD6IvW8DpaSkWCBFkfmUYSz9uKpGiypfQSVFl9lNeZSEZBrGgIX//B12StrKxgZWUFR44cwezsbLqLpl6vp8/0lFPaDJzzcrjJ3XD1JXS+dSNyh+7C9om34iWFW1Bw49jXeCG+WXs5OiigizaQOOzf++dAtg0oi14t8MWvaW0VIk1WIVqP12+l8+mQmOdDPFH6QY3dIM9YgNDKw+Jf05ta/rEUkiNgO5CcNF0aytd3X+oOTZ9awMjKT+tTfAxLvfVUgxFggwMSoD8CQte03R78tef8fRkhY+k78VDywf9raXjeRNrpnlQ2LfrUUL7mgWgdSuuMmiLghjnGs9KiSLItQp3b8lgkULFkyxco8/I0nng6baGg1oYhT9Sqj3Yt5NH62lX7L+VsAW1+jXjQzuLh60H4WKFoiAQh9KnVaumZIpVKJV0vUqvV0vUiPQtYL7wGeMH/AyABXBcJEly051L87b/9ILY35tAaeT8SZHDp2N9iubMD75n9Z3xt9Ahmz7sB+Z2HUUyKUX1UykwDniQ76xntv9ZHBiGrP2vl+eoYqnuMbCRQ8+UT4+Fbjo+vD3e7XVXPaWRFOyy+rehJyPBKuQxi/AclC1hJ3i29TcSn+YksMMvzlG/6tXh8KgDKhgckRD5DIBUxP5o6Zt7sWBrCp0w0Q6UZZQBqlEBTanLdBeBXEjTo5GJGmqPXFAD/71sQTPxrHoYWGZG8WoZcKrOQQtXy0N74qpXBIxV8IMeuMeKKRPNyeFkWOLYUp6ZUJbDh9eBGmddHRkUkALEAC0VM6L00KysrqNVqqNVq6UI5yosiJO5pVyL7ol9htchiemUW7/23H8RYcxkAkE8AOhCtnD2IH97+XfjAtjORmdyOKUz1yYDXS4syynQ8qhQDvGO+tfbi5fJr2hjV6qTdDxlhXoY8WtwywjRGtSglL4vnCfS+rylk6GV9YoGHDyzIvH35+hy6mLIsGcprPj59eRMfMfLR+oYlcwvUAL3vpIqZkhzUAbB49dGGBiQaGtQahhtdbhy0xpP5hdIcS3hLPmMZXrruO0HVqleoTM1blB3SFyXwDWJ5zgP/lsCL80L/tbNieBkEnCT/kg9Nhlz58ikdbQDKNQ8kD+19QL56xpDsYzISYBko7T4HHJbhds71KCQJQvg1vs230+mkL8kjILKysoJ6vQ4A6dt3nXPpoUvOOWQvvRbOdZEka3L7obvfg/HmErKuv4/lAIwD+I7zXor/8+Kfw+jKLM46cCtOP/RlJG6tXYg/euOvJlcOSChNbPtoxpX/j9EdVn7aNdmPQmm1PLW1KhYIIwfNV6dYsp6lMSOjDdrBiFZ7SH2nRTGItOshWWoknR4NlPDfEgD4HEQrr5AMrEiIdd2qNz8GY702TPvwe766SIpbtfltSlqIWhOAJiTLaFhGPUawVuP48uTGwcpTLjTkz8lrkg/Ju/zwRYY8skHy5d8a75bcZL18PISUhFYWAR4JMrlM+Iee5dMHWjuE5GvVkX6vZ3CH+mFoysjXrtoBZ/IwM5qGIcDBgYc8SbXZbPYBkWq1mm7pdc6hVCqlL7zL5XJrCndiNzKbn9YDRgDge+7/EDIKGCHKwOEV930a3XwZyxOn4KtP/wH850U/jU4m562vlA/9tmQmx6PWP7Xf3PCFAL7Vdr51P8eDeD8K6TDim3+H+LF4t3QNJ6knfDpC/ufAOSSrGD2jfcv6WPfkf0sPxPSpUL19vGn5WCQjJDFyfDJpQ0dIBhEe93blNAHPQ/PCeRorxHasFMpHW3wVGmD0PyYEZ6F4HgJP84B/YGgn0QL6rhUJfsh7JWNJZfu8O+u/Np2hXaffWp1CA1tTBNIoWcTT0n9rCkyCRo0vnzEA7EgJARKSuVwr0mq10i29dBQ8vRiPAArNX+czO1DOXIZiroml7sfR7R5EPp9fbct8WZXDaLMKny+eABhtrhz9s9q3ZqfOxD2nXYlnPfrRnrrz+nH58TShfkR5WECC5wPYu7G09temZ7W0nAfJqw8UhKIavI4SLMkoojZWLe8+pkxeL54nz8uSvZRH6L6vzj7+tKiSzJ90nBy/8hmep4ywyPScN5+ekrKS1+X6EB8517vlV0aseL2fKjopAInWqa2Op3nIvmelkvKtuNYMQ8yAXY8S0TqKdk0OKJmn/G8BBh9f8r82f20N3NA1bjz49dDblDUerXylJxOrdK0+JO/5lGAIWPB7lD+fbrDaVQMe/JsACI0JuY6EwAjf2ksREIqKNBqNtefao8is/Dra43vQyq7Wd6zzBmyavw8P138C2WwLncp+uHYDSa7YU79vbDkfu5afQM7pi8dbmSzu3nFe78Ukg4d2XYYLHrs5VWI++cUCBstgyvT021qL5OPFKt8HNCRxIxdTro9CusAH3kJjI8YoaoaYtwPpk1B51tSJxhcny0GRecSAEQmKfKBFy8s3ZeMDGRKoxbS/tIe87Bi79WTQhgck9B1r1OWuDB4x0TqPVPAhg68pNB9vMR1RdnKtXpyHkAy0MjgvvF48OrH2sH3Uu49HuWhOS8/rwAcZ3xVFeQH6ljyf18JBiDblw9OHQI7PgFj1s8jqXxbv8jnrmna2iHZd2z1D0ZF6vZ7uoul0OqhUKmg0Gmm/6LRzaDX/BI3JEfBQh8smaG45F6fnP4JvPPAiJO0VdL/5IWTOfyWSzJrqee9Fr8WVD33MlE2+28G7Ln1N3/V2roxqeStKjcNBOfO6EmlrTbicSZY+QxC6r+WttRX95unkc6H0sTzEGivLoA/itPDyKB0HGFbe1piMpUGeiwFPXE9zPabJg4CGlY8ED4PUS0sr8xykH3A9IA/aC9mS9YDfEG1oQAIMFjaUypZ78YA+HSO9Z1/o3DdnKPMnsgamTK91cjlgtQFM16yoj8yPKw0CAhpJQBJC57JMuZBNTvPICEgItBEvpDA0QCI9AL6OQqbV5MZ/W+s6fO3r8+58A1yCsJjrHHjI/3zhKj0vF6xSZKRer6fbeOmskVqtlr4bJkkSuKXXrYIRjRKgNbUJuyZ+Fg/PXgf3+T9AcurzgIldKSj53J7vwD9c8CN4zd3vQRdrC9u6SJCBw/993o/gM2e9UM0+AzuMz4k7HtqUjTQwsq/FGOiQs8KfCdGgjoU0+tZ/i3ctf9KRoZcQamPTGjdSXlaUgT8baltZhlWfQZ+TPFrGXspa1l06d5ZsfEBPli+fkxGkmD4mzyCR/J4I2tCARBuMEsVKRc+VMt+hwQeKD6XLskO88fy0NJrxBXqnFCzkq/FmGVKtI2v8cOAT06nb7XbfgLDKaDab6SJHqp/1vhoeASGeLG+E14tkGlNXKkeCpGMh3qbA2tkAsQrTB0zkokStfbRoiAQj2pZeesGWXENC54rQItaFhYW0zdvtNvL5PFzu+YADzIUgzmFi69Vwh38X3eocWv/0A8g+938ie+EPIimOwcHh1/ZcgTsf+hz+Z+VRnJNZrdf9W8/En7/wp/APl/wQIOXnHEqtZUw25pHJrhlNOeaJaAeO7G8hkBtzT45VX3rL+Gogw2esLUBugQaNfHqJ39fGhyU7y4On79Dpx9ZzGo+awZZj31dWDB/HkkYCEHlUQCwY4f81IK3ZLR+ffHzwsR+i2PEROxY02tCABIgDBtxIk0fMvUNpxPiL7awyBlEWIXTt83g47/TR5q1l/j6lpvFg/edgQEszyPH6fO0D50sDb5bC0ZSlNVhl+aE205RqrBw1+WvgKdQPNEAieZTPcyPMAQj1dRkVod0zFBnhQEQDMisrK+kbe+v1Orrdbrqtt1QqIVsu2mBklUG40sQaCFyZQ+czbwc++/8CpUmgtQK0angvgPeiiM3d1T5+4Pv/Dzq7L+wHI0fp/P3/iXx2zchp2+KJ+A6PQUGDdk9rW1kupQkp+xg+rN8aXzF6yRcZ4fnwPuszMj6jHEoTIm50Y+qpGe3Ycqz/2m/N0bCcj0EiIxYPUtcRcXAd43zy9iTnhOejUYztOh604QEJkeyM1oAjpc1BiAQt/JqWh2x0nwGJMdjWCXvaTgEtEuFTQLIDDwJQZAcPPRPrbfL0vrUgMc9LYGTxINtJMxYxhojnx7/5fc2T9ClPi0cNrPI1L7IPaMCCRzvocDJrvYiMmtTrdczOzmJmZqZnDQkprnw+j2w2i3y+jU6+4ImQAEmz3g/6XAeozVPlSQo4AiCbZFD+pzej9pPvQmfrmUC3C2QySLoduEwWTzt4G86fuS19fYF867Ok0MvDQkp8PaQBEa18y4DGghAqS46BkKHz3beASIi0tBqY0PgbRPY+HW+Vy69r+la75/vtk7N0IC19oY1/LV9ZHtcr1K9Jn1qy0eTBnRNNVk8G6PDRhgYkfF7Y51nLxtcUvTQAvME1wyYbVxoSnnesUZd14/nysuk3RS4sZKvVPQS4gP4XAloGNUah8f/yvrwup2V8dZM8+dA95aUpeMmXFhmzlISlxPh9yafMM8RTCJBwWTjXu42PoiHyGj/kjJ7hC1vpvJH5+Xns378fCwsLaXo+5jKZDNrtNnIrd6BV3mvKHgCqsx83jbH8T5/syhFs/pvXoHPRleg+6yq4kUlM1Odw1syXcGrzUDr9R7zQ6cIauOULeQGk07WSD81QPJUKmZcZ6zho/cbSTzHl0vO8j/p0WAhIDAJorLwlX9YuO16eJRef08qft8CIti7M+g7VS8tnPcCH6wZfeVye8vUQx9JOx4s2NCABdO/C54VQQ/i8F80QyTTafbrHG9daya/lFSJudOX6Fy1vnyG0+CGU7XuhHfEiB2ZIfhbA4QOKX5MemvQMBh1Alox5PhLYhBS6VT8iedospbNADhEHHhycyqgez9NaM8I/UnHJPkTvpjl8+DBmZmZw5MiRnpNWOUgk4LKc/ClKjeeiXcgCGVEX55CttfDw4bep/YnLjAAFTQcBQD4DFO//DHYsfxMTExMol8soFotIji6o5SAkm82mEZMkSQDWZN1uN90ZVCgU+sbl8VbGIT3ko0EAUAzgsPRDCMDzb5qm86WNyU/jxwcG15ufBUao/2rH3sv8LdBg2QUfyNCuS7IAkK8Mfm8Q+QFIp2OtjQsngjY8IAH6w/WWxwqsRVW4IpbGkHuAUvHLTikVu6QY5RLqSJqh1wyElIfGl0Tc3LjLelG6NGKCXiVAA5t4IZCkHafO89a8L1J2cp4/dO4L95Z87R6ruEMGw1KmMg9O1iJbmZfskxp4kN8SZHPw0Ww2+6ZmKL02ZdNsNlGr1bCwsICZmRksLi72vJOG6iKBd7e7D8366zG5+U/QGh9dAwIJkFtcxAP3vxqt9kJPP5PnL2QyGeRyORQKBRQKBeRyOXQ6HeRyOZTL5Z51Irz/ykWSPdc6a3Lla2foHVY+QBgDGjRDMwjY0Npfo1iAzO9pIFjLyxcJ4uNTq5sWaYiJlljlxhpSrvN89bHqxcuT9eL3ND5jxjuvi5ShRj75cf4GndaOaQu+w8b6firppAAkGllGWAIQX0eR+YWMHS+Pfx9vkgBC8ieNmnxOWxRrDUpuMJKjiwQc1sAIhf+B3vfEcLIMsRz8sh6UFwcnmsKQdedlyjUimvKTeWkklRc3ypI3DYBI2cu2knLRwIjsu9YOGv6GXjpdla+b4ivr+bROrVbD8vIyZmdnsbCwkB58pgFgXtdOp4NG5k5UDn43xg9fgXL+u9DtdjG39EHMrdycTg1JefP/pVIJIyMjafSCg9tSqYRcLpdO0fh2e9D/TCaTAhKSH5cJASB69w3nyWfUnqwxbZUn/8v+wvu+b5yF+JbjR5IWIZHj73jJL6Rrqf/5jkyw6iBtgY9fWSdNzpoTLK9Zz1p5afXxOTMhHi2KAc9PNSg5KQCJNjC0AcaVkm++jacHetdzaMZRlmPlE1MPjZIk6eGBeAp53No92ck0I0vX6ZvSaBGSJEnS8LrGg2Z8eDrtfwg0yPpoQCKE8kN8ajz78tMWJvNnYvnkClf2UR7hsKZm5HVqI+dcz5QNGWb+fppKpYLZ2VnUajUzish54HxQP6m5D6HT+YD6fhHpNQKr4yifz2NsbAyjIxMYz74QJfcsdPNFLG/JolVuopPUgdYdyOXme0AJ3+rLlbG2tZR4ITDm3Oo7d5Ik6VtPEjKIsk9wivFKLbIAccw1DtRCus3y+n1jKzSO5TSIj+RCdp6XBJgSdEkZhIBQTL01Wcr/PXrwKBiSAEADIjG8yP8hYMLHXyxAlnUA/P1EAyNPBTjZ8IBENoql+In44j1pRGIagPK3QoZAv9cWMm7aQLTqyhW5Bhq0vPmzPsDGeZFgbPWm+Ba8+/LXyrNCkD7Fp+XND7njz8l1LlZ+FnHlo/Em02oeClceWh01oCiNvgYICFTwQ8w42O52u2l0hL88j37TPXo3zcrKCiqVCiqVSpqODDgHORIoaYtsJWCS/YJ+07qPcrmMyfIzsAt/gGx7Oxa2tTBzOoAEgHNoJl2sJFej0roTZ7l/Qjbr0rUjlJ80EFZbUp2SZHUNDIGRQTxKaUAHIe5cWPkTaR69BfJkGaG8ZTqZpwZKfMCE86uRlJmWn/Y7JGtNr2kAJQQwiSx9Qb9jwIavvJh0vE4+kGrpkNAx+0Ryh43MX6MnG5RseEAC2A3o85K4Z2cZDAtsaIZfklZ2SFGE+Ob/5ZSTNQ2jdVq+aDXkkVhgyVIcPrn4wFCMEtX+a0DF4onfi1G4MZ6axaPlAcl25AZeM/I82uFc/44YGRnhrxOnF+LxqRweEaHfdDT88vJyel0DQ1JuGiiX9dJkQL+TZDW6NlLcjl34M2TcOCqbupg5s+chAKtTBUu5i/CIA87P/nMPINEORbMMCeePy4UvhvX1H17HmHTHQpxnGSGV/cznBPF0GsXWNeaeBQ5i+AiVp+nBmDylkyDJAiEWoLHGdohkviFg4wOj8lvjMdSm2kmtMi+tnCeTjuloyne84x1IkgRvetOb0mv1eh3XXnstpqenMTY2hquvvhqHDh3qeW7fvn246qqrMDIygm3btuEXf/EX0zMSjpU0hS//SzAi70uDrnUE675FmnGzypHeZUzZ0mjIkLuUR6gca/CH6u5LpynVkNysNghdk7K0+oEmV6u9LNlo6bR8tY92tDvxy98n026307frUnSDgxMCH+T10Bt5V1ZWUK1W+45/p2hIpVLBwsICFhcX8bRTC/ilnzgH//gHz8c//v7z8eYfezrOPHVUPdeEy06LovC60/SK9dlS+O/IYAJABrOndnoicD2UZDCfeRZq2NLzvG9scR6kN88BiRaV0to01qDG9I++6iVJlLHSjEdMNIDf8zliFo9W/44xloOMF6s9rTy0tVYh8oE3mU5+YvLnddHaJgaMaDxQ3jEgTPsQ8WlbWV6oTk8mrTtC8qUvfQl/9Vd/hYsuuqjn+s///M/jIx/5CN7//vdjcnISb3zjG/GqV70Kn//85wGshqOvuuoq7NixA1/4whdw4MAB/OiP/ijy+Tx+93d/d2A+ZIe2jhgHkO4I4Z6d7NBavvya9KB9v31ev2/wxtabyrAGl1UHXhfOr/RofYpY1k+Waw24kFw070TK1fK8Ocj0eTE+5Sk9Ev68nPvWDINWT0vxyxXuvC/KRah8IaqmhKvVKpxzPdMyjUajD8TwaRqKlNTrdXzfd2/H665+GtqdLnLZ1fpdvncnXvL8XXj79V/FRz/7mKn8NSUdUsgA0u2948kVABK0i0BzFAHqYg4XYBJfCObPSS5cBdAHoiT5xlIoUmZds0gzxrI835y/Nj3pK0Pr7yHeiQcelRq0HqEyrHxC+VFf1M5Q8pGlJ6z29QEMbcdXKNpilaHxadkXqctDJBezW1OInK+nAowA64yQVCoVXHPNNfibv/kbbNq0Kb2+uLiId73rXfjDP/xDvPjFL8bFF1+Md7/73fjCF76AL37xiwCAj3/847j33nvxnve8B8961rPwspe9DL/zO7+Dd77znSlaOxaSjSMVYJIkaogqlGcI9ccCAf47lgefd2F9tEWJWh7c4FmGU7suOz73mH1l8jRaGdquDkrjOz9G41tGjLh3r8lMPq/9D4VBuTysjzwTRE6p8LUhHGD4FrDSotTFxUVUKpU0KrK0tISlpaX0GkVJarUa6vU66vU6zt5TxuuufhoApGCEficJ8KtveDZO2T7S0zZShlZ0hBP31HrWf2AECRJ0I7RRgi5cUlLD7HwKR66xKhaL6cmy8lmNb82r5PcGJZ7/eox1jK7wyV+CyEHzpvy1fEPPc+B6vD1xafS1NtSeCf0PRRi067Js+Zv3fW1Btm/3mMWrj1+NOK9c1/AlACeS1gVIrr32Wlx11VW44ooreq7fcccdaLVaPdfPPfdc7NmzB7feeisA4NZbb8WFF16I7du3p2muvPJKLC0t4Z577lHLazQaqWKlDxA27JqxIUDCD4MJGfxByTJ2IY9gkGdilIqVlw8QcOKd17cQL1YpWdc0Xvh16cVaRtFXVwuo+dL6ZO/zqn1gS1scyrfsalMjHHxIfjmgqdfrWFxcxOHDhzE3N4f5+XnMz8+nx74TAKnVaikgaTabePl370K7o7cvKfnvf8npakTH137yHuUn3z1Tdw/AoY1cE0gC7/hyyGEsO9sHaug/OR185xeA9HyTQqGgPudbzEj/LeOjjUUfAJDprLwGGVu+Pqz1d63v++pvtbXGA/2XY9bKj+sXiy9Ll1uRASvyoz1r1Xk9FGMvrP7ii1TQM8S3Vo4ENj6QQmvMeNTVuf4F/IPYP1/bxdLAUzbve9/78JWvfAVf+tKX+u4dPHgQhUIBU1NTPde3b9+OgwcPpmk4GKH7dE+j6667Dr/927+t3uOVpoaSq701o8Q9Tp9yGYSIByuv4+Ed8I5pgQjtOpdPbPm+tJwPfo3LQMqDL6bV7vHBRv/lWh9rek0zFtaqf6nkLA/LJ4NQpIR45+XIsrV7GmjRPF/eh6vVKmq1Go4cOdJzmJnvyHgeobnwnKmeyIikXDaDZ5+/ReWZ9zffrimtT9Bz8+33Y7zwnch2gPHZBEtbHdT34rguckkD23Pf7FlDQnxwUELABG71Xj6fB4AeedCUEb0FWNupE9P+Whrf2AmNQ66rrPHM04aML+fX0hfamJRlSB0bOrSQvvk0ipSbHNs+Png9rDpIPaDVT37H6uUQT1rdYkmOp1A7yDIsYGaVRfrDiozztD6dOMi9GBoIkDz22GP4uZ/7Odx8880olUoDF7Zeestb3oI3v/nN6f+lpSXs3r0bQJwRkYqUry5+KkiiW7omebcMrZYfXdfAV4gPC5z4FJvlMVn3rfIlKJH3KR9L2bXb7R4vwOJVM/xW/WIGjnxWUwaafH1nM1BflOBDTqHJCBFF+Wq1GlqtVjpVMzs7m27bpZ0y2hSP5LXbDde/0+mPRsk20GQc6hfdbhdHGp/FROYDmMq+AtP7EqxMOLSL6AUlrgMkCc7PfwD5rOsLbxMIyefzyOVyqwCksvZ4oVBI+SN50DMaGAkZxhBpY5Jf80VaNAA7CGnlracuMQ7MIPzEpJNjR2sH+T/GGIdAV0w+FrCmZwYBBTJfQD9xnO6HwKYEViFZxKyfCk3BPhk0ECC54447MDMzg+c85znptU6ng89+9rP48z//c9x0001oNptYWFjoiZIcOnQIO3bsAADs2LEDt99+e0++tAuH0kgqFosoFot916WwQt4Cea2xi1jls6G0FgrX+JX3BgEqnJdBFnFZsgrd9ykmrT5csVjPy8hHqG6knHyHWFlAS6ufdm8Qj0mrO5+H5YZbls2BsVQOFhjhfZYvWq1UKpiZmUmPeq/Van1gW/LC+el2u7jta4dx5YtOMaMknU4Xt371YN+4sSKRlgwt+Xc6HTzefBuaxfuxuX0N9ty9E/OndrG4tQOXXc1nKnkQZ+Q+iy35/chkcj19iwBqPp9P14rQAWqUhoChjIjw7cMxvGoUAvcxukMDI6FnfP10vcYxtowY4y//y7ppMpfXfOCA37f0qq8sed9XV6sNNQArx4DVB3g6X54WyTGg1dN6huurY6XjCVoGAiSXX3457rrrrp5rP/7jP45zzz0Xv/zLv4zdu3cjn8/jk5/8JK6++moAwH333Yd9+/Zh797Vt4Hu3bsXb3/72zEzM4Nt27YBAG6++WZMTEzg/PPPPx51UklTnHTdZ/xDeXHSwIg01D7AJPOO6ZDUsaywqI98SlTjJ/R8rCKwQrNcTnxHC5eDNsg1I6kZR1mmbB+r/pqcZD58cFsglAMDORWjgREtPYGRlZUVLC8v49ChQzh06BAWFxfTM0S0RdsWKHHO4f0ffQj/7TtOVftOt+vQ7jj8200PQqNBjaUNVBxqhQ/hSPmTmBw/DaePbELeteAwiWK2g1K+3QMgKC8OVPP5fHrMfDab7Zv2oefy+Xw6ZtKpHcaLz3hp/dXnmQ6qX6y0gwKkGJI60BdB8OkVa3xo/GgyC/Uhn960/scCTK1O2rOyPjLyqU0P+/iWgEXboaPlA+gnEQ9CpE+0Ba1SJ2rP0rcle03XxNJAgGR8fBwXXHBBz7XR0VFMT0+n11/3utfhzW9+MzZv3oyJiQn8zM/8DPbu3YvLLrsMAPDSl74U559/Pl7zmtfg937v93Dw4EH8+q//Oq699lo1CuKj9aIyCUi0l+itlzQwEkLvGsV4FLw8vh3veJPGs8+I03Uf/zHekDTmmufgA38yD85rLHDT5splGfK6BbI0MCJ3/ViLd0mB1Ot1tFotLCwsYP/+/XjiiSd6pmliFh9Lud7/6CLe9ud34DevfQ4cXBop6XS6aHcc3vy7n8PBw1W0nnYRalf8MFrnPQ+AQ/6uz6P88fcit+++oBwlUV8lUJDP59O3+Y5PdlAarR99yV49BRx8zQifZslkMj0v5aMISAJdwRMIkWeZxIzN9egIPhasfqcBYxrX6ylTMyzHM1piARgfIOCG12fwtOlYX3pZRy1yoNVB+6+RzEfTQ3IKUeZtOUYWjz7n0LrP/4ccX9In/J1WGi/Ws7I8Hw1iW4/7Sa1/9Ed/hEwmg6uvvhqNRgNXXnkl/uIv/iK9n81m8eEPfxhveMMbsHfvXoyOjuK1r30t3va2t62rPGsAhJ6RqHC9NMiADxnBkNdgGfFBOwh/1jfofZ3bUkQ8X+JNO7wqVjFYXhy/J8GKlYfvuq9dNLDlq4smC37P2iEk25EDEdraW6/XsbS0hAMHDqSRkWaz2We4ZJvx/CUg6Xa7+Ogtj+Jr3ziMq688E895xlZ0ug5f/OoB/NtND2FmbgW1l/4Iqtf8EtBpA9lVtdF8wcvRfOErMPp/fwOlz/1Hnww0mcqtudlsFoVCAaOjoxgdHU1Biba+g0dD6D9NzXBAom3t5XLheVh9W7ZziAZV5JrTooEImffxcJp85BuXvE2p3/BIQQww0fKJiWbE6ActvaYbYg2vzM8Ckj7gY4ENHyil+1rdtN+xtoKukyNEi981vS9BhFWGdW+9dMyA5DOf+UzP/1KphHe+85145zvfaT5z2mmn4cYbbzzWogHYSN1HfCvl8YwoWN567LODlqWVHcuj5HdQ3iwAouXLO7flHVgKSrsuf3OSRmaQulmKUQNivJyY7XoSfHDAoQEUPoVDu2NarRbq9Xo6TbO0tNTz3hnpgW6bLGD7VAFHKk08cmilb0Gr5OvxgxX88d99rU8htc68cBWMACkYSX87h+pPvg35B76O7MFHevKXbSTbmiIbIyMjGB8fx8jICEZGRnpABQcvXO4EKgiQaACGE+93se/6WK+itcaLpfiB3jdEx4zJkGPC+fCNoVgHKZQPB1Q8vayH9MRlH+EHW3KHxgfS+PNWGvnbel7qp5AstLrElK/xqulLzp8mTwlmrfzkM/wlkzx/rQ9p+kKSpue0skO0od9lI4UZS1zRy1dqPxlkKehB8wgpKtrKKAeTr3PEGmyZnwRzFighvnyKUOOX/+eDhNpL1skCppox9BkM2UbHKjMNhFCd+Bwu3eNghIAKTcfUajXMzs7i0KFDWF5e7gm38rLP2jmCN3/faXj+uZvS6994bBl/+MGH8J/3zPUpHS1aw5VK7Yof7omM9FCSAF2H+uU/iNH3/p4qC01hU5sXi0VMTk5i06ZNGBsbQz6f71krIsEIBxMESGhBqy8y0suyHU3jaXztLNs4RJZSjwGzki/tv+YEWB6ylo8sT46lkDOgjV+NB3qGXoipnaArjTwfm1o5saBKk53VF2Q9tbK082vougU6LF59II/zwK9J3rVpFwkg6B5fr0YnmMfY0JgxcSy0oQEJ0D/wfAOOp+HokCgWMAwieA2dav9DCoL+x3QabUBzD0ymlWVp12IMs1QsfBD7lJ3GDz3vk5+Pp5CiCpHmYWi8SVlpwE1un7N21BDxewRa6vU6jhw5gtnZWVSr1fQoeCnXs3aO4O9/9kIU872K8pxTxvBX/+si/Mxf34WPf2Xt3VKW0uPUOu+5OhghyubQOve5fZelQeMRj1wuh1KphMnJSWzZsgWTk5MoFotpemu6hn7LM0RkVEWjUH8YFGTEkDQskqT3H9JlmjEiz1YbU5Zzwu+F6mqNSa3v+0CIBg5kfr4xbYEWrmfkRytXc4i0b02X0bevn2lRIK3cEFk6U7tukVVX+U6q2LExCM/roQ0NSCwvNka40rPUnj8W4YaMpSQtZKt5JSGiwcAjCT6FKGnQckih8oXBfIeM9m6h2EFAitYnL54Pr6fmuYQUnvxN/3nYOOSh+aIP/L+8zuvQ6XTQaDRSpdFoNDA/P4/9+/fj4MGDqFQqGC8A3/OMSewYz2F+pY0b713A/qU2fun7z0AxnyCX7ZVZNpOg6xx+55pz8emvHUaz3VENlSafxHXN992tZdA/ljSFzMHI1NQUJicnUS6XUSwWe7bqckDCd8LQtXw+j0KhkEZUCJhYpLX98RrnxwJ8gX5QIsEuJylTec8CNxrfIb2pGTJ61spPWwAu9ViMbotpEx/Q0cgCHvK35WhxvabVVeMv1O+4vtIcHP6Mz5EbhCjqSi/jtIBSyPmTdZBppaMTQycFIOG/YxqLh8t9DXysDa/xKZGu5iGEFImPPxo4licQQtMy31BHkp2OvHt+L5ZPjVdNJpoMZN183qVVD352h1QactqJP+cbzLx96TcdWsY/fNcNTdHQupHl5WXMzMyk60b+x7Mm8ebv3oFMAnSdQyZJ8Kbv2oEP3n0El549uTqNolAmSbB5vIDvuGAan7hzpq9sS075r38eje94pR0l6XSQv+sLfZdl20owQjvxisViT3SDe6AaqKGFsARKLG94UAoBVpn3IMbBZ2g0Wo/hkUZe8/h5HS1QI/9bXrZlyKwxSTwAvbvXYvWdnF7XePORpk+sull8hxwSX578GR+PPA/VQUjs6G1IHvzoALKBpPd8h03K6xJkSFvmc3B8tKEBCRCvLKRRsF6wt160Pmja9TYYPaMBGglu5CFiWh6SB3meSQjE8Py0tQhkXIg43zKSQvclhXZMyLx9iF/rJ5x/npdVT8mDrw/6yuDp5JoS8mJWVlZQqVSwsLCAer2Ol58/hl++Ymf6bJZtbX3lBZvQXa6jO1FWeQeATtfh1C0l02uRfSJJEpQ+8U9ofOf3A871g51uF+h2UPrUv/TkIcEFnZ5aLpcxMTGBTZs2petG0m26SWJ+83wpQkLTNIMaJi5367oPfMYobOI31lGS+casheF8cg+b9zErYqHVzzKiUrY8DwuoaPlYZYVAPefBkqn1O6Y+VhrL6Pv49snDkrmvb2jjUeahlSH5JCL9Io8JsECktDFa2ceTTgpAEhpcmkAHfeOvlq8sc1B+KT+J2GPy4M9q9zkgkHKxBpi2l95SFjEoXBoT2ekl7wSgpCzkGSs+ryZ0HoDl+Ugw5wMvljL1eddWWZyojhRBabfbqFQqmJ+fx8LCAtqtJq594SneNsnUWuiOFQHDoGUSYLHaWuNpajM6V74K3Re9FCiXgX0PIfPRfwNu/TSSozzmH78fY3/9a6j81NsBuLVISacDdDsY/7OfR3buQA8f9E3AmG/vpR01pVIJhUKhrw5avTgYCe2o0ShGofr6ibxutWEM+cByyIPm6TXvWBs/mp7Qxon1W45dzYj58gF63wbOv2WdYgGHxhvPl8uCrklwpgGFEPDyjXWLp9Bz2jXpoPn0dky/kWVQhITnaT0fsjEynXY9dqxseEACrFVYm+e30nJvXiJvIq1TD8KTRlaeGlDh1zVD6gMmWkjU563FKEEfUd7aVlYAfR4z50WunzkWfgdVBnRdG0Qx61C0/zxPqxy5k4aHT2u1WrqQdW5uDocPH0alUsHTp7PYOdFrwPv4A4B6GxjR0zXbXdz81ZlVfs44B623/TlQHgUoHD65Gd1nXYrksx8H/vA3kBxtw9KtNyL/0N2offcPrC5ydQ6Fu76A4qf/Bdm5/pdiShCRy+UwMjLSM1VTKpWQzWZ7ImX8Wf7h+ciX4XG5W2QBUY1vLd2g498CAyE+Y3WNlc4yLDHjO1YHHKuuIPLpVl8dfGM8Rr6+9D5HwWe0rXK4jbHIAsu+ZyybEOKP3oJNukezQ5xvi89YGnT8nBSAhCjWuwDWvHh6DtBR6CBey6A8WiCIp9XIZxTl8zFRD+u6jzRj7VzviwuTJEGr1UKtVkvfMWKdKUHARCodTf6aYpDejOVFaANNtoccRDxvyaNWhuRLk5MEJBLA1et1LCwsYGZmBgsLC2g2myhlwy+07DqHDIC2W11bIukvP/owlmttdJMMWr/6+0B5ZA2MAOlv96KXILn/XiT/8Y8pz9lD+zD2vv+T/t8yksPuiQIWpop4eKGRykC2FZ03wsFIuVxOp120tuNTMXwXTT6f7+tHWlv4SIsu8Hucd5ley0vmI8dgzJi2KIY/6798zgfMfQA/hic+Tnkf8B3ayKeUBwFt6yVLp/jaSNMnWp/z6RteRqj9uWMXiv7J9oxJS+R7uazlmFv3B4mAhOikAiREMYjUipBYA/B4CVwjDbWvx0vSePdFgLTnYtJoA5LKofl9KpMWadJ7Vuitq3ybJo+YSG+Z568pCs6zD4xQGdr2SE2hSllono5UBL7IifQ8+G4bmsvlMqhWq1hYWMDi4iJWVlbQbDbx4KxLF7FalEkS/OUn9+EHrzgd4+Uc2p0uMpkEnY7DX33sYfzZh1bfSeOe9yJgy3YzHziH7vf9EJIP/RMg+sQZUwW89TtPwUueNpHycvfMCq773AF8+pG1V+wmSYJ8Po/R0VFs3rwZmzZtwsjISAoogDVPjaZhJDDhZ5IQGCkUCj07b9YDSmKM+bGMed7+ll45Vp3yZBlunrdPthbY1rxsbfz68rHS+ZwvzZkJ5as5iDIfjRd+LeQsUTmD6nQfGJVpeRm+vs0XtDYajWAbWuWFADr/PUg/P6kACe9koVNYeYSEP+/r7D7Pwid03lGsqIVvgGqAIrZcUvbHg0IeAbC2XZMMBpVPJ4rSIGi32z2HWdHhPFz+MjLBQY8EIRZfRLS9jaeRbT2IjH3AhK7Jwek7gIy/+bfRaKBWq6FSqaBer6dG+4kjdfznA4t4wZmTfdt6gdUFq4eWm/j9Gx/FH9+0D1c8aytO2VzCQrWFm74ygyOVJlYndRy65z0TaLeAXF6VFzIZYOsOYPNWYPZQWrfTJ/O48ZpzMFbI9gCj87aU8Q/ffyauvXEf/uNbi0ezyGBkZARbtmzB9PQ0xsfH0+292oFn0hBwMEIAlr61Q9BCjohFx/qMBfg1BT6IczOog8LJMv4hQ+3jY5CypYPhS2MZRa63LGfEd02CC3lPu87zC/3WntF0i6VTeL+RtofrBp6fNq1JeoPSSceKEwckzWaz7/56QPIwQiLI6iRk/DUP2lIesvNJ79nnJYca5VgRsla+doYBEYEBn6I+Fi9LkyXxw09gzOfzPdtZ6T0K3W4X+Xy+L798Pp+2nfR2NPlYikDWjw9UmlLS2lvSsRgDDkT4dJaMkJByaDabWF5exsGDBzE/P5+exkp5/eaHH8EH/uczMF7M9YCSdteh23V48789hG7XoT4yipt2Xgps3w4sLaF936eQVGfW5BM+WWS1zG4XCZPNW7/zFIwVsshlevsNnXPy/15xKj756Aq6mdXtvVu3bsXWrVvTNSN8d4ycl5e7rQh40A4dArBadGQQ0sZRWt/AeNbK9fGigVrNCGnPyLxjPGQtnQVOLJ61Z7U8pBxlXUMOnO+UWl4f6gvHSprh53xq+iBm7QiXgRWh0PKVZfMoLr+nkcwztGWX80q6mDvk64lmaHnLPAbN76QAJEAYuUoh8VPqiKRx1/LVTlb0zZNa5AM6FijhAIs3vtWBqZ4hJaQBrBBJoMCVhxYBolC7cw71ej09lKfZbPYcNERrCmj3hTV1wz0GLjftMLYkSdJ8SVaagguBNkuxWwOQP8fBBz+dVS5qpS2+hw4dQqVS6YuqPDJXx8uvvxtvvvxUvPzCaeSzGXSdw+ceWMQff3o/vvZEBdlXvAKF179+dT1IpwNkMsj/5E+i/cEPovHnfw50OkjuugP4vv9htC5Wt/MeegKYP5xe2jaa75mmkZRJEkwUs3jZ2VP4xOMtlMtlTE1NYWpqCiMjI6ksrHVEFgDlO2v4FmFLzjEgxTKiofShfHzPa6BE5mMZcUsvxTpHgwI3rpdktNkCIdLga+ksMKPlLSm2DlY6qot0pAYFaTyNBkY02WughjtHJGMeJY51mnn9Yvh2zqUOIZG2wDVEUhfLe77/Fm1oQCKF4fN4OJEBoLl73zkXVt7aQFoPGPHx7qPYzsMVSgjp+8qy8h7Ey6MdFXQQFj/+vNVqIUlWF8H6XpjGjTiBQcpf2yrH+aRrvK1kWt80n6+95NoUbUBKcMGBJHkts7OzOHDgACqVSqo05HP75ut40/sfwG986BFsHctjsd7FQm11C1/2iitQ/JmfWSuY1Sf3fd8H12yicf31SL5yK3DgMWDbTv3As0wGmQ+8twd8nDqR965fAYBW1+G0yQImltbASLlc7jnAjJ8xImXJ09AiVjoEjdaeDOKx+khLI/uHdZ8/vx5D6cvfuq4Ze82J0fLXjJkEdlq9fWOf35fpuCPEvyXPGsW+V0XjXUbE+X2t7XzRjfXoSl/f0/SOxqf2m6e1+m2IL9IzdOhiLJjQ+LfS+p6NoQ0NSIhiBpEcDNQw1Ik1sgAOz4d39PUgTKsc33WZJlS+9tIlixeLBw2QSSPL02onbVIaftQ3X19CA4ZCicVisadMHpbk/PJoh6yn1k5y0EuFaZGm7GTdrXaQoIK/S4LACC1kXVpaSl8NLnff8DIqjQ6qTRadA1D4sR8zlWKSySD/qleh+d73IllcRP5//wJav/NOYGoaq/MyGaDdBnI5JB/9V2RuuqHn+cWGP8QOANkEqHYS7Nq1K13Iqh1gRoCTZEP9Qp41QtM1x3LuSI8MhGG2xoxvbK7HeZA8+fLQeNLGkbweA3gGLTvkdFjjga7LaGmo/bR6W/rZAl1aHaX+D5FVbgzY1XSQBJMaySiFr29aYDQkWz5FzB0in+2SeQ7ieA+SHtjggIQbA6sRNWVAipA3SiyYCDW4PBAsxLvkzedly+clT1rHkooiVIeY8uR1bjRpoHDDwoGA/PCBz9d10DoTus6NlFQKclpKgkSqc8zplxZZIIdfkwvLuAIgJaCBL/JYFhYWcOTIEVSr1R6wYm3P43UFgMxZZyGzc6eZFgCQzSL7ghfAffSjwP59yL3x1ei++HvhXvgSuJFRJI8+gMxNNyC5+yurZ5qwej+80MS9h2t4+nQJ2Yzeh7oO+NrKGDafuhljY2M9hkAuYpXrjTjooIgIf/uv1QaDjlsNZIfyW8+YieFJ9iWtH1u8WEb3WJwinwH0AarYMmWdySHk+kAadO6I+NrB2ior+6C8Z/Gp1S8WDPO20J7RwJQFRKV94NdD7S3b1TmHRqOBarXat4mA7sf2CfnfAiD/pQAJffsMsda5yBCEFlaFypcegcWfBpi0hosZ+LI86szallZeX0BfO6Hx7FOUGi9kPLmyJPCQBTC5cBiZbheLk9NwuXxPHYgnAiJ8KywZb/7+EjJOcj2PRPzyKHhrjYJVT0v2PD1Py6MgMvrB301D6VqtFur1OhqNBhYXFzE7O4ulpSU0Go2+yMjo85+N7OQ4ql+5G+2Dcz3lpgpwdNTXtACAQrOOM0YcGhM5PLDUglupIvvhf0bykX9R6yVl8Xu3zuBvX75H3X7snMMNj3aB0dXtvYVCoU/m2kJW+uY7tLRFrJoBj6WQk8LvaWDWkkeMt6z9lv1NAyKWgfLlz/PzeagxhliOrZg6+xwtX3n8W/YTyyDzPHwggN/T6qnVme9OHEQn+q5LCvVhXnfJo5zW4vpO1lWWR0cwDHIWyVNFGx6Q+Iw3T8e/qeF4SFzrbHIQhhavWtdCjWuhYgv9yoHKebYGsMXf8SBSIAQQKLrhul1c/NDX8Jx7b8Poyur5FK1cAfed+xx85ZLL0crlexa0EvBotdaONudGvdlsolAoIJfLpdtHJcnpKd5m2uvpfcrD8pJ87cyjH8Q7DX6KjlCfazQaWFlZwcrKCmZnZ7GwsNADWJIkwY5f/Z/Y9gOXI1fMHS0HqDx0AA+98e1oPfx4yqdzDp3HHjM9skK7iWtvvwE/dNcnMYYa8JIdOFzv4K+/tYy/vG8pystKkgSfeqSCn73pCfzud+/ERDGLVtchm6zy9e+PdnHD/Gbs2LEF5XI5Veh8KoYDC258+HZemqbh24M1zzdkrGXaEBDR6ivvaUCFQJV0bizDcCxOECfL29dASww4saZEtbRSnj75WiBH8mmtAZN5aYBRe0a7b5HGv8Ur77cWWTrc1w6SH1meBvYsWVG0SJbdbrdRr9d7jo23yg3xGmMDB7U5GxqQAP0NF1I8vFG73W7qtcpzDXhn8hl5mSeforB40PjQBo80LtqgkFGAUOcZRDFb/PJnKb2MvLTbbbzoy5/Acx76es8G03y7ifPvuQ3bZh7HTS//CXRy+b5dQvwME2BNUdGiWJpq40YsSRJ1Okfy7AMklvdp9QH6yMWpcu0HARE+RUO/uWGmqBApu51/8EuYvuI54M2UJMDYmTvxjA/+Ce7+3jeivW/tHTKYnUXnttuQveQSJAys5TptXP+hP8Bz938TWZbZ1lIWb7lwEmeP5/DmL8+rbasZ5488WMGn9j2E//a0cZy+qYSVThZ3tyZRmNqFqU0llMvlvsPOfDto+DQNP29EAzI+BWeBsRjSxpplEK1nrf5j9UeLfI6ITOMztpbTwkkzqoOCdF4G/1iyonGo5WvJUCNtPIfyk2NZysgCn1b5FkCT0Vr67ctL8uPjIaTngN7XhxBxQGJF1GU5PjvKPxovvjw02tCAxIcYfSu1qdH5kd3aPKXsqBZ69Hk8GmDypeFpJT++PPh96blwwym3xIbK95Uj+SPecrkcthx+As956Our18WzGeewdeZxnPPNO3DfRc9PDROPbvC24W1KRr/dbvccmkVetdwGLOsn15vEGA1NsfNzRLRvftAZRUr4NwHhdruNlZUVLC8vo1arpRGSwtmnYfPlzzlavpQ9kM1ncfof/BLu/4E39fBZ/7M/w+hf/AXc6GgKSr73vi/g0ie+0dcOwOpW3R88Ywz/8kgFtx5uqG2sgYhOksUtMxncsVLApk2bsH37dkxNTaVgQqbn+dCHg45CoZBGv3gkSwMy1A6xgMF33VdXSXIsh8qLAQoybzmWBgFYmvEFwrtWfEbbAjI8/xiv2AIBMUDOJwcff1paKVcLhHB96Ss7Vm9oaaQ8pMx5vSxniKfR+OQ637nV7b6NRmM1gm2ACAmoYj5a/QcBIkQnDSAhIyaVnjTQRNzoSYFqA0BLI/OkfK17Vh1kh7QG76BEdSdjD9jKSQNO0nDLwacpA/r9jAe/jm6SQcbZYO3p99yeAhJuuDjo4OtIZBnOuZ40nU6n55wKHq2S7aK1m5SDlA/lw2XJ+w8HJQB6QAm/TvV1zqW7a6rVajq32+12sf2N15hyW80DmLjgdLhCDq7RWmuD/ftRfcMbUPzJn0TuO74DSS6HH7r7k+gCsFYPtbsO15w5ngISq90JKBQKhfQE1k2bNmFychLj4+PpFBqP8kj50nVtrYjcTcPLl4baL5vwjgEfCLfGhxwXVnoLjGh92CrH0iNa22hgivdVTX6WYSSSO7s0PmOACEXKLONt9bVBFqBb8uHXpTPG5Sz5kc9Z/Mu0sk21sz0sGVl9VitXc44A9EUS+bN8+pgAibZUQe4G5GX69Od6bZSkkwKQWEhUDlh+nQyEPOchZJh4uVo6X6Np+UietLpoaF4b0Fr+6/G0rEEriZ8NIvmdXJz3gpEEwNjSPBqNRt+2UMobQM/CY74AkvgE1qbe2u12j9fNwYl25oVUFFp7aO1M13m/kbKT643kinZKs7Kykr6vhura7XZROGWrKTuiTCZBftd2tB5+vLeNDh5E43//bzTHxpBs2oTdz+ogm7fbP5dJ8LTxXA9v9FtuxR0ZGUnfTbNr1y6Mjo72RKdI8csdNXwscmBDC5X5otYQWeMjhnzAW4KNEEjnZccqZN9YlGNaS2cBEJ+O4NelztTqqEV8pVGSPFt8anILGfdBdVYIfHL++b1B9TO/JsGejDxrusTHrw8ga89q+si53iks/jyfCaDXUVh8SL3m+609rwGZ2PFxUgASeU16GHJtCA04MmKDCm0Q78HydLRnLCXI/2vPDUJU/0HPc/Cllfecc2iUyugmCTIe/urZPBYWFjA6Opq+ip57xxT6z2QyPesrpGwzmUwaIaEFsdKYkiEkkGLJTQMY2nVZf015kNLiyoL+ZzIZtFotLKOByaufjT0vOhu58TJaLYd6toj26Cg62RYa3Sy6sM5hADrzCz1GpqdNqlV0KxUsnrcdU3l7qHe6DgvN3vlmrmiz2SyKxWIKROjAs4mJiZ5D7Hh6vqaIAxKKhORyuRSMyHfbSLlznjRjpl2LIVlGKI8YI6Y9rwEILb3mQMXe95Uds1hVPmcBDw0ohMbSep0mDSxabcXlo/El9TAHEBYIpWvWVDcnbdeLD4xo9sEnV+7kSLnydtXWjXAea7UaGo1G35vueZkW6NDspC/temhDAxIiTRB88Gq7Y/h8vgVIZINr6bROFGPErXr4lJGWxlKSWl3W20l8/FLesqxHzroIpz98r/lsBwm+NH0q5ufn0zagUzkpD27ouEcnp0qSZG09Al9QyheNkhKiyAmAPs/d8nZ5n+EkpxakTLQFx0mSpKHT+nQeu952NTKlPJLsaj4lB0wkQK3bRaXTQinbRqVdQMv1Trg4B6wcPIL2kSWVZ043PLaCn3n6uHl2SDaT4AOPrfTIHUAauSqXy5iYmEhfkjc2Ntaz3oOMHm8zKRsOVAh88pN4Q2B70LEUS30gDrbxk/xYfWY9fGv9R94PGXaLV62MWGDCn5WgRDP+Ph3mI0v2PAoRAijcUPtAakhnyucsR9GnLzQeLLnw9JpsSQ5c/xFJJ4fLTMrVudXXdtTr9T6nmsvb0n8aae0h7W4MiCba0IBEG1jaoJEeAqXhe7HltIAcgM6tneWhlaf911Cwj7SGixncvsFl3VsvaOJ5+KIs+04/D/PTOzA1P9M3ddNNEjSzefznrqejVqulnbdcLvd4JIViF4VCF81mBu1WvmeRKx+YJDce/eh2V88y4fxSNKzRaKhTCtq0jmw7qSBl9E37BpBGeehwtEp9BSM//8IeMLJa3up3OdNB23VQ7+YwlmtioVWCAw3+ozJ+x7uiQOw/PFTFa84cxWQ+0/dSvHbX4dFqGx96vNYjD4pklMtlbNmyBdu2bcPExER6DDyfEiM5yekdkistOuZrRvj0mwXqZH3kPZ9HaT3PyTJyWnrffwlM5LiPGWOW4dKAkS9f37j3UQyfPp1hlcuNrFaOJiurHTQAoj0r78ek5/ZCggILjFj9lOfD5eXr11Zkg5elTRFzcCLXUPLFrPQ8HZ8gIyQy+mLxKWXJ08trWpoQbWhAQgaKk9bhJUjgBku+6AzQ36DLFzJaZA3oWDBi5eMb0BaFAAlfrBVDoU7aR9ksPv69P47vuvl92LH/YXSTBECCjOuiMjaJG1/4SuRyZZSOHgTW6XSwtLSEsbEx7DyliLPPb2Jqunm0HGBpYQSPP7IZ9ZWCupCUiC+spPrJwcz7hDX9Jj0MoP+cBHrekgkvq91uo1arpWeN1M/bhMnJsik+54BypoWGy8E5oJhpo9bJI0mAbtdh3x/8A5ZuvCXK+5htdPGDn53FX1+2GU8bz6PdXeUxl0nwtSNNvOH2I+hm88hnV+tWKBRQLBZRLpcxPj6OnTt3Ynp6GoVCoW+btZyzlu8fymQyKJfL6YJVkqNctNpff3+Y20pjy9P2VjVDawEQCwQNAgB89eXyoG/5agRNBxyrgwEgeCIwBxcxByyGQAXn12qDQdPz5yxZaFO/1nOWQyLBgwZiNP6tdFJOmnGX1ygPvn1XAiJq02539biElZWVdA2JbyeNJn8tnWVf1zMugA0OSDqdTo9RlYNV65QkLFpzIIWvTe/w50I0iEIYtLEGIUsZ8LJ9842cfANLlsHLaY6M4eZX/hQ2zx7ArsfuR6bTxuz23dh/6tPQdcB0vY5cLofFxcU0jDi9tYtLnl8Gd+STBJiYWsG5F9Vw/z2noL5STiMOfCEo0Ksw+PZTnkZ6DT4kLwEJj6jI+jvX/zZmWtVOb/GtVCpwzmH0rKfBtTtIcrpiTxIgB4cEbvUcl4VFHLn7EFbufgAz1/8TXL3hXQsj6/BgpYMX3zyD528t4rlbimh3Hb4w28Rdix1kMlmUSoUUjIyNjWF0dBQTExMYGxtL39bL13nI9Tl8nQ7fjs2navjx78diOH3kk0cIzMv293m2vuuD1M1K6zO8McBpUPKBBp5GM1Za+N/i39IbFvjQrvP85LOyjWX5sW3jcwotfvk9CeAkUNCesQChtEscgCRJktoxDUwR0WGVtIZE03u8LdczPmNtpI82NCDxNaJlaLnwyVjwBqVG4enls5xCDeeLmvg6p3YuisZTqBxpIPnzoQ4kgZ4kGR2QA5a8qCNbd+HI1l3pvWwmg1zSu/1zaWkJ9Xodz//OApLEIRFTC0kCZDIOe54xgwfLO5DpJEgezCLzWMbcHkz1l4slpSw4mNHkxPPixlfKgfIioFSr1bCysoJGo4GlpSVUKpX0LZsjAw7czsOPY/7Nf4xOp4OJYgnNJNPzAj7nHOAcvmtTHs8Zz6HjHD631MFXK71rbW6bb+O2+Xb6P5PJYHR0FKOjo8jn85iYmMDExATGx8dRKpXStSI8qqEBExk14QuI+e4ZKzoQY/xDaXwkDYMvDx8YOF7ky1MzKsfCi/WsBWZ8bcAjj9YUtgY+fLzJNuFHtltgRN6zABGvt4w+SR3L85Nbaa06+oCOpXNlem6TJJ/Eiw8sS6BI3+R0k/NWq9XSt4jzKRtNJ5INkuBKlmWVL3mJHbMbGpDIRYNaKMwnCGokWsdgoWD+n8oKUQxQiRmwsnOGlIs1QLgHQSQXhvrWQ2gDKLZ+ciDR87lcDiMjI+mOi7GJOianqp48gZFsE2PjK2jm8yhtBVrPyaD2iQI6y510gRc/94P4JgPJ+eAfWV8OMvgzXAZyTUuz2US9Xk/7FB0LT/O2ANZ2ljxWMaMjq/mvLvx1SADXRfGReZx11lnpNCNtFaaDjs7MdfA3Ty/jtFIGra5DkgD/T5Lgq5UOfvq+GmZarieKkcmsnoxaLBbTnTP5fB6Tk5M9i1apzhyAyIPL5Bkicspm0O28PoN4PMhS7PRf9nkfGLL6t5a3VR7/Xi/5wIYFPOQ1yadvevpY28KSmRy3/JvaQe5o0UhOJUq50wJ4+q21B5VprS3TIhJypxqPQvscPN/SA74Bgz/PF7FyXUv/+anR9XodS0tLmJub63PCqTyp0+Q6E5639d/65q8D8dGGBiRLS0vI5/Npx9DmpkkhakSCqtfrAFYbloyWz7CHQIv2DL8fIt5hJUiwDKQvL1+5Gr9ah4qJ0mh5a8qcKxYAqSHcvBUAbEBCVOh00MrnAQC5QhfllzRR/1AR3c7auTJUDv3n+/ClJ68BWV99OOjh76mpVqtYWlpCtVpNF69SRCRJEpRKq8eql0olFB9rolFpAiN5QNn9kiRArZMHug5Jx2Hng8vIn38+nHPp2SXVahXLy8vAkcN43yl1jGZX+c6z/C4czeCfnzGCl3+jhXaytsuoXC5jenoamzevvpW3XC4jl8ulUREeBeFji0/FcFCiHUgH6BEqS7brIe7NHQ+S/dXHtwY+rDx5mhhHJJZi5ap5sdyIhUCL9rxWB6uO0hhbOlE6kjKqEQKtlJ4v8LR4tMoH1kCK1U5yhx4R39lH+cldMBYglPLRgIVml+T6OL7LkPRTpVJJ3yRO93hdpF6TfUPu8uF1sKa9+W/f+iROGxqQLCws9B1TTQqRv6hLO4qa0lcqFYyOjqbGhe8CkOkH2Z7IOxhHtjGkPauVRZ1GRiNCgzBG+Vj3fLxKvvgg0UAJNyKr0Y247tjlXgmAfL6L+tPawLd6d8vI7cEESLiykWshJPrvK/volEyz2UznZCuVClZWVlCr1dL3RPCX5NFW14mJifTMlVwuh9YNj2LhB86AK2RXK5IkcO4oGOlm0ehkkHQdTvnQfRgvTyAzusY7nba4srKCFz3xFYw19iOrtHsuSXBmKcEPnDqOmxtlFItFlEoljI+Pp2eJlEqlnvfIaFERLeKhrSGR62tkP5G/03ZUDKvVt6jvSKUo0/qI91HtHuUVw6scfxb/Vn2s/OU131giktPNUjdZBsNydKznY0GgjBDwvmXV0YoE+IAM14NSF8qohXxWtgEHNLwOUi9zQOIDWlzO9D4uKUNuk/hGCw5GuNw4b3w9JP9P0dpms4nl5WUcOnQIR44cQb1e74lY8H4rp72lPrSAre/aIOB7QwOSer3eBxKkx8YX1XHvLUmS9GTPYrGIVquVhrFplwHtKuBGTOt0vDP5dq5o6FaSVDhWWs6LtbhRgiE5WDXeQusoiOR0mfzN0XTIy6F7S0fK6HQSZLM2CGpnMqgdPask5RFA9rQWlr7SSr17nj+BTX6N+gi1NzfE8ln+YjxaFNZoNFCtVlMwwtexEPGtsxMTE5icnESxWEzLyS90kP//HkTl/Ek0zp5At5xDp5ughjxco4Otj8xjyzfmMFp3yE1O9smQPi964tPIesZ8F8CrtpdwcPLCNELDQQid/SJfDCnXh3BwIp0A3sZcaWqKyZIzv+br+5pRscakRlb/s9JZ4MoCIPwZ7VkuX61usfxrddHkqaW1xrWVR6hNfOTjhcgCf0D/VnuZB78u+6V83qcveV4yHwmM6JvnZzmr/EP80D2uL+h5/uI7ufmCP0dAhaZxM5lM6hiRo7S8vIxKpZJGU+fn5/veY8PHk3TitLpI+Wv/Q2kt2tCARFvESMJttVppZ+Er/2n6hjpZLpdLF1RSQ9CR1iMjIz0v/aKOwOfsSMGTUQNsL48+nIf1Eu/kGjiQA1iWpXlcMn8faR5pyEOUv+XCMecSHHhsCqeefsQsd25sfDWEIPlJOjh8+DCKxWLPYko52DgRKOFrS5xzqRfTKSZwIxk0F1dQm6ukL8KjOVialpHrTcjIl8tljI6OYmRkJF0rQ4CVnsnVu5i8Yx7JV46kfPQeGlZCfiLfp0R525e6a+etaJQBsLVcwDOe8Yy+KIgEDLL/aFM3sj1jgDanmP7G87Welf3NZ9RkGaF0GoCOyXeQsWTprliShi5G6ctnJGlg73gSydanP7T+KPPQ8qTf2lih+9JhlPqP/kvd1e12kT86TazxIp1IqiMf61Lf0j3ZhjwCS6eJyzOz+Evy+LQxOUz0biyKhtAp1jSFzJ1szhvnySf30PjxgRMfbWhAohEXMI9qcPDCQUuz2US1Wk1RJR2aRUaFH3UtGytJEoyOjmJsbCw1OhSO5+sVZGRGWyxI+UkP1beAi/MjvVSeV0jRaZ3SSuO77vvN+eH/gd6Q6IHHJpDJOOw4dQFJAjgHJJnVKMjc+DgWR0ZUPqpzdRw6dMhcXMnlIw0IlU0DvrR7EoUrdiH39Kk0feveWczd8E1UZuZ6+gCPIhC4obUiHNDyaSReJl93QUCKQAP/cJlKBVcZ24yphYPIwPAwkwxWNu3Ali1b0vpwD8xaHC7XY2nAwzIYGgDm3z7lpuU/qPflnKMN03DoXSgpn/fVIZROGi8+lkLjz1oTINP7xpVv4an2jPSA5ZiQdfEZEhl95aTVIVYPyeetdrMcHi572Q7tdrsvMmUBbJmGdhjRmJVrQzQ+Sa9IO8SjrvyFd/V6vScCW6vVUr3EX4/BgYgEE9QuPLpC9ygiwxf5y3tcjlrbPFlgFdjggMQaMFzp0n9aVU0dk/63Wi2srKykHafRaPQgSIuo85EBzOVyGB0dTQ+BIuBBnrJc12LN2fPoCQ8FyikFn9KWcpELNzXQIpH7IN4aHxCadwHoJxFKoHK0Nnji0Skc2j+OTVsqKBS66JyeQX1LAc7THg985HG0Wq20/XjecppBkyE9U9g9jk0/dS6SXK+8yudO45Rfej4evu4WVO+fQ5Ik6S4VPsVHv4vFYs9iag5E5Jy4XO/EAQlvN+rTUlk8+LRLcMkdHzJlk3FdPHrWc1MPj+dBoFfKA7DXTPlIGjY5/87vyd/ymla2tvvJzJMuuX4jIZ8NASvN6PI0vnsaWUZcPqPJU7suy5X/LafDMi4aEPRRTDpLV3Pwy9PSdQkmrbx9AJBf08qSAI0/I/sBPc+nVigd2RS+loM2ThDQ4BENDkIogkHbcqWzoPEredbamQMVnh85P1QXeViaT95PFm1oQAL4vR0OSqTiKJVK6Ha76SEx0nOgZ+V/TkmS9CwOokW2fL2KBCJ87QKF8aWXzb17GgDyjbgc1FgDh64BvavC5cJDDhb44s5BlVEI1NA8JwcLPA+uUJqNBDP7J1ZvHgLGrmohmx6efvQZAAmAhX1tlCojmJ7OpYtKSRF0Oh1Mbstj97njyOQSzD5ew757F48+udaOVPdTXnPRKhjJCi8qmwGSLnb/9HOx/3e+gEwm0wNEaKpIvmxOfuR13ta8L0gj7gMGj+y5CHv23Y1thx9BwqIk9OvhMy/BwrbTkVH6BoGSQQyQNqevKUSeRpYpQXDKswIsYnnz9VkfgLEAhRY5kPeB3l0KmodtjUtZLgdvsmwZ2pf50vM8fUg2of8WaUBK/veBSp6O7nPwrvULKT+Zh3Vf8it/Sz2vnc8h13zwyAZNk/D1HBS9oOscmPC1ZvLVFpI/+V86kpY85HNk24gv51xqa8ixIueZ6uDLzxovXG7rpQ0NSDSvWxIXkFykR3nQehOfApQonndY7vGSMaRnNGTNjRKfVtCO3eZ8UwdKkqTnwCo62wJA6rVru4xKpVLPugkqn3vvNLD4oOMy8cnZ+s/lStvi6D+/Z3X6pJag+tE8ii/qoDjRSaFEt5ug9nAGjc93sXPnznR7Gw38Dpp4zlUT2PG0ErpdBzggk02wstjGZ//5AGYfq/fwXNw9ifLpU2Ydk0wGhZ3j2Pas04D9tZ5pGpIzb2uubOW0EW9/7UVz0uD7BrnLZPG5F/4PPOMbt+DMB7+MQmu1XvXyBO4/9wV48JzLkCR+gMOBg2YsiaQHpY2VEM8hD30QIKL1HytPqw9bBlYzqrIcPi3M66aBMcmH/M2vxYARjXfNiFrEy9fSx8pLAi6tXlY+FjDjfPF2kE4asObo8J0mPBrA5cenMeS6DOccarVa33Qm14U8csHBBl8vEnJsiafYnWGyb9AzMWCP+qd8GzoBJ+rDfJmCnL7hPFr97XjRhgYkMUQNQxEFmmLhHYeMZAjc+BQJv0/EGytJ1hZy8QN5rB0LkuRuB268ZJifh/7z+TzGxsbS7Z50FPj09DTGx8cxOjqKdrud5kMhRNoyRos45WAolUppBALoBYeaApWIXsqa/9eiA0klQf1jWdQKGWS2OaCVwM0kgAMKhTXllc1mjyqcLs65ooOxLQRG12RaHs/iJT9+Kr74z1WsLKwpi/zZm/vkrtHoqZuQWV6LTHFwJ9uRD3je5rxNNaArF5xpcuHUzeZwz0UvwbcuugLj1Xl0kKAyNg1kMkjQq9Qs79Xy4jlv3OD4FGIMQA1RrDHVlKIP6Pjy1oCYDzhoU2gyP/5cjPGJ4Y1+y/bUwH4MKJCfQfjn/dzHk48XzivXKdzBozJGRkbS7fPkUDabTSwuLqaLQfmUCE2h0DW+MJQWeUrZy6h5TJ8N9Tl+bZDxEAKGvM21JQD1eh3VahVJkqS6vtlspjaPdptKYO0rez11jqH/EoCEtnbyxYWaRx5CnxLRy2fom0978Hzk4ictf/7NiVCrLJd7DNQZCe3SAC4Wi+ni23a7jenpaUxNTWHPnj3YtGlTD7jhnsTy8jIeeughHDhwYHVK4mAO6Kwa4HPPPTfd007ou9FoYHFxEbVarWegS145MOTy53LS2iJJEiSdBN1HAaB/zpjO+3DOYXRbC+PbVgAoCjSTIAPg3L0TePyOtS2v7ZEiVtSW6aWxfBmFqWxff5D8yrpxIEltQ+3NjVuSrEWSpAGi9JrhSJIELpvD8tSONa9MKD7Zt2QZnDQAJOtnGXDtt/af9zmLNGCkXfcBg1iDIvMkstYxaDqA8yY/nHx6QNMbnEftNyeKGGh5a/99URhelk8v8t8SlJBO4Ybe2mHCIxD8PB/nVndAbt++PR3r7XY7PYX0iSeewOHDh9MIKQcfWkSCL0q1+gcfk1Y7attyuVwlhRxfzmMMmKN0tAGD9Cdf09btdlNdzQFkLpdLQYqM7qyHjuVZ4CQHJNR5KFIgBzb/1jz3EDq0ytMUj9bBNLIGvcYzgJ6oC4UuAaSdjBQTX7VNJ30+/vjjOPXUUzE9Pb16eujRMzKoc3MlJHcGbdq0Cc65dJ6Ufs/OzmJ+fh4rKyt9SoCHBmnNBQdwvP4EHokfPq3Fw7X8PpfzxCltuC6QGGM/yQCbT3fIHN6zll82wd3NGjoF/RkASFoOW6ojSCbLPcrOOX27HG8z30LpkMcU42lyGRJf/JqlNDVPS96zQIDPgGl8WmBb7hyy6ibLtsa0HEMaeNTyt3iU8vKBkxQcsjLlAmE+pmTf5X1ay5fzqxlZCsfzBZTcMGoghH5zvvl1CTJkGg4k5Lul+EGB8gAvAiqUJwcP8m3spBeWlpawsLCAUqmURnSXl5extLSUTrnQ85QnyUhuDuBysNra6ie+a5ZhjrUD67E98vRpki1trKhWq8jn86mz2Gg0+sb6sQASS2bWNY1OWkAivVJqLD5weEeVCoBf58Q7uEV0X9ulIJW/RlLJWWmkcuVzpgROCJBks9l0F9HS0hIOHjyI+++/H1u2bMHU1FQaQZFTP3Nzc1heXsbY2FiqODqdDg4dOoRqtZpO6QCr26jn5+dx5MgRLC0t9XhFZDz4wJFraoj3JEnSdTD8GfIAaBEWX7vBI0WZTAYu09GCIz2UZB3OPuds5HNrO54ylYP46uYnzGdO3z+OPTvH0igGKT46EZFW1HMFz9uLfkuwQHXUFAJf6Kj1Cwk6SO58/Y8FMDSvSPZ/DWjEAGsNBFlprbVK0vDzfiT58Y0bvvicy19zRCTxvC2AR/ck+JDeOB9bExMTKBaLGBsbS9eAZbPZ1DHgi+MlmCWPVgMpfBzOzMxgeXkZtVotlbPceiqjEtQneDpprEjXyHUYUrcCSMuIMVTUzyUPVD+ahllYWOipg8anljdfsGnxQuVbPBLJjQC+9KHoOJHvvgaCNeK6gupBxxFMTEygWq2mAJEvvJWvTaHfXB4WoD9edNICEqAfjfIwHheqnDrwdR4NpMTw8GSQ5W1KD4g6XJIkPSvDa7UalpaW0kWxXBGS0qR98JOTk2jnVwdzp9PBgQMHeo4vJ7nSe1xowRT3cEge3AiQkpaKfnl5OU1Dz/D1MXy7NX+PCg28bfOjmNyVATzyLyRjOOP0M1Ijl81msTPZibHuOD6f3IcuHDJI0IVDggTPqp2KZ43uRvbstfKTJEnnr6vVKubm5nDo0CEsLi72KEapsGjNDe+jfAqHEzdy2tobCcZofrzZbJpTDUQEpGR5lLd2iJ8GmDTDKA25BWw08OLzJPmaL40vaTzJG6TfmgesgYjQ2CUwKqMXXH/wQ/dojI2OjmL79u047bTTMDEx0RN9lOVqDk2MTpmensbCwkLP4VgUQaAIBj9wi67xxZo8AiLlK2UgdQ7n0bcOw2fcZDtyr582IvBTSqms9Xj5sv9qgFd+c8BOdZbPauNC/o6lWMdA2ijSsYVCIV1PyNfaAGvrGrlzB/SOC2prjR8fj1ZajU5aQEJCJKMhV0Rz0oxljFKKQawaonyyQIo0fhri57zQoXDVarXH+ABrHZM8y8XFRbRPawPZVQ/szjvvTGXJF7fSceqWAeAdnUd0NKAnDTSVRUonn8/31JnydM7hwa90cOpFmzzSSrCz/Kz02HTueT8f5+JZ7gx8E09gGTWMoohzcSpGR4qAci5buVzG5OQkOp0O9u/fn75Vk5+0yGXBlTZXaBQhkSR3XnGvmWRaKBSwadMm5PP5dEpuaWmpB5TwCAzJlIAp8cfbh5fH24fzKBdbS3AkZcs/g06PyrEUMty8fy0vL/etX+BTbVIJU55WvmQYm82mWn8CIxTpo//0CoHzzjsPo6OjZj1lmbH3eB+rVqtYWFjA7Ows5ubm0nct0XiV0ydalEMzKhIg8P4sgSDJX/Jq1UH+l4d1Af0HRnJ9NwgYke0rHSUtXSxYlWkGeS5Esk0s3cAjR3S6ND+8Ua6zkQCK8iY9q60BsvhaL52UgIQEStuYKFRnzfGHOp+Vv/U/lF5eG7STxgAh6zmuLKnj0hSINhBpkEvETSvaeQg4SZJ0Cke+O4bkTMqeb//lvPH/3KhJ3nm+lBc902g0UKtl8Y3PZ3HeCyb6vUqXoIBJlGqnodKtpNuhOY0kRTwHZ6pyttqLwu18pxKdi0Jy5MZMRpBowGveGlc05OmUSqV0odr4+DjGx8fTxcjz8/NYWFhIy+dtx3nRwuJae3DiaSXQ0KI1EjRqxlsDo1Z0SWsHbRqnO94FMqt97fHHH1flL6dttbqHFK/WRtROxWIR5XK5h18CCKVSKV2r5ZO3rx2o/WjdSL1eR6VSwezsLO677z7s27cPR44c6XvdAXcUJCDh9+T41epPMpK61Pes1qZaGg10yD4g+8og+ljT5ZZ+t+xCzCLVY6FQpIGcGdLj5XIZzq2+FZxkQ32E9Autm+OvROFtTn2BTxdzp+/JopMOkJDg+HkcNNgAHY3zgcjzeaooVK5PUYVAiVSyHE3zqRm+HoM6oHOrC1UzmQzK5fJaXpkEk5OTadiPK7kkSXrkyb1UX500JaFtieULY6k+5PlzLyGXy+HzH1jG3IHNeOblWzG+aTUS0mk5zD4ELD7QxTczH0/fWcQH8wg7np4GML0kj8BtqVTC1NQUSqUSqtVqutDu8ccfx0MPPZSGxynCJL1JDaBwJSANNf9NJwHTehXq23NzcwCQRkfII5beLm8XaXhiyfIaNQPi8xT5fckDX3ehpfcBWgDojq4CEudcuqaJyyDkeMiy5T2tfvyEXQIAtOUyk8mk/WR+fh7nn38+JicneyKTWjk0xcrXJlH4nE75rNVqqFar6XZ9WsdVrVZ7wvLy3UvcAHFgKo2sFvnQ5KHd9/WtQfQd3ZOOCl+zJ6d4fMTb3+Ix1EcGpfVEEaRsNXtBupGmZprNZjoV3263037A1xZqTk8K5ru9xzjwMmTa40kbHpDIzkceP3m9cqBZz/tCfb4OHtv5ted8+cXmaQ1oy8u2QAmAPg+dPnyBJAAkSHrWm+RyufTEW5qz5gAwxuBZXpdWv2w2m84hS4Uhz2q57eOLuO3mh7Fl1xgKhRwqR9oo5Eo9p+bSjh4OzEgePMzJo0rkAVPok96mSVNgFCWyFoNRO2gy0DwQ2R7dbjfdskfPEZjkbyGmKRsOQix5W+2igXWfl6nlwdOFnuWAWKaxIhoSvPLnyaj76qEpWHrOqo/PQPP85VqXQqGAffv24etf/zrGx8fTCIpGBDwowkGy4W3KPWD65ous5UJVrX/J/qHxEQMs1hstCPUJamMuU+KbvHhrvRTPLwSQv90oJjpCdaezRajPcuDB+4jWJyXg9/VxS8Y+oBqrbzY8ICEiIZOBIcXtAyOD5M2N6pOJltcLQmLSyPI0D1MOXvpww0fhQFJycjpM5rte+WveACljrpA0pUI8E808tpSCCJRWPSky4HJBF0XV5PsdqI/J03XJYNCHv1NHyprLg4CPZSB4nXn5SZKkAInaoF6vo9lsYnx8PI2cECDRooM8aqIBxpCXGnON18XqG1pkw5eXBkj4f23qhvdXDXTEjufYCAGvg5Y3RW8bjQaWl5fTl3jKNQDUP/miU5oWlJFM6g90yBUtKpdrQvjc/yDjMgQ0NK85FHXwXZeAdFCdGyoj1F8HSR9Dx2KDeL/T+hlfx0e6h0fdaBqXr/ULOQcSyMg1I4NEwQahDQ9IqLPSgKTBrU3DWMQbWiLEp5q4guS8SGML6POwRHItAs9L60whtCuNplx7EFLSGtCRNIhhkDLgnqOG/ulDUy+lUgnA2lsv5RQWDW7pWTvn+o54lzuJ5Hy6JRtt8aDmzck6Ar0nvpKXSGdP0KJWAik8PyJtzt8ia7otxrDwcvlz2m/JowUWrGuWQXNuNWpk9f1BAYn2XIgnDpgocks7Hug1ELRzi+8mk4Akk8n0RH/5y0A5MKWwPXnEnCe5WJx+H6vOGxSA+NJYAPZ4UYxzafXL9fQXem7QiIGVl7zOdYnc2txut1N9QHxIZ0DqTP4qDL4Di8iyrcfaRhsekADoGcR8msZHvvBXSEnFeEjrIenZcTASCpn5SDPe9G0pIusZAOnr3EOL/WQ9Qjz7lIQGrOR9CeboOh908jl+uiH3TPl9PkctPXGKyhEAdM6lIMDiSQMrmndJH74CXjtVlz+/sLCQHoDHX6YV27clWdNmMaBA1pUrQQmueHr6lqCal6uVL+fGZT14/sdKFuCV/ElAQnP8+Xwe4+PjfS9m5K8hoP5KgIR2rhH4IIBCkRNyzPg0ncYTyUGT03rlE6szY/uSvH88wYjGl8ZHSEYhPWw5eccLjBBJoCDLI8eKL2bWQCofO61Wq2c7ugQglq63eI+t84YGJARC+IvmyHMflDTj+mQNAossr98aAL4ICV2TIWwrVC8Np2Y8iRLY0zuWYbXq2pPvAArNZ8j5rhsqnytxeS4LBxl81Tk35rwcyouv7ud1IINCZVjy0ICnbG/u0WqGjyiTWX1FAC3O5Qadh+olQPDJ2vofaqeQ0dGAlMzDAhA8/9ixIflYr9G18pQLBGV6Sx4cBGvtSr/lG1rphFJaR2UtvAX8ztPxAmc8X8uA+/qQxUcs6B2URytvX3qLrxgHi34fS9+LkZEEGzSO5DoyLbrBnQOpZ2Qf9fGjOWCD0IYHJByMWAu2Yogai29rihkQPtTo8xhjB9Z6G5jScg+XAzZabS2nGzjv9AzlQ1RNqvib8b+BgwO9414CmPQ/S3NcKKGveKVkebPWfTis7s7I+xmntC7n4Cb9II6oRx5JejGyIv56k9LoAZwe+Tt2I0HS858lMnnpv3R8Abw7eiDduigBmoVmmk/IcKzXyGmgyEdyfNA1nh8ZAZo6pDFIpxTzd3Jp23Vlvta14w1IZFny+nqALL+vGfhj5YlokPa3HMVYPtYj9/Xqfr7Gj9tHq/3luS8cOPMDKHkaoHeb/rGMpw0NSOjlQcDa8cRa+JfIAhiW1yrJShPT4bV7Ia/mWJAmPa95otQ5taPKNZnJqQ6XOFSSyrp42lAUO6ae2kBamOxdpEOC3scHHWMyiinz5lEvbaxbUTCgf2qQR8h4xI8cDBkNXK8+ejJJ7oDy8aDJStOFsXX16fIQTxZ/Wjtr+foiVMeDrLzkImZt67/Mx4oGEajpdDo97zvjjp0VAaX/sYGCDQ1IaO6evPxjOaCGhMbXD2gdTV4LdS5fqC0UhovxGuQz8r4VEucf8sK0/HhHLnfKfYpW8tKT9/GOjnAaNFKS9E41WeH02HAsj1b0RYWMfnG85OGbjqDye76fzHb4dqajYio0PW9LxPoiJJpxov88T5+B4v2GdBm/Jnc10H/uxVKUU/ZbOiHY2llkRVOOhWJkOIjhl8SnIDkIo2c0uWltEANQBuVNS+cDS4NGZCyK0VdS38fkqwEXWugvX9fBz96R/T7GRnLa0ICEd1BO6wkXaQ0gBTtoxCIWrFjeEwcJoToN0ujOrS6U49tbiWQ5HJC8/MDLMTo6msqCtpvRDo8kWXuvCx3YpK0F4O0l10doSFrzrjRQ4TtQLEnW3nMzMjKSLiSUW3hpt4Ol1LnXSrsiVlZWsLy8nM7ry/M/uNy1AerzoqQ3xvkkMOmcQz6fx8jICJIkSU+I5e9tkmVrY8bynnyG1Zr6iiWqk9ZPZBl8DPJvckZ8U3FPNmlGxmpXbjh5pJIvVJXhdbn1kp7np28SD9Q/aDzxMSxBjwTS6yUpe424PguVZUVJtP7C+7Q8dViWoy1sj+FHS2s5ZzGA53gAQCsf3r/kJ7aPaveoP9Fiea6HAPSBEt+xAhZtSEBClaNFXZxCg0JrEMqPL4b0PTcoytc6a6w3ETPQY0iGgPkgpsgQgQtJZFwBpNEUAjPUBs1mMwUm/J0Zvr3rxBf/9qXhvzXwwQ9O0oCLXIHO50Xp+Xw+nyo1vt1SticBHKo7vWiQfvM9//SM9N4suVj/qe1obQE3VHy6kmRPZ5BIY8n58AGAGOVF5Bsfoec0QOID4rIsCUg0XkKe4XrHGH/O2uHD0/JD/ehsFP5qAQLElBe9jZXGKQfMfJE2gPQ1AfxkVxqD2plM6zEYxyKfQWTM9RUn0l2aA8PrKPu7Bmb4vVDbWaTJTp4szL+fCpILWMmxIhDh493HMwdepPu5E8e3Csu+ZZ2ULmlDAhI6IvvRRx89wZwMaUhDGtKQhjSkGFpeXsbk5KR5f0MCks2bNwMA9u3b563ckNZoaWkJu3fvxmOPPYaJiYkTzc6GoKHMBqehzAanocwGp6HMBqcTKTPnHJaXl7Fr1y5vug0JSCiMNzk5OeyMA9LExMRQZgPSUGaD01Bmg9NQZoPTUGaD04mSWUzw4Ml9b/KQhjSkIQ1pSEMaUgQNAcmQhjSkIQ1pSEM64bQhAUmxWMRb3/pWFIvFE83KhqGhzAanocwGp6HMBqehzAanocwGp40gs8Q9lfuRhjSkIQ1pSEMa0pAU2pARkiENaUhDGtKQhnRy0RCQDGlIQxrSkIY0pBNOQ0AypCENaUhDGtKQTjgNAcmQhjSkIQ1pSEM64bQhAck73/lOnH766SiVSrj00ktx++23n2iWThh99rOfxctf/nLs2rULSZLgAx/4QM995xx+8zd/Ezt37kS5XMYVV1yB+++/vyfN/Pw8rrnmGkxMTGBqagqve93rUKlUnsJaPHV03XXX4bnPfS7Gx8exbds2vPKVr8R9993Xk6Zer+Paa6/F9PQ0xsbGcPXVV+PQoUM9afbt24errroKIyMj2LZtG37xF3+x5yWFJxNdf/31uOiii9IDlfbu3YuPfvSj6f2hvML0jne8A0mS4E1velN6bSi3Xvqt3/qtnvcRJUmCc889N70/lJdOTzzxBH7kR34E09PTKJfLuPDCC/HlL385vb+hbIDbYPS+973PFQoF97d/+7funnvucT/1Uz/lpqam3KFDh040ayeEbrzxRvdrv/Zr7t///d8dAHfDDTf03H/HO97hJicn3Qc+8AH3ta99zX3f932fO+OMM1ytVkvT/Lf/9t/cM5/5TPfFL37R/ed//qc766yz3A//8A8/xTV5aujKK6907373u93dd9/t7rzzTvc93/M9bs+ePa5SqaRpXv/617vdu3e7T37yk+7LX/6yu+yyy9zzn//89H673XYXXHCBu+KKK9xXv/pVd+ONN7otW7a4t7zlLSeiSk86/cd//If7yEc+4r71rW+5++67z/3qr/6qy+fz7u6773bODeUVottvv92dfvrp7qKLLnI/93M/l14fyq2X3vrWt7pnPOMZ7sCBA+nn8OHD6f2hvPppfn7enXbaae7HfuzH3G233eYeeughd9NNN7kHHnggTbORbMCGAyTPe97z3LXXXpv+73Q6bteuXe666647gVx9e5AEJN1u1+3YscP9/u//fnptYWHBFYtF90//9E/OOefuvfdeB8B96UtfStN89KMfdUmSuCeeeOIp4/1E0czMjAPgbrnlFufcqnzy+bx7//vfn6b5xje+4QC4W2+91Tm3CgIzmYw7ePBgmub66693ExMTrtFoPLUVOEG0adMm93//7/8dyitAy8vL7uyzz3Y333yz+87v/M4UkAzl1k9vfetb3TOf+Uz13lBeOv3yL/+ye+ELX2je32g2YENN2TSbTdxxxx244oor0muZTAZXXHEFbr311hPI2bcnPfzwwzh48GCPvCYnJ3HppZem8rr11lsxNTWFSy65JE1zxRVXIJPJ4LbbbnvKeX6qaXFxEcDaCxvvuOMOtFqtHpmde+652LNnT4/MLrzwQmzfvj1Nc+WVV2JpaQn33HPPU8j9U0+dTgfve9/7UK1WsXfv3qG8AnTttdfiqquu6pEPMOxnFt1///3YtWsXzjzzTFxzzTXYt28fgKG8LPqP//gPXHLJJfiBH/gBbNu2Dc9+9rPxN3/zN+n9jWYDNhQgmZ2dRafT6elwALB9+3YcPHjwBHH17UskE5+8Dh48iG3btvXcz+Vy2Lx580kv0263ize96U14wQtegAsuuADAqjwKhQKmpqZ60kqZaTKleycj3XXXXRgbG0OxWMTrX/963HDDDTj//POH8vLQ+973PnzlK1/Bdddd13dvKLd+uvTSS/F3f/d3+NjHPobrr78eDz/8MF70ohdheXl5KC+DHnroIVx//fU4++yzcdNNN+ENb3gDfvZnfxZ///d/D2Dj2YAN+bbfIQ3peNC1116Lu+++G5/73OdONCvf9vT0pz8dd955JxYXF/Gv//qveO1rX4tbbrnlRLP1bUuPPfYYfu7nfg4333wzSqXSiWZnQ9DLXvay9PdFF12ESy+9FKeddhr+5V/+BeVy+QRy9u1L3W4Xl1xyCX73d38XAPDsZz8bd999N/7yL/8Sr33ta08wd4PThoqQbNmyBdlstm9l9aFDh7Bjx44TxNW3L5FMfPLasWMHZmZmeu63223Mz8+f1DJ94xvfiA9/+MP49Kc/jVNPPTW9vmPHDjSbTSwsLPSklzLTZEr3TkYqFAo466yzcPHFF+O6667DM5/5TPzJn/zJUF4G3XHHHZiZmcFznvMc5HI55HI53HLLLfjTP/1T5HI5bN++fSi3AE1NTeGcc87BAw88MOxnBu3cuRPnn39+z7XzzjsvneraaDZgQwGSQqGAiy++GJ/85CfTa91uF5/85Cexd+/eE8jZtyedccYZ2LFjR4+8lpaWcNttt6Xy2rt3LxYWFnDHHXekaT71qU+h2+3i0ksvfcp5frLJOYc3vvGNuOGGG/CpT30KZ5xxRs/9iy++GPl8vkdm9913H/bt29cjs7vuuqtnEN98882YmJjoUw4nK3W7XTQajaG8DLr88stx11134c4770w/l1xyCa655pr091BufqpUKnjwwQexc+fOYT8z6AUveEHfsQXf+ta3cNpppwHYgDbgKV1Cexzofe97nysWi+7v/u7v3L333ut++qd/2k1NTfWsrP6vRMvLy+6rX/2q++pXv+oAuD/8wz90X/3qV92jjz7qnFvd8jU1NeU++MEPuq9//evuFa94hbrl69nPfra77bbb3Oc+9zl39tlnn7Tbft/whje4yclJ95nPfKZne+HKykqa5vWvf73bs2eP+9SnPuW+/OUvu71797q9e/em92l74Utf+lJ35513uo997GNu69atJ+32wl/5lV9xt9xyi3v44Yfd17/+dfcrv/IrLkkS9/GPf9w5N5RXLPFdNs4N5SbpF37hF9xnPvMZ9/DDD7vPf/7z7oorrnBbtmxxMzMzzrmhvDS6/fbbXS6Xc29/+9vd/fff79773ve6kZER9573vCdNs5FswIYDJM4592d/9mduz549rlAouOc973nui1/84olm6YTRpz/9aQeg7/Pa177WObe67es3fuM33Pbt212xWHSXX365u++++3rymJubcz/8wz/sxsbG3MTEhPvxH/9xt7y8fAJq8+STJisA7t3vfneaplaruf/1v/6X27RpkxsZGXHf//3f7w4cONCTzyOPPOJe9rKXuXK57LZs2eJ+4Rd+wbVarae4Nk8N/cRP/IQ77bTTXKFQcFu3bnWXX355CkacG8orliQgGcqtl1796le7nTt3ukKh4E455RT36le/uuc8jaG8dPrQhz7kLrjgAlcsFt25557r/vqv/7rn/kayAYlzzj21MZkhDWlIQxrSkIY0pF7aUGtIhjSkIQ1pSEMa0slJQ0AypCENaUhDGtKQTjgNAcmQhjSkIQ1pSEM64TQEJEMa0pCGNKQhDemE0xCQDGlIQxrSkIY0pBNOQ0AypCENaUhDGtKQTjgNAcmQhjSkIQ1pSEM64TQEJEMa0pCGNKQhDemE0xCQDGlIQxrSkIY0pBNOQ0AypCENaUhDGtKQTjgNAcmQhjSkIQ1pSEM64TQEJEMa0pCGNKQhDemE0/8PahOAodGnSM8AAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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pxZtSA/JkIY0pCENaUhDOrVoCEiGNKQhDWlIQxrSSachIBnSkIY0pCENaUgnnYaAZEhDGtKQhjSkIZ102pCA5KqrrsKZZ56JSqWCiy66CDfffPPJZumk0Q033ICXvvSlOP3005EkCT75yU8OvHfO4Xd/93dx2mmnYWRkBJdccgnuvffegTBzc3N4zWteg8nJSUxPT+ONb3wjarXaI1iKR46uvPJKPP3pT8fExAS2b9+On/3Zn8Xdd989EKbZbOKKK67Ali1bMD4+jssvvxyHDx8eCLNv3z5cdtllGB0dxfbt2/GOd7xj4COFpxJdffXVOO+889IDlfbu3YvPf/7z6fthfYXpve99L5Ikwa/92q+lz4b1Nki/93u/t+Z7U+ecc076flhfOu3fvx+vfe1rsWXLFoyMjODJT34ybrnllvT9hrIBboPRRz/6UVcqldz/+l//y911113uTW96k5uennaHDx8+2aydFPrc5z7nfuu3fsv98z//swPgPvGJTwy8f+973+umpqbcJz/5Sfed73zHvexlL3OPfvSjXaPRSMO86EUvcueff7676aab3L//+7+7s846y7361a9+hEvyyNCll17qrrnmGnfnnXe62267zb3kJS9xu3fvdrVaLQ3z5je/2e3atct96Utfcrfccot75jOf6S6++OL0fbfbdU960pPcJZdc4r797W+7z33uc27r1q3uXe9618ko0sNOn/rUp9xnP/tZd88997i7777b/df/+l9dsVh0d955p3NuWF8huvnmm92ZZ57pzjvvPPe2t70tfT6st0F697vf7Z74xCe6gwcPpr+ZmZn0/bC+1tLc3Jzbs2ePe8Mb3uC+8Y1vuPvvv99de+217gc/+EEaZiPZgA0HSJ7xjGe4K664Ir3v9Xru9NNPd1deeeVJ5OrHgyQg6ff7bufOne6//bf/lj5bWFhw5XLZ/f3f/71zzrnvfve7DoD75je/mYb5/Oc/75Ikcfv373/EeD9ZdOTIEQfAXX/99c65lfopFovuYx/7WBrme9/7ngPgbrzxRufcCgjM5XLu0KFDaZirr77aTU5Oular9cgW4CTRpk2b3P/8n/9zWF8Bqlar7uyzz3Zf/OIX3XOf+9wUkAzrbS29+93vdueff776blhfOr3zne90z372s833G80GbKgpm3a7jVtvvRWXXHJJ+iyXy+GSSy7B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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n", + "superanimal_analyze_images(superanimal_name,\n", + " model_name,\n", + " in_image_folder,\n", + " max_individuals,\n", + " out_image_folder)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6VEjHu-00Z4Y" + }, + "source": [ + "### Zero-shot Video Inference Without adaptation\n", + "Independent of the use case (i.e., zero-shot or few-shot fine-tuning), to\n", + "optimize performance on unseen user data we also developed two\n", + "unsupervised methods for video inference that help overcome differences\n", + "in the data SuperAnimalmodels were trained on compared to\n", + "what data users might have (Fig. 3a, and Supplementary Fig. S5a).\n", + "These so-called distribution shifts can come in various forms (e.g.,\n", + "spatial or temporal; see Methods). For example, a bottom-up model\n", + "can not performwell if the video resolution or animal appearance size\n", + "is dramatically different from those data which we trained on, and the\n", + "animal datasets are particularly diverse in size, which can pose challenges\n", + "(Supplementary Fig. S5b, c). Therefore, inspired by45, we\n", + "developed an unsupervised test-time augmentation called spatialpyramid\n", + "search that significantly boosted performance in three OOD\n", + "videos (Supplementary Fig. S5c–e, Supplementary Video 3, Supplementary\n", + "Table S19; and see Methods). This is unsupervised, as the user\n", + "does not need to label any data, they simply give a range of video sizes.\n", + "Note that in practice this does slow down inference time depending on\n", + "the search parameter space, and this method is not needed with topdown\n", + "posemodels as top-down detection standardizes the size of the\n", + "animal in both train and test time before the cropped image is seen by\n", + "the pose models.\n", + "\n", + "Secondly, to improve temporal video performance we propose a\n", + "new unsupervised domain adaptation method (Fig. 3a). Others have\n", + "considered pseudo-labeling for images but they always required\n", + "access to the full underlying dataset, which is not practical for\n", + "users33,46,47.Our approach is tailored for pose video adaptation without\n", + "the need for the ground-truth data. The method runs pose inference\n", + "\n", + "on the videos and treats the output predictions as the pseudo groundtruth\n", + "labels and then fine-tunes the model.\n", + "First, we used the animal’s size (estimated by convex hull formed\n", + "by animal keypoints, see more details in Methods) as an indicator to\n", + "measure the improvement in smoothness of video pose predictions.\n", + "Qualitative performance gain for SA-TVM is shown in Fig. 3b–e.\n", + "We also use a jitter score (see Methods) as the indicator to measure\n", + "whether video adaptation mitigates the jittering that can be seen\n", + "in pose estimation outputs. Overall, our method had a significant\n", + "effect on reducing jitter (F(1, 23286) = 190.03, p < 0.0001; Supplementary\n", + "Table S20, in all but the dog (p=0.36, d = − 0.03) and Golden\n", + "lab (p=0.62, d = − 0.06) videos; Supplementary Table S21, Fig. 3f–j and\n", + "Supplementary Video 4).\n", + "To quantitatively measure the improvement of video adaptation,\n", + "we define adaptation gain and robustness gain (see Methods) to\n", + "evaluate the method’s improvement to the adapted video (a subset of\n", + "the video dataset) and to the target dataset (all videos in the video\n", + "dataset). We used Horse-3016 where 30 videos of horses are densely\n", + "annotated to evaluate video adaptation (Fig. 3k).\n", + "We compare our method to Kalman filtering and so-named selfpacing33\n", + "(see Methods), and find that it significantly improves mAP in\n", + "terms of video adaptation gain (p<0.003, Cohen’s d>0.785) and\n", + "robustness gain (p = 0.0001, Cohen’s d = 3.124; Fig. 3k; Supplementary\n", + "Tables S22, S23, S24).\n", + "\n", + "Notably, video adaptation outperforms self-pacing by 4 mAP in\n", + "terms of robustness gain, demonstrating that it not only adapts to one\n", + "single video, but to all 30 videos in the dataset. This is important\n", + "because our method demonstrates successful domain adaptation to\n", + "the whole video dataset rather than to a single video.\n", + "Our method does not take extensive additional time, and practically\n", + "speaking, can be run during video analysis. For example, if a video\n", + "(of a given size) can be run at 40 FPS, our video adaptation would slow\n", + "down processing to approx. 12 FPS, while self-pacing would be closer\n", + "to 4 FPS (thus slower and less accurate)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Upload a video you want to predict" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 94 + }, + "collapsed": true, + "id": "PK3efA0I0Z4Y", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "59dc148a-6546-4946-b645-6a82595a2639" + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " Upload widget is only available when the cell has been executed in the\n", + " current browser session. Please rerun this cell to enable.\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saving dog-agility.mov to dog-agility.mov\n", + "Uploaded files have been moved to: /content/uploaded_videos\n" + ] + } + ], + "source": [ + "# Step 1: Upload files\n", + "uploaded = files.upload()\n", + "\n", + "# Step 2: Define upload directory\n", + "upload_directory = Path('/content/')\n", + "\n", + "# Create a new folder for uploaded files\n", + "in_video_folder = upload_directory / 'uploaded_videos'\n", + "out_video_folder = upload_directory / 'processed_videos'\n", + "os.makedirs(in_video_folder, exist_ok=True)\n", + "os.makedirs(out_video_folder, exist_ok=True)\n", + "\n", + "# Step 3: Save and move files to the new folder\n", + "for filename, content in uploaded.items():\n", + " # Save the file to the upload directory\n", + " file_path = os.path.join(upload_directory, filename)\n", + " with open(file_path, 'wb') as f:\n", + " f.write(content)\n", + "\n", + " # Move the file to the new folder\n", + " destination_path = os.path.join(in_video_folder, filename)\n", + " shutil.move(file_path, in_video_folder)\n", + "\n", + "# List contents of the new folder\n", + "print(f\"Uploaded files have been moved to: {in_video_folder}\")\n", + "\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "JoA-RATSICj_" + }, + "source": [ + "#### Choose the superanimal and the model name" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "collapsed": true, + "id": "OiRAP9XD0Z4Z", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "superanimal_name = 'superanimal_quadruped' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", + "model_name = 'hrnetw32'" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "93xGQKr90Z4Z" + }, + "source": [ + "video_inference_superanimal(\n", + " videos=[\"/mnt/md0/shaokai/tom_video.mp4\"],\n", + " superanimal_name= f\"{superanimal_name}_{model_name}\",\n", + " video_adapt=False,\n", + " max_individuals=3, \n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Zv3v0QgSJNOg" + }, + "source": [ + "### Zero-shot Video Inference without video adaptation" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 0 + }, + "collapsed": true, + "id": "poqynL0UJTBp", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "c4877231-0aff-4cf2-9ea0-b20ef230549f" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "running video inference on ['/content/uploaded_videos/dog-agility.mov'] with superanimal_quadruped_hrnetw32\n", + "Using pytorch for model hrnetw32\n", + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: auto\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 39\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 39]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n", + "Processing video /content/uploaded_videos/dog-agility.mov\n", + "Starting to analyze /content/uploaded_videos/dog-agility.mov\n", + "Video metadata: \n", + " Overall # of frames: 183\n", + " Duration of video [s]: 3.10\n", + " fps: 59.03\n", + " resolution: w=1128, h=630\n", + "\n", + "Running Detector\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 98%|█████████▊| 179/183 [03:43<00:04, 1.25s/it]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running Pose Prediction\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 98%|█████████▊| 179/183 [00:35<00:00, 4.98it/s]\n", + "WARNING:root:The video metadata indicates that there 183 in the video, but only 179 were able to be processed. This can happen if the video is corrupted. You can try to fix the issue by re-encoding your video (tips on how to do that: https://deeplabcut.github.io/DeepLabCut/docs/recipes/io.html#tips-on-video-re-encoding-and-preprocessing)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saving results to /content/processed_videos\n", + "Saving results in /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32.h5 and /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_full.pickle\n", + "Duration of video [s]: 3.1, recorded with 59.03 fps!\n", + "Overall # of frames: 183 with cropped frame dimensions: 1128 630\n", + "Generating frames and creating video.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 179/179 [00:01<00:00, 96.79it/s] " + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Video with predictions was saved as /content/processed_videos\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "data": { + "text/plain": [ + "{'/content/uploaded_videos/dog-agility.mov': scorer superanimal_quadruped_hrnetw32 \\\n", + " individuals animal0 \n", + " bodyparts nose upper_jaw \n", + " coords x y likelihood x \n", + " 0 563.710938 338.242188 0.944513 557.664062 \n", + " 1 561.429688 338.039062 0.880739 561.429688 \n", + " 2 437.992188 360.476562 0.826738 432.382812 \n", + " 3 436.710938 360.632812 0.808081 431.164062 \n", + " 4 654.023438 337.070312 0.335497 654.023438 \n", + " .. ... ... ... ... \n", + " 174 -1.000000 -1.000000 -1.000000 -1.000000 \n", + " 175 -1.000000 -1.000000 -1.000000 -1.000000 \n", + " 176 545.460938 83.882812 0.881381 542.445312 \n", + " 177 545.460938 83.882812 0.882614 542.445312 \n", + " 178 542.195312 96.273438 0.590828 542.195312 \n", + " \n", + " scorer \\\n", + " individuals \n", + " bodyparts lower_jaw \n", + " coords y likelihood x y likelihood \n", + " 0 350.335938 0.887882 551.617188 356.382812 0.572390 \n", + " 1 350.570312 0.885944 548.898438 356.835938 0.514241 \n", + " 2 371.695312 0.488187 432.382812 371.695312 0.278069 \n", + " 3 371.726562 0.451573 431.164062 371.726562 0.266889 \n", + " 4 343.242188 0.291900 573.789062 546.914062 0.423012 \n", + " .. ... ... ... ... ... \n", + " 174 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 \n", + " 175 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 \n", + " 176 89.914062 0.783046 542.445312 89.914062 0.540425 \n", + " 177 89.914062 0.784810 542.445312 89.914062 0.544063 \n", + " 178 96.273438 0.610255 538.335938 103.992188 0.712697 \n", + " \n", + " scorer ... \\\n", + " individuals ... animal2 \n", + " bodyparts mouth_end_right ... back_right_paw belly_bottom \n", + " coords x ... likelihood x y likelihood \n", + " 0 533.476562 ... -1.0 -1.0 -1.0 -1.0 \n", + " 1 530.101562 ... -1.0 -1.0 -1.0 -1.0 \n", + " 2 443.601562 ... -1.0 -1.0 -1.0 -1.0 \n", + " 3 442.257812 ... -1.0 -1.0 -1.0 -1.0 \n", + " 4 623.164062 ... -1.0 -1.0 -1.0 -1.0 \n", + " .. ... ... ... ... ... ... \n", + " 174 -1.000000 ... -1.0 -1.0 -1.0 -1.0 \n", + " 175 -1.000000 ... -1.0 -1.0 -1.0 -1.0 \n", + " 176 536.414062 ... -1.0 -1.0 -1.0 -1.0 \n", + " 177 542.445312 ... -1.0 -1.0 -1.0 -1.0 \n", + " 178 530.617188 ... -1.0 -1.0 -1.0 -1.0 \n", + " \n", + " scorer \n", + " individuals \n", + " bodyparts body_middle_right body_middle_left \n", + " coords x y likelihood x y likelihood \n", + " 0 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " 1 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " 2 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " 3 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " 4 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " .. ... ... ... ... ... ... \n", + " 174 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " 175 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " 176 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " 177 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " 178 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", + " \n", + " [179 rows x 351 columns]}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import glob\n", + "videos = glob.glob(os.path.join(in_video_folder, '*'))\n", + "video_inference_superanimal(\n", + " videos=videos,\n", + " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", + " video_adapt=False,\n", + " max_individuals=3,\n", + " pseudo_threshold=0.1,\n", + " bbox_threshold=0.9,\n", + " detector_epochs=1,\n", + " pose_epochs=1,\n", + " dest_folder = out_video_folder\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Display the processed video" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 171 + }, + "collapsed": true, + "id": "ObMlVSHAdAcR", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "eac63ca5-c66b-4ece-dac9-79788a3a4949" + }, + "outputs": [ + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Video, display\n", + "import glob\n", + "import os\n", + "\n", + "# Path to the video folder\n", + "out_video_folder = '/content/processed_videos' # Replace with your video folder path\n", + "video_paths = glob.glob(os.path.join(out_video_folder, '*.mp4'))\n", + "\n", + "# Display each video\n", + "for video_path in video_paths:\n", + " display(Video(video_path, embed=True))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "qF3C-L19fItR" + }, + "source": [ + "#### If the video is not displaying correctly, it could be due to the video encoding. Run the cell below and try again. Or you can download the video from the processed_videos and display using your computer's video player" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 0 + }, + "collapsed": true, + "id": "iiz-Yck8e67g", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "f5078d12-1cd1-4e92-a2c3-43254d244048" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ffmpeg version 4.4.2-0ubuntu0.22.04.1 Copyright (c) 2000-2021 the FFmpeg developers\n", + " built with gcc 11 (Ubuntu 11.2.0-19ubuntu1)\n", + " configuration: --prefix=/usr --extra-version=0ubuntu0.22.04.1 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --enable-gnutls --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libjack --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libpulse --enable-librabbitmq --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libssh --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-libzmq --enable-libzvbi --enable-lv2 --enable-omx --enable-openal --enable-opencl --enable-opengl --enable-sdl2 --enable-pocketsphinx --enable-librsvg --enable-libmfx --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-libx264 --enable-shared\n", + " libavutil 56. 70.100 / 56. 70.100\n", + " libavcodec 58.134.100 / 58.134.100\n", + " libavformat 58. 76.100 / 58. 76.100\n", + " libavdevice 58. 13.100 / 58. 13.100\n", + " libavfilter 7.110.100 / 7.110.100\n", + " libswscale 5. 9.100 / 5. 9.100\n", + " libswresample 3. 9.100 / 3. 9.100\n", + " libpostproc 55. 9.100 / 55. 9.100\n", + "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled.mp4':\n", + " Metadata:\n", + " major_brand : isom\n", + " minor_version : 512\n", + " compatible_brands: isomiso2mp41\n", + " encoder : Lavf59.27.100\n", + " Duration: 00:00:03.03, start: 0.000000, bitrate: 6874 kb/s\n", + " Stream #0:0(und): Video: mpeg4 (Simple Profile) (mp4v / 0x7634706D), yuv420p, 1128x630 [SAR 1:1 DAR 188:105], 6872 kb/s, 59.03 fps, 59.03 tbr, 59032 tbn, 7379 tbc (default)\n", + " Metadata:\n", + " handler_name : VideoHandler\n", + " vendor_id : [0][0][0][0]\n", + "Stream mapping:\n", + " Stream #0:0 -> #0:0 (mpeg4 (native) -> h264 (libx264))\n", + "Press [q] to stop, [?] for help\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0musing SAR=1/1\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0musing cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mprofile High, level 3.2, 4:2:0, 8-bit\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0m264 - core 163 r3060 5db6aa6 - H.264/MPEG-4 AVC codec - Copyleft 2003-2021 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=20 lookahead_threads=3 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00\n", + "Output #0, mp4, to '/content/processed_videos/output.mp4':\n", + " Metadata:\n", + " major_brand : isom\n", + " minor_version : 512\n", + " compatible_brands: isomiso2mp41\n", + " encoder : Lavf58.76.100\n", + " Stream #0:0(und): Video: h264 (avc1 / 0x31637661), yuv420p(progressive), 1128x630 [SAR 1:1 DAR 188:105], q=2-31, 59.03 fps, 14758 tbn (default)\n", + " Metadata:\n", + " handler_name : VideoHandler\n", + " vendor_id : [0][0][0][0]\n", + " encoder : Lavc58.134.100 libx264\n", + " Side data:\n", + " cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A\n", + "frame= 179 fps=0.0 q=-1.0 Lsize= 800kB time=00:00:02.98 bitrate=2198.1kbits/s speed=3.46x \n", + "video:797kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.359801%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mframe I:1 Avg QP:23.34 size: 13947\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mframe P:63 Avg QP:23.52 size: 7640\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mframe B:115 Avg QP:25.05 size: 2785\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mconsecutive B-frames: 3.4% 27.9% 15.1% 53.6%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mmb I I16..4: 35.0% 61.4% 3.6%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mmb P I16..4: 13.8% 35.9% 1.3% P16..4: 18.9% 2.1% 0.5% 0.0% 0.0% skip:27.5%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mmb B I16..4: 1.7% 3.5% 0.3% B16..8: 21.4% 1.7% 0.2% direct: 1.4% skip:69.6% L0:45.3% L1:53.5% BI: 1.2%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0m8x8 transform intra:69.0% inter:86.0%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mcoded y,uvDC,uvAC intra: 22.5% 34.9% 5.5% inter: 2.9% 5.8% 1.1%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mi16 v,h,dc,p: 23% 51% 10% 17%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mi8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 27% 29% 33% 2% 2% 2% 2% 2% 2%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mi4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 27% 21% 20% 5% 7% 6% 7% 5% 4%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mi8c dc,h,v,p: 55% 26% 16% 3%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mWeighted P-Frames: Y:0.0% UV:0.0%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mref P L0: 77.2% 7.5% 11.6% 3.7%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mref B L0: 86.9% 10.3% 2.7%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mref B L1: 98.4% 1.6%\n", + "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mkb/s:2151.78\n" + ] + } + ], + "source": [ + "!ffmpeg -i /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled.mp4 -vcodec libx264 -acodec aac /content/processed_videos/output.mp4" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 826 + }, + "collapsed": true, + "id": "epM2uJg4e-1A", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "8841ab20-5062-4f9e-cd1d-dc45a8e50c8a" + }, + "outputs": [ + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Video, display\n", + "import glob\n", + "import os\n", + "\n", + "# Path to the video folder\n", + "out_video_folder = '/content/processed_videos' # Replace with your video folder path\n", + "video_paths = glob.glob(os.path.join(out_video_folder, '*.mp4'))\n", + "\n", + "# Display each video\n", + "for video_path in video_paths:\n", + " print (video_path)\n", + " display(Video(video_path, embed=True))" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Z8Z5GSti0Z4Z" + }, + "source": [ + "### Zero-shot Video Inference with video adaptation (unsupervised)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 11353, + "referenced_widgets": [ + "8f396755637f4a779f3e77bf8e4c5f2d", + "67677e7c5e284682ae8aae107833e702", + "255dee8feaf74901b7a412fce53e0beb", + "765e9d1889f3472c9e050d96ca1c0e24", + "7b5f401de8f647bbb1f241d0ae61a106", + "a5cf5d546d11442f86cb3b048f6e1b51", + "30df28e721be4dffa0271edad4cd5ce3", + "cfee5f910ac14a95b770b77fd6f9629e", + "ff5459e8346a48edbb9fc6bdfcdeb690", + "1066fb18c9d045bea6568909a49a4a7a", + "aec1058e27ab48d0bdeaa934f12a2a04" + ] + }, + "collapsed": true, + "id": "5mhOmtzw0Z4Z", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "d830c849-91e8-4597-f388-cec2cd3b4eb0" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "running video inference on ['/content/uploaded_videos/dog-agility.mov'] with superanimal_quadruped_hrnetw32\n", + "Using pytorch for model hrnetw32\n", + "using /content/uploaded_videos/dog-agility.mov for video adaptation training\n", + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: auto\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 39\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 39]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n", + "Processing video /content/uploaded_videos/dog-agility.mov\n", + "Starting to analyze /content/uploaded_videos/dog-agility.mov\n", + "Video metadata: \n", + " Overall # of frames: 183\n", + " Duration of video [s]: 3.10\n", + " fps: 59.03\n", + " resolution: w=1128, h=630\n", + "\n", + "Running Detector\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 98%|█████████▊| 179/183 [03:43<00:04, 1.25s/it]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running Pose Prediction\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 98%|█████████▊| 179/183 [00:37<00:00, 4.80it/s]\n", + "WARNING:root:The video metadata indicates that there 183 in the video, but only 179 were able to be processed. This can happen if the video is corrupted. You can try to fix the issue by re-encoding your video (tips on how to do that: https://deeplabcut.github.io/DeepLabCut/docs/recipes/io.html#tips-on-video-re-encoding-and-preprocessing)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saving results to /content/processed_videos\n", + "Saving results in /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32.h5 and /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_full.pickle\n", + "Duration of video [s]: 3.1, recorded with 59.03 fps!\n", + "Overall # of frames: 183 with cropped frame dimensions: 1128 630\n", + "Generating frames and creating video.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 179/179 [00:01<00:00, 96.18it/s] \n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Video with predictions was saved as /content/processed_videos\n", + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: auto\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 39\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 39]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n", + "Video frames being extracted to /content/uploaded_videos/pseudo_dog-agility/images for video adaptation.\n", + "Constructing pseudo dataset at /content/uploaded_videos/pseudo_dog-agility\n", + "\n", + "Running video adaptation with following parameters: \n", + "(pose training) pose_epochs: 1\n", + "(pose) save_epochs: 1\n", + "detector_epochs: 1\n", + "detector_save_epochs: 1\n", + "video adaptation batch size: 8\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Downloading: \"https://download.pytorch.org/models/fasterrcnn_resnet50_fpn_v2_coco-dd69338a.pth\" to /root/.cache/torch/hub/checkpoints/fasterrcnn_resnet50_fpn_v2_coco-dd69338a.pth\n", + "100%|██████████| 167M/167M [00:00<00:00, 218MB/s]\n", + "/content/drive/My Drive/DLCdev/deeplabcut/pose_estimation_pytorch/data/transforms.py:68: UserWarning: Be careful! Do not train pose models with horizontal flips if you have symmetric keypoints!\n", + " warnings.warn(\n", + "Data Transforms:\n", + " Training: Compose([\n", + " HorizontalFlip(always_apply=False, p=0.5),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "\n", + "Note: According to your model configuration, you're training with batch size 1 and/or ``freeze_bn_stats=false``. This is not an optimal setting if you have powerful GPUs.\n", + "This is good for small batch sizes (e.g., when training on a CPU), where you should keep ``freeze_bn_stats=true``.\n", + "If you're using a GPU to train, you can obtain faster performance by setting a larger batch size (the biggest power of 2 where you don't geta CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` for the backbone of your model. \n", + "This also allows you to increase the learning rate (empirically you can scale the learning rate by sqrt(batch_size) times).\n", + "\n", + "Using 17 images and 10 for testing\n", + "\n", + "Starting object detector training...\n", + "--------------------------------------------------\n", + "Epoch 1/1 (lr=1e-05), train loss 0.07259\n", + "Loading pretrained weights from Hugging Face hub (timm/hrnet_w32.ms_in1k)\n", + "/usr/local/lib/python3.10/dist-packages/huggingface_hub/utils/_token.py:89: UserWarning: \n", + "The secret `HF_TOKEN` does not exist in your Colab secrets.\n", + "To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.\n", + "You will be able to reuse this secret in all of your notebooks.\n", + "Please note that authentication is recommended but still optional to access public models or datasets.\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "8f396755637f4a779f3e77bf8e4c5f2d", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "model.safetensors: 0%| | 0.00/165M [00:00 #0:0 (mpeg4 (native) -> h264 (libx264))\n", + "Press [q] to stop, [?] for help\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0musing SAR=1/1\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0musing cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mprofile High, level 3.2, 4:2:0, 8-bit\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0m264 - core 163 r3060 5db6aa6 - H.264/MPEG-4 AVC codec - Copyleft 2003-2021 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=20 lookahead_threads=3 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00\n", + "Output #0, mp4, to '/content/processed_videos/after_adapt_output.mp4':\n", + " Metadata:\n", + " major_brand : isom\n", + " minor_version : 512\n", + " compatible_brands: isomiso2mp41\n", + " encoder : Lavf58.76.100\n", + " Stream #0:0(und): Video: h264 (avc1 / 0x31637661), yuv420p(progressive), 1128x630 [SAR 1:1 DAR 188:105], q=2-31, 59.03 fps, 14758 tbn (default)\n", + " Metadata:\n", + " handler_name : VideoHandler\n", + " vendor_id : [0][0][0][0]\n", + " encoder : Lavc58.134.100 libx264\n", + " Side data:\n", + " cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A\n", + "frame= 179 fps=0.0 q=-1.0 Lsize= 810kB time=00:00:02.98 bitrate=2226.3kbits/s speed=3.07x \n", + "video:807kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.353296%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mframe I:1 Avg QP:23.44 size: 14043\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mframe P:65 Avg QP:23.60 size: 7621\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mframe B:113 Avg QP:25.09 size: 2803\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mconsecutive B-frames: 2.8% 32.4% 20.1% 44.7%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mmb I I16..4: 33.7% 62.7% 3.6%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mmb P I16..4: 14.0% 36.1% 1.3% P16..4: 18.6% 2.0% 0.5% 0.0% 0.0% skip:27.5%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mmb B I16..4: 1.5% 3.1% 0.3% B16..8: 21.4% 1.7% 0.2% direct: 1.3% skip:70.4% L0:42.6% L1:56.3% BI: 1.1%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0m8x8 transform intra:69.0% inter:85.0%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mcoded y,uvDC,uvAC intra: 22.7% 35.2% 5.7% inter: 2.9% 5.6% 1.2%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mi16 v,h,dc,p: 22% 51% 10% 17%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mi8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 28% 29% 33% 2% 2% 2% 2% 2% 2%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mi4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 27% 21% 20% 5% 6% 6% 7% 5% 4%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mi8c dc,h,v,p: 55% 26% 16% 3%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mWeighted P-Frames: Y:0.0% UV:0.0%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mref P L0: 77.4% 7.3% 11.7% 3.7%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mref B L0: 86.5% 10.4% 3.1%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mref B L1: 98.5% 1.5%\n", + "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mkb/s:2179.49\n" + ] + } + ], + "source": [ + "!ffmpeg -i /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled_after_adapt.mp4 -vcodec libx264 -acodec aac /content/processed_videos/after_adapt_output.mp4\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Display the video adapted video" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1684 + }, + "collapsed": true, + "id": "GDJye14Fhthb", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "77377c77-ca47-49e7-a7be-a7c16fde7bec" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled.mp4\n" + ] + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled_after_adapt.mp4\n" + ] + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/content/processed_videos/after_adapt_output.mp4\n" + ] + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/content/processed_videos/output.mp4\n" + ] + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Video, display\n", + "import glob\n", + "import os\n", + "\n", + "# Path to the video folder\n", + "out_video_folder = '/content/processed_videos' # Replace with your video folder path\n", + "video_paths = glob.glob(os.path.join(out_video_folder, '*.mp4'))\n", + "\n", + "# Display each video\n", + "for video_path in video_paths:\n", + " print (video_path)\n", + " display(Video(video_path, embed=True))" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "br3pwGf40Z4a" + }, + "source": [ + "## Training with SuperAnimal\n", + "In this section, we compare different ways to train the model, using and without using SuperAnimal. \n", + "You can compare the evaluation results and get a sense of each baseline.\n", + "We have following baselines:\n", + "\n", + "- ImageNet transfer learning (training without superanimal)\n", + "- SuperAnimal transfer learning (baseline 1)\n", + "- SuperAnimal naive fine-tuning (baseline 2)\n", + "- SuperAnimal memory-replay fine-tuning (baseline3)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "L2wxevEn0Z4a" + }, + "source": [ + "#### Uploading your DLC project into Drive. Note you have to zip your DLC project and select the zipped file" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 129 + }, + "collapsed": true, + "id": "visacW8i0Z4a", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "db60ca6e-b38d-44c7-bc69-a327b40c8d4a" + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " Upload widget is only available when the cell has been executed in the\n", + " current browser session. Please rerun this cell to enable.\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saving daniel3mouse.zip to daniel3mouse.zip\n", + "Contents of the extracted folder:\n", + "- __MACOSX\n", + "- daniel3mouse\n" + ] + } + ], + "source": [ + "uploaded = files.upload()\n", + "for filename in uploaded.keys():\n", + " zip_file_path = os.path.join(\"/content\", filename)\n", + " with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:\n", + " zip_ref.extractall(\"/content/dlc_project_folder\")\n", + "\n", + "print(\"Contents of the extracted folder:\")\n", + "extracted_files = os.listdir(\"/content/dlc_project_folder\")\n", + "for file in extracted_files:\n", + " print(f'- {file}')\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Change the path to your project in dlc_project_folder" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "collapsed": true, + "id": "nY7Sv9pslaMh", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "dlc_proj_root = Path(\"/content/dlc_project_folder/daniel3mouse\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "BPvoL9uZ0Z4a" + }, + "source": [ + "#### Comparison between different training baselines\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "eVmpaLdB0Z4a" + }, + "source": [ + "Definition of data split: the unique combination of training images and testing images.\n", + "We create a data split named split 0. All baselines will share the data split to make fair comparisons.\n", + "- split 0 -> shared by all baselines\n", + "- shuffle 0 (split0) -> imagenet transfer learning\n", + "- shuffle 1 (split0) -> superanimal transfer learning\n", + "- shuffle 2 (split0) -> superanimal naive fine-tuning\n", + "- shuffle 3 (split0) -> superanimal memory-replay fine-tuning" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### What is the difference between baselines? \n", + "\n", + "**Transfer learning** For canonical task-agnostic transfer learning,\n", + "the encoder learns universal visual features from ImageNet, and a randomly\n", + "initialized decoder is used to learn the pose fromthe downstream dataset.\n", + "\n", + "**Fine-tuning** For task aware\n", + "fine-tuning, both encoder and decoder learn task-related visual-pose features\n", + "in the pre-training datasets, and the decoder is fine-tuned to update pose\n", + "priors in downstream datasets. Crucially, the network has pose-estimation-specific\n", + "weights\n", + "\n", + "**ImageNet transfer-learning** The encoder was pre-trained from ImageNet. The decoder is trained from scratch in the downstream tasks\n", + "\n", + "**SuperAnimal transfer-learning** The encoder was pre-trained first from ImageNet, then in pose datasets we colleceted. Then decoder is trained from scratch in downstream tasks.\n", + "\n", + "**SuperAnimal naive fine-tuning** Both the encoder and the decoder were pre-trained in pose datasets we collected. In downstream datsets, we only finetune convolutional channels that correspond to the annotated keypoints in the downstream datasets. This introduces catastrophic forgetting in keypoints that are not annotated in the downstream datasets.\n", + "\n", + "**SuperAnimal memory-replay fine-tuning** If we apply fine-tuning with SuperAnimal without further cares, the models will forget about keypoints that are not annotated in the downstream datasets. To mitigate this, we mix the annotations and zero-shot predictions of SuperAnimal models to create a dataset that 'replays' the memory of the SuperAnimal keypoints. \n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "collapsed": true, + "id": "AgIsUu6v0Z4a", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "imagenet_transfer_learning_shuffle = 0\n", + "superanimal_transfer_learning_shuffle = 1\n", + "superanimal_naive_finetune_shuffle = 2\n", + "superanimal_memory_replay_shuffle = 3" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "kuKcxM8F0Z4a", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bb09b5cd-bd25-416b-8b94-aa25a5ffd1a7" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", + "Creating training data for: Shuffle: 0 TrainFraction: 0.95\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 152/152 [00:00<00:00, 3254.90it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" + ] + } + ], + "source": [ + "config_path = dlc_proj_root / 'config.yaml'\n", + "deeplabcut.create_training_dataset(\n", + " config_path,\n", + " Shuffles = [imagenet_transfer_learning_shuffle],\n", + " net_type=\"top_down_hrnet_w32\",\n", + " engine=Engine.PYTORCH,\n", + " userfeedback=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_6RncQbr0Z4a" + }, + "source": [ + "### ImageNet transfer learning\n", + "\n", + "Historically, the transfer learning using ImageNet weights strategies assumed no “animal pose task priors” in the pretrained\n", + "model, a paradigm adopted from previous task-agnostic transfer learning." + ] + }, + { + "cell_type": "code", + "execution_count": 63, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "H2z8kM340Z4a", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bd322c11-20e4-4b75-da4d-fdae1b7e5937" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Training with configuration:\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " rotation: 30\n", + " scaling: [1.0, 1.0]\n", + " translation: 0\n", + " collate: None\n", + " covering: False\n", + " gaussian_noise: 12.75\n", + " hist_eq: False\n", + " motion_blur: False\n", + " normalize_images: True\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " rotation: 30\n", + " scaling: [1.0, 1.0]\n", + " translation: 40\n", + " collate:\n", + " type: ResizeFromDataSizeCollate\n", + " min_scale: 0.4\n", + " max_scale: 1.0\n", + " min_short_side: 128\n", + " max_short_side: 1152\n", + " multiple_of: 32\n", + " to_square: False\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " variant: fasterrcnn_mobilenet_v3_large_fpn\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0001\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [160]\n", + " lr_list: [[1e-05]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 0\n", + "device: auto\n", + "metadata:\n", + " project_path: /content/dlc_project_folder/daniel3mouse\n", + " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset95shuffle0/train/pose_cfg.yaml\n", + " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", + " unique_bodyparts: []\n", + " individuals: ['mus1', 'mus2', 'mus3']\n", + " with_identity: None\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " freeze_bn_stats: False\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 12\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32]\n", + " kernel_size: []\n", + " strides: []\n", + " final_conv:\n", + " out_channels: 12\n", + " kernel_size: 1\n", + "net_type: hrnet_w32\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " gpus: None\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0005\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-05], [1e-06]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 3\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 64\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 3\n", + " seed: 42\n", + "Loading pretrained weights from Hugging Face hub (timm/hrnet_w32.ms_in1k)\n", + "[timm/hrnet_w32.ms_in1k] Safe alternative available for 'pytorch_model.bin' (as 'model.safetensors'). Loading weights using safetensors.\n", + "Unexpected keys (downsamp_modules.0.0.bias, downsamp_modules.0.0.weight, downsamp_modules.0.1.bias, downsamp_modules.0.1.num_batches_tracked, downsamp_modules.0.1.running_mean, downsamp_modules.0.1.running_var, downsamp_modules.0.1.weight, downsamp_modules.1.0.bias, downsamp_modules.1.0.weight, downsamp_modules.1.1.bias, downsamp_modules.1.1.num_batches_tracked, downsamp_modules.1.1.running_mean, downsamp_modules.1.1.running_var, downsamp_modules.1.1.weight, downsamp_modules.2.0.bias, downsamp_modules.2.0.weight, downsamp_modules.2.1.bias, downsamp_modules.2.1.num_batches_tracked, downsamp_modules.2.1.running_mean, downsamp_modules.2.1.running_var, downsamp_modules.2.1.weight, final_layer.0.bias, final_layer.0.weight, final_layer.1.bias, final_layer.1.num_batches_tracked, final_layer.1.running_mean, final_layer.1.running_var, final_layer.1.weight, incre_modules.0.0.bn1.bias, incre_modules.0.0.bn1.num_batches_tracked, incre_modules.0.0.bn1.running_mean, incre_modules.0.0.bn1.running_var, incre_modules.0.0.bn1.weight, incre_modules.0.0.bn2.bias, incre_modules.0.0.bn2.num_batches_tracked, incre_modules.0.0.bn2.running_mean, incre_modules.0.0.bn2.running_var, incre_modules.0.0.bn2.weight, incre_modules.0.0.bn3.bias, incre_modules.0.0.bn3.num_batches_tracked, incre_modules.0.0.bn3.running_mean, incre_modules.0.0.bn3.running_var, incre_modules.0.0.bn3.weight, incre_modules.0.0.conv1.weight, incre_modules.0.0.conv2.weight, incre_modules.0.0.conv3.weight, incre_modules.0.0.downsample.0.weight, incre_modules.0.0.downsample.1.bias, incre_modules.0.0.downsample.1.num_batches_tracked, incre_modules.0.0.downsample.1.running_mean, incre_modules.0.0.downsample.1.running_var, incre_modules.0.0.downsample.1.weight, incre_modules.1.0.bn1.bias, incre_modules.1.0.bn1.num_batches_tracked, incre_modules.1.0.bn1.running_mean, incre_modules.1.0.bn1.running_var, incre_modules.1.0.bn1.weight, incre_modules.1.0.bn2.bias, incre_modules.1.0.bn2.num_batches_tracked, incre_modules.1.0.bn2.running_mean, incre_modules.1.0.bn2.running_var, incre_modules.1.0.bn2.weight, incre_modules.1.0.bn3.bias, incre_modules.1.0.bn3.num_batches_tracked, incre_modules.1.0.bn3.running_mean, incre_modules.1.0.bn3.running_var, incre_modules.1.0.bn3.weight, incre_modules.1.0.conv1.weight, incre_modules.1.0.conv2.weight, incre_modules.1.0.conv3.weight, incre_modules.1.0.downsample.0.weight, incre_modules.1.0.downsample.1.bias, incre_modules.1.0.downsample.1.num_batches_tracked, incre_modules.1.0.downsample.1.running_mean, incre_modules.1.0.downsample.1.running_var, incre_modules.1.0.downsample.1.weight, incre_modules.2.0.bn1.bias, incre_modules.2.0.bn1.num_batches_tracked, incre_modules.2.0.bn1.running_mean, incre_modules.2.0.bn1.running_var, incre_modules.2.0.bn1.weight, incre_modules.2.0.bn2.bias, incre_modules.2.0.bn2.num_batches_tracked, incre_modules.2.0.bn2.running_mean, incre_modules.2.0.bn2.running_var, incre_modules.2.0.bn2.weight, incre_modules.2.0.bn3.bias, incre_modules.2.0.bn3.num_batches_tracked, incre_modules.2.0.bn3.running_mean, incre_modules.2.0.bn3.running_var, incre_modules.2.0.bn3.weight, incre_modules.2.0.conv1.weight, incre_modules.2.0.conv2.weight, incre_modules.2.0.conv3.weight, incre_modules.2.0.downsample.0.weight, incre_modules.2.0.downsample.1.bias, incre_modules.2.0.downsample.1.num_batches_tracked, incre_modules.2.0.downsample.1.running_mean, incre_modules.2.0.downsample.1.running_var, incre_modules.2.0.downsample.1.weight, incre_modules.3.0.bn1.bias, incre_modules.3.0.bn1.num_batches_tracked, incre_modules.3.0.bn1.running_mean, incre_modules.3.0.bn1.running_var, incre_modules.3.0.bn1.weight, incre_modules.3.0.bn2.bias, incre_modules.3.0.bn2.num_batches_tracked, incre_modules.3.0.bn2.running_mean, incre_modules.3.0.bn2.running_var, incre_modules.3.0.bn2.weight, incre_modules.3.0.bn3.bias, incre_modules.3.0.bn3.num_batches_tracked, incre_modules.3.0.bn3.running_mean, incre_modules.3.0.bn3.running_var, incre_modules.3.0.bn3.weight, incre_modules.3.0.conv1.weight, incre_modules.3.0.conv2.weight, incre_modules.3.0.conv3.weight, incre_modules.3.0.downsample.0.weight, incre_modules.3.0.downsample.1.bias, incre_modules.3.0.downsample.1.num_batches_tracked, incre_modules.3.0.downsample.1.running_mean, incre_modules.3.0.downsample.1.running_var, incre_modules.3.0.downsample.1.weight, classifier.bias, classifier.weight) found while loading pretrained weights. This may be expected if model is being adapted.\n", + "Data Transforms:\n", + " Training: Compose([\n", + " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", + " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "Using 456 images and 27 for testing\n", + "\n", + "Starting pose model training...\n", + "--------------------------------------------------\n", + "Training for epoch 1 done, starting evaluation\n", + "Epoch 1 performance:\n", + "metrics/test.rmse: 58.034\n", + "metrics/test.rmse_pcutoff:57.757\n", + "metrics/test.mAP: 1.523\n", + "metrics/test.mAR: 2.222\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 1/3 (lr=0.0005), train loss 0.00532, valid loss 0.08804\n", + "Training for epoch 2 done, starting evaluation\n", + "Epoch 2 performance:\n", + "metrics/test.rmse: 24.018\n", + "metrics/test.rmse_pcutoff:4.871\n", + "metrics/test.mAP: 41.487\n", + "metrics/test.mAR: 48.519\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 2/3 (lr=0.0005), train loss 0.00385, valid loss 0.00458\n", + "Training for epoch 3 done, starting evaluation\n", + "Epoch 3 performance:\n", + "metrics/test.rmse: 14.797\n", + "metrics/test.rmse_pcutoff:4.767\n", + "metrics/test.mAP: 64.759\n", + "metrics/test.mAR: 71.852\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 3/3 (lr=0.0005), train loss 0.00298, valid loss 0.00334\n" + ] + } + ], + "source": [ + "# Note we skip the detector training to save time. The evaluation is by default using ground-truth bounding box.\n", + "# But to train a model that can be used to inference videos and images, you have to set detector_epochs > 0\n", + "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, batch_size = 64, shuffle = imagenet_transfer_learning_shuffle)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "J-udMck7nDbG" + }, + "source": [ + "#### Though the evaluation was also done during training, let's just do it again here to double-check" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "TDHMdKz4m_16", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "091082c1-0c61-4967-9750-092541dad0ae" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", + "100%|██████████| 152/152 [01:20<00:00, 1.88it/s]\n", + "100%|██████████| 9/9 [00:04<00:00, 1.86it/s]\n", + "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle0_snapshot_001-results.csv (pcutoff: 0.01):\n", + "INFO:root:train rmse 54.74\n", + "train rmse_pcutoff 54.74\n", + "train mAP 0.58\n", + "train mAR 3.46\n", + "train mAP_pcutoff 0.58\n", + "train mAR_pcutoff 3.46\n", + "test rmse 55.73\n", + "test rmse_pcutoff 55.73\n", + "test mAP 2.78\n", + "test mAR 7.04\n", + "test mAP_pcutoff 2.78\n", + "test mAR_pcutoff 7.04\n", + "Name: (0.95, 0, 1, -1, 0.01), dtype: float64\n" + ] + } + ], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles = [imagenet_transfer_learning_shuffle])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Transfer learning with SuperAnimal weights" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "ZGhAuyqs0Z4a" + }, + "source": [ + "#### Prepare trianing shuffle for transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "wOSdZQtOp8qa", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "5c76dbca-4706-4f9d-a70d-0d7763cdcda0" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", + "You passed a split with the following fraction: 94%\n", + "Creating training data for: Shuffle: 1 TrainFraction: 0.94\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 152/152 [00:00<00:00, 7673.55it/s]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "weight_init = WeightInitialization(\n", + " dataset=f\"{superanimal_name}\",\n", + " with_decoder=False,\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", + " from_shuffle = imagenet_transfer_learning_shuffle,\n", + " shuffles = [superanimal_transfer_learning_shuffle],\n", + " engine = Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init = weight_init,\n", + " userfeedback = False)\n", + "\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Launch the training for transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "W60UgRQWqghn", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "1ebd95f1-830a-4ba0-9f4b-71ac749ef50e" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Training with configuration:\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " rotation: 30\n", + " scaling: [1.0, 1.0]\n", + " translation: 0\n", + " collate: None\n", + " covering: False\n", + " gaussian_noise: 12.75\n", + " hist_eq: False\n", + " motion_blur: False\n", + " normalize_images: True\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " rotation: 30\n", + " scaling: [1.0, 1.0]\n", + " translation: 40\n", + " collate:\n", + " type: ResizeFromDataSizeCollate\n", + " min_scale: 0.4\n", + " max_scale: 1.0\n", + " min_short_side: 128\n", + " max_short_side: 1152\n", + " multiple_of: 32\n", + " to_square: False\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " freeze_bn_stats: False\n", + " freeze_bn_weights: False\n", + " variant: fasterrcnn_mobilenet_v3_large_fpn\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0001\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [160]\n", + " lr_list: [[1e-05]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 0\n", + "device: auto\n", + "metadata:\n", + " project_path: /content/dlc_project_folder/daniel3mouse\n", + " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle1/train/pose_cfg.yaml\n", + " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", + " unique_bodyparts: []\n", + " individuals: ['mus1', 'mus2', 'mus3']\n", + " with_identity: None\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 12\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32]\n", + " kernel_size: []\n", + " strides: []\n", + " final_conv:\n", + " out_channels: 12\n", + " kernel_size: 1\n", + "net_type: hrnet_w32\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " gpus: None\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0001\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-05], [1e-06]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 3\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 64\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 3\n", + " seed: 42\n", + " weight_init:\n", + " dataset: superanimal_quadruped\n", + " with_decoder: False\n", + " memory_replay: False\n", + "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=False, memory_replay=False, conversion_array=None, bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", + "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", + "Data Transforms:\n", + " Training: Compose([\n", + " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", + " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "\n", + "Note: According to your model configuration, you're training with batch size 1 and/or ``freeze_bn_stats=false``. This is not an optimal setting if you have powerful GPUs.\n", + "This is good for small batch sizes (e.g., when training on a CPU), where you should keep ``freeze_bn_stats=true``.\n", + "If you're using a GPU to train, you can obtain faster performance by setting a larger batch size (the biggest power of 2 where you don't geta CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` for the backbone of your model. \n", + "This also allows you to increase the learning rate (empirically you can scale the learning rate by sqrt(batch_size) times).\n", + "\n", + "Using 456 images and 27 for testing\n", + "\n", + "Starting pose model training...\n", + "--------------------------------------------------\n", + "Training for epoch 1 done, starting evaluation\n", + "Epoch 1 performance:\n", + "metrics/test.rmse: 45.606\n", + "metrics/test.rmse_pcutoff:nan\n", + "metrics/test.mAP: 1.715\n", + "metrics/test.mAR: 5.556\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 1/3 (lr=0.0001), train loss 0.00603, valid loss 0.00577\n", + "Training for epoch 2 done, starting evaluation\n", + "Epoch 2 performance:\n", + "metrics/test.rmse: 47.635\n", + "metrics/test.rmse_pcutoff:nan\n", + "metrics/test.mAP: 0.216\n", + "metrics/test.mAR: 2.222\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 2/3 (lr=0.0001), train loss 0.00542, valid loss 0.00507\n", + "Training for epoch 3 done, starting evaluation\n", + "Epoch 3 performance:\n", + "metrics/test.rmse: 35.083\n", + "metrics/test.rmse_pcutoff:nan\n", + "metrics/test.mAP: 21.118\n", + "metrics/test.mAR: 27.407\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 3/3 (lr=0.0001), train loss 0.00476, valid loss 0.00445\n" + ] + } + ], + "source": [ + "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, batch_size = 64, shuffle = superanimal_transfer_learning_shuffle)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Evaluate the model obtained by transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "jpO3aIAIsWbz", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "51ab747e-bf7f-4cf0-bdb5-02774439a08b" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", + "100%|██████████| 152/152 [01:22<00:00, 1.84it/s]\n", + "100%|██████████| 9/9 [00:04<00:00, 1.90it/s]\n", + "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle1_snapshot_003-results.csv (pcutoff: 0.01):\n", + "INFO:root:train rmse 34.03\n", + "train rmse_pcutoff 34.03\n", + "train mAP 21.50\n", + "train mAR 27.52\n", + "train mAP_pcutoff 21.50\n", + "train mAR_pcutoff 27.52\n", + "test rmse 34.55\n", + "test rmse_pcutoff 34.55\n", + "test mAP 22.24\n", + "test mAR 28.15\n", + "test mAP_pcutoff 22.24\n", + "test mAR_pcutoff 28.15\n", + "Name: (0.94, 1, 3, -1, 0.01), dtype: float64\n" + ] + } + ], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_transfer_learning_shuffle])" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_Es6RR-_0Z4b" + }, + "source": [ + "### Fine-tuning with SuperAnimal (without keeping full SuperAnimal keypoints)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6oo9oJ8XyZrn" + }, + "source": [ + "#### Setup the weight init and dataset\n", + "First we do keypoint matching. This steps make it possible to understand the correspondance between the existing annotations and SuperAnimal annotations. This step produces 3 outputs\n", + "- The confusion matrix\n", + "- The conversion table\n", + "- Pseudo predictions over the whole dataset" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### What is keypoint matching?\n", + "\n", + "Because SuperAnimal models have their pre-defined keypoints that are potentially different from your annotations, we porposed this algorithm to minimize the gap between the model and the dataset. We use our model to perform zero-shot inference on the whole dataset. This gives pairs of predictions and ground truth for every image. Then, we cast the matching between models’ predictions (2D coordinates)\n", + "and ground truth as bipartitematching using the Euclidean distance as the cost between paired of keypoints. We then solve the matching using the Hungarian algorithm. Thus for every image, we end up getting a matching matrix where 1 counts formatch and 0 counts for non-matching. Because the models’ predictions can be noisy from image to image, we average the aforementioned matching matrix across all the images and perform another bipartite matching, resulting in the final keypoint conversion table between the model and the dataset. Note that the quality of thematching will impact the performance\n", + "of the model, especially for zero-shot. In the case where, e.g., the annotation nose is mistakenly converted to keypoint tail and vice versa, the model will have to unlearn the channel that corresponds to nose and tail (see also case study in Mathis et al.). " + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, + "collapsed": true, + "id": "vEHeuKSKyjA6", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "f85fa523-910a-444d-914f-4a67730f1bc7" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Before checking trainset temp_dataset\n", + "Before checking testset temp_dataset\n", + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: cpu\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 39\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 39]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "keypoint_matching(\n", + " config_path,\n", + " superanimal_name,\n", + " model_name,\n", + ")\n", + "\n", + "conversion_table_path = dlc_proj_root / \"memory_replay\" / \"conversion_table.csv\"\n", + "confusion_matrix_path = dlc_proj_root / \"memory_replay\" / \"confusion_matrix.png\"\n", + "# You can visualize the pseudo predictions, or do pose embedding clustering etc.\n", + "pseudo_prediction_path = dlc_proj_root / \"memory_replay\" / \"pseudo_predictions.json\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sA8yyLgs0zoO" + }, + "source": [ + "#### Display the confusion matrix\n", + "\n", + "The x axis lists the keypoints in the existing annotations. The y axis lists the keypoints in SuperAnimal keypoint space. Darker color encodes stronger correspondance between the human annotation and SuperAnimal annotations." + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 406 + }, + "collapsed": true, + "id": "luDxpD9H0zYZ", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "d6420e08-3e9c-40dc-8a13-92bc0d8c220b" + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "\n", + "confusion_matrix_image = mpimg.imread(confusion_matrix_path)\n", + "plt.imshow(confusion_matrix_image)\n", + "plt.axis('off') # Hide the axes for better view\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "i0QWikYmy_Mj" + }, + "source": [ + "#### Display the conversion table\n", + "The gt columns represents the keypoint names in the existing dataset. The MasterName represents the correspoinding keypoints in SuperAnimal keypoint space." + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "CeA-NzDMynYV", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "ae27fb36-223c-4aa2-f63f-42adadb95f02" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " gt MasterName\n", + "0 snout nose\n", + "6 rightear right_earbase\n", + "11 leftear left_earbase\n", + "14 tail2 left_antler_end\n", + "15 shoulder neck_base\n", + "20 tail1 back_end\n", + "21 spine3 back_middle\n", + "22 tailbase tail_base\n", + "23 tailend tail_end\n", + "24 spine1 front_left_thai\n", + "31 spine4 back_left_thai\n", + "37 spine2 body_middle_right\n" + ] + } + ], + "source": [ + "import pandas as pd\n", + "df = pd.read_csv(conversion_table_path)\n", + "df = df.dropna()\n", + "print (df)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Prepare the training shuffle and weight initialization for (naive) fine-tuning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "xEeM_hrOu6k8", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "4a5f4d5f-d1c5-42f8-f4ed-e8c208a1dc10" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", + "You passed a split with the following fraction: 94%\n", + "Creating training data for: Shuffle: 2 TrainFraction: 0.94\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 152/152 [00:00<00:00, 12111.90it/s]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "from deeplabcut.modelzoo.utils import (\n", + " create_conversion_table,\n", + " read_conversion_table_from_csv,\n", + ")\n", + "table = create_conversion_table(\n", + " config=config_path,\n", + " super_animal=superanimal_name,\n", + " project_to_super_animal=read_conversion_table_from_csv(conversion_table_path),\n", + ")\n", + "\n", + "weight_init = WeightInitialization(\n", + " dataset=superanimal_name,\n", + " with_decoder=True,\n", + " conversion_array=table.to_array()\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", + " from_shuffle = imagenet_transfer_learning_shuffle,\n", + " shuffles = [superanimal_naive_finetune_shuffle],\n", + " engine = Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init = weight_init,\n", + " userfeedback = False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Launch the training for (naive) fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": 62, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "c3XAr6uRyXOD", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "03740373-f9cd-4708-d140-0127033bfdc8" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Training with configuration:\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 0\n", + "device: auto\n", + "metadata:\n", + " project_path: /content/dlc_project_folder/daniel3mouse\n", + " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle2/train/pose_cfg.yaml\n", + " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", + " unique_bodyparts: []\n", + " individuals: ['mus1', 'mus2', 'mus3']\n", + " with_identity: False\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: False\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 12\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 12]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "net_type: hrnet_w32\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0005\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 3\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 64\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 3\n", + " pretrained_weights: None\n", + " seed: 42\n", + " weight_init:\n", + " dataset: superanimal_quadruped\n", + " with_decoder: True\n", + " memory_replay: False\n", + " conversion_array: [0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]\n", + "eval_interval: 1\n", + "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=True, memory_replay=False, conversion_array=array([ 0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]), bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", + "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", + "Data Transforms:\n", + " Training: Compose([\n", + " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", + " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "Using 456 images and 27 for testing\n", + "\n", + "Starting pose model training...\n", + "--------------------------------------------------\n", + "Training for epoch 1 done, starting evaluation\n", + "Epoch 1 performance:\n", + "metrics/test.rmse: 11.625\n", + "metrics/test.rmse_pcutoff:5.703\n", + "metrics/test.mAP: 71.971\n", + "metrics/test.mAR: 75.185\n", + "metrics/test.mAP_pcutoff:36.845\n", + "metrics/test.mAR_pcutoff:40.370\n", + "Epoch 1/3 (lr=0.0005), train loss 0.00387, valid loss 0.00279\n", + "Training for epoch 2 done, starting evaluation\n", + "Epoch 2 performance:\n", + "metrics/test.rmse: 9.842\n", + "metrics/test.rmse_pcutoff:4.464\n", + "metrics/test.mAP: 77.846\n", + "metrics/test.mAR: 80.000\n", + "metrics/test.mAP_pcutoff:33.924\n", + "metrics/test.mAR_pcutoff:35.926\n", + "Epoch 2/3 (lr=0.0005), train loss 0.00210, valid loss 0.00188\n", + "Training for epoch 3 done, starting evaluation\n", + "Epoch 3 performance:\n", + "metrics/test.rmse: 8.163\n", + "metrics/test.rmse_pcutoff:3.807\n", + "metrics/test.mAP: 82.317\n", + "metrics/test.mAR: 84.815\n", + "metrics/test.mAP_pcutoff:49.699\n", + "metrics/test.mAR_pcutoff:53.704\n", + "Epoch 3/3 (lr=0.0005), train loss 0.00167, valid loss 0.00154\n" + ] + } + ], + "source": [ + "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, eval_interval = 1, batch_size = 64, shuffle = superanimal_naive_finetune_shuffle)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Evaluate the model obtained by (naive) fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": 61, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "VXfdKS-H2yqw", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "53b1b8fa-6aa3-4dad-a5be-153e96eb0323" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", + "100%|██████████| 152/152 [01:25<00:00, 1.79it/s]\n", + "100%|██████████| 9/9 [00:04<00:00, 1.84it/s]\n", + "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle2_snapshot_003-results.csv (pcutoff: 0.01):\n", + "INFO:root:train rmse 48.46\n", + "train rmse_pcutoff 47.76\n", + "train mAP 10.08\n", + "train mAR 21.36\n", + "train mAP_pcutoff 10.07\n", + "train mAR_pcutoff 21.29\n", + "test rmse 47.00\n", + "test rmse_pcutoff 46.74\n", + "test mAP 12.16\n", + "test mAR 22.22\n", + "test mAP_pcutoff 12.16\n", + "test mAR_pcutoff 22.22\n", + "Name: (0.94, 2, 3, -1, 0.01), dtype: float64\n" + ] + } + ], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_naive_finetune_shuffle])" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_nUAMlbZ0Z4b" + }, + "source": [ + "## Memory-replay fine-tuning with SuperAnimal (keeping full SuperAnimal keypoints)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Catastrophic forgetting** describes a\n", + "classic problemin continual learning. Indeed, amodel gradually loses\n", + "its ability to solve previous tasks after it learns to solve new ones.\n", + "Fine-tuning a SuperAnimal models falls into the category of continual\n", + "learning: the downstream dataset defines potentially different\n", + "keypoints than those learned by the models. Thus, the models might\n", + "forget the keypoints they learned and only pick up those defined in the\n", + "target dataset. Here, retraining with the original dataset and the new\n", + "one, is not a feasible option as datasets cannot be easily shared and\n", + "more computational resources would be required.\n", + "To counter that, we treat zero-shot inference of the model as a\n", + "memory buffer that stores knowledge from the original model. When\n", + "we fine-tune a SuperAnimal model, we replace the model predicted\n", + "keypoints with the ground-truth annotations, resulting in hybrid\n", + "learning of old and new knowledge. The quality of the zero-shot predictions\n", + "can vary and we use the confidence of prediction (0.7) as a\n", + "threshold to filter out low-confidence predictions. With the threshold\n", + "set to 1, memory replay fine-tuning becomes naive-fine-tuning." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Prepare training shuffle and weight initialization for memory-replay finetuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": 64, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "BKEF76AI0Z4c", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bf107c7b-6e3c-4ece-f680-067e4d7641f0", + "vscode": { + "languageId": "plaintext" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", + "You passed a split with the following fraction: 94%\n", + "Creating training data for: Shuffle: 3 TrainFraction: 0.94\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 152/152 [00:00<00:00, 11984.40it/s]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "weight_init = WeightInitialization(\n", + " dataset=superanimal_name,\n", + " conversion_array=table.to_array(),\n", + " with_decoder=True,\n", + " memory_replay=True,\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", + " from_shuffle = imagenet_transfer_learning_shuffle,\n", + " shuffles = [superanimal_memory_replay_shuffle],\n", + " engine = Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init = weight_init,\n", + " userfeedback = False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Launch the training for memory-replay fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": 65, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "Ru8tIFmD2Mkv", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "81a0ec13-6ba7-4089-bed5-f19c6bae0bcb" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Training with configuration:\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 0\n", + "device: auto\n", + "metadata:\n", + " project_path: /content/dlc_project_folder/daniel3mouse\n", + " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle2/train/pose_cfg.yaml\n", + " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", + " unique_bodyparts: []\n", + " individuals: ['mus1', 'mus2', 'mus3']\n", + " with_identity: False\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: False\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 12\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 12]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "net_type: hrnet_w32\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0005\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 3\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 64\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 3\n", + " pretrained_weights: None\n", + " seed: 42\n", + " weight_init:\n", + " dataset: superanimal_quadruped\n", + " with_decoder: True\n", + " memory_replay: False\n", + " conversion_array: [0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]\n", + "eval_interval: 1\n", + "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=True, memory_replay=False, conversion_array=array([ 0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]), bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", + "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", + "Data Transforms:\n", + " Training: Compose([\n", + " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", + " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "Using 456 images and 27 for testing\n", + "\n", + "Starting pose model training...\n", + "--------------------------------------------------\n", + "Training for epoch 1 done, starting evaluation\n", + "Epoch 1 performance:\n", + "metrics/test.rmse: 11.625\n", + "metrics/test.rmse_pcutoff:5.703\n", + "metrics/test.mAP: 71.971\n", + "metrics/test.mAR: 75.185\n", + "metrics/test.mAP_pcutoff:36.845\n", + "metrics/test.mAR_pcutoff:40.370\n", + "Epoch 1/3 (lr=0.0005), train loss 0.00387, valid loss 0.00279\n", + "Training for epoch 2 done, starting evaluation\n", + "Epoch 2 performance:\n", + "metrics/test.rmse: 9.842\n", + "metrics/test.rmse_pcutoff:4.464\n", + "metrics/test.mAP: 77.846\n", + "metrics/test.mAR: 80.000\n", + "metrics/test.mAP_pcutoff:33.924\n", + "metrics/test.mAR_pcutoff:35.926\n", + "Epoch 2/3 (lr=0.0005), train loss 0.00210, valid loss 0.00188\n", + "Training for epoch 3 done, starting evaluation\n", + "Epoch 3 performance:\n", + "metrics/test.rmse: 8.163\n", + "metrics/test.rmse_pcutoff:3.807\n", + "metrics/test.mAP: 82.317\n", + "metrics/test.mAR: 84.815\n", + "metrics/test.mAP_pcutoff:49.699\n", + "metrics/test.mAR_pcutoff:53.704\n", + "Epoch 3/3 (lr=0.0005), train loss 0.00167, valid loss 0.00154\n" + ] + } + ], + "source": [ + "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, eval_interval = 1, batch_size = 64, shuffle = superanimal_naive_finetune_shuffle)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Evaluate the model obtained by memory-replay finetuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true, + "id": "sfMcK3gq8WxZ", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_memory_replay_shuffle])" + ] + } + ], + "metadata": { + "accelerator": "TPU", + "colab": { + "gpuType": "V28", + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.19" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "1066fb18c9d045bea6568909a49a4a7a": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "255dee8feaf74901b7a412fce53e0beb": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "FloatProgressModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_cfee5f910ac14a95b770b77fd6f9629e", + "max": 165432914, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_ff5459e8346a48edbb9fc6bdfcdeb690", + "value": 165432914 + } + }, + "30df28e721be4dffa0271edad4cd5ce3": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "67677e7c5e284682ae8aae107833e702": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_a5cf5d546d11442f86cb3b048f6e1b51", + "placeholder": "​", + "style": "IPY_MODEL_30df28e721be4dffa0271edad4cd5ce3", + "value": "model.safetensors: 100%" + } + }, + "765e9d1889f3472c9e050d96ca1c0e24": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_1066fb18c9d045bea6568909a49a4a7a", + "placeholder": "​", + "style": "IPY_MODEL_aec1058e27ab48d0bdeaa934f12a2a04", + "value": " 165M/165M [00:00<00:00, 250MB/s]" + } + }, + "7b5f401de8f647bbb1f241d0ae61a106": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "8f396755637f4a779f3e77bf8e4c5f2d": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HBoxModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_67677e7c5e284682ae8aae107833e702", + "IPY_MODEL_255dee8feaf74901b7a412fce53e0beb", + "IPY_MODEL_765e9d1889f3472c9e050d96ca1c0e24" + ], + "layout": "IPY_MODEL_7b5f401de8f647bbb1f241d0ae61a106" + } + }, + "a5cf5d546d11442f86cb3b048f6e1b51": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "aec1058e27ab48d0bdeaa934f12a2a04": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "cfee5f910ac14a95b770b77fd6f9629e": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "ff5459e8346a48edbb9fc6bdfcdeb690": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "ProgressStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + } + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From 11cedcc2bbd779d260b8a9091da0b3d55b023e52 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 20 Jun 2024 17:22:52 +0200 Subject: [PATCH 172/293] Niels/pcutoff arg (#2630) --- deeplabcut/gui/tabs/create_videos.py | 29 ++++++++++++++++++-------- deeplabcut/utils/make_labeled_video.py | 27 ++++++++++++++++++------ deeplabcut/utils/plotting.py | 11 +++++++++- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index 1fa3dac33d..bdf19c55d4 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -146,6 +146,24 @@ def _generate_layout_video_parameters(self, layout): ) tmp_layout.addWidget(self.use_filtered_data_checkbox) + # Selector for p-cutoff + pcutoff_widget = QtWidgets.QWidget() + pcutoff_layout = _create_horizontal_layout(margins=(0, 0, 0, 0)) + pcutoff_label = QtWidgets.QLabel("Plotting confidence cutoff (pcutoff)") + self.pcutoff_selector = QtWidgets.QDoubleSpinBox() + self.pcutoff_selector.setMinimum(0.0) + self.pcutoff_selector.setMaximum(1.0) + self.pcutoff_selector.setValue(0.6) + self.pcutoff_selector.setSingleStep(0.05) + pcutoff_layout.addWidget(pcutoff_label) + pcutoff_layout.addWidget(self.pcutoff_selector) + pcutoff_widget.setLayout(pcutoff_layout) + pcutoff_widget.setToolTip( + "This value sets the confidence threshold, above which predictions are " + "shown in the labeled videos." + ) + tmp_layout.addWidget(pcutoff_widget) + # Plot trajectories self.plot_trajectories = QtWidgets.QCheckBox("Plot trajectories") self.plot_trajectories.setCheckState(Qt.Unchecked) @@ -259,6 +277,7 @@ def create_videos(self): shuffle=shuffle, filtered=filtered, save_frames=self.create_high_quality_video.isChecked(), + pcutoff=self.pcutoff_selector.value(), displayedbodyparts=bodyparts, draw_skeleton=self.draw_skeleton_checkbox.isChecked(), trailpoints=trailpoints, @@ -273,15 +292,6 @@ def create_videos(self): failed_videos_str = ", ".join(failed_videos) self.root.writer.write(f"Failed to create videos from {failed_videos_str}.") - if all(videos_created): - self.root.writer.write("Labeled videos created.") - else: - failed_videos = [ - video for success, video in zip(videos_created, videos) if not success - ] - failed_videos_str = ", ".join(failed_videos) - self.root.writer.write(f"Failed to create videos from {failed_videos_str}.") - if self.plot_trajectories.isChecked(): deeplabcut.plot_trajectories( config=config, @@ -289,6 +299,7 @@ def create_videos(self): shuffle=shuffle, filtered=filtered, displayedbodyparts=bodyparts, + pcutoff=self.pcutoff_selector.value(), ) def build_skeleton(self, *args): diff --git a/deeplabcut/utils/make_labeled_video.py b/deeplabcut/utils/make_labeled_video.py index 4ee2ec11b9..a2e6518862 100644 --- a/deeplabcut/utils/make_labeled_video.py +++ b/deeplabcut/utils/make_labeled_video.py @@ -20,6 +20,7 @@ Hao Wu, hwu01@g.harvard.edu contributed the original OpenCV class. Thanks! You can find the directory for your ffmpeg bindings by: "find / | grep ffmpeg" and then setting it. """ +from __future__ import annotations import argparse import os @@ -382,7 +383,7 @@ def create_labeled_video( init_weights="", track_method="", superanimal_name="", - pcutoff=0.6, + pcutoff=None, skeleton=[], skeleton_color="white", dotsize=8, @@ -501,6 +502,9 @@ def create_labeled_video( For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will be taken from the config.yaml file if none is given. + pcutoff: string, optional, default=None + Overrides the pcutoff set in the project configuration to plot the trajectories. + overwrite: bool, optional, default=False If ``True`` overwrites existing labeled videos. @@ -560,13 +564,16 @@ def create_labeled_video( ) """ if config == "": - pass + if pcutoff is None: + pcutoff = 0.6 else: cfg = auxiliaryfunctions.read_config(config) trainFraction = cfg["TrainingFraction"][trainingsetindex] track_method = auxfun_multianimal.get_track_method( cfg, track_method=track_method ) + if pcutoff is None: + pcutoff = cfg["pcutoff"] if init_weights == "": DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name( @@ -659,6 +666,7 @@ def create_labeled_video( keypoints_only, overwrite, init_weights=init_weights, + pcutoff=pcutoff, confidence_to_alpha=confidence_to_alpha, ) @@ -699,6 +707,7 @@ def proc_video( overwrite, video, init_weights="", + pcutoff: float | None = None, confidence_to_alpha: Optional[Callable[[float], float]] = None, ): """Helper function for create_videos @@ -716,6 +725,9 @@ def proc_video( if destfolder is None: destfolder = videofolder # where your folder with videos is. + if pcutoff is None: + pcutoff = cfg["pcutoff"] + auxiliaryfunctions.attempt_to_make_folder(destfolder) os.chdir(destfolder) # THE VIDEO IS STILL IN THE VIDEO FOLDER @@ -751,7 +763,10 @@ def proc_video( s = "_id" if color_by == "individual" else "_bp" else: s = "" - videooutname = filepath.replace(".h5", f"{s}_labeled.mp4") + + videooutname = filepath.replace( + ".h5", f"{s}_p{int(100 * pcutoff)}_labeled.mp4" + ) if os.path.isfile(videooutname) and not overwrite: print("Labeled video already created. Skipping...") return @@ -779,7 +794,7 @@ def proc_video( df, videooutname, inds, - cfg["pcutoff"], + pcutoff, cfg["dotsize"], cfg["alphavalue"], skeleton_color=skeleton_color, @@ -801,7 +816,7 @@ def proc_video( cfg["dotsize"], cfg["colormap"], cfg["alphavalue"], - cfg["pcutoff"], + pcutoff, trailpoints, cropping, x1, @@ -828,7 +843,7 @@ def proc_video( bbox=(x1, x2, y1, y2), codec=codec, output_path=videooutname, - pcutoff=cfg["pcutoff"], + pcutoff=pcutoff, dotsize=cfg["dotsize"], cmap=cfg["colormap"], color_by=color_by, diff --git a/deeplabcut/utils/plotting.py b/deeplabcut/utils/plotting.py index 500f42e709..d059c14391 100644 --- a/deeplabcut/utils/plotting.py +++ b/deeplabcut/utils/plotting.py @@ -17,6 +17,7 @@ https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS Licensed under GNU Lesser General Public License v3.0 """ +from __future__ import annotations import argparse import os @@ -187,6 +188,7 @@ def plot_trajectories( resolution=100, linewidth=1.0, track_method="", + pcutoff: float | None = None, ): """Plots the trajectories of various bodyparts across the video. @@ -251,6 +253,9 @@ def plot_trajectories( For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will be taken from the config.yaml file if none is given. + pcutoff: string, optional, default=None + Overrides the pcutoff set in the project configuration to plot the trajectories. + Returns ------- None @@ -266,6 +271,10 @@ def plot_trajectories( ) """ cfg = auxiliaryfunctions.read_config(config) + + if pcutoff is None: + pcutoff = cfg["pcutoff"] + track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method) trainFraction = cfg["TrainingFraction"][trainingsetindex] @@ -308,7 +317,7 @@ def plot_trajectories( linewidth, cfg["colormap"], cfg["alphavalue"], - cfg["pcutoff"], + pcutoff, suffix, imagetype, tmpfolder, From 4be4a705591aea1b3b665e1a268a495f35075fab Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 21 Jun 2024 00:45:53 +0200 Subject: [PATCH 173/293] Update COLAB_Pytorch_SuperAnimal.ipynb --- .../COLAB/COLAB_Pytorch_SuperAnimal.ipynb | 10579 ++++++++-------- 1 file changed, 5115 insertions(+), 5464 deletions(-) diff --git a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb index 2d9f708f70..2d48d59042 100644 --- a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb @@ -1,5464 +1,5115 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "id": "5SSZpZUu0Z4S" - }, - "source": [ - "# DeepLabCut Model Zoo: SuperAnimal models\n", - "\n", - "![alt text](https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1616492373700-PGOAC72IOB6AUE47VTJX/ke17ZwdGBToddI8pDm48kB8JrdUaZR-OSkKLqWQPp_YUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8N_N4V1vUb5AoIIIbLZhVYwL8IeDg6_3B-BRuF4nNrNcQkVuAT7tdErd0wQFEGFSnBqyW03PFN2MN6T6ry5cmXqqA9xITfsbVGDrg_goIDasRCalqV8R3606BuxERAtDaQ/modelzoo.png?format=1000w)\n", - "\n", - "http://modelzoo.deeplabcut.org\n", - "\n", - "You can use this notebook to analyze videos with pretrained networks from our model zoo - NO local installation of DeepLabCut is needed!\n", - "\n", - "- **What you need:** a video of your favorite dog, cat, human, etc: check the list of currently available models here: http://modelzoo.deeplabcut.org\n", - "\n", - "- **What to do:** (1) in the top right corner, click \"CONNECT\". Then, just hit run (play icon) on each cell below and follow the instructions!\n", - "\n", - "## **Please consider giving back and labeling a little data to help make each network even better!**\n", - "\n", - "We have a WebApp, so no need to install anything, just a few clicks! We'd really appreciate your help! 🙏\n", - " \n", - "https://contrib.deeplabcut.org/\n", - "\n", - "\n", - "- **Note, if you performance is less that you would like:** firstly check the labeled_video parameters (i.e. \"pcutoff\" that will set the video plotting) - see the end of this notebook.\n", - "- You can also use the model in your own projects locally. Please be sure to cite the papers for the model, i.e., [Ye et al. 2023](https://arxiv.org/abs/2203.07436) 🎉\n", - "\n", - "\n", - "\n", - "## **Let's get going: install DeepLabCut into COLAB:**\n", - "\n", - "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "0qb_Vh8F0Z4W" - }, - "source": [ - "### Here we are testing our private code on colab\n", - "### We put the code in google drive, mount it and use the module" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "collapsed": true, - "id": "Kx13wriE0Z4W", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "from google.colab import drive\n", - "import ipywidgets as widgets\n", - "from IPython.display import display\n", - "import shutil\n", - "from google.colab import files\n", - "import os\n", - "import zipfile\n", - "from pathlib import Path\n", - "import sys\n", - "import matplotlib.pyplot as plt\n", - "import matplotlib.image as mpimg" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 0 - }, - "collapsed": true, - "id": "wsSZF4Pk0kae", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "322ca9cf-d779-40fc-9e9c-b241be75131d" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Drive already mounted at /content/drive; to attempt to forcibly remount, call drive.mount(\"/content/drive\", force_remount=True).\n" - ] - } - ], - "source": [ - "drive.mount('/content/drive')" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 0 - }, - "collapsed": true, - "id": "AjET5cJE5UYM", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "b9b5b6d9-6b9c-4cfe-e3fa-9bbb03079fe7" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Requirement already satisfied: albumentations<=1.4.3 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 2)) (1.4.3)\n", - "Requirement already satisfied: einops in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 3)) (0.8.0)\n", - "Requirement already satisfied: timm in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 4)) (1.0.3)\n", - "Requirement already satisfied: wandb in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (0.17.1)\n", - "Requirement already satisfied: dlclibrary in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 8)) (0.0.6)\n", - "Requirement already satisfied: ipython in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (7.34.0)\n", - "Requirement already satisfied: filterpy in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 10)) (1.4.5)\n", - "Requirement already satisfied: ruamel.yaml>=0.15.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 11)) (0.18.6)\n", - "Requirement already satisfied: intel-openmp in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 12)) (2024.1.2)\n", - "Requirement already satisfied: imageio-ffmpeg in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 13)) (0.5.1)\n", - "Requirement already satisfied: imgaug>=0.4.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (0.4.0)\n", - "Requirement already satisfied: numba>=0.54.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 15)) (0.59.1)\n", - "Requirement already satisfied: matplotlib>=3.3 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (3.7.1)\n", - "Requirement already satisfied: networkx>=2.6 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 17)) (3.3)\n", - "Requirement already satisfied: numpy>=1.18.5 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 18)) (1.25.2)\n", - "Requirement already satisfied: pandas!=1.5.0,>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 19)) (2.0.3)\n", - "Requirement already satisfied: Pillow>=7.1 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 20)) (10.3.0)\n", - "Requirement already satisfied: pyyaml in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 21)) (6.0.1)\n", - "Requirement already satisfied: scikit-image>=0.17 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 22)) (0.23.2)\n", - "Requirement already satisfied: scikit-learn>=1.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 23)) (1.5.0)\n", - "Requirement already satisfied: scipy<1.11.0,>=1.4 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 24)) (1.10.1)\n", - "Requirement already satisfied: statsmodels>=0.11 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 25)) (0.14.2)\n", - "Requirement already satisfied: tensorflow<2.13.0,>=2.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.11.1)\n", - "Requirement already satisfied: tables==3.8.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (3.8.0)\n", - "Requirement already satisfied: tensorpack>=0.11 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (0.11)\n", - "Requirement already satisfied: tf_slim>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 29)) (1.1.0)\n", - "Requirement already satisfied: torch>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (2.3.0+cpu)\n", - "Requirement already satisfied: torchvision in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 31)) (0.18.0+cpu)\n", - "Requirement already satisfied: tqdm in /usr/local/lib/python3.10/dist-packages (from -r /content/drive/My Drive/DLCdev/requirements.txt (line 32)) (4.66.4)\n", - "Requirement already satisfied: cython>=0.29.21 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (3.0.10)\n", - "Requirement already satisfied: numexpr>=2.6.2 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (2.10.0)\n", - "Requirement already satisfied: blosc2~=2.0.0 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (2.0.0)\n", - "Requirement already satisfied: packaging in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (24.1)\n", - "Requirement already satisfied: py-cpuinfo in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 27)) (9.0.0)\n", - "Requirement already satisfied: typing-extensions>=4.9.0 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 2)) (4.12.2)\n", - "Requirement already satisfied: opencv-python-headless>=4.9.0 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 2)) (4.10.0.82)\n", - "Requirement already satisfied: huggingface_hub in /usr/local/lib/python3.10/dist-packages (from timm->-r /content/drive/My Drive/DLCdev/requirements.txt (line 4)) (0.23.3)\n", - "Requirement already satisfied: safetensors in /usr/local/lib/python3.10/dist-packages (from timm->-r /content/drive/My Drive/DLCdev/requirements.txt (line 4)) (0.4.3)\n", - "Requirement already satisfied: click!=8.0.0,>=7.1 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (8.1.7)\n", - "Requirement already satisfied: docker-pycreds>=0.4.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (0.4.0)\n", - "Requirement already satisfied: gitpython!=3.1.29,>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (3.1.43)\n", - "Requirement already satisfied: platformdirs in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (4.2.2)\n", - "Requirement already satisfied: protobuf!=4.21.0,<6,>=3.19.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (3.19.6)\n", - "Requirement already satisfied: psutil>=5.0.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (5.9.5)\n", - "Requirement already satisfied: requests<3,>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (2.31.0)\n", - "Requirement already satisfied: sentry-sdk>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (2.5.1)\n", - "Requirement already satisfied: setproctitle in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (1.3.3)\n", - "Requirement already satisfied: setuptools in /usr/local/lib/python3.10/dist-packages (from wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (67.7.2)\n", - "Requirement already satisfied: jedi>=0.16 in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.19.1)\n", - "Requirement already satisfied: decorator in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (5.1.1)\n", - "Requirement already satisfied: pickleshare in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.7.5)\n", - "Requirement already satisfied: traitlets>=4.2 in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (5.7.1)\n", - "Requirement already satisfied: prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (3.0.47)\n", - "Requirement already satisfied: pygments in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (2.18.0)\n", - "Requirement already satisfied: backcall in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.2.0)\n", - "Requirement already satisfied: matplotlib-inline in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.1.7)\n", - "Requirement already satisfied: pexpect>4.3 in /usr/local/lib/python3.10/dist-packages (from ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (4.9.0)\n", - "Requirement already satisfied: ruamel.yaml.clib>=0.2.7 in /usr/local/lib/python3.10/dist-packages (from ruamel.yaml>=0.15.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 11)) (0.2.8)\n", - "Requirement already satisfied: six in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (1.16.0)\n", - "Requirement already satisfied: opencv-python in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (4.10.0.82)\n", - "Requirement already satisfied: imageio in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (2.34.1)\n", - "Requirement already satisfied: Shapely in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 14)) (2.0.4)\n", - "Requirement already satisfied: llvmlite<0.43,>=0.42.0dev0 in /usr/local/lib/python3.10/dist-packages (from numba>=0.54.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 15)) (0.42.0)\n", - "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (1.2.1)\n", - "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (0.12.1)\n", - "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (4.53.0)\n", - "Requirement already satisfied: kiwisolver>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (1.4.5)\n", - "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (3.1.2)\n", - "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.10/dist-packages (from matplotlib>=3.3->-r /content/drive/My Drive/DLCdev/requirements.txt (line 16)) (2.9.0.post0)\n", - "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->-r /content/drive/My Drive/DLCdev/requirements.txt (line 19)) (2024.1)\n", - "Requirement already satisfied: tzdata>=2022.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->-r /content/drive/My Drive/DLCdev/requirements.txt (line 19)) (2024.1)\n", - "Requirement already satisfied: tifffile>=2022.8.12 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->-r /content/drive/My Drive/DLCdev/requirements.txt (line 22)) (2024.5.22)\n", - "Requirement already satisfied: lazy-loader>=0.4 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->-r /content/drive/My Drive/DLCdev/requirements.txt (line 22)) (0.4)\n", - "Requirement already satisfied: joblib>=1.2.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 23)) (1.4.2)\n", - "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 23)) (3.5.0)\n", - "Requirement already satisfied: patsy>=0.5.6 in /usr/local/lib/python3.10/dist-packages (from statsmodels>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 25)) (0.5.6)\n", - "Requirement already satisfied: absl-py>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.4.0)\n", - "Requirement already satisfied: astunparse>=1.6.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.6.3)\n", - "Requirement already satisfied: flatbuffers>=2.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (24.3.25)\n", - "Requirement already satisfied: gast<=0.4.0,>=0.2.1 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.4.0)\n", - "Requirement already satisfied: google-pasta>=0.1.1 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.2.0)\n", - "Requirement already satisfied: grpcio<2.0,>=1.24.3 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.64.1)\n", - "Requirement already satisfied: h5py>=2.9.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.11.0)\n", - "Requirement already satisfied: keras<2.12,>=2.11.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.11.0)\n", - "Requirement already satisfied: libclang>=13.0.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (18.1.1)\n", - "Requirement already satisfied: opt-einsum>=2.3.2 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.3.0)\n", - "Requirement already satisfied: tensorboard<2.12,>=2.11 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.11.2)\n", - "Requirement already satisfied: tensorflow-estimator<2.12,>=2.11.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.11.0)\n", - "Requirement already satisfied: termcolor>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.4.0)\n", - "Requirement already satisfied: wrapt>=1.11.0 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.14.1)\n", - "Requirement already satisfied: tensorflow-io-gcs-filesystem>=0.23.1 in /usr/local/lib/python3.10/dist-packages (from tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.37.0)\n", - "Requirement already satisfied: tabulate>=0.7.7 in /usr/local/lib/python3.10/dist-packages (from tensorpack>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (0.9.0)\n", - "Requirement already satisfied: msgpack>=0.5.2 in /usr/local/lib/python3.10/dist-packages (from tensorpack>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (1.0.8)\n", - "Requirement already satisfied: msgpack-numpy>=0.4.4.2 in /usr/local/lib/python3.10/dist-packages (from tensorpack>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (0.4.8)\n", - "Requirement already satisfied: pyzmq>=16 in /usr/local/lib/python3.10/dist-packages (from tensorpack>=0.11->-r /content/drive/My Drive/DLCdev/requirements.txt (line 28)) (24.0.1)\n", - "Requirement already satisfied: filelock in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (3.14.0)\n", - "Requirement already satisfied: sympy in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (1.12.1)\n", - "Requirement already satisfied: jinja2 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (3.1.4)\n", - "Requirement already satisfied: fsspec in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (2024.6.0)\n", - "Requirement already satisfied: wheel<1.0,>=0.23.0 in /usr/local/lib/python3.10/dist-packages (from astunparse>=1.6.0->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.43.0)\n", - "Requirement already satisfied: gitdb<5,>=4.0.1 in /usr/local/lib/python3.10/dist-packages (from gitpython!=3.1.29,>=1.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (4.0.11)\n", - "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /usr/local/lib/python3.10/dist-packages (from jedi>=0.16->ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.8.4)\n", - "Requirement already satisfied: ptyprocess>=0.5 in /usr/local/lib/python3.10/dist-packages (from pexpect>4.3->ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.7.0)\n", - "Requirement already satisfied: wcwidth in /usr/local/lib/python3.10/dist-packages (from prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0->ipython->-r /content/drive/My Drive/DLCdev/requirements.txt (line 9)) (0.2.13)\n", - "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests<3,>=2.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (3.3.2)\n", - "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.10/dist-packages (from requests<3,>=2.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (3.7)\n", - "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests<3,>=2.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (2.0.7)\n", - "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.10/dist-packages (from requests<3,>=2.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (2024.6.2)\n", - "Requirement already satisfied: google-auth<3,>=1.6.3 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.27.0)\n", - "Requirement already satisfied: google-auth-oauthlib<0.5,>=0.4.1 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.4.6)\n", - "Requirement already satisfied: markdown>=2.6.8 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.6)\n", - "Requirement already satisfied: tensorboard-data-server<0.7.0,>=0.6.0 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.6.1)\n", - "Requirement already satisfied: tensorboard-plugin-wit>=1.6.0 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (1.8.1)\n", - "Requirement already satisfied: werkzeug>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.0.3)\n", - "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.10/dist-packages (from jinja2->torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (2.1.5)\n", - "Requirement already satisfied: mpmath<1.4.0,>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from sympy->torch>=2.0.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 30)) (1.3.0)\n", - "Requirement already satisfied: smmap<6,>=3.0.1 in /usr/local/lib/python3.10/dist-packages (from gitdb<5,>=4.0.1->gitpython!=3.1.29,>=1.0.0->wandb->-r /content/drive/My Drive/DLCdev/requirements.txt (line 5)) (5.0.1)\n", - "Requirement already satisfied: cachetools<6.0,>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from google-auth<3,>=1.6.3->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (5.3.3)\n", - "Requirement already satisfied: pyasn1-modules>=0.2.1 in /usr/local/lib/python3.10/dist-packages (from google-auth<3,>=1.6.3->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.4.0)\n", - "Requirement already satisfied: rsa<5,>=3.1.4 in /usr/local/lib/python3.10/dist-packages (from google-auth<3,>=1.6.3->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (4.9)\n", - "Requirement already satisfied: requests-oauthlib>=0.7.0 in /usr/local/lib/python3.10/dist-packages (from google-auth-oauthlib<0.5,>=0.4.1->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (2.0.0)\n", - "Requirement already satisfied: pyasn1<0.7.0,>=0.4.6 in /usr/local/lib/python3.10/dist-packages (from pyasn1-modules>=0.2.1->google-auth<3,>=1.6.3->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (0.6.0)\n", - "Requirement already satisfied: oauthlib>=3.0.0 in /usr/local/lib/python3.10/dist-packages (from requests-oauthlib>=0.7.0->google-auth-oauthlib<0.5,>=0.4.1->tensorboard<2.12,>=2.11->tensorflow<2.13.0,>=2.0->-r /content/drive/My Drive/DLCdev/requirements.txt (line 26)) (3.2.2)\n" - ] - } - ], - "source": [ - "!pip install deeplabcut==3.0.0rc1" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "5h0vq6E50Z4W" - }, - "source": [ - "### PLEASE, click \"restart runtime\" from the output above before proceeding!" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 0 - }, - "collapsed": true, - "id": "LvnlIvQm0Z4X", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "61bd7b86-525f-4eab-8441-d0bfd927b6e9" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Loading DLC 3.0.0rc1...\n", - "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n" - ] - } - ], - "source": [ - "import deeplabcut\n", - "from deeplabcut.pose_estimation_pytorch.apis.analyze_images import (\n", - " superanimal_analyze_images,\n", - ")\n", - "from deeplabcut.core.weight_init import WeightInitialization\n", - "from deeplabcut.core.engine import Engine\n", - "from deeplabcut.modelzoo.video_inference import video_inference_superanimal\n", - "from deeplabcut.utils.pseudo_label import keypoint_matching\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "UeXjmtu40Z4X" - }, - "source": [ - "## Zero-shot Image Inference & Video Inference\n", - "SuperAnimal models are foundation animal pose models. They can be used for zero-shot predictions without further training on the data.\n", - "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another dest folder." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Zero-shot image inference" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Upload the images you want to predict" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 535 - }, - "collapsed": true, - "id": "c4yfTj7r0Z4Y", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "1d4f71b5-fd2f-4215-ac3a-5d92011fc25d" - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " Upload widget is only available when the cell has been executed in the\n", - " current browser session. Please rerun this cell to enable.\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Saving img41.png to img41.png\n", - "Saving img53.png to img53.png\n", - "Uploaded files have been moved to: /content/uploaded_images\n", - "Contents of the new folder:\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from google.colab import files\n", - "import os\n", - "import shutil\n", - "from pathlib import Path\n", - "\n", - "# Step 1: Upload files\n", - "uploaded = files.upload()\n", - "\n", - "# Step 2: Define upload directory\n", - "upload_directory = Path('/content/')\n", - "\n", - "# Create a new folder for uploaded files\n", - "in_image_folder = upload_directory / 'uploaded_images'\n", - "out_image_folder = upload_directory / 'processed_images'\n", - "os.makedirs(in_image_folder, exist_ok=True)\n", - "\n", - "# Step 3: Save and move files to the new folder\n", - "uploaded_files = []\n", - "for filename, content in uploaded.items():\n", - " # Save the file to the upload directory\n", - " file_path = os.path.join(upload_directory, filename)\n", - " uploaded_files.append(filename)\n", - " with open(file_path, 'wb') as f:\n", - " f.write(content)\n", - "\n", - " # Move the file to the new folder\n", - " if not os.path.exists(os.path.join(in_image_folder, file_path.split(os.sep)[-1])):\n", - " shutil.move(file_path, in_image_folder)\n", - "\n", - "# List contents of the new folder\n", - "print(f\"Uploaded files have been moved to: {in_image_folder}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Display the images you uploaded" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 812 - }, - "collapsed": true, - "id": "Ybm7pvPKNKR-", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "da0e87d9-5b91-4545-cdba-09976f5d0b01" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Contents of the new folder:\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "print(\"The images you uploaded:\")\n", - "for f in uploaded_files:\n", - " img = mpimg.imread(os.path.join(in_image_folder, f))\n", - " plt.imshow(img)\n", - " plt.axis('off') # Hide the axes for better view\n", - " plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Jashzdjb0Z4Y" - }, - "source": [ - "#### Selecting a superanimal name and corresponding model architecture\n", - "\n", - "Check https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/ModelZoo.md to learn more about superanimals\n" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": { - "collapsed": true, - "id": "uH9LXig90Z4Y", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "superanimal_name = 'superanimal_topviewmouse' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", - "model_name = 'hrnetw32'\n", - "max_individuals = 3 " - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "2OGXW7WmHRR1" - }, - "source": [ - "#### Image inference API predicts images in in_image_folder and outputs predicted images into out_image_folder" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 6654 - }, - "collapsed": true, - "id": "OmJtVmHq0Z4Y", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "2362df9a-3aa7-42cb-ec81-4f8381e9168f" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Task: None\n", - "scorer: None\n", - "date: None\n", - "multianimalproject: None\n", - "identity: None\n", - "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - "engine: pytorch\n", - "video_sets: None\n", - "bodyparts: ['nose', 'left_ear', 'right_ear', 'left_ear_tip', 'right_ear_tip', 'left_eye', 'right_eye', 'neck', 'mid_back', 'mouse_center', 'mid_backend', 'mid_backend2', 'mid_backend3', 'tail_base', 'tail1', 'tail2', 'tail3', 'tail4', 'tail5', 'left_shoulder', 'left_midside', 'left_hip', 'right_shoulder', 'right_midside', 'right_hip', 'tail_end', 'head_midpoint']\n", - "start: None\n", - "stop: None\n", - "numframes2pick: None\n", - "skeleton: []\n", - "skeleton_color: black\n", - "pcutoff: None\n", - "dotsize: None\n", - "alphavalue: None\n", - "colormap: rainbow\n", - "TrainingFraction: None\n", - "iteration: None\n", - "default_net_type: None\n", - "default_augmenter: None\n", - "snapshotindex: None\n", - "detector_snapshotindex: None\n", - "batch_size: 1\n", - "cropping: None\n", - "x1: None\n", - "x2: None\n", - "y1: None\n", - "y2: None\n", - "corner2move2: None\n", - "move2corner: None\n", - "SuperAnimalConversionTables: None\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 250\n", - "device: None\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 27\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 27]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 10\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 200\n", - " pretrained_weights: None\n", - " seed: 42\n", - "metadata:\n", - " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", - " bodyparts: ['nose', 'left_ear', 'right_ear', 'left_ear_tip', 'right_ear_tip', 'left_eye', 'right_eye', 'neck', 'mid_back', 'mouse_center', 'mid_backend', 'mid_backend2', 'mid_backend3', 'tail_base', 'tail1', 'tail2', 'tail3', 'tail4', 'tail5', 'left_shoulder', 'left_midside', 'left_hip', 'right_shoulder', 'right_midside', 'right_hip', 'tail_end', 'head_midpoint']\n", - " unique_bodyparts: []\n", - " individuals: ['animal']\n", - " with_identity: None\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 2/2 [00:31<00:00, 15.81s/it]\n", - "100%|██████████| 2/2 [00:04<00:00, 2.41s/it]\n" - ] - }, - { - "data": { - "text/plain": [ - "{'/content/uploaded_images/img41.png': {'bodyparts': array([[[6.0902344e+02, 1.0450781e+02, 9.4288880e-01],\n", - " [5.9838281e+02, 1.1160156e+02, 9.0683377e-01],\n", - " [6.1611719e+02, 1.2224219e+02, 9.4751132e-01],\n", - " [5.9838281e+02, 1.1160156e+02, 4.9548081e-01],\n", - " [6.1257031e+02, 1.2224219e+02, 4.3269035e-01],\n", - " [6.0547656e+02, 1.0805469e+02, 4.9392006e-01],\n", - " [6.1257031e+02, 1.1160156e+02, 4.8222214e-01],\n", - " [6.0547656e+02, 1.4352344e+02, 8.8368005e-01],\n", - " [6.0547656e+02, 1.5771094e+02, 9.0701991e-01],\n", - " [6.0192969e+02, 1.7544531e+02, 9.0866005e-01],\n", - " [5.9128906e+02, 1.8963281e+02, 9.0004200e-01],\n", - " [5.9128906e+02, 1.8963281e+02, 6.1854482e-01],\n", - " [5.8774219e+02, 2.0382031e+02, 2.9714042e-01],\n", - " [5.8419531e+02, 2.0736719e+02, 9.3075049e-01],\n", - " [5.7710156e+02, 2.3928906e+02, 9.1254854e-01],\n", - " [5.5582031e+02, 2.7121094e+02, 9.0838885e-01],\n", - " [5.5582031e+02, 2.6766406e+02, 6.6969872e-01],\n", - " [5.6291406e+02, 2.5347656e+02, 1.1113699e-01],\n", - " [5.5227344e+02, 2.7475781e+02, 1.3679989e-01],\n", - " [5.9483594e+02, 1.5061719e+02, 1.8572396e-01],\n", - " [5.9128906e+02, 1.6835156e+02, 3.1358978e-01],\n", - " [5.8064844e+02, 1.7189844e+02, 1.5388538e-01],\n", - " [6.1611719e+02, 1.5061719e+02, 1.8709363e-01],\n", - " [6.1611719e+02, 1.7544531e+02, 4.5999062e-01],\n", - " [6.0547656e+02, 1.9317969e+02, 1.1289987e-01],\n", - " [5.3099219e+02, 2.9603906e+02, 9.0115750e-01],\n", - " [6.0547656e+02, 1.0805469e+02, 3.7565941e-01]],\n", - " \n", - " [[4.1302344e+02, 1.5013281e+02, 8.9839083e-01],\n", - " [3.9211719e+02, 1.5013281e+02, 9.1423309e-01],\n", - " [4.0605469e+02, 1.6755469e+02, 9.1672188e-01],\n", - " [3.8863281e+02, 1.5013281e+02, 5.6915891e-01],\n", - " [4.0953906e+02, 1.7103906e+02, 4.4163048e-01],\n", - " [4.0257031e+02, 1.5361719e+02, 3.6568445e-01],\n", - " [4.1302344e+02, 1.5710156e+02, 4.1134340e-01],\n", - " [3.8166406e+02, 1.8149219e+02, 8.5191375e-01],\n", - " [3.6772656e+02, 1.9891406e+02, 8.3902079e-01],\n", - " [3.5727344e+02, 2.1633594e+02, 8.9918709e-01],\n", - " [3.5030469e+02, 2.3375781e+02, 9.3533570e-01],\n", - " [3.5030469e+02, 2.3375781e+02, 7.4649817e-01],\n", - " [3.4682031e+02, 2.4421094e+02, 2.7727538e-01],\n", - " [3.4333594e+02, 2.5117969e+02, 8.5074162e-01],\n", - " [3.2939844e+02, 2.8253906e+02, 9.0284985e-01],\n", - " [3.1197656e+02, 3.2783594e+02, 8.1207591e-01],\n", - " [3.1197656e+02, 3.2435156e+02, 5.6623256e-01],\n", - " [3.1894531e+02, 2.9996094e+02, 5.3559665e-02],\n", - " [3.0849219e+02, 3.3132031e+02, 2.0988575e-01],\n", - " [3.6424219e+02, 1.7800781e+02, 1.8389371e-01],\n", - " [3.2591406e+02, 2.9299219e+02, 2.0259061e-01],\n", - " [3.3636719e+02, 2.1285156e+02, 2.1805577e-01],\n", - " [3.8863281e+02, 1.9542969e+02, 1.5399683e-01],\n", - " [3.7817969e+02, 2.1633594e+02, 2.2692600e-01],\n", - " [3.8514844e+02, 2.3027344e+02, 2.1960309e-01],\n", - " [2.9803906e+02, 3.4525781e+02, 3.5328293e-01],\n", - " [4.0953906e+02, 1.5361719e+02, 3.3797303e-01]],\n", - " \n", - " [[3.2492188e+02, 3.6645312e+02, 9.2720670e-01],\n", - " [3.0064062e+02, 3.8379688e+02, 8.9140952e-01],\n", - " [3.2145312e+02, 3.9767188e+02, 9.2880279e-01],\n", - " [3.0064062e+02, 3.8379688e+02, 4.9237543e-01],\n", - " [3.2492188e+02, 3.9767188e+02, 4.2291871e-01],\n", - " [3.1451562e+02, 3.7685938e+02, 2.9230788e-01],\n", - " [3.2839062e+02, 3.8032812e+02, 2.6238552e-01],\n", - " [2.9023438e+02, 4.1154688e+02, 8.8332975e-01],\n", - " [2.7635938e+02, 4.2195312e+02, 9.5248973e-01],\n", - " [2.6248438e+02, 4.2889062e+02, 8.9019632e-01],\n", - " [2.4514062e+02, 4.3582812e+02, 9.0826660e-01],\n", - " [2.4514062e+02, 4.3582812e+02, 5.9481096e-01],\n", - " [2.3820312e+02, 4.4276562e+02, 3.9131784e-01],\n", - " [2.3473438e+02, 4.4276562e+02, 9.0042192e-01],\n", - " [2.0004688e+02, 4.5664062e+02, 8.7398684e-01],\n", - " [1.6535938e+02, 4.4970312e+02, 8.6447608e-01],\n", - " [1.6535938e+02, 4.4970312e+02, 6.3416219e-01],\n", - " [1.8270312e+02, 4.6010938e+02, 9.7222522e-02],\n", - " [1.5842188e+02, 4.4623438e+02, 2.6928642e-01],\n", - " [2.7982812e+02, 4.0114062e+02, 2.2257748e-01],\n", - " [2.6248438e+02, 4.1501562e+02, 2.7101427e-01],\n", - " [2.5207812e+02, 4.1848438e+02, 1.7106722e-01],\n", - " [2.9370312e+02, 4.2542188e+02, 1.4119300e-01],\n", - " [2.6942188e+02, 4.3582812e+02, 2.7226987e-01],\n", - " [2.5901562e+02, 4.4970312e+02, 3.3760059e-01],\n", - " [1.4107812e+02, 4.3582812e+02, 9.7957635e-01],\n", - " [3.2145312e+02, 3.7685938e+02, 2.3908456e-01]]], dtype=float32),\n", - " 'bboxes': array([[509.58276 , 85.064926, 128.88495 , 227.21083 ],\n", - " [288.62692 , 124.81932 , 148.07205 , 222.38081 ],\n", - " [122.01376 , 342.69934 , 222.54361 , 131.66571 ]], dtype=float32),\n", - " 'bbox_scores': array([0.9999999, 0.999997 , 0.9999958], dtype=float32)},\n", - " '/content/uploaded_images/img53.png': {'bodyparts': array([[[4.72656250e+02, 1.33656250e+02, 9.16062176e-01],\n", - " [4.55468750e+02, 1.33656250e+02, 9.10584450e-01],\n", - " [4.65781250e+02, 1.50843750e+02, 9.20696080e-01],\n", - " [4.52031250e+02, 1.33656250e+02, 5.48837364e-01],\n", - " [4.62343750e+02, 1.50843750e+02, 4.87998635e-01],\n", - " [4.65781250e+02, 1.37093750e+02, 4.98770237e-01],\n", - " [4.69218750e+02, 1.43968750e+02, 4.21647400e-01],\n", - " [4.31406250e+02, 1.57718750e+02, 9.20776665e-01],\n", - " [4.14218750e+02, 1.68031250e+02, 8.32261205e-01],\n", - " [4.00468750e+02, 1.78343750e+02, 8.56697619e-01],\n", - " [3.83281250e+02, 1.85218750e+02, 8.27098489e-01],\n", - " [3.79843750e+02, 1.88656250e+02, 5.89772344e-01],\n", - " [3.69531250e+02, 1.92093750e+02, 3.87809008e-01],\n", - " [3.66093750e+02, 1.98968750e+02, 7.55185008e-01],\n", - " [3.45468750e+02, 2.26468750e+02, 9.04548585e-01],\n", - " [3.21406250e+02, 2.67718750e+02, 8.51889312e-01],\n", - " [3.24843750e+02, 2.67718750e+02, 5.88238776e-01],\n", - " [3.28281250e+02, 2.50531250e+02, 5.10663651e-02],\n", - " [3.17968750e+02, 2.78031250e+02, 1.99461669e-01],\n", - " [4.17656250e+02, 1.47406250e+02, 1.98352650e-01],\n", - " [4.00468750e+02, 1.61156250e+02, 3.82132381e-01],\n", - " [3.83281250e+02, 1.57718750e+02, 3.83855522e-01],\n", - " [4.27968750e+02, 1.68031250e+02, 1.11350164e-01],\n", - " [4.14218750e+02, 1.85218750e+02, 3.06811243e-01],\n", - " [4.00468750e+02, 2.05843750e+02, 2.46663406e-01],\n", - " [2.97343750e+02, 2.95218750e+02, 9.00896847e-01],\n", - " [4.65781250e+02, 1.37093750e+02, 3.99487644e-01]],\n", - " \n", - " [[6.10117188e+02, 1.09257812e+02, 9.50750053e-01],\n", - " [5.99382812e+02, 1.12835938e+02, 8.89275432e-01],\n", - " [6.13695312e+02, 1.23570312e+02, 9.25537527e-01],\n", - " [5.95804688e+02, 1.09257812e+02, 5.22700429e-01],\n", - " [6.13695312e+02, 1.23570312e+02, 4.27219480e-01],\n", - " [6.06539062e+02, 1.09257812e+02, 5.17836511e-01],\n", - " [6.13695312e+02, 1.16414062e+02, 5.11843443e-01],\n", - " [5.99382812e+02, 1.41460938e+02, 8.64772022e-01],\n", - " [5.99382812e+02, 1.59351562e+02, 8.87489796e-01],\n", - " [5.95804688e+02, 1.77242188e+02, 9.03314292e-01],\n", - " [5.88648438e+02, 1.91554688e+02, 8.98596525e-01],\n", - " [5.88648438e+02, 1.91554688e+02, 6.42297685e-01],\n", - " [5.85070312e+02, 2.05867188e+02, 2.90820181e-01],\n", - " [5.81492188e+02, 2.09445312e+02, 9.32401717e-01],\n", - " [5.70757812e+02, 2.41648438e+02, 9.19849038e-01],\n", - " [5.49289062e+02, 2.73851562e+02, 8.86915267e-01],\n", - " [5.49289062e+02, 2.70273438e+02, 6.51529133e-01],\n", - " [5.60023438e+02, 2.55960938e+02, 7.66883641e-02],\n", - " [5.42132812e+02, 2.77429688e+02, 1.29956439e-01],\n", - " [5.92226562e+02, 1.52195312e+02, 1.47577330e-01],\n", - " [5.81492188e+02, 1.70085938e+02, 2.71813959e-01],\n", - " [5.67179688e+02, 1.77242188e+02, 3.45247984e-01],\n", - " [6.13695312e+02, 1.52195312e+02, 1.65641069e-01],\n", - " [6.06539062e+02, 1.77242188e+02, 4.92357016e-01],\n", - " [6.06539062e+02, 1.87976562e+02, 1.71018571e-01],\n", - " [5.27820312e+02, 2.98898438e+02, 8.94161284e-01],\n", - " [6.10117188e+02, 1.12835938e+02, 3.94761950e-01]],\n", - " \n", - " [[3.06601562e+02, 3.29710938e+02, 9.13135529e-01],\n", - " [2.90570312e+02, 3.56429688e+02, 8.61805081e-01],\n", - " [3.17289062e+02, 3.61773438e+02, 8.98008704e-01],\n", - " [2.90570312e+02, 3.59101562e+02, 4.81671065e-01],\n", - " [3.19960938e+02, 3.64445312e+02, 3.54580611e-01],\n", - " [3.01257812e+02, 3.45742188e+02, 2.27038369e-01],\n", - " [3.11945312e+02, 3.40398438e+02, 2.37113133e-01],\n", - " [2.98585938e+02, 3.88492188e+02, 8.50156963e-01],\n", - " [2.90570312e+02, 4.01851562e+02, 8.79369020e-01],\n", - " [2.79882812e+02, 4.15210938e+02, 8.32222164e-01],\n", - " [2.74539062e+02, 4.25898438e+02, 7.87312865e-01],\n", - " [2.71867188e+02, 4.28570312e+02, 5.75566053e-01],\n", - " [2.69195312e+02, 4.36585938e+02, 1.90131292e-01],\n", - " [2.66523438e+02, 4.41929688e+02, 9.00796175e-01],\n", - " [2.47820312e+02, 4.60632812e+02, 8.32954049e-01],\n", - " [2.26445312e+02, 4.68648438e+02, 4.07222718e-01],\n", - " [2.26445312e+02, 4.68648438e+02, 6.00789249e-01],\n", - " [2.29117188e+02, 4.65976562e+02, 1.31227106e-01],\n", - " [2.18429688e+02, 4.73992188e+02, 2.13691890e-01],\n", - " [2.87898438e+02, 3.88492188e+02, 1.71175405e-01],\n", - " [2.77210938e+02, 4.01851562e+02, 1.45578876e-01],\n", - " [2.58507812e+02, 4.09867188e+02, 2.74997532e-01],\n", - " [3.03929688e+02, 3.96507812e+02, 1.03413269e-01],\n", - " [2.98585938e+02, 4.12539062e+02, 3.89558613e-01],\n", - " [2.98585938e+02, 4.28570312e+02, 6.41631544e-01],\n", - " [1.99726562e+02, 4.73992188e+02, 2.66729474e-01],\n", - " [3.06601562e+02, 3.40398438e+02, 2.24778220e-01]]], dtype=float32),\n", - " 'bboxes': array([[275.63156 , 112.20724 , 219.51932 , 197.95145 ],\n", - " [504.64838 , 86.445526, 128.49219 , 228.65271 ],\n", - " [174.0281 , 307.49316 , 161.32147 , 171.05508 ]], dtype=float32),\n", - " 'bbox_scores': array([0.9999994 , 0.9999994 , 0.99999714], dtype=float32)}}" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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5HI53HLLLfjTP/1T5HI5bN++fSi3AE1NTeGcc87BAw88MOxnBu3cuRPnn39+z7XzzjsvneraaDZgQwGSQqGAiy++GJ/85CfTa91uF5/85Cexd+/eE8jZtyedccYZ2LFjR4+8lpaWcNttt6Xy2rt3LxYWFnDHHXekaT71qU+h2+3i0ksvfcp5frLJOYc3vvGNuOGGG/CpT30KZ5xxRs/9iy++GPl8vkdm9913H/bt29cjs7vuuqtnEN98882YmJjoUw4nK3W7XTQajaG8DLr88stx11134c4770w/l1xyCa655pr091BufqpUKnjwwQexc+fOYT8z6AUveEHfsQXf+ta3cNpppwHYgDbgKV1Cexzofe97nysWi+7v/u7v3L333ut++qd/2k1NTfWsrP6vRMvLy+6rX/2q++pXv+oAuD/8wz90X/3qV92jjz7qnFvd8jU1NeU++MEPuq9//evuFa94hbrl69nPfra77bbb3Oc+9zl39tlnn7Tbft/whje4yclJ95nPfKZne+HKykqa5vWvf73bs2eP+9SnPuW+/OUvu71797q9e/em92l74Utf+lJ35513uo997GNu69atJ+32wl/5lV9xt9xyi3v44Yfd17/+dfcrv/IrLkkS9/GPf9w5N5RXLPFdNs4N5SbpF37hF9xnPvMZ9/DDD7vPf/7z7oorrnBbtmxxMzMzzrmhvDS6/fbbXS6Xc29/+9vd/fff79773ve6kZER9573vCdNs5FswIYDJM4592d/9mduz549rlAouOc973nui1/84olm6YTRpz/9aQeg7/Pa177WObe67es3fuM33Pbt212xWHSXX365u++++3rymJubcz/8wz/sxsbG3MTEhPvxH/9xt7y8fAJq8+STJisA7t3vfneaplaruf/1v/6X27RpkxsZGXHf//3f7w4cONCTzyOPPOJe9rKXuXK57LZs2eJ+4Rd+wbVarae4Nk8N/cRP/IQ77bTTXKFQcFu3bnWXX355CkacG8orliQgGcqtl1796le7nTt3ukKh4E455RT36le/uuc8jaG8dPrQhz7kLrjgAlcsFt25557r/vqv/7rn/kayAYlzzj21MZkhDWlIQxrSkIY0pF7aUGtIhjSkIQ1pSEMa0slJQ0AypCENaUhDGtKQTjgNAcmQhjSkIQ1pSEM64TQEJEMa0pCGNKQhDemE0xCQDGlIQxrSkIY0pBNOQ0AypCENaUhDGtKQTjgNAcmQhjSkIQ1pSEM64TQEJEMa0pCGNKQhDemE0xCQDGlIQxrSkIY0pBNOQ0AypCENaUhDGtKQTjgNAcmQhjSkIQ1pSEM64TQEJEMa0pCGNKQhDemE0/8PahOAodGnSM8AAAAASUVORK5CYII=", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAiQAAAGiCAYAAADX8t0oAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOz9eZhlWVUmjL/nzvfGmJFDZM1VUEzFPBaFoIAlCOhPxRawEcuZRsoWsT9b/CEI2iK2n22jiEMrKAp0iwo2aAkUyCBViIWMxVQD1ERmVlZmxhx3PN8fkevGum+stfc+NyKrjCTW89znnmEPa++99lrvXns4WZ7nOfZoj/Zoj/Zoj/Zoj+5DKt3XDOzRHu3RHu3RHu3RHu0Bkj3aoz3aoz3aoz26z2kPkOzRHu3RHu3RHu3RfU57gGSP9miP9miP9miP7nPaAyR7tEd7tEd7tEd7dJ/THiDZoz3aoz3aoz3ao/uc9gDJHu3RHu3RHu3RHt3ntAdI9miP9miP9miP9ug+pz1Askd7tEd7tEd7tEf3Oe0Bkj3aoz3aoz3aoz26z+k+BSRvfOMbcfHFF6PRaODyyy/Hv/zLv9yX7OzRHu3RHu3RHu3RfUT3GSD53//7f+PlL385Xv3qV+NTn/oUHvnIR+KZz3wmjh07dl+xtEd7tEd7tEd7tEf3EWX31cf1Lr/8cjz+8Y/H7/3e7wEABoMBLrjgAvzMz/wMfvEXf/G+YGmP9miP9miP9miP7iOq3BeZdjod3HDDDXjFK14xfFYqlXDllVfiuuuu2xK+3W6j3W4P7weDAU6cOIH9+/cjy7J7hec92qM92qM92qM9Kk55nmNpaQnnnnsuSiV/YuY+ASTHjx9Hv9/H/Pz8yPP5+Xl86Utf2hL+da97HV7zmtfcW+zt0R7t0R7t0R7t0Q7T7bffjvPPP999f58AkqL0ile8Ai9/+cuH9wsLC7jwwgvxtKc9DZXKRhE06sqyLNlzkuc5ZNaqVCqhWq2i0WigXC4P36eS5Cm8MA+x+1CaoXxiJGWQeHwf4y3EZ4g/L//Ua33PbZplmdk2VrgQWenE2skqj5Uu03ZmR8/EzCqnGctD3us+w/Wt343DSwpPXvpWO4Z4ieVllVc/D7W/xClSPyn1Nk5cqYdY2wwGg8L5xt6F3ofqMaUuhN+icsztoa81T55MWnFjsjYYDEbaIM/z4bPBYIDBYIBerzcSTp5beemfl65cW2W3+i//e3U3Tv/u9/v47Gc/i6mpqWC4+wSQHDhwAOVyGUePHh15fvToURw+fHhL+Hq9jnq9bj4XQKLJAiSeUbEASbVaRaVSQZZlw8a1yMuDAYnXwPcWIElNXwtbilH3ePPqWscJAYcQKOHn3DYeGIzxz+Xe7lSgFz+1M1tl+/cASnSYmMEtyq8Vr4ixs+SMeWKFHAM8sfJwepxOSO+E8vVou2AmFD8G6MbN2yurB0hS5cYDJKH8Q3nd24BEg47BYIBSqTS8ZmBhDQJKpdIIIBGeBoMB+v0+BoOBq0vvbUDC+Xt0n+yyqdVqeOxjH4trr712+GwwGODaa6/FFVdckZyOhfq3Y0gs5cyCsx2FGyJGvaGfhN+JPPk6lF/qcw4jJB1oO7xrMCadUv+s8Cn1uF2+LB6tZ0XbmNtop347XT7msSi/ReooVLfjGnqrL1jXsXexPhqruxQ+i7ZfkfC6T/F1qP69PGKem53UZyn5WO84nMVTahopPHhyzbqM693iQQ8K7q36PJN0n03ZvPzlL8dVV12Fxz3ucXjCE56A3/md38HKygp+9Ed/tFA6sY4g74uAFu5EntvrTBizM0WpHXKnR+Femp5it8JZHTmWZyxdDheSDyudImXabr2eyXbRfFojek0pnkKtIEMj8u2QVc+6PFyGUqk0UrZQOawRMl/rdLw0rfrV4TzjErr36iCFPH62k6aXRyz9WP2n8iHtyhTz6jAfqXxz3JhO9TzDHFYGUmyz9HMZFOtwwoO+PxOD5XuT7jNA8vznPx933303XvWqV+HIkSN41KMehWuuuWbLQtdUSkXorChZaLQy9ZS1vg4ZodD77VJqWrH8U4xvKO3tKLpY3TFZ3rAio5aQMuFy7DTAjCljJnHfMn87zVMRtzPH9erYc8XHiBWslYZnYCQue8lSDAdfWzqjSPxQvkX7l8VPUdL1M25aKe3A4WJ1YMnJmZBzzsN7b/WF0IBqHM+8F0/AlQYenC7rhNBU2JkEJCmgzIsTo/t0UevVV1+Nq6++ekfTTO04HEYUbLlcHqlwHtHstKGyeNbPt6PUdsq4ejxw+ikjiZ3kxaqfFIDlKcCixiJFKQPjG6czpZzHpXGU7k7VqeetCPEWGjiMC5o82ol2ipUhlF/IcG6HH8/opk6DnWn5tfLgQec4cXeKNyDuTdXPBFAL8PCAkecJ0YDkTNW95umsAiQ7QSlKPfZcGk7P21lh+Z0HUkKG2uOhyEg/5V1qHkUB3E4Z/KJ1EgI9RdNmfmKjnVRFrNPbKUM3zmikCBVVWqHRrfW8iMdFwqcCOi9uDGzEplos5R/KP+RNjYWNvWNQFQJsqXIfIk/2xzHuMT5SBjCaxpHVIl5PDr/T5OlKDT7kpz0msbplWQ3tlorRTmyU2A7tekAyDlkGKctGvSNCjAYtQKLv+TrFUI5DRUeeRcLGjO64o9/Qs3GBxriAVL/XnT5VeaWG8eIUHWHu5MjN4men4sXAHVNshKr7njVlZ8W1wEFoasoCnEWAupdeaAoqtS3ZQBSNb8X13ll1x+9DaVnyGuqzZ3LKJhVcnKl+VYRSbYwAFb3zU/9vB4xwPvcVnbWApOgIRRpbzh/Racgzy3sSyju1Yb0RdWzEsx1jEhtR8VRVnufRLbUpI54ideKFL6JEYtMqRYynF69oXIs37ardjkIoYkit8EXJc0cX8YxwG7DsWAAjlF4orAdALX6sfGLyZOUdeufl7ZEFUD39YfFcRC96/IQA35kAF0WIjbvw6IUrOjAoQt50ipYLXW+8iFU/9wCIXHug+98D4CpCZy0g8ShkUCyDa3VSoSKeghjgSDHy45CXR0hZWs+98CnlCrloY2lbHSrVIIVou65Zj1fvvVcGVjQhRRpTLkVAbYyvWLgYTyHDnmrUQxQDC7p9YyN1TiMU1jJiHkjg8oc8PEU9YSInKQBWwul8rDAxHmL1OQ5td8Chw1t90PMYeGAkpewh3ovyzM8YmACb60kYmOifnGOiy8e7kKz2G6cMZxLg7HpAYnX62CiHn6eONEJp8L3V4CmjmHHBTSxuqkLmjrLTh6/pe66bIqBnXM/LOPx67z0DWIS/WJvp9zHjUxSQ8AirCHmKLiXvlHqy6tNLk9vBexcrR8p7C1ylAHCOr0fLRcGefpfabl54a1TNINkKK7LIwC9E2zFkMSBulauILKSCk6JgKcYnpyuAxPKQMDDRPylDuVze8pwP90yxW7FybBfUeLTrAYkQo/uilRQyiN77ot6EGP87kY5F49SF/KeAA+tdqlGP5WUpgJ0coVk8xd6diY6YQjEDNU47n+nRrkUpHhIuqwVOUnjxjEpKOil8WmG0wYjxF3tXFIDeW+TpwqJpbMfAx9IGzhwIkjxSgHkovg6v21PWimgwYuXHssgAkUHvuDK5nbBF6KwBJEK6AYqMHsY5RbToiFSH4dGIl8aZAiNSZmvPu7j7UkFFSl4WeTuaUkfaRXgIkcQJeYNCI/NxjEZRIGC5pK3RbNH6SPWAscv/TCkkLYOewvcAwbgDESsteRczLtKHdNtYXlvOK5VCfSFU3iLTERZZxpXbJpRXEUrhi8vplT3kBdoJ8OP1L8vTlDKY8myO6GcBI3Lt6WxJm22Kd5iadZ5JCMTfW3TWARKhVHDBI3QLLOjnZ5Lf7bzfqbA7yUcofAjMcZukjqi3Q/flaPO+pNRR3U4aIE53p40EEB6YFJki4TQ8j0iMn1C+XrwivFtpjQNKQvogZNhT+ymnlcrHuFMnMb520mNggZIUsjxqfFBalm3sAhUgYZ1TYnlCtC3T8sTrVLZDO+llPWsByW4gbwR4b5FekxBC7eN4AGJxdB6clzWKENppYxjj1Xt/pj0FRWm7gDlWx2eqDbw8vHxjHgJrEBGaPkmtM6+vpqYb2o45brsVbZNUcBGKX7TtU/VDqvzF2sADS5rY2xjz+KT29RSgZumcmKfLSkNvwLAAB3sONYDR7/v9frB/WGXk9HdSB57VgCQVuUkD31vGxTLAMSpSlpRnOu8igEPzOy5o8eKlAoSdoHE9Q+Ma4zMBYHbCu/DvgYqOuGPxiuSr/z3DpMOlDiJSjaq1pVOHTymfB8yscsVAXeooP9W7kArcUr05KcAk1Ea6vF7eO9WvtH4NyYzFn+ctkTAsmxp06OeyoJUBuk4n9avJmr8zRWcdIClaWdxYrHzGzdsztPqaXW0hz0CR/FPAAY8SYnl6acfyshSlxUcqFQFcqel5ZTsT4GPcNC2lluf5juyCurcpZDxj8XbKxR5LP7UPeYbR6iee7GZZZm7bjHmArL5UpIzjEOdbpP+m5p0yYAulFcrDmh6JgSKPxyJ8WXrd4oE/BKn/rZ1Neuol9rHBLBtdl6LXpnD4IvWbMqhNlZGzDpDoyvSQaRHBC+VhxQ8BCs9AWTxbnd1D2THerQ6eagA4/nZHDiEDmtpWzNNOUeqotkh8zx0a4z9kbIoYlVBbnwmvzU5R6sh8XN6tsocGBNuVwxSDlAKIU4x17J1+P65h1+89GS9CHh9F5NwKb/UjHVae662xqfp0pwYwobjWAJK3BVsDWl2fPPjUfFu7dzjvFF69uhO+UutmVwMSy3XF71MUhvcNmzNFntIbR7ltN88zSazwing3YqMw/WxcN2JKnFjaXueMGYSiivZM0r2RR4i2O2qPkedtkLwlTIiflGfeICJFsVuDF57O4fRD/csLb+XF76zrGKUCII92sv1jumE78hYDgSGerDBeXXttxh/fk8+diNdDwnqyws/lmSdTRetgu7SrAUlRCglSUYNWBE2HAEjI2+KllxIulbw0vFFA0bRidZsCWnS41PxjHb0IGCnqSdgJz0OK8dhuvkUVy06MhFPzKfLOAhU6vB5BWnFCMhFq/1jftUCBF9d77xkSj8dx34WAiOXJYf5SF+3eG8DTeq754LKF5NrTHRY4lfBenTMfHlBKGWgJYNWLW62wegpItx1vVbd4iIESq3xevaTSrgYk1jxYqGN5oyF+xwqMEaQVviiFhHqnaLugwuuARQCUFY7LzVvYvLDcJp6hCCnAIsaO25ZHrNb0k36/U8qXyxNSnkXlKoVH3Rax8Kl5ewYvxFPMsFny4fVhraStvC0QEyNLR4SUe5GBkITlhYshXWe1RQjQecAiBQx5+jGV+HyNomTJkKc7mDhf3YeFuJwhEJxCoXpLqUPr3CxPvi05sH7bKc9O0a4GJIL0hDxw4cWVsDreOIpoO+R18tizEHIWsjpWbBGkp+CsMCnU7/eTlVMI7IXaRJepqILnvDmMfs7AqWj9eIouxEeKQfdoHIBbFHDEwngG37sOyZ1X795Aw8tLpxPibyfIAw3jDj5YVkLyEcvDCmN94C2UL6cRy9MzfLE2j5WJw1n/On/PWIeAstd+oTJbQMbq7xboE4AYArZeWimUZfbu0lh+sbYbV7aBXQ5I9CExusG9Rg4JvSegmjwFKvcsTEVGQDpe6N57pvNkXjWlKCoOF6oTSxEwjykjN48HIWtrmsTr9/vDew/AeIrXKo/HA/Ofarx0eG0IY21RdHQeMlahkXGRfFPAuqcYY3KSmp51r0eEnAcbtFTQnWJcPQPlldED217aoUPXWIY8D4MFwDz50HrU8xxoXZkCMLz288pu8eeVK5S37m9cNuYrVa65/lL0tfee+bHChT6QVzQf7gchGxUCJUJenyvavzXtakDS7/fR7/dHOi0fABNT/JZQWAIeU94xpWPl4ymxVBBiKfyYEHk8p1BMmbLBjfEvz0MKuYiy0PnEkLwFSLIsQ6VSiXZUL77OQ/Ms171eb7iav8jILIX0fHLoC6cpchRTQjE+WdnF4sTkldMNufa5XUJKchzj4VFsusEqnx79hvoU8+TxpdvfAxsxcKfJmqJkA2/Ftww3x0ltc26/mG6R99zPuBz6WUr+Xn4h8vgMgVUvP6sOY3ISyyuF/5CekvtQWkV12VkBSIDNOTVL8GLEI61xDfZ2aZz8PIWg/3X6nkBxHUintgyLJWR5nrvGtkg5uCyaJ++dDmMZJEsZ6rhizCuVCsrl8ggoYUUsz3maiPmQqSoxVP1+H71eb3gyot5maIGXIhQDu1xXMVASA9987VGoLcbhLVROD2TwNztSyllEcet8RQ6431gUMiiecfHKYfGUKkfWIMO6t4C+Z6xi/OqFlp7O5fxYd1mASp6JTQj1K92HQ+nyv8STuN7al6LG36onr06sePq95smqN9Zj8s9fBR5Xl2+HdjUgGQwG6Pf7GAwGKJfLKJfLI8Iuz3kEwkqEDbKQNRLQlCIgKQgyNlqw3vPIyhMeHllYHdnyMHE8qUcrDcl7MBig2+0GR4spAm91wJCS1vWcujBOKxRJSxZJl8vlYbo8px4yMpovLXeiwCTdPN/8TDhTEQVggSvhwQNuonS0IhKQpMPGgEMKr71ezzV4TGJEdLqWUUz9bozEjxkLfc/KOmYI9Ds9IArJSUi/pABBrwweFWmvWD7ee69vWgBF1433kbgQeW3HaWsj6/GiyasbXT4P/Mb0dyhN6znn6fVxfc1ybvHN76Tvj0teutuhXQ9INMKWSgY2K0ufgChCajV2aI1CaiXr/GPPUtNNBSz6n+Nzfjy6Z6EUAKKN68TExPDjTtq4DQaD4ci/VCqh2+2i2+1GyxjqeDwCYaXD291465vXcbkO9AhB59vr9UbS4/q1jIdVXs2bpL2dnQRWfsxXimGTPuHJNwMSnab1lVAvL1bYPCL1QJDmM5aPVT4d3zNg3tdOLSWeYiy17HmAhAEP64YQ6PbKqMPxACFGIVmJDYJibe9RDEBbsshxrX6u34tsW4d+WboyZvCtZ6nyYcmWFScFnFrgS6jIZgWLH3mWavO2Czw82tWAhEdA3MHFIHgHn4nQxub1vVGCNqoakbPh4p0mqQrDKyfzEYvLZRHAoacmdL2VSiXUarVh2SYmJrB//340m02srKwMAYxMmbXb7eGUhJ6WiJGAHgsYaDDAz6Ucujya95T60PH1p71jdcj5MbDietYjZz0iSTFysXJYRj80EvTKYbWV1xdY/j3yzjlISauobHvpsgua+QvlHTKMVpps/EJ1qmUjZLBiaVjlFBmw6j+Wns6bw6cYKwsAWnl7fVeXKwYOPNL1GkvLA6teW3gDFCsOU0ymGQyzXgnxlJqHFUaXyUv/TIEPi3Y9INGVJYbQUtIyguf92xLG255qNYgFSoDwMbwc1xpphICKxQP/mG82unIvU1vVahXVatU8YEfuu90uer0eOp0OBoMBTp06NfI9hDwfXaxZqVTQbDZH+NDEwEcDJI4TG5FYii115BKiUHvrdD3jwyBKnsl00HbBiORpyXqI2EXu8RFaFGtdh8KGAEksj3HI6hdeH44BKiE2bEXBkw4jAyCdroThuuI6DOkLLrM1BabLo3WWRbxANtavYunpcLH+FaKi327y+oX0R32v/zUAsdqGw6QQp8n5cTgOYw2EQmUNARoAWwbPVvm9/pMKdENhLNr1gET+PYHRSkm7qsU4iFdD1qOkKHbvfcpoMKXxvHQtgZN/nqpiL4M84/UMeuu0xUupVML6+jqOHTs2woPVmQWQ6DUYXA4GD95oiQ06p8Hpe8p8J8hTOBYw4TIyPwwkrHQ8soyRVRcxYn5T6yqm4DhsUb5i6VogQsu9rg/PWMu9JUc6bWvRcmwA4fXPULuF9EIKeUZJdBzzrQ2xN5ABwlNzlqFmQ+bxp3m00rbqT1Po4LYQWfUUAjcxcGg9DwE1S26temUgEAJC3vOQfHHaFkgqSlZe37SAhD+vbK1w1wZUwsqovlKpjICUoiMeIW5QLwz/c+f1fsDolyqlQ3lGnXcdAaNTJALGPF65HNbip5ih9gBJjELHIXtpW8aiCFntFwINnoFi3rQx4y3pobQt0s9TvR2auA/oPpNKReq4KBjx0g3ViwcAOZ41MBhHEceMrUc8OBKK9UFv0BWTf9FlPJ3DYIDbnoGIBeQs2ffqwjOeEidUDtZvVp5W+4UMI4exeIrp93HkOrWPhuKnDgY4HmD3KR7EpqTDZKVr9ddvCkAi0zCWAfAEnTtqt9sdMXIpHc1CmxyWgYI1JSIAgUdj2q3PUxy6s2ri/Kz/IgJYhES4tXHzzvkIjTA8xanDWkrIMjahzmCVn89Y8OKHPEoxheXJlWcsPbJGPSlkGWPmKzUNIOye94BCLC8LFMYAifyHRryWEdV8enF40SnXnUWpypvz8AxrSvrME58hovnX1ykGxQMw8twbyMWmsGPn5bAuYX4YsFjpxMjTDezBiPVJnZbVt702s56lTP1bpO1RDLzJtefp9sgrjyVbOnzqoGdXAxKpUG3kPWOmAUiv1xsCgjzfWANRq9WG4XixJa9v0GdWhNamhECITk/HkXCpQq3JEjorj1ides9DYILX4KRsJ9PljB0xn9o5i+zMsACC7kyWsvPODymSl84vxKs2gDqeB2aK8BICC0W2bXuk1yV58a3nXjksAwGkjzw9ECvvLH5S+kyqobF4ELLOfyiahsdHSO600ec4Vvl5x6KE0+tiUsog7/W1dy/62WoL7R2Xd5xnUZ2SomutOrX6aizNkE7V6fLzWB8qwrc3oNf8Wjx5dcd88gAuRrsakHCFaZe4Z0h0xej1FFm2eUqnBhjWFlTdkFYczZu+1qNGfu95FKxyenVgCYQHbGJGkMtg3Yeeh0Z3RdJP4Xkc0uAtNLLYDriz8gvdaxJZjQESjpNCGpBYQCxlhJSaH/e5FEASes7/HrCz0vGMXqpshWTaug+1U0jJWzoilmcK31Z5+Rm/s+TPStuqg5AhZnkTsOPJYZG6LFI3TFY9cHksHjSPMRm24qTwVIRSBjzjkiU/1hEc49CuByReB/HQqgYh1WoV9Xod9Xp96PHQYIMXgEqe+l+uva3FzIvVcXUn4HJ4KNm6B7AFcKUQGyXPy1BkNOgRT0/JvwfgLASfykOozi3AaSkhTsPiP4U8IAqMyofe3cC7xqwyeXUYIh7pCg/yv9OAxPoPhfOAg2XIuFzWtcWzdQCgJfshY+KB1xRgx2W3fpZeAGxj4+m7FB68wRHXibyX8N5xCVa6sfd6IbzX/qE0xjWAIXmMAVzNQwyMxOrCAkGp/Ibi8FZsC8DJfwxYWflqD8i48q9pVwMSdguHDJcIDU+jyLNqtTpc6CkLXS2hs/KIGf6QArHy0IIRmoLYKeSbMuLn0RLXB+/qYYOn3+k47IGy+PDajsPq6TCuSw5rGXAGJ1YcqQtdL144K69QHVs8h8IypY4omSd+7smatL+ESQHI8jx1Gsh6zqMwecbGU/iX05s135rYoMq1kL4WUMhgiKckWSastEPgRsJ5Lm4LBIUWpFqk+eJ+a+kczk8oNOiKGSMLcFhhdJo8YLLChuQ2lVLl2YsXCh8DJrE6ieVTBAiFdIwHtkO8jTuNbdGuByTaeyHkjRYAe/TtGRM+uMgyYnmej3XYlSdcnEZs50/IC8AjLM/IsnLSoxWuJwZh/I7j8L8FSDwQY/FglcMCRfo5d04GLaH6t+J5YDKURgy0xgCBx5OVhveew1jvLNAl99pAWMbPMy4hJZWixC1wIf+ybd/KzxrlWv03lId1D8QBCX9PxTM4KYBEeyI4jE6fPRZWG1qudebHAoL6ndYrFlgItbfVj1NlyQoT07mx/pTS/728xwnDPKcCkRBYipUxFlbrzZRt1VouYl7JIjYR2OWARL5fk0paWYmR0wtYPWUl11ZHl04+DsWMXwhAWACA41trXkJhQoDCAxXeOy+cdW8tCubyhrwXFjAJ1bdVryFjxfHlOhWQxMBKyGB5tBOAhJVGiIdQWKsMEpaVXOqoSz/3DF3IIHF/tfq4DhMCPRaPKYDE4j+UrgdIPPmwvDeShlUOD5B401ccx1q8KnxYh2x55U0FaJ7hjpHVv2NhvfuUeLG+4T3zSMs1y6fHh7V+MgROOX6srlnfaTnTcTy9kVr+XQ1IWMGkCqD2aMiPO54XF4gLnMWTZbT1lIUFGnQ6bLQt4+tNfeh0Y3lafMtPprKA0TNNOC7XmcS10mSwocnzmFh5WHXmKZtYJ7SMFJfV20HijQzEMFthLEPJPHll9p6lABJdNv6P5e+lZfETSy+0/dMLp+swNlKOARf+T5leqlQqSUqWjXwKKBEDnzIg4vceELHKx6NbbWQ8N7wFdnq93pb3oS/uel4anScbQK+uLJn3DKr+t9KTey9NL/2Q7vP0QVGStGJ1YsmbVeaiAMKTIWv3m9YlFo8hOqsAiTzzwgKjiowNoXRIy1Bo48ijeeteiK/Z42ABBCE+p4TBiGckufOFvBceqLCMtz5MTa5TQSB7OKx69Yysx6+XV+w9h/M6JX+RN6Z4LOXFfFlKxVOSRQGJlu2QAfbSZQXi8eWBAKtceZ4PlZbnmtcyHzImzIPOwwsfo5hB8sKH6tFL3wMLzLcGJV46Xj6hUas2/BKWedPvLZDjASV+z6DDSo95YVDE+TFfeleOZSyt+krVV174UN+KDRJSyANEWs5jslBk2z63BXvLtM7id6LT5V4DabZl3/SAhEewurPr3TOyiFWP4PVaCjb4/G+tW/BAg7eugkGJlY4ug1XOEMCwQJT3z9ectrfmQ3ckNiCeF8cDQ5wvh+eFzBYiD1ERJcH1aimDlM4nPHrG3KKQ4onFDwGSGJ9evJhit+LqRacWsGCAwcaLZcSr/yIUAz08DezVk66TWH3H/iUN6S8MhmNAiI22l5cFFKwyMiCw8rfisJfZMmShtS+WZ8YDMnzN01UhQOSV26tPT79pShmQhNKI8cI635MJb6Al70J1agHaLLPPq9FlCMUtQrsakAh5iNJTZrKrptVqodlsDgGJABRvasQyzLwlOIagNRAKrfuQPFPL7oEgDXis+uJnnpdFFKUHOuTf+3gcd+iUDqp5s0AVlzWVxgEkVlzPIKWmHwqn68tTcOOmbeUl/yEjqxWhZ8BCPFiK0gqj8+c+qA2FfucBNovXFLAWCs/gskjaIePo8S//XGYrfSueXu+i69ADFjpNSwfFtnpawILTtUCFDuuta+G09RSRlY4Fxjg9Pb3E5eR8dFx5rtdv6Drm/ssDFx2HwzPo0GG8vsd6gnWr5Tnh+rOAnEcij4PBYDhtl6rTPdrVgEQ3sKW85Lm+7/V6QwBSq9XQaDRQr9dHpiBCizd1vlYYee4pKs8bIv/eh+mstPg6NP3hxeH4Fk9WOh5g0XwIeR3JS88CL15HLkoeGIsZSSuO/kBhzDBxncdGNGyYhUJ1GFMgKWQpwJ2gEI+h/uLVo5bVneAzlkbK6DUEXFmuPQDhlVc8u3LNZMmUHoBpXtiwWoArRNaZIZwue3g4f4sPD8TwVI3nfeEpB5bllOkmnbc1/SRljoEqnQYvC7DqQ+ozBKQ9Ylmz1nVoAG3Vv1UfHlnxgPCnSVL7564GJPowM8D2ErBiE0VWrVZHfvoje9qjYBn5kMH3ABHzZKXleScs8pBwTJFb/FugIAX0hMrvGQuvDvR/aM1MiEKGLfTMUsqp5ZNrS9lZZZV38h9SRjsJNFLIKnOorvQ73nXitZVn/KypnVBbcn1ZFALCwNYPFBYhzXfR9gmBSo+sPshkzf979e3tmJF4KWVib4sFIry0LaMWA076OgRsOF3hJ2Rsvbg8DWQBEm+aiEEK/4eAAcdLIWlvXhzM+YWAmzcQsupJP7NkTafxTQFIeNuvNXrS/ywAAkTEO6K9JPLzvA4xhWsBoxiI8OJ7xAbcU1RWWixEMSATKoNOT649PryFsBYfHkDzeLWARQqlxgnVkVcmq970ewYxXr4piknCxWRGKxxuN/1M881rE7hfsWG2AJZ+xzxZ15ZMpQBMjmtRatqxsGxMQu3M9Z3arlYbhcJ44TQP1pSAEO9eSSExhDrNFN3FPIQMtvAWuuc0vOd5nke/t+WBLYs/HcbbKWXF99a/eAuLLRBjrXOxSMubV0cSTv9LmSy9EcpzHF28qwEJG1QxYp6x1dcCZgSQsIdEG0SOy9c8uuNwIUPORisETkJxOKxlHEL1EePRq4fQMyuMZey8sNZ/kfxC6cbS8JRqrF60srd4ZwVgyQIrpRA/TF596XQsAJISl/nkPK2yWXx7xtnjM4Uni0dOx6t7730sbbm2yhgDVtp4FR0Fh4BrkTSsPijya4GSneJT8xozaNZiSk16aijF2Mpzln2LF092OW2uqzzf/IirVy4GF3qHkaQpaYU8MPze4tMKE9L7ch+bItRl4Xoal3Y1IOHFmtYaEE1677T2iIQWs3rG1wMJHKcoEOB3/EyurR05VliP53FAU4xiACOWj/csBEqskXcI9cf4TA3D5Qh5CMbNK2RUd6Lza8Mk6Xpz/5b3zat/fc0gy8o3JjepBjhmMFOMYFGjG7v3AGCoflLy2ok68UBgqG2K1E9KvpYMeUDV44eNowcQLfnjclnPrGsd3urrsnvTKzsDBg84sAfEAiexLdbeuTa8RZd5S/liO9fZdmlXAxKpSH3vIT6ueO0h0QtNmWKdwzOwIaPKYUJG2XpndTrubFYcL7wXxosfGtmmKH2OZ6VhdXJPiWi++NrKi3n3wlr56DAWIGHF4KVn8eDVV6lUCn6eIATEYmFZmYeuQ4rea0/rWqeXImse8Ttr9JpKVnt4wDZUXo8/T1ZD8WIAJzVeqByx9L06jclKqA+GDL3Fp/fOI+bD09+hvIvoL+u0cMuehGTLAiM6rDeFY+3ysc500enKM9kZMxgM0O12h3mGzjFhSm2X1HC7HpCEDLd+zgLEW31TDYeXvhcmBkBihpmfhxQD8xyj2OiD8ymChFNAmhfHUiYWPzqeJ/BW3Xv1HOOLyTuAKQZGQu+9fMvlsruOYxwq0tapfIbA7pmkcQwcy4xnnHWcGFlyGBocWAbbA31eeIss2ZBnoX4TMxrMtx6U6fTkx2DWq/sixP2XDbiXp5UO86rTDOnc1EEBr9MJlZ9343De+l/Xbb/fNz0Zoiv0dl5e49Pr9Ya2UAAKl8/TYUWnGlNpVwMSIRZSufcEIMs2PSRZ5i+0LJK/x4/8e2d4eIZbx/PyKcrXuGHGyZvjpJypYpGlbDxlsZPlLRrfUoyaP60ArTCDwWA4XeIp0tiODk+uQnUUq19OP2RwrX7gAeYssz/kFSLP0DIfRShkHK3t6+OMCFPazAKBIUDFaXM6LHf8X5SseN6AQdpWt3Nq3wy1Z0g+LZCSkqcVjsFKKC9+ZvW7lEGj1x9iYLtcLm/5lpC8z/N8CDYYkOR5PrJ0YTAYoN1uo9/vm/ZL07iLnlNo1wOScQ0l76gRSh0dWp3Ru089ldTLJ6U8sbCpvKaCJo8shVgkviYt+Dqe1fmYLMXNinIcShlFWmBEKATMvF0KlqGyAM44NK6BSo3v1YXXz6R9xvWyeCCIKQXInokRoKaQkU/xaMgzzasVl7fnxsCLTlcoNhCw+JJ8Q0Da498Ko68ZzHvpxuTT80pY9TkuxXSvpB86v8nrLxpYWGXQOoWnlfWZWfo7ZTH9KLzGANQ4tKsBieXZSKlUAST62yxA2J2uBULS1j9On6+3I9AeeflbPFu8eLzFhFELpDeiCY0GvPAhYOGNzC0l6Sm1mCEPldvzbFhrmHSc0KhUv/PAl1X2FBBgGTOvHWLgJmbki8iPl653NHUK6LLy9+J4hjUEXlIpZqQ5vxRQpOPEgFUKWQCkaN9jfr2+IW0oo26vD3t86nAhWQ4NAFLqTPOqr73wReTbophu4vhWO2jdEtIXDKhY31QqFfT7/ZGNHSl1pgHnToL7XQ1IgGLeBv1eryEBRoV5OyBC4lhHwLNQhBRBCOikov9xiUdTVue0tqTqe44TAnhF43hKnTueVSahFJAUAqdWGIv/FFmSzs1KMcTfOKAq9N6TxzMBpDl9zwVs1WdRcBZKczvkyYYH2CyZLdoWKfyEKGaoY2nG9FYq6VE68zUOFS2XxQsbYm1sU8qbWgavbxcl6TeWTgyFE5Ly6aMvBJToNSmhPpeqa60wHu1qQMIoOWVEmJJeSlwLNHB8fueF9crjhZWyWiOnmOG3SIex0uRV3ON0qnHmHT1eU96HRg8ho6/DhcLGRgWeoeL48tz6oJtXBl0O5sUiD+DKu5Q6iSnlmFFgXi0l6sl1qofAGgkyb8xLijHzwB/H9/r7ThhxSdMCw/zOAg5ctzq+BXStMntrfjwgbz0rApxCCyf11KsYXR1Xp+XVWawudRuGpoWKUGygEdMzDJpifDHY0vIgca0DQb00YwOW7cr4rgckHgiwFBPHCwEaDh/jIyVsDGl6aVjC5IVnQxXjpwjIsPJPMYqWchwXnOh0vLrzkHsIIMYAhpdeDIykyFQIcFh1FgIqsTw8w8V5x9LT4Yq0pdcf9SgulE8KIA3xk9rnrXjsFUwZ5XuGrki+Kca8iCEoYqBDIC0lbw+ceEDSA4lF+1EI1OZ5PgJgNFgLrXcJAcDtUsjgp7S/FT82uNDv9SYPq2xF5CumE0N01gAS614/B7Z+sddS8la8UJ4eFRnBFiFPCW63U6QYt9gIWl/vRCf18gjx4AFUeWZ1WG0IWalZ4b16t+qIO7imkPKQHTehtRVF0vP4Z96t91phh9Ln8BZ4jBmWEN+e/IXSjPU9C6B5+etdUNa3d0LlkXhFQGDMMBQBNuNQStpFgVCeb35ZFtgog/UxOJF9Dfz4V6vV3F2IGuR2Oh10u92RviS7S6rV6hadITz0ej33w5kxXVSEYmAsFjfUL0N9UMuitIPldWH+UoBR6nOmXQ1INHlgwhuRpczZC+n3FpjxeEhB/qlAIJViAhESWEsBFv24oH7vgTyrQ1vhYkewpwq/ZTwsUBEyLCH+QwCR6yF1BFPkWhOPpkLtrd+zkfX40fmEjKRXfqs/pIL3mBGPgUMrXEof9eJ6C5Gt/sH5pfZp7yNoIf6K5nGmiAEEgOEhXNojxh9JteL3er3hmRv6wC9gc8qBQU2n00G73cb6+voQXACbuqVSqaDRaIwAG0m73++j0+mMgCd9EjiDoZ2gGJhgsmSC04r1rZCsMg+ermOetkO7GpB4lRkCJxqM6G1P8j6khEKG2FOWIYNrARvvmYdqLb48IxAqFz9L3a0UqzNNqR//swCLl5blkfDIChfq0J7hCvHL/HFevKuLeWDjbrU38xST4RSj7JVdpytpx9L36s3KK4U8QMR8We+9tCzDHQJr1ns+Zl/Xj9VHNW9eXTLp78p4cuJRCsiLxS8a1zsssAgfXN8aJGhQsra2hl6vh3q9jmq1OrLzT87VWFlZQbvdHj6TdAUIZVk2srGh3++j1+sNAZCOB2x6VkqlEqrV6kh6RcqYUn6+TtW1Idm3ZF7+U/ToTpbTol0NSIRCRsgKq8FIrCG0gebG0/fWMcEp6ernofNKrOtQHgxKilAIhOj3IbCledgJsnhKBVxF87HievUv5J3K6KWt28b6XLikVRRghfKOpRUacXtlCFFsBG+FLdqXJa7mh9OK8VkU0HkGKCQzRfOzgGloS6bV97dDlvGKlcV7z/f6sC7WJboNGXR6XpR+v79lC6oAC/nInQc0q9XqcFvyYDBAp9PZcuS65ksfQqa/Nn9veKNS8/D6RShMSpr3RhnPCkCiKaSYRfD0ITAczmqg2Ifs2DinKH/rWczQW2mMCxxiBswalXtltJRnDBCxKzGlTkO8W20YqpsihqQIKLAUJvOm46Uq85R4IZnwAEDREVjI86aNDJNWjqH+pvny0vHKyOXx3jMgkH/Nf8zDEvOqpMaN8W/Jl5e3V8dWnqkAzEozln5MNjXA0vE9j5EYf7nW3lvxWABbv5CrPypn8SdgRntVuDyaN8kjy7KhF0XA1U5REfBntYdVzlhbSf1a3tsUvkI8FqVdDUg8cKDvhXhxEoezGi9kGD0AkQIovBP52JhzWa3yW+W2RhdeuVOAjUUpyDoVjITCpoKo1HCp6et7K4w1GrfAQijNmPFPST/Gr/U+xehaIIHTsPLywEWIb44fCqd5t5QmGxIvDc6L6y7Gq1bmRdqR31vPU5/FKCWdGKUAFn6my2bVkVXvXptI/NB3YTzdWKlUUKvVkOeb32nRcWq1GiYmJoYLWy2drvnX4EbWpOzUcQa6bFz2UP/TcT0gqZ959SxramLf00ntw+PSWQdIQmHln094DSlwS+DlmTeN43UQz2CEwE1K2UNgJfYsRJbS8DpK6ihxJylkDFPCW88tmfLy2amRUUwWPWOp2yIU30ovZBCK8GwZECufomTxYQHZFABi8aGNpZUPMHrWRRFeY2DEi58C/EI8x8BuKi+xkXUoDj+3nlk6zpPhLEv/3AMb2Gq1ijzfOPxLFtJqPdtqtdBoNIb8a2+LTo8/XscAZRyK6fYYmOS+H+p3FlDXQE8+7KcPR7O+PJzCp1e2VL1yVgESeWaF09eyvcnqBDHPgiBI72N5IT6LhGeeijRyzKBa5CHs0HUsvVQqmnYKGIuNKL18Yga+SN2mApdQ+a2dLxZ/Fi/aBa2Vrih5oVi9pxoma/Qa8o7EPD3jKEOJlzKql2sPXKf0U6vuQp4T5t26DoEC5t9KM5Rn6ARSi5eQwbEGKB5oFuLF6FqvhtrAM76huimVSqjX68NTuQeDwchC1kqlYsbJsmzLglatw3U/KgrkNZ+Wbom1IccPeUJiNkO3gQYkrF+8tkzVA0VoVwMSTTGDoytfI0FrMSrfs/EbBwF6AMdKIwZcUgQtFK4oqEhRijpsETempdxCHgCOF3vGz71rD3SFtnYyjTNa0mW0ZE9vR7Ty0gpd85/CS8iIMHmAyKKQsvTCe/x5fKXWdUwmLHnz+BtHwYb48YyuB3Is3rWRtMJ5bWGVNRU4c/qhsF5/TgE6cs0fhbOAiVUuiafryPrCuw6v85dvvAAYrhcRqlarw102MQq1ZSwskwW0pY7kfWrf5/RYhiz9kAJKttNPzgpAYhkKLYSMgOVAHC1gHI7TiT3jaw7Pz2OK0jO4POoNkafgQrynKCVOLzbiLFpWD4RYoCdUx0XvWYnqMEXPHOC6TR29atJ1qRfyWaMhXTeeUfMM304aXKZxQFoqSVlDBzkVoZhxlWvL6IfiWe1l5RlS9LF3KWXdqTA7QUUGLOMCfa0brB1KVrqylVvil0ol9Hq9LR5xWW/hlcMbZFjhJE3m3+PRKiv3/9B0EusHq47H7UPbpV0NSFjoPKPLgIR/sjjJSt9KL8SH9y5FOEOAxuMxRqneEJ1fSAgt8FGEB6seLcAUqm+N5D3A6JUtxmeR8EUo1OnlOlRWL14oP22sY0Y1lF+IJ09mQmX1RmAxHizQxWmnlkHzleopKCITKX1ju2DEC+f9C6X28xB4CpWF87K+pSLvdgo8/hH+CMtYPp3w6d92zi0b/bQUchj9EDsP4GNk8aFejpfe6XiDg4OkdTEjPHBQVSW1dg0P+/jDNoIltu+uBiRCFnCQa6sDaEVoGQLLUKQYDx3XA0r6mTUSt9KLjWAt3oqAFw/MMYXQfEhxeQYvFUzouGzEmKdQW3r8xeKNC3BCFPNaRZVCBDyFjH1q2uMYeiuulg8PjKSmXQQ8pfJvgRIrX6vuigJGL68QD6F3MRDigUcGA9wuTKEP64X0k1VnRdow1ZAtYxlL2VJS2LOWtouPyvEgRWgcwHZWABKLPNDgjdBDafAR5tvhSedpKYJx8/BAAgOqkNKITUuEFMuZJvaKMHF7W4p4nNFYKlhLJQ9A6X9dRm+KLmZEmffQiHccA2rxE+MlNFJn4D2uZ4jDpXhbtkOcfkxXFPUEMHE9MVAqYuxDFGuLcdPbKXJlJM8wickdzevfOw09Ftv1kAwGGOQD5IMCHhKDuvXu2OBoVwMSDRbkXv97cUIeDqbYbpoUD4o1smeePbIUg/xb2+HGMbrMJ5Pn4bHy8kbuDHjYGMV4iPGyU256nV5qWxXxJFhhmR+uQw+MpRpuBiNW2WMeDStd/SxmEKVeUz7jHgJA29lqaVHI4+HJgNQln7BrrQFKzTdGsX5dxPOS2l9S0rPa25KJFPA8Tp1wnElM4uV4+cgz3V5MsYFOKHyMN/0f4nscL1HomZZL3fctXZvn+fA4/uXlZZw6dQrLy8sjW53193xCnwaQsn3qaZ9Ct9EdApci7bqrAYmQZZBTjBp/r8VSoB54CT2zeAgJPHswUvKMjfys5yHlpBWtdziOZ0x1vhbfHiDjfHk076VnlVf+9Xa8VHBmKVK+HgeMeGX13qXwGsvfeybz+HrXUBHgNK4nIfQ593uT2AB4xlNf6/4QAwJStzyA8dLdDu2U10KnB/h8eSeYcnxNVlnH4dnrM5Le8H1APC3dI9f6I387SSF58cJbA1lLbq06tXQnfy3Z0xX6e0khXeflGSp30Tbf1YCEv/3CZAEDCW+5VouCAi++FS4UN0X5Wwas6M4PNpaeYIeEajsdNzUd4cHbkp1qhENAw4pvpZ9SxyGlyWFSAQzH9Z5ZADNWRgYmsbxSR96xMEU8BjtldLeTjgYbcm+d7VKEiq7t8gyQXBc1/B4oCgFkHrQwf959Cj+p5PEw5F0eZRheW2XQz7UxDvEZAmEpgx1rIJnSpvpe+iy/50XrOi8+Sj90Ciswak85H6usGvjoNIZtkdtlCtGuBiQeeQqZv4HghdegJWY4WRBC4XfKsIcATEjgY+G0IO7UCFnnx0olpc50/gwYdSdLqZNU8BjzimyXWJnJPwNsKZ81ovd412XQ7/U/KxJLWYcM1jhltXjz7nVcLS9sKENxt0MxY1wEWKXymGKoOHwqWPHSSpGpEIj32tGTHQZ0TCHQod/LtU5PL6C0ZJjLYYEpr1wepYTz5D+mky0QIlMrcgw+2zCxWdpuWdM3GnRwvuPUwU7qycIboz7ykY/gu7/7u3HuueciyzK8613vGnmf5zle9apX4ZxzzkGz2cSVV16Jr371qyNhTpw4gRe+8IWYnp7G7OwsfvzHfxzLy8vbKoiQZeRY8CwlzcLqjYy9Th9rlNjoV3goapw1T6nom39eWE0xgzUOWeXV27F1nXD95Hm+BYxY7WiBS36u8/K+Al20vFY7enxY7/WhfV49eOUN8eLVT6jsO6VwQvIdM/IhBV40Xog3r269UZ7Vv2SLPn811jsvJbUM+r4oGGHeU9YCaD1o6VWPLNnh8nt17fUNjicUGzR6ZbPuU3Vwyoif24hBAfPm6W4GJ/IF406ng06nM/yasYAV62OCHk+pZYmRJ5NcxhQqDEhWVlbwyEc+Em984xvN97/5m7+JN7zhDfiDP/gDfOITn8DExASe+cxnYn19fRjmhS98Ib7whS/g/e9/P97znvfgIx/5CH7qp36qKCtBBRIKw+H0/BkbKKGUhrMEPSTgVie3OkdqB+EOEOK3iILR+XB8fR9Ky1MCHJc7UgxUWOUJGVo2+Ppafvok35S6Z4Ua4sUrvzXasUBCipK2SMfXpxRn2fgndnKb8ejVMzieLHB8L9/tUmq/TE3LoqKK3uuPnoK3+nkoT13H1kg8xIsHFmJ9elwK9aFQ/sCGp2Rc2Skqd6lltMBGkTysssvXjDUAtrwqVhvzibRFyhDiNRQvtS8UnrJ51rOehWc961lu5r/zO7+DV77ylfie7/keAMCf//mfY35+Hu9617vwghe8AF/84hdxzTXX4JOf/CQe97jHAQB+93d/F89+9rPxW7/1Wzj33HML8RMzglyRIbCR2kCpo64Yz5If5xsDCyEUqucNiyqNmEIJGRWu51QDaYUX74f+bpCXdkq9hcBLjLRcaK+MvNuOsvIUvn7Ph0pZeVry4KWln+vPrltppsp3qD04HV2XOkwobYuXEBjiuFYcjwdLFjXJfeibMFYZQml5ZY2l7XlIQiTppRpHXU6Wk5SyW3nHnsXCS96aj9T8U/O20vXuPVke14hLeA+oy3MBJFpHsOzoa7YR+pnFd8q/liWrPmPrc5i2c5bdFrr11ltx5MgRXHnllcNnMzMzuPzyy3HdddcBAK677jrMzs4OwQgAXHnllSiVSvjEJz5hpttut7G4uDjyS6UUo9vtdrG4uIilpSUsLy9jfX195HPVmoqMelIMRWxEY40GvBECNz4LS9FRgjc6iS0m9sqq41th5NhmId2p2JDG8vHKlQogRAFbceXa2xLO9eb9xBsj97qDW3lbPy+MV+4UoFFEeXptHKoXL7wX1ooTM6hFZb3oO4tHDs/z+dsxSkIMJlI8Hpp2ctu0p8tiMsGUujnBe1a0XpmnFHlJAUDjgCQvvvDHng3ms9/vo9PpoN1uY319Hevr60OgIutNZFpHPCkytSN5yjUvao3xyt6YcU8T17Sji1qPHDkCAJifnx95Pj8/P3x35MgRHDp0aJSJSgVzc3PDMEyve93r8JrXvKYwPynuIt1Act3v91Gr1VCr1VCtVk1B8JRkrPNYzzzwUbSjWcZ83M4aIjZulodn3LxDhpfz1+H5+TjkdcRUQ8qjZnluHW6m64y/U+N5zDh+6ihXSPPFHyzjeF55tYdAk7eKn3m28uHRHIf1ysSA1VLo+rllMD0jKvFkF0aMHy6b1LFFntfGoyLemFC63FeKGg/OO8Uwp/BlvZcwXp8OgZ8QvzG9VLRtUvtgyIsQStsKK0BX2y0tH4PBANVqdWRNSZaNTtXq3UXW7p0YTwyEQ+FjYTTtil02r3jFK/Dyl28edrO4uIgLLrggGEdXhHWAERsMvY4kz3N0u91hQ9ZqtSQEHQsTMmz6n9+lNqYOW3QEwbylggHPCPBiOMvtaAGxFHBhoXiPT4u/WBgvvHcfAivAVuPNipGN6nbbW7+3pvDEE8Xgh9sjJkvcp5gsZcSAzWsfL+9Q3H6/P+TFAhEhYGTJp6Sj59xZl3AaqUDEI4sfK7/Qe85Py5Un89bgguvJ4j/Wd0Lpxfoi12sKCPGoSJ/iOCkgyQK+qXxwmpwOG38ZNAvg0HZMZHRtbW3EY1Eul1GpVJBlmx8GlLVyWq5Z/3h9OFSeUNlitKOA5PDhwwCAo0eP4pxzzhk+P3r0KB71qEcNwxw7dmwkXq/Xw4kTJ4bxmer1Our1+lg8WZUMjHYIaQz5CjC7sEJGTefD6eqwKYaL04rde/noMCEDFwNHqYIUSkffCx+8iFhvb+WwRZVQKjAJxbfa0gNKIdngd5anhPOw5lytPD2DAowuWrPi6ZMXrTKOc5hZrH00zwIcZLqKDY6kl2IQNaXyHZOJGND1+pYGIywHDJBi9WUBk9BHQGNG0DI0nnxaBoe3j3K4GHlGLHTOD+uDIoOJIgDKA8HWAKGoPonlqcNoe8S2SdpfZEkAicTVQAUABnmGQfYoDMqXI0cdWfd2lNavBQb3oFqtotlsYm3iQtw9+yR0q7NoLVRxyb+WcdHt61is3YLb9r8b67VjI/yzJ0U/98ql+UuhHQUkl1xyCQ4fPoxrr712CEAWFxfxiU98Ai95yUsAAFdccQVOnTqFG264AY997GMBAB/84AcxGAxw+eWX7yQ7Q5IKEcPHuy7ElQVsKEu5FyS5XfJGmLF3wrtHoRGgHulth+dUxRl7Js+lLawTVQHbmyD/5fJ4X38qYgh0mBCIlHQ1f0VAkycHqfxZBtuLz0aNwYgYcgYnKeWMKVtrIWS1Wh0Jq0+KTDUiOp+YsvPSZfAp5bQMkb6PnQ0T4307I3yLZ6+MVv9ivlPq26sHlj9rIKHjjAtydZvwMx1OiKe4rDJ5gCA0KOL4Xp/34oamNTUf8s9eC72rRtaHaKA6XCeCGaD1K0D5/kDeQ44MOYABXoDu4hvQ638It93/x3HqnKcDgz6AErLpDLddDFx8Qwnf8n++BRd97YfxpYt/B7cdfueIzrCAqcU/10eqHS0MSJaXl3HTTTcN72+99VZ8+tOfxtzcHC688EK87GUvw6/92q/hAQ94AC655BL88i//Ms4991x87/d+LwDgIQ95CL7zO78TP/mTP4k/+IM/QLfbxdVXX40XvOAFhXfYhMgy9Ho7p/wqlcrQ+yJIr1KpDN1bRZRHURDgdagUEBBSdCkjxdCI3ztsTIcJGW2vDiyFoo1iKC7H9/hjw1pUCXI5rHxCCpt5SB0N6nzY+McMsqQdU476GStfa6U+l8czRPqZJRds7DltAFumkThvi2L8evx79ZMiMyEwxrLr1U1RYkBp8eMBxVTDqq+5riwwEZJJCwhYdWv1LwsYcnjm26OYnrQATyickAforP7LcSzgY6WrvSEaDOgzR2TjhV4Hmec5MPkqoHTRxlFxWWXkVP3azM/hyNzDsHj4yRsPShuDPOH0a48ZoLncx+PfU8dDvvbzWK0ewZGZf9pSplSgXdSGFgYk//qv/4qnPe1pw3tZ23HVVVfhLW95C37hF34BKysr+Kmf+imcOnUKT37yk3HNNdeg0WgM4/zlX/4lrr76anz7t387SqUSvv/7vx9veMMbirLiVgDfi8KVf/aQ1Ov1oRBk2aYrWcfXaVv/XsV7jeEZcqGYsuMwlrGOjQpSeAvFCRnBmJHQdWY99/grOiot0hksY5IS31MwRfO0Rp+hUZiOJ6Sna6y4XEZveqcohZS//PQHuyyePCMUA2aaioQN8e4ZHXlnATCdP4ez+Iw9t/K2toGnlI/TT60fXR4+ptzKJwQgdRi5tnSWV38hAJpCVp/2dGSoP8cABfOWMqCw0tBAJM83d81YO2Z6vd7GYLL6SJQrDzDzA4BBNsDSxU8BVN6Hl+7Ciz77FnzXV96NVmcFN+97IG6efjG+tPjduP+dP4I7J691gar8p7R9ChUGJE996lOjI4jXvva1eO1rX+uGmZubw9ve9raiWbv5ec89w1SpVIbu/1KphGq1OkSjoR01WoFzmjHFn8q3V5btGLoUflLCaT64noqU10vXCjPOlJkFJj2+QryFypFav6l1zYo4ZOBDo8aQgrfCSH4ybcLbjVMNvITzlL6nrK1rq8xFqKgBDvFhgUQvv5Q6SnnG70Jt7qVjtZk888rj1YPoRi8vkR/NWwxUWn1EwqRsTY6BvZC3Q/eJFNkO1aX3LKUfauIpS14vMhgMhqez6q282mMCANXW5cjzHrLMNu2r0zny8mbeDz/6GfzF3/wAmt01VPKNNGaO/AueMH09vlD5HrzzxJtRWp9Cu3xi2Ma81sjTtxKmSP/dFbtsUikEJLTCFEDCIEUW26WmG3tXhOeUdFLQe2hU7OUVylO/Y8EbF/SE0tPxYielxsoZCpPCMysurTDYAMd4C1FKOaz3Fg+WkrTWiPB7Lh/zYAEmnacHpsQ4hIyfVT4rHS8cl4X5jnkjvFGfl47XLjHQ4IVNIausMaPP+VnbO7UB1PcWcPHStYw8hw8BAqusHJ+vi3hFOG5RQOcBzlQwk/KMt+GLR0R7QPRPnkuYTT5q2JyA2Uq5Gt9V+x388d9dNQJGAKCSb8jCZc2/w+VTb8R7+lUMsq0LqkPgS0hvLU6hXQ1IYkbRugf86RDPOLHxDPGTyrd1P44R88pidUL+twwM14XFa1HQxTzr69QzXlLS1PylgKMi+bBy9ZTvdgGqjuO1cWr6VlsLcZo/dv2P4UTnhJtWUQNalM5E+juZZk5KPlMz8zlyZMiGYTLYbZIjD9mKHaeU8hetIw8YngnyeJvKpvDS6kujnh4GkXJvAXgL0DF4svKyQHMofU7DAu4ajDDw4Of6HJJSqYRB9yaUm/7i//rqJm9X3vKPOLR6zA2bZzkecf7v49gTfhjNf/lLZPlWQKLLx2sXrXaI0a4GJCGy0Ks21qGO5f3rdEPgxUrTercTlGqQ5N67FrJOluSRZ2j07PFkxdPggY2mPjpe8rDSsu5T6oXDFB2xeWFCz1La3wNYXt6xfHmNiAdUT3RO4O723VH+9miPttC9CLJ0fpYuHgYJeL0YmFh6UsJx37MAy9jFIO8hgxL56Q/qaSCivSUCSPrL/4jq1H/a2OqbbZ3urq2XMH0sw9KBHI+765PoliqoDuxTyUsAzl36BlqP/V6cOOdBmP3bV57er7NBlseE20Kmor4pPCRClpGVe2ksaXS9uNWLGxphx0bfIQOROnL3ysgo3ovvuVE94+/xKP/eR+1Cht0rg6THaWnwoY9Tl3Ahr5bHU8hr4T0LAYAibVcUJMXySQHQ3vuYK19TCSXsr+8307Nc8B5v26Ei6cW8EsMw94LR1F6U+9pLEqrDWP1upz0ted3OQEyns4SlzfaO6MBYminya/Vf67tPIYr1X+vsF+350AtZeYeN/m3auHX0v/FKzJz7eiDPAOH1dPKNJeAx/1DDx/9DG32kHaMwKJXRvuzbsf6F96Hx5X9yyxN69k3lIbHc0qyALePMWx15AWUR4FEEiHjhrfeeizDVA6DrxjLcHk8e2AiVSeeZkq4H0Kx37A7Msmy4MDnEi3fNbZ8KFi1vEAMfjxcLLIXqikdyITDK98yTNaqz2mp/fT/e/W3v3rLeQB/QZOXluaZ1fKvONG/eCNELY9WRV4+8UNLKI7SLJGbAvAPLrLrSCxb1c6uvSTi5try7Vrn0NX+WXhs1uZcRtrWWAcDIgkZ5ruuLdzHqcvCzInWq6+jX138di1hMihsi78DL7ZKns0P9TZ8qzO0mnhE9TaO9Je12e+S91NfKyjVYXbkT5+1/PfK5C4EsQ6UDzB7JMHtXhlKe4Vvf3sDNFz8F1cEfuuUZIMPNBy7G0alDwKCP1cc+F/UvfWhLmWIDI0tmPTorAEnMkFiGTH6WcoilH0P/RYBNjFjBh5Ql82WBkdhoWocJTdOkpGU998CRdfaJp6AtPthoC1l1Zn3My0vHA4Q6fa/MFnjw6i0EMrlDF5WhUJzQqIZlKCSHoTbnwYD+99K06s07PC2lf3kGzAO9Mf40WeDSklXreG5PUXObW8DM4sEKr/WbXItx07s4eMehTkfe84fZuKzsgdbny+iw/O/VhQccioKJWNreew7rrZOI3aeWgb+rJtM04gnpdDrodrtot9vDa566AYD19U+iffdVuBIfBEoZSnlpxGtXXwaWP38lbs2ruCDromKIdgk53vCUF294WbIy+gcuCdaj1UdCBx5adNYAkpghE5Iz/avV6nCELVumYiMjCwlaBiZ0n5qHXMdAiMUHK8LUr2l6ZfVAhMdvKH39Y8DDAMUCKrps3pdULUUZqwurXJYCCpG1FZpHmJy+NlbWmhnLeBYBNZby13VpnX7LbSFxhD/rpFIvHwtYeIDCA9RWOYoouVC+sTTGAX9W/BSDZIEyDhcDKdo4CLDQgy55r93/Yvx0XM2/gEFtLOWd/kgb9zeJJ4dRalkLgVpPrlIpBGI8WxFKPybPOv0QL5Ze0tMz+uAzvcW33W5v8ZJob4l4SRjsLg5uxnX5T+JJlT/aWHh9eivwIO8ByPHP3Z/GDaUy/rrSwzxyABlKyNEtlVEd9PH7T/oxvPVxL5DCIVtfNuunKDgM0a4GJDEg4sWxvngYUzwhA1YEZHh56Wfs8vQ6QQxg6PtQHC+8BURC4VkZeXnpNrDKrMEIp8WjbSuvECDxeLN41SBCe9C8j+VZ4Cml/nSZNf+6DJxOisx5Bi9FgXA4Tts7oCuUr5W2LnMRcOCBr1BZUsMzsVwzKEjhPbV8XpjQOSDyXrevdY6FpKG9I3k++m2jbrc7wo/ug5KWLgvrKpZRyVc8JTzQkHBWfYXKm/LOopDODAEKCxylgBmrL3O/0cCx3+9jfX196P3QUzJyrz0jGlTqdKWt78yvwXs6V+DS8g9jPts4EO1I/6O4efBnWMUdaFQbeHZ9Cs+56OH4zvYKptpLuHH+wXjz5S/Ev1z0OF0Q1D//j65OkHy3C+DPCkASMoCa9NHxQHj+Xogr2VrLEIpfBJgUAQ1F3nvppsTh+B5wSuFN/vVpuZwuAxbmwzKMXrkYvHA6Xj3zAXnW6JzTtO4t70oojgVkLAq1myebHhgJjVRDgFKux1XWngEIhdfvY0YsNT+rjTlNb2TrpZ+iizw+OG/vXl9rL4i843UF/B0UBisMenV6ui+IMczz3Fx8zv1X69rBYDD8PpgGd7H+67VrKhjx8onFseLr/3E8A1476629vIgVwMh7BipWeppWcQc+0/tvpi0rlUroNZr4h3YDf/Ajb0avCmxZj50D5X4Jk1+4Ef2I98pa+lCknnY9IPHc9mz8AAy/5stx9FoSq3PEjKJ17T3zlH1oxBDqlBZZijH0bxmuEFiwDDorNKucPDqyPCRaoVlpxdqH3/G1/vcAlXYt6/T1CC+UVwywcnk84BQiDmcZaq0w+FsxoZF9TP4sg+EdSBYrj5euxQew9eh0BgKhUa6Xn5DlLdVtFCtPSBcI3963aPjeAz5W+/HH1SSeBh68GFX+u92uu+CXD4rMsmw4Mmfg4fUv+Xq6nrLRQMaSe26jlHblOuL/kAzzfRHgosOPC1Z0e/ChZ3p6Rr/Xa0YsubIoVK6JAz+N879Sx52XdtBt5IAkVwIqnQzn3VTDxHlX48jxfzYBWoxS62PXA5KQsdIkHUIDFN0pZPqGDRBXuNUIqUZR36cCm9QRJ+cRUryxNSU6HvNrgYmUUbYFMnT6lseE28pK3+KZ68FSetzO+j3fCy8Mury8ra2BVppeXaWsW4mBBqYiitaqQ52OxwMrKe5HDBwsRW7x4QGUIuCH280CZNYUrldea3Gj3HtfpeZyhM6I0WlqntgI8doDDUjk42se2NBxddnzPB8unOSpVf0dFfE4W/pG9K32isiCWfmlrG3z2lX3R0turL4Q8uiO0zdC/c2SXS+8vLPWiOg1JeIZ0dM1AExAmcKP5DsYDNA/8AjU2iVcfGMdq1MDrE5tyEJzuYSJxdMLYucfavKfohNSaVcDEiHPOFhIWxMLi7U7xopneTOKAJKUZzovD9lbRi6kxC0gwB2M09JAwiuzV/8W6PI8H95WwZjCsp6lgCWrPNY7L4z+t5Sxzt/j1UpLrmMjnljHDymCWPoWILEARSyOx2soHr/T4CaWfygfBkAWHyz/DLA8AMLEC5Q5z5Dx0OCEwYf+18BC3PfaMG09Unwzfd5mqnfPiGz0+3202+2RehXwIospZXOAJU8CVur1OqrVKiqVCgaDwfBDpgJWLHCa6mGIGboU8FDEWFpxY+sQLX2ticGlBiX6mV47ots21TPi9cehnJXKQAZkyDCxVMbE0lZQnSsAzf1xp2hXAxLLsKYaSb0X3RrRWnGt+xTjxag0ZNxS8rbiyrUedVlCyEY+lHZsAZq+jq3l0Hzq80OYfyvfWN3EAEFKh4m1o3VvecpY6cTWK3ntEFuf44EPUVA6vuX21x5BTtdqY8/D4xltyxhY7zXvmicPcLBcWyA6NlrU7zQAiIUNUcr7EKDSxsVbkKp/2ojpBaocTwMbPY2jebbSkFE4AwQBJO12eyR93qkj//V6HfV6Ha1WC9PT05iZmcHExARqtZoJdIWsfp1lGfhQuZjXQd9vhzwwa8lcLA2djm533UbsJbG+Y8OAJEXXWWCk2+2itHQ30Dh343hWi3KgtHQqmlcIdKXQrgYkFnkVJYIjH9bTgETiWXG9Ebb+t/LhcGbnMiil41hK2JvDt8CQBTA4TcuTwWXyys+gKAQ8uE7459WJNZoFwmfAeFS0E1vPNc96xOTFtdIdV4ly+S2gxCSgJGWUI21lKUCrXPqZVSYdTxsw4R+wF43qurUo5MZnnjQvoToIGTwGgZ7+sNJk0KTBgTZM1rdLGKToNPTaAjZuntfEWm/CoEfS1yP4lZUVrK2tjQASCas9nOVyGc1mEzMzMzh06BDm5uaQ5zmq1SpqtRpqtZo76PH0bwgEhGQuJuepZPU5njqJ9WdvASjLgPaW6LayDH0MVHPdChhdueWPUZ1/daDAwNqtf+m/3yHa1YAkJEBWw0jn0LttpHEt4+c91/fWSNZC+p7xLlImj0I8pgIfK1yKZ4SVupRV5211Sv631vVYgIjTs8o7Th16VARcemCElaGlSFKMY4w8IxfKL5aPB25CBj91NOTxwmBJh9NhvX/mnYFxUf6YB6usXh1xOG4j9mxoAMDHhevnPELWgEEDB4tHnt5hAzh8npfRy74V3fLTkeeHUO4CM3eXMXmsh/XKl3B77R04uf4B5NgKXACgMpjAA1eeh0tX/gOa/QNYrX8Dxy/8R9z90M+gVCphcnJyWBcCSmJgQ5fVkmuvrSxQYumV7cgI5xci5t/ygDEwtQBoCHxbZfFA88Kxd+Dc256LzoUPx+kjSU6/3Liu3vFVHL/rTW5Zdkrn7mpAIlRkJMlrE0Tx6QZm46ApthsmdG8Z61g5Up4VATZW3tYo1ZsysMKwspUFb5KXXq1vKQfdDpK2/HuG3QOcMUASMiQecZretfXO43OnSedltUcRsvqClY8HCjgtLw2rHhlAcD9kAGiB3VCeTAxgrHcxxR8jr10YhPAomD0bAjCsL73yr9PpDEe/su6g0+mMhGHX/0ifyJro1l6NPLsUG/76Eno14J4LgMUDwPlfuAIPW30KJspvxufy1w5BidRZs3cIzz72Tkz3L9xIEyU0Vg/iwJcejYVvfA6L3/1GNBqju3XEe+21Qax+dRyrvXResTa0wIVl5K3+bvU3T+cy8ODv1TAYsYBfrD5SeOj3+zj62R/E3MIvoXLJc9GdbAAAKqtt9G99L75x8//fTNNK1+IlFeDtekASMg5eeGsU5Rng0AjIyjOkZK3nMd5jZUrJ34vDICRmzDkOP+d7q+6k88U8S945JKH8vC3g+t5akW7JhMQJyYbFk6cMOV2Lx9i7FNIKgMtp5cG8hhSdBUasd1YcCzBZYVPy1dM6FmiQfz3Y8AybVS9ePViGjHnT/9rg6Lz09Ia1JkC8FPpod2tNiD6tU/LRuzJ6vR7W1taGiyHX1taGnmHNk6QnxyKUy2VkEz8NZPcDsgzD4fLpv24TOHpphvO/CFzS/1Ecx6dwe/4ulJDhstnzUc1KuOBrv4Kp/vnI1KKE0umPuU0vXIb8+u9H9znvRaVSGa5jkDLIQlmrvax2ixHrkBQD6sWVMCEdaMmEl4cFJrUcWMBkp0nL3sIdr0flyP+LrDSDPAd63XuS1lhyecalXQ1IWAGwsHnz0NwpdRrA+C6oFEPj3XvPQvlY9yn5pIS1jIjUX0hZ6FGCB7xCC2uLAiTOOwZIirQDA5yQktHPtfyE6orlbFy50+nHgIHFbygtK6weWXn5WflmWfpnyDW4YN68NgkBDx3fA8temFB5itSp5QGxzpPQ23WtUzh1PUh+vV4Pq6urWFpawtraGlZXV4deEjFmnU4HgD/NLGs6mq151CpPRZY5X4PNgNV9QKeRo7I+wP3zn8CzzrsFLzn5JRw89QUAQHvuP+IDlz4f//qlX0W+NkvRy5i+5dtwavX9yBtbyy3e1VB9Sh2kyp5+FkovFpaBL8eL9UOLRx2X1+7w2iEdh/P3wHZMp8gAUQ/O83wRMPSYRSn9LpV2NSABwojXem5NNYTibcf4e/cpu0e8NMbhS57FDJ4XT/NtAQVL8LXS46/y6p+kqTsEH5wkvy1uZUXeAlkmvU2Pn1t1GgI5RSkGPoqASS99rTDZW2LJyHZAUCqlLp4NUQoI9sBCCEzyfYq7ndPU91z/1oJQ3kXBO180COE21MZbPra2srKClZUVnDp1anjsuCw25TgMZKS8MmXS6XRQrj8B9SxuGtamc8ysl/Hc8/4Mz73rn0fe1fttPPP4X+LS+92At3/lfUB3cuR9aVBDduRc9GfuHPZJKav+zpgu+zjEfY7loAhAljSsd558WWCF3+s29qbvrMF1LG1+7sk+e960vvX0JZd3u0BEaFcDktBaByY2bvLMUir63ksn9J559Pgpgt75fdHrUN4pYEbqWk78DKVtTbVwnek0NeDR8Rn8WOd7MM95bp+mygZYPDgWX1ba3PHGAQtMIQ/DdsCI3IcUCcfTxICZ21IDR89oevlY0yhFeLOAVGiE5tWHlgMdlgFwLC0LmOu1HhpgWCBEH3AFYMuoWD+XtGTb7cLCApaXl7G+vo61tTUsLi4OT17VI2qrXhgs6TjV1RVMIYFyYGb203juXX9qvq7kfTxg4Ubc/7I/xM2f+fkt77OS7Y3q9/tmHy5CnjxYwNwLo2WdQa/V7ryg3QMMVpn1PU/RhfqTRUU9M5pfDVAlLV5nmZquxC/SjrsakMRGrvo9gxFL0EJp6TRjawpCACEGeGJUBIhweb2wViezwoQMvZDlQeF74Ud+vMiYgUKsfflZKI6EYa8N8xaqo1DaXgf0PCIxcFeEWL54rYAVxkrDAmlePvpaKzRLIVsAQtqcjf+4o2LmjfPnd7xF3VuobaXPvDI440WpvGsGGJ2/12lIeOFbDJR8BXZlZQUnTpzA8vIy1tbWzBM8Nb86D6+eBZQsnrwBhwYdZKWaX9E50FrM8Jj9b0FvoYxKvvWbKhLwqe234GaMApJBuY3+oTtQUuXW+kW307h60vMqhECJByK8dHQ4b8DE4ULAnQFi0T5ghe+f/wCsf8cPovewK4AsQ/UL16PxgXegcvtXRuLxLiurH8V01nZpVwOSIiQuSTnGWO/kALYCFHnG70JrG7zR2TjPrLRTwEjqVl02NhYo0PlaIIE7pvee8+U8+cwTeebVdQw8SnxLAXjnlMTOQfCAjlZwXGescDzgYymRccCI/HOeDAYsJchpjWsErDIyX97iUIsXKw9LIY4LYNhjw8AzliaDCX3NH0Hj8ySsaRlrO6+enllbW8Py8jKWlpawvLw8BCgCRqxtoQBMbwvLufDRad+DU3f/FWYPvQDmOpIcmDgBVNsZDve+HgAjG+dsnbNyJ0UfYPmBHwbqbeR5ZUu/ZL70P5OnT3WalheM44f6KcfVdWv9Mx8e2NE/C5jGdtPodLw6an/Ld2P1xf8NyHOgvGHu2/vPQfvbvh8Tf/JqND76rpG0eLs463ctX1ZdhQBWCu1qQOIZPiEt5HrKgfe7izB528EYkFh86PD8nK9TDUBqehbA4GsrHQ6rAYIX1vrna+198ABJCJzwVI4FSHQ+XF9cn6EOwfxrRcbXIUPNZRFipcIG0AMl4wICTjcUVpdLk3W4mpeXTksvjmMgYgETL73UcnLeVtn0vb7Wz0TWPGBtGRJNIl8MNARI8G6a0HZd+ekdNu12ezgls7a2hrW1tZFP1PNBWRYQt5579QUA37j111Br3B8TM09Envc3gEkOIAPqq8ChmwYAyjhRKqOXhTwkwFJ9Cifn+5g5mqOEClYPfwGrT/o71EvlLQOh1Cl44TsEVLg9YzKtw46Tdwg88DtLZjxvWQoPVrj+ufffACNZCSipcpUrQJ5j5cdfg/Itn0fljq8OwYbInGVfPF0VAicpda7prAEkFlkKRh+MphGfNSes89H56efaUHkd3bsX8jpiCP3GAImleGNGT4MSJqvcnLe+Z9e3BVw4TwYkFo86PpctBGC8D54x8VdxQ8bNqxtdfpExSxZjymYcYjDitblO3zr7IQQcgNHpIB5xs6LkEZ/XTl5eTDFDE1KC8g0VnY6VF6fh9SO9i8XanmsdaMWLFbmeZWpGtu0uLy+P7KCR3TPWlmAG3yFwYtXbkIf+Gm79/Asxc+A7sW/++ajVLkSlU8bMsRJaxzu4c/Bp3Dx4C0711vCcABjplcp4+2Oei794xiKe/oe3YyK7BvkDb8RErYVSqTocJHp6XOtoi7w4bPx1HVh58cJanZYlG15eVn7cH4u0kdePY5TnOda/4wUbnpGS0ReyDBj00f6OH0Tlza8dxmFZtcpblI9vGkAC+GABGK0MPWUjgCSUXiwPBiMeD5ZSY4ELLeCKGcTQ4kydhiUYrNA9kKPJ4lV4YzCh0/HqSv/YS8J5WGFFYYXKmAom+FlKm8TSt/JhviUtTbGdYFb6WqY0TwxsTUOrB1CnQZlluJlHb3GnB2DZk8F9NDSSt+pZ9yF9z3VryXrKHL0l69qoWGtEGBzoXRLyXAMJvZ1zMBig2+1idXUV6+vrw6kZASICVBq1Ei57wAwy5PjyradwarE3kj7XGect5RKvFuumjTrKsXD8vVg4/l4TNOV5jvd/A/jU/IPwiJM3bfGS9LIylmsTeNO3/Dj6lQE++OIDeMZHv4zJ0+eM6J+ub35u9WlvvQa3W55vPfXXA2FW/Jh+sAahDKIsnR2bstPk7XSJUe+hVwynaUwqV9B96BNH+ORpctbbobreCdrVgMQzvvLPq5696QCrY1jps+LTlOJqtBRpzPjpsFa4mJGNxWNQFApv8cHPim6f1de6fSwlwgZSeyE8xWIZsVgdhkb7unzWXLynYC2FqN+lgJ/Qex3GUxopIy4t46II9SJJeW4Bjlg98jt+znVllUfXuQcEPVDGeXjtoo2YBZAESMi6Dd49wwZfx5dDwPTiUzkUbDAYoN1uo9PpDL0iKysrw+mZXq+HDAP85PMegO/7jovQrG+o715vgH/82O347T/9LJZWeiN6Lwbu9Dt+xobfSmeQ53jR0iJ+9+DD8fRjn0Y/KyFHhkrex9fnLsBV//FNuH3f+QCAfrWJoxc8ETN33zAydc71b+ljllezD+eb19z2Vl9j+bJ4YblhsuQKGPW46Hi8RkhI1gjF1rukgKlN/hMGMwaYsrzTIR0boqLxdjUgAbYaNH7GSoZHdCw4McMg195oPJVnPU+fkqf1jr0G8rwoaOH7EHCwOokmrbBC+YY6mpW2HvFym1ujfz3SK1JOuWdD5YEGC5jJvf6P5Z/SaUNh4srJzicEXlIoFNaqR+lvVjyuv1C/sLweVnreyJjjeYBJ32tPiAYV/HE5YHPrL0/VsAHTYEY8I7JORLwk7Xb7tOelj9f+7GPwlMceRkm54SuVEr7zKRfg0otm8GO/+EG0Oz4AsdoqBewy6TV5aJbxn7M2Wi/7AK78yodR7Xfxb+c9HB+5/7cAWt7zAU7NXoLqqc9u+cip/nkLzzl/j3fRCdrzxDqJgSbLCstGyKaEALSXnwa3Akb4TBodNmZbLN1T/fx1aM9f6HtJ+j1UP3/dFj2o+2ksj52mXQ1IUg2nBh/SibRb2hrlpuQdAyYpI1UGFKlk5RFad2HxrsN6nc6afuF43ujKMkZWfMknVBcyxeaBMA/cee0aAnFcHk2egfM6awpQ8MBMKnlxPQAVUsRWvJCxt+TQGlV6I8mUMul0LUDD11mWmZ4UnU6sztl4MAjR3hEdVm+d1PPxOi2JL//r6+tYWVnB4uLiEIAICNGg53EP249ve/w5Zn2VyyU84KIZfO+Vl+Ad7/1qsG4tYrlh2dYjf6FarYbWgYehdfH3IW/O4yuHHoCvHHpAMJ8S8pGPm6asFwvpWGlri3deQC7lZHCQUiecrw7jgVkLCMl7Pd3X7XZHdkppHnmqL1W3AED9/W9H+9tfAOQDICMPfp4DWYbG+982wp9e02OV3SpbjIrYtl0NSIqQCKpe1MquaE3evKNlSEN5CoUM2XYACXfm0HoUK37ISHPaFhhgkjAyN63Tii2YjdWDzls6Z2hHkFUuq5xWHB75ePJgjbz4OjbCYTnxAG0IcMm/NXdtKUyt6LIsQwY7Xb11kstqpZ8CXGLhPRBhlcXi2TNgFtDU+TF40OCCAQnvmhGSHTV8JLzko9ecdLtdrKysYHV1FSdPnsTS0tLwuT7gTHh5zlMvQK8/QKXs65zve8YGIDkTI1gpQ6lUQrlSw8xj/xsq530P8rwPoIT6chvtiRxwmigvlXH+0q3uln7hmXWHJQ8W2NB8ppQlZcCYMljQcaxBGPPJi56tNUYMmhjce+17/uFJfNfTL8GBfQ3cfWINf/P2V+DLP/g6AL1NT0m/ByBD6w9fgfJdtwy9WFmWoVYp4zsunsATDteBPMcNR9fw/q+top2whGWcdmDa1YBEG025D4UVQKLdfXxcrlCK18TLzxJgL36sET1jZhkH3s4c49V75nkrUoADx9OGgD1DnqdIv/MAEAMTi0cOK5QiM5YCCBl4jmuNyqw8OJ1YJ44BEq/t9TNeW+XlIfxoMMb56ZGzZ+w95V+UYuDO62tyz98CYcCYZdkIANBbby0AokGLpNvtdtFut0dGuxJH8mi328OFqisrK1heXsapU6dGvC3WtuBzDraCYKRUynD4QMut61AbpMid6M9qtYrJh/0XVM797tMvN7yXc8eq+Mb9Onb8QR+t7hIuWfrqlhG4BoPyr73YrOc3y2vvjLF0Ao/sU2SJdUAq0OHF2V767KWRfwYoKZRlwH/5icfgRd/7EPT6A3GA4CWlDH/+vv+MXz3xRHQf9iQAQPUL16P+/rej/I1bkSng9+CDLfz591yEcycr6A5yIAde8OAJ3P34Pv7T+47ixnvstt1J2tWARCgGHOS53u4LbAoFn0MAhI98j+VlhU8xmvKvkbRn/Dylm8K395wBAAML5o/jCaV4Lax3bMAsUMLvJJ5WXBZQsYyqxZcFRIsYU61cLKWYahRSALF+ruVMezWs9rLOCrHS1PPI1jHxVjj9jvO2RptWubhteS5bl0/CM/8MCK2Fnjpv3hkjgxUGFfqdBi6SnmzPFXCS5xtTFJ1OZzgaXllZwdLS0sj0jN7Gqw2mru97Tq2j3x+g7ICSPM9xcrHtjuqL7NQIgdVKYxaVC38QPA0wdaqMzl0V3HNuD3Jmyca6yhyN3gqecdNfoFr2D5fkvqPliPWaBYAtXiU9XZ+p4EKnYfGpw8h2civtLMu2nICqZS2040aIPXGc/otf8DD80Pc8GAC2gNarnnEelt76HvzhL7xuVF8oPvc1KnjH912E2cYGuKyW9LsS3vyseXzXX9+Foys7/7VhTbsekHgdh8lzE/J2QUnTeq/fsTEM8cfKkdPwVptbB395I23PqHjPPFBhAQ+PP10+HcdacCbPLcCkvVacJ6fpGTSOx+3lta/Vfp4BTyHd3tYIaDueAl0WfmYBAP2en+m0Qt4bXmulw1qAj42/ZRTknV7YzYbCGsVahtorJ9eVt+tBG2sNCmTaRX95l42IPs5dwiwvL+P48eOYmJgYAo1SqTRcEyKAZG1tbehJ4XNMBFSygXrvP30d3/r4c7e0ldAgB979gVtGBlpWXcUMckhmSqUSagefiKxUN+PuP1LF5EIZpw70kCNHbT3D4VszPHLq99DI+sgqcZMjdazlxTLGRftkqHxF0/TyiYFhHU6/D02JxqjZqOBHvv8hwUHLj/3AQ/Hnf/NFrHdGlykIry942BzmmmWUjDQqpQwT1RKe/+Ap/O6nTm6rjmK0qwGJp4QtpWmNnAXZMhq1DKBlkLURDPEofEgnC+0G4usUYiChy+2l7a1k5/qM3XNcnjay0mXAx2CFefS2mUo5PaMcAmUyorEOKLLIkrHUERrzbYEkTltfc3ms3RpC2nugFwJ7vFrtyXmnuKB1Wllmf7xQ86xHvzovDu+dwaDDeSNHnZ+1Y4Dr2QIjDER4VCvbfzudDtbX1zDTPY5zplex2l/Fp4630e2PrheQ9SH6qHcGfPpel+OfPnEnPvfle3DZpfu2eEl6vQGOn1zD/z69fkTKN8hz9C9+CPqHLkS2uoTKF/8FWa87UocpbQqoBY/lZjBOfa2E+dtrOOcrZUydKAFZH40r+u4gg9tLT1lkWYZKpeKCKqsPecA5ZLBZNi3ivCygE4qv07AAiQVKUugJj5hHq1kNhploVvGER87jI5+8a0tZsizDdz1wxlv6AwAolzI8+34T+L1/O2WW0Xo2zkDsrAAk+l7+rdG53mqmT2oV5W0JlyY2Aqkggg1oaFubZ8T0tTdC1mGs516eoXBeeVKIDYxVb7GFrtb5KLoOuYNLeHbRcx3LyLVSqQxd6/p9qvEVHixiebH48NLWaXC4EAC2ysD8pXp8vB1WMRm0lKquA6teOC+WYbnnaUxtmKw6ADDiLud3mh/2fMgzPZjQ+UrYbreLedyD//jQVZw/lQGYAACcWK3hD64/hXd9YRF5vrkwVk/NsOH0ngFAr5fj6td8BK+6+nF42hXno6Tq5XNfuQev+O//jMXlTW9N934Pw/KP/Qr6Fzxws9zLC2i+601oqt0VKbIw0sYrN0fDA0B9NQMwQKV5dMtprDEdwobaAi+p/VTC870HiFLrJEYxMC9A1QOlljxY1GykmfHm7BQwOQ0sLw7zl7JOVuObIVpV3zbtRH0BZwEg0f9ybY1CectvpVIZmfuVsJx+EaQaMqwecPLia0NrjRL1+xSwEQJQofAWsAnViWVAOT0GDzydJs9CPOi0U9esaF60x6xy2o1sjZgtT4Rn3OSdN8qxgIRVXzqPIvLHaWoAEAM5engUAqtcRxpQSjt6u9dCcsr86ecWoAsBEGvNCS9o1cTTNewBkfzYi7K+vo61tTUcGBzHyx+5umWEOdcq45eevh9Z3sPbP3ViS7q8INP60iobpKWVDv6f138c5x6awOMefhDlcobPfukefPVrp0bTuughWHjFnwLl0ZFzPjmD1R/6RaDexMR7/3RLXXBdcj0CAFZvBpZvBCYeBDgf32suZaitb6Qxcd51I33d0y/8zjo8TfPngRJLlixgEhqAhvpdSM974bkvalnVAJgpxeDfcvuC+07TrT/3u8h+7iBw+63I//rPgfe9a/juxuPruGRfHRXrmHkAvUGOL5/YXNS6U6CNadcDkhAo0c9FwMVLwulwPLm31kNosrwRVtqivGPhuWxa2VvGjg0yMLqLIjQ1E6IYiNDlsrwLnqG1FJIFSDTv3LZZNjpS1kpL88Vzt1adWApPysOfcef3elpCK46YsrOIPTsplKpsea1GjFKUsc5L6pPXAlnTJFZ8fW/lz3Xs8chlszwxXhwLLEhcHq32+310Oh2srq5icXERP3HZCjJkKDvK/GefchB/9aljWOtvXYNyaKaO5z3lPFx24RQ63QE++JljeO8nj6DTC++yuPPoMu44srSlPiT8yvN+dmObp/OJjNXv+2k0/+mvUVrZNGYsP7pPsfxXbnk9+pf9HvJyaxSU5EC5B8zfnAHI0dh/I6bO/bfhYNDqqwxG5Dr2jZuQ98ACz6znU/qDNSBJCRvzdum0NDi1puo8UCL5fOXWU/j8V+7Bg++/z9yF1UOGG/N5fDU7uJHOeRche9mrkV/6EOBNvwEA+PPP3IPvedCsW7ZKKcPbv7g0ki9f7wTtakAiZIERyyDoXTb65yk6C/CEOgDHj917HR7wP/bH99b6ilQeQs9ZcWhDo68tMOKVjXljRaTbictjlZvjeiDAGpnwe6tdKpXKFrARUmyaDwv4aNJp6XehjwDqUbpFnsIUMKzD8JqpDFvLz144bUg5L+1l0mWygJHElzSsHTxMvB5G88a7Eazyc/2wodFGUPhjI6FPapWv79Y7p/DAufAUWqtWwtMuncLffe7ECA/fc/lh/MaPPPQ0D0A+AJ7z+MP4+ec+AC/6rU/iliMrbn14HqI8z5HPHkD3YVdgy2FYmkpltC9/Jpof/D9RAKLTrlQqmJiYwHT1FBrHXomVfd+HxcaTkGc1ZIMBpo9VMHdXCY3sBCbu98+YPv+TqFQ265b1isgmeznZo1KEPD2k6y5Fd3E4b5AV44NlWwNbPXXHO704vNxb9Or/eT3e8pvfgWa9gkplsy57yLCGKl6dPXOTL9EF3/U84BMfRvbpT+D6O1fwls+exI88Yh8GeT5c3DrIc2QA3nXTCj5yZ/uMghFglwMSCzB44UTotSELzaV7IMVSbhzOMm4h3q1woYWv1sgzdG89D4ESXUfMq/ecR8zWomAPkHCbMEAJlSlWFiGtTKyRhnWtnzGgYXAi5ZUFeBoEeIpMP4t54pgPb4uhlZ8VRq+ZGskzs0E51yUDKuFNe66s+taLbpm3UFmkPrncbEg1f/q9xOUw2hBwWdmbIUd8yyLWlZUVTJdXAbS28K2pN8hxcLIykuej7zeN3/zRhyHT9X0aix6aqeOt/+XxeNp//TDava1TtRbpehhM7w+DEQAY9DGYPTjyyOpv3J+r1SomJiYwOTmJyeY65vO/QTP7R2TlFoA15IdKKB+uoVLro1wuDddpSToCLC0AImXTnjZLDzLPHjhjsJqiQ2LPUsOE+MrzfLi7iqftgK3TxfIslM9NX1/AC372H/DiH3w4nvWtF6FaLaM7yPD3eDD+qHQFbse+LXHzXg/Zs/8D8OlPAABe89Gj+PpaGT/60ElcOLlRrjuX+/jzG5fxji+OeuPOBBgBdjkgEdLCzGhfiEdCnhGzAAcbJA+s6E5mGT0rTY9Cgh5bMxEyuJqsBaDsOuWRpFd3ujzWiIfjeQDFuw+BjhDQ0G0nyjC0yJHJkycPDFiy6KUbeh4ajYUMvubPyyd1hOMBSMA+ZCw24tTlsmTCis//nKeOzyCH44VAH4Mz+ddTOLIodXl5Gffccw9OnDiBvNJGDJCUM+DYUmckzR+98gIM8hwVY11RpVzCuXNNPPvxh/G31921hUePhuBr8Z5gOABAqYzSqePDW69/6rV3pVIJjUYDzWYTrVYLExMTqFaraNRrKJdzDAa10/FzlEqVYZ/T8XmwA9hb9VlPM/G24CLk6a0YINb3fM1payAs91J28Y54oCPmkfH62e3fWMYrf/s6/Orv/QumJ2tYeP1foHPuJX49VCrAJQ8cppnnOd510yo+dHcVk6U+Op027l45veA/y0bWSKXWfVGv0q4GJCHDlhKPR2s6rlawOn0xat5WRAlnncVQhDc2GhYvVpohA23lr+tBKyBrKsiLJ3nptQRcBomvRz6W4rMUo1duj0JxsmxjG6G1PiQVpKS+s0BMalopAInDiJJmT0CMRhQc/HrWbcjTJCkgSb+3+q01qGCDEHrPsu/F9/hiUKU9KPro+G63i1OnTmFlZQUn2m187hsTuGy+4a4hWesO8P4vbS46zfMcT334weCpq/3+AE9/5KFkQDJizBfuQfVzH0f3sif4H1Yb9NH45Pu2tJv+FyAh6+5arRamp6fRarUwOTmJZrM5AiYYwGgQImlYspXynaohb6erIKYLvEGjJs23l4YlO0UGkpy+pKd3cVleEiusxyPz1O70cfeJNWC9A5wGEy6tr42kJUDpVC9Hux3vM5z3dmnXAxJrjlG7Zz3Aol3WHgqV7WZeB2FiNMy8enlxuJDxZQUeAiex+FbZQsBAjzQZkFi8b1EmgTy5rbhtWfg9Q8bl1DzoUYpVf9ZI3BqJWW3pyRIbSE08wvfe6XKE0hGZZi+N5s/iN8QzE4MSPepPrQ+rLj0vCMf12pXTC7WrVUa5t7Ze8s6axcVFLC4uot1uo9Pp4L9/8Aj+5AUXA4PcBCX//do7sdrR3w4CKuV4P61VCLDUG8gfcwUwsw+4+wjwb59ANhgF1vJ72DVvwCPnfxJZqYSPTT0YX62PfpRv4t1/iPLKwtBYeX1SwESj0cDMzAz279+PyclJ1Go1s78KCKlUKkMQosGJbhNuCyH2rjJQ1M8t0v1Eg2dr2i6FLD3BvHNf1jwwyNXn0VjfQ4rxYv2b9LEPABdfCnMnFAD0+xthRh75O34s/RrzgKTaPaFdD0jGCe8BFSY2lBKXR18WP56STAkTKqOXXyhO6nsPvFl1EDIYGsBoA2bl4eUZc9nG6tFLWxQFj/K9csXWGWnQ5I1mPGUaavOUcrNijYEQK53QyIzf67w9T2HImzfOSMoDEjo9XY/a+IS2H2vAodOSNrR+8g2aU6dODT8XPxgMcMPtK/iJt9+MX3nW+bhkf2OYz4nVLn77g3fhHTccH8l/MMjx5TuW8MDzplyvSg7g819f3Izz7B/A4EUvAVqTm6PeE8eBP3g9sus+NOR9rlnG7zzzQnzH/WvAkT8bxn/f5MPwoxe8GHd3M0y8+4/Qev9fuvqQQUa9Xke9Xkez2USz2USj0Rg504kHMeJVqdVq5gDHao/Qc+99yAvn6UnWTxZQZ09djN+YnLOnjb/irEGJJYcen1G65m+A7/shoDWx1VPW7wOrK8A1f22WXZeLBzlcN7F6KmKndzUgAbYW2lL+8m9NRci7ELhgAyqN4+Xl8Wg9j3k5JG9rCih1wRanHQITvP6DDa4OqwUxZYonVD4P+Fh8MrGnRlNI4XgUMnr8TPMb87jF2qjIe0sJhN7zNSveEB/cxjzy04tC9Y4Zix89cg0BjRSFy0pReNSg0zt/JGTk2J2uv/i7vr4+LIMu03W3LuEZb7wRjz5/AhfM1nByrY/rblnc+EiZUZd/du1teN1VDzV5GOQ5BoMc7/jI7QCA/rP/A/IX/z+a+Y3/2Tnkr3g98te+HNknP4pGpYS/+oFL8UAFioSetvh5fPhffxbf+dYvYn29swV0WJ7LarU69Iy0Wi3Mzs5icnIS9Xp9CDokvsRlr4jXR7gNQrLvGbzU8J6+4Xdef7H6t5YBj3/2sOnTehmIhMCHpDU7UcXznnI+nvW4eUzUK/j81xfw1g9+HZ/8yoktPAMATp1A9ksvBl7zu8DcQeRyQm+lCiycBF7zsygtnNxiU4oMtIu2TYx2PSBhsoRPuxArlcoIqtdK0kLPbMB1nBD4CaF375lWqkzsYdDhrZMMY/VjdSSuF3kfmpqxRsO63q04XFZWjqzkmE+vHLGRNPOS2pEsA20toAQwMk+u5YTrhnd2eGXUXhge0VvgQL8LlUfzxvWgp7S4DLruOBz3FW6DFOAmz0Pz6l4dWSM4nae+1tsqtcHQ0zPyL1/wXV9fx9LSEtbW1raMbiXvf7tjBf92R3i7bp7n+KuP3YFvech+fNcTDmOQY+gp6fUHKGUZ/suffBZ3L7QxqNaQv+ilZj2hVAIGA+DHXob8kx/Fcx8yh4ccaJj1Wi0B92v18bwHTuKtnzs5lCnt5RCQ0Ww2UalUUK1WMTc3hwMHDqBaraJer6NarQ7jacCh06hWq0OwEmpjfc06xgMH+llMz6YOGFPJku9YmnJmjZzQK+uQ+AvSci1l49+Dz5/CX/4/T8DMRBVZBpSyDJccnsD3Pel8/OHf34zXvu3zNgO3fAX40ecgv+LpwCMet/Hs8zcA//xBZP0ekFAGIa17rOc7QbsakKRWpAgOf+2XkTuP3rzttWzQ2CizwrVQJWAvfPUMr8eDBiOxuMwD52u5Vi3DH7rO89xcvObl6f00T2JwZDEqt5tXPv2eDWqofjgtKw8+O4PrUCtMD9yFQJ9Ok8NqvlgOdTt45dPhLMPveS84PV6MqMNbbeD1Ky6f7ofA1l1RVp3oOpA8vHUtzK/E0QBDgIhs8ZUtv/qjeHoNgFce5mvzPfCyP/4MPvqF4/iRKy/Cg8+fQq+f4wOfOYr/dc2t+NTNpzYCPv7JGy53j0ol4IKLgfs9CD9w2WAD3ATE+vkPncPbblwcWe8herHZbKJer2N6ehq1Wg21Wg0zMzOYmpoa0Z/SH7VnRa8diZ2wGvrXdebd8/OiQCMVJIUMrQV0mVd53u/3sb6+PgQjIk/sIdGyq/tJrVLCm3/ucZhuVUam+GRR9IuffX/ceNsC3vmx2+2y9XrIPvo+5B/5R2Fs9B9bB5mcjjd4idVV0bbZ1YAkhbTAWx1FK1896rOMhGesrVGw/g/xE+I59DzUGUMgQt+HAInl+eC41qiGp3wkrgWadDohcKJ/1WrVReoxoMFlTiFrJMadVhtc7tjA5of8dN7W2pRUvixDXLTjjwOG9DNeg1NkasQCJqzg9b3Oy+p/XCadjw4nwMaSA3ara++IjHIXFxexsrKCTqez5QNwzAPLjKXM8xz4q4/dgb/62B2+0Z3eB+QDxM4VyWbncGjilLsmBdgYVR9qbU6piLdYvCFTU1PYt28fZmdnh2Hq9TpqtdqWQYZ4m7U8VKvVEY9LSBZ4WtgsewQQWF45/c7LO0ax/u7FYb6lb8k0zdra2gjA1QBY4mkvnaTzzMfM4/C+rdNwQv1Bjhc/+/5458duD5Y71l+sQYRQkROkt0NnBSCxBJIFXk/ZeKc+WmlZI2D9zjPcIcToCUkqHxzOEyKeGw4Jp3a/Wp1Pg4fQIjUN/GJkTc/oPNig5PnGSZG6c0iZ2bhpo8d8WkDTI2tkoEFKlmUjXhurni3j740CdbtanjfJk42hfmaBJa+cOh4/1wDdInkX2okkCpkBhwU8uC50OrxVm8OF+Oa65TbV93rdiPaUrKysDBezttvtLWl4INXjM5WyE8eQxw45A5Ddcwx3LmW4aNb/Hkl/kOPO5d5wWqVWq42cKyK7aGQxqtXngQ1dIbts+JwRHcfkU73TMiJk1dW4o29PJi2daxlnkSMPjFh88U4e7eWTqRtZ1MqHo8m/gBVJ//IHzaHbG6DKu65OU7mU4bILZzDZKGN5Pa2PeM9C3vYUgMgUA3JMuxqQaIXmIVTuUJah80CAlZ8ckc1utZT4Hp+WR4EVmzZKKXUg6WpFYhlLfhcCOJwmL+4V4fPctRbf1lH+HuixDLHO1zLqVt1Yna0ISfjQaakSjr0nnIaUKcQnl0cbXN1GvKaDefWUaog3i1jJixvfAknCl+fVkZ+1W0f/Sz56i6Rudw94MWDVwEP+e73eFkAiz9vtNtbW1rC0tITl5eWR0a2Vr+UJ4XJb9x5ln7oOWDy18ZVWy8gP+sAtX0Hpjq/h7a0ZfOtFU25a5VKG//OlRVSr1Y2TVicnMTs7i4mJCbRarS3eEN0X9TO9Fk/ATUp5rP5o/fOAw+uvofq2woYMsQUoQwMXz/hyv9PTXHoNiezQGgwGqJWAR1/QRLU0wJeOrOJrx0ePkU9VT1vaoFpF9ZGPRNZsov+1r2Fwxx1byvKouTrOaVVwvJPjc4ub9c42UpdBT/PqheM7QWcNIJF761qH1YZU0CwrZR2XFaU+m4TD6ikYS2l6POpnWhC4M+rOYil1r360YQjVE4MeNnoabFjGRAMMXXaLN8srIs9F0XHeVp1wGawRa8jzZKVhvbM8GileII7D+ehRGOfpAUOrfmPGzivDlueZP7K04oRAH78LTbtY7WSBEq8sXnr6ue6T8s8AJc/zIdiQuX/Z6ivrADQo8owUG8oQv6VHPQrlBz4QebeL3vXXY3DnnZvvez2U/ui3MPgvv7axgFXLXL8PDAYo//H/iyzL8Pc3LeLjty/j8vMmtkzd9Ac5Pnv3Ov7+lhU0J6YwOzuLffv2YWZmBo1GA41GY8vZTBqQ6J93togHMvh9CsXARkzXC3EbeLx5nhLNS2jaQtsSYFP/a10GYOSAvUG/j6sePYUXPnoak/XNevzoTQt4xd/dittPbOzo+tevnsQPftsFbt6DQY6bv7GMxdXu8FnrhS9E66qrUJqeHj7rfOpTWP6N38Dgjjvw1MNN/Npj9+PS6drw/deWe/jvX+3g092tAyApi5SNdVOonTwbYNGuBiRCHvjQ93r1N8/pa0Or09NGRyND71C1UCex+LUAR6gzW0JgNbZOw0rTAzmhNDk9TkPHCxlqDmdN28TWoLDR2RxJ+FNTsU4ReueBIPaQpIAdDqNdvLwuQ4fRfFjeBovfkPfGU8AZ4gqE0xWlyyMlT95Cysvii8vM6XNellfEA3F6zl5P04h3ZHl5GUtLS1hZWRlZiMj8eMaTwY+m0qWXovnqV6N8wQXI+30gy5D97M+i8+EPY/XXfx1Y2zhFs/TR9wHdDgY/+rPA4fM2E/j6TSj/0W+h9OXPbZQny/Aj//c2vPLJh/H8y2ZRP+3i7/RzvOury/jv/7qASr2J/fv3D9eKTExMjGzhlfphj4hetCp9Va5Zl1rtY7V9qL8IH+yFHYdYDq2pYP3PPIT6kcc7gKEMdbsbQEHkTADJy75lBj/w8KktaV9xyTT+9icvw7Pf+FkcXerivZ88gl963oMwO1FF2Tjdt1TK8Ef/cPMwnYmXvAStH/7hLeGqj3gEZv/4j/Hon/8JvPWR9S3vL5wo43cf1cQv3QRctxS2RR5w88J/UwASLqgnvFoQPWNnGW7LAHhGI6XzhQTbMvYe0OA4llH03PAp6epn3CktPtlA5nk+nNqyjJRVzhSgw7uSGERJ3rw4l9uX20XnmWIshfT6CCv9FEWm+eO1Fh7wSFHqIU+SJddWOE7PCp8ygo3xaqXp8cBl4PiWl8Xqi/JMT9FosCFz/gJK1tfXTTCiAbFVH1zPQ/7m5zHxP/8n0NhYrJipUXT1yU/GxOteh5WXvWwznes+hPInPgxc+hDk0/uQHT+C0m23jJQnyzK0+8CrP3oMv/OvJ/HI+SaqlQq+eKqPdVRRaU5iqtnE7Ozs8GwR3hUj8si7ZgS06CPgpZ5ji/OtPp3a5imGLAWsaF6sNStWX2P+U+RehxMPm4ASkZ9ut4sLpkt43iOmzfiVcoZ9rSp++lvPw6vf+zV0egP8xBtuwJ///OPRqmUon95G1esPUCmX8Jcf+hre9k9fBwCUDh9G84d+yOarUgFaLbzucfuRdZeHX/QVKmUZBnmOn7soxydvzNAx9GyovEXfmWUvFPrfGVnIKwVRp6JtXtDE0zUeH6ykdDivDGycJX+L31AHtxZheYqBiRV+jG/rXvPvHbITAiOWkrLaWceVcNpNyryEeJdnXP4QCLCUllen+p/DxDo74H83SaehjaN+bylaPe8bUyYhOdDvtMtat5lVh/yMAYTHgwWoLPChw+i+pI0ub7PU3hSpn3a7jZWVleHOGh3Hkw2rfEx5nqP+/OcD9foIEBmWuVxG9TGPQeWxj0Xvhhs2y57nwFdv3PzImdF3pIxL3Rz/enyAqak6puamcHBiAqurq5iZmRl6RnhXjOSjp2T0ehFZX2JNMcYoBCRjg4DtkCfflvxo/kJ5W3rS+pf601McAnyf/aAp9Aa5uwC5Us7wvMcewq+891bkAD59ywKu/KWP4IVPuxDPefw5aNXLuPG2RfzZB27FtZ8+OozXeNazNuTEoUcd/xou6q0ATr2UsgzzNeBR0xk+vr5ZXqv+9LudaCtglwMSIcvg6neeEWRvSch4WVM6ViNoZclTF9wZPaPGnhxRgJ4RshZfeeWxyhZDvh5gsvKU9KyFTtb6kxiJgpQObvHm8cLgIsVjIZRqiK1yWPKRsm2uiKIGtm5ZL1K2WNtbecbAAi9u9YyxB0h02by4Ol82jDwYkb6kgSrzZ50BIVMznU4H6+vrIwdbsTckZLSsa6Hqd3zHxojVobzXQ/XKK4eAhOtHrrXOEBAhO2imp6eH60RqtRq63S4mJiZQq9WGp63qtSFC2msiB6JJ+twGFtC2QKRFXh9OlWMvvGUsrTBZlo1MNzFAsXQ134f6aalUQrPZHE7diF7M8xwHJ53vyyiaqJcxUStjqb2hS4+eauO3/vrL+K2//rI76C0dOrSx1sjxWs2vnIjmCwDzdXv9l0U7BUaAswCQpBpecT8K2mdDHhI+VmSh8AwSYkKtgQ6vZtedxOIJ2PTaeAogNEKwFIcH1rx/Hola+eh0PSAYAha8YEy/D/GpPUy8y8Q7aZXTEoqBA6v+eSQf67ReXIvYC8RGkhUmp8UKmPmwvBJWmdmLpw/qs9Lltrbk1ZJJ9k6EZE/3VZYPPUWjz3vQYETAh3w8jxeyMo3UzdQEyvv3ob+whP7xTeU/Uo9ZhmwicNgZAJTLKM3MbKmjA/US9jfKOLY+wFI/G9ntIiNyDUQmJyeH357R9c9f49WDBTmXRPRltVo128p65ukK730IeOr7ULgixpA9Zfpfe5dT+63Vh7T8VqtVtFotLC8vD+scAO5Z6QMRtte7A6z3N8uodZYHiAYnTrjeDwC4pzkTzvQ0neqP9htvp5wOI3xaz1PprAAk+h+wK4VXilufny+C7D23pZW3JdzWyFAb2BSerLRDRob5486ny+aNGCyDEuKL68ZSWDHQZK3q1vGYf+ZTx00BGsxTzDMQAiupYCTEi8eXJxu6nFadFwFGsXCaB2+61GobL08BORYA1Wl5AInDsrzLPZ//wGdArKysYGlpaeS7IyEqnXMIky9+ERpPfRKyyobRaX/qc1j8w79A5zNfAKC8ZHmOwfHjKB044CfY72PwjW8Mbx+zv45feOgMnjy/seakP8jx/iNt/M+b2rhtffNskImJCRw8eBD79u3D5OTkEKRo76LeLaOvpe+Lh0XeseGOUSpwkb69USVhAByS2Vh+MXAUysPSA9y3LPCv45RKJbRaLbRareFJv9d8eRk//Lh9bpl6/Rx/8+m70euP6mXmg/Nr/+M/YuLHfsxN91MH74e7Ki0c7q5sWUOykTawMCjhM6ubn1jhqXfLE7lTVGzf4i4jbVwtZa5JI2cmD6HrkUmoA7CR9Iy7jq8BisW7Va4ihkSnI2lZ2/h0Xill4neh/K36toARK0/L+HoKT5eB60yPuHUbxwy251UKyUAKCChSl6G2jslBLK2YTOp7/d6qY8D22lhlYuXq7cCy4rEBs/qF/LOXRHtLer0e1tbWcPz4cSwuLqLb7UaBUfm8w9j/v34LjadeMQQjAFB75GU48Pu/jvoVj9vCR/fv/m5jZ41DWaWCzt//PbIsw5MO1vHOpx7CEw9u7owolzJcebiO/3PFNB4y18TMzAwOHDiA+fn54RqRWq02XCfCp7Pq9tKeEH0WCfd7T8+N8G3IEufHXmFr2igl3Zh+8frROHrS0rmWXFr6SQCJfJiwWq3illMDvPfGRQwM2er1c6x0+vj9j9zl8sYe0qFs33471v72b5Eb3ry838eg18OvfH4JpdP6D3kODDb+N/of8NaFGQwwXr2l1q1Hu95DAqQpYMuwa7KMFeC7Bblj6jj6WQgscBrWIWFWXP2cPTVe+SzDrfm2PDM6jJc/h5G0LKNtrSHRz7SHhzs5MHrqoeTF5WN+uLySB+8MiHlzdNxYXXN+XjvpOktR7jpN794rkwUOPPmyvH+h0bEFTPj0yVjZPM8i32fZ5vebQvPasrVf4kqZ9TQNf3lV7peXl7G4uDhcP8JrR4QGgwGQZTjwmp9Fc66OrJSj0++jNygByJCVy8gHA+z75ZfhyHdfBfR6Q747f/3XqDz96ShdcIG5sLX9znciv/VWlLIMv/34/SgDW84WqZQyNDPgly9r4TcWzxsaPAEXDAS47+m1IrL2RACMBTysEbElj/wfklFOR7exppgXkp+FSPLhA9i89PW/tgfsmeb0dX3Jt4KazeZwkfRrP3AMJ1a7eP6j9qGmTmH98tFV/Nxf34zbT7aDZfbWkK3+9m8DKytoPO95yGqb54z0v/51LP/qr+LvvvJ1zC1M4VWXTqDV6W1MHWVAu17FW9an8c/ZFCqVFbPMIXvm1WMREHjWARJPOC1QEgIn2kUbGiFp45niltcdT9KxGowFnPlN6eipQhMSFitPTSEPlOTDu5Ms46YNTAgESVirfjhdS5lw2h7I4Pr13JPcnjosv4+BjtROmxouFD9Fdlix8rtQ2iGjwsraS4ffa0Us03iWHHB4DUa0N4TPHel2u1hdXR2CEd7my3nULjkXF77hF1A/fx55PgAwQKsG9PoZFts1DPISslIJ5blZNJ70OKx/9BObZVpbw+p//s9ovOQlqFx5JbLT6zQGJ06g8/a3o/vOdyLLMjzlUB3nT/hqupIBj2z18IDKBFYa08Pt9hqIaC+u9kbohasMTkK6Qw9gQn2ODZindzwjxjrWi5vSrxiEA6Pr8Kxy6vRD8ujxrm2D/Guw2M0z/NY/3Y03fuQbuPzCJurlEr58bBWfuWN5S5pWnWheR+ogz7H6pjdh7a1vRe3yy4FmE/1bb0X/xhsBABe3Kvj5+Spq611A4uVAZa2LH8xP4ovZLG6KnJwsPOi+aPX5orpqVwMS7ebzhJJHCEXTtxSfpKnz9YSbBcjrwBbvXI4QeeW3OkxsdMn56XlkK2xoGkUvuvWUioTVXxINlTHEv+X69RShnguNgb6inhGddxHlOS6ljEAlnOW90uFSlK8FwHS/EC+UXp/hpRcbnVp9SPc3BgyWd0eDEA00JJ1er4f19XXcc889uPvuu0cAicTVfJf3TeGS//UqlGcmTz/fzK9cyjHT6ODkWh1AhrzfR+XCzQPNhgZreRnrv/mbwO//PkoXXQR0uxjcdBMy1X/uP13DIM/N+X5N5zeAm9V0jPQnARh6USs/F68It2GoTULE8hMDv1baKf1mS9zczp/lVsu4BqtWHK8/cLpeOTQ4qVQqI+tIRAaX2338wxcXh8fJe2mGPI2txzwMk5c/HIPFFdzz19cgX+8Aq6vofuhDW2zUbz1gAvuqGSpUnkoGTGCAn8ZdeDkOjdjPUN/XejJkh1No1wMSIK5QrdGCNkS8bkKUacggWfnoPfo6jrUYyVLw2rhLmnwKohVHk2dMvPrxhEm/0wLNz626s/K2Rii6XXi+Oma8dT1bCjTF8HuKRpPn3YjF0eFSQaiXRhEKKa6Ql8crX4wPC+yxTFngxeIjxh97WFhZho50119XlbUiOtxgMBgehCZHxHe7XdTudxjnP+9bMXvFQwEApz7xRdz1l9ei+eTHojwzhcw4OTPLgBJyNCp9rPcqQFZCvrK6pTxDWllBfuONG+XBhl2VPrHSRxSMAECvUh+uD5E89OFmvHZEpmi0pwTwP66mn8W8UpYMhoy59S4VxFg8ev0rlCbLiwbLooN5St2a8vHAkAASWXgsMtbv95M/RqrLMAQilz8S9/sfv4Dm/slhmEt++Sdw9z9+Al9/2a9vKdsljRKePFt1069kwIOwhotKHXxe5WnZTA/wbYd2NSAB4uhLd0De6qa3KFoGwwIkKYYpZPg0wLDKwd9w8dLWIMhyk2lhsfLj0xatNSQWQOH5aP3OE1CuQyEN2Dg9DmPx4nkuPKARGmF4763nIT6t59Lu3JE9SuXTM/qstCTPECBm0iA65OHwjI/Il8imlYaun1jZOT+v3+m2YQ+MXriqP3Amz+RIb7mf/c7H4ZJXv2ij3U4vVj30/3sS5r/3yVg4toq+c6iVUF0AyWCA9Y9cP8KTrl9uQ5GTcrmMjxzvozPIUXPyygEsZnUcqe5DRfVpPR2jt5vyQlZvMaYHKhmMhGTQah8drwjF+kRKfM0nD4C0PmGAKwubK5UKms3myLQY86PbV+cpu6Bk0XCr1UK73Uav19tik6xyWrLffNRD8JA3vwYZyUapnGH+2U9EZe43cMtVrxjh86GTaSb/0kofn6cyefYwpEeKtvOuBiSWEfOMvQYk0jmtb11wPM+gC8WACsflRZw6PIMCVvheBwh5CCwFHao3z5gL795OHHnPRoB50srAAjXWSCPEU6jeY2GFOE9dBjbIzDeXldPgckgdhs61iAEGCxhoYnngOvHkySPPIHk8xurJA6YeWYCH0/LKpKddeDeN/rbI+vo6VlZWhlM11QsP4pJXvwgoZShlm/UpwGRmvoXFXo5+7o3GgSzLkQ9yrL7zPchPLY7wx8CEyydysooS/uKOAX70gjKs5soAfKhxKUrqSHcBHHrLrz5cUA/QLIDs6QQLCHOdW+0dApwhOU/xkHh6ysqHp/f0VLIl13Jar8iKhG82myMeAy9PeQ5s2JpyuYxGozEEv/rL0RqU6HrzgHyWZbjo11+GrJSZcgEAc5dfhiMPfxDWv/DV4bN2otOpm9k6JOYJ2i7takAilOLu0p1LGt3zRoQ6WEzBWqMAS5i00dF8aVBinXjpKY/UsqeGsRatWWtxLMAlFDKolrLTZQ8ZzVCb7ATpdrG8MFxmq7OykpOyaaOh4xQhix9+bq2j0SOwlFGNNzrW97F0eIF4qBxFgYoOZ4E79s5ogKmf9ft9rK2tYWlpCWtra+j3+5h/7rcAeY4sc3RLDtSzDlbzhsPTxuLW1b95L5Z+/y1byujpBCG9xuPNRys4MNvCd02tbJQny1BCjgEyfKDxQHy2cQEqpdHts+IZ4V0z7CVmik3BSn1zH4kRe1a4PkLxtgNYdFgBp7oPhsrIcfXuvlartaWfe9dib6RN6/X68ARXOXhPgAkw+iFXpqFNmWxh8n6HXTAidPg//xC+/p9+ZXj/iaUBVvs5WmU/YgcZvlCaQpalneqaKgMptOsBSRGk5hlmnQYvdvPSSfEEcPpawfMx29pIaX5SRwkx4898WPE18LCMaghYhcqt89R58Twk78ax0g21XdGRfwppAMtTS7psWh4sXq264HcWAIi5cK3nXrpWnrGya56tdomBcg/Ee/eWLHun9HpeErkWDwgfDe8BTA1gJh/3wJEzRbbUTSlDNQPgrA3PMuDoL/9PLF3zzyN1wm1ntb1e49FsNjE1PYN35YfxL/0qnlBdwkx5gKVKC1+onYtOpY4KHXAmgER2z+hRN5+bxP3cGwHrerXuQ/JkgRFdZossEBniwyLuf7KjCtjUcd6OIt0Owku320W73UalUtkCxjw9xAv6ZepGT6FJG1UqleEHNvWSAksH1C84JwpGAKA6Pzdiy9aR4U+O9nD1ORWz3AMAH6rOYz3fXGdi9Ul55nmZxqVdD0g0caWw4rEMCndMFgCvw4QMYUoc3ZAhY+uBKC9tXe6YEQ/xqtcAhPIOAbxQWAYkHDcEdLx8xgUioboDtn44zqMQeAPs4+qtNtJy4RkEqw1TFIMHMkJh5JkV3lOYOi1df96I1xp1W33LUvbyr8GGtTak1+uNvOe1ZQCGZ5RUqhlapQ4yAIM8w3peQY7Rsg/W2shLNWSGV/DUez6C5X/8+BaDz3WsFbvWT5VKBfV6HZOTk8NTV7vNJj5ZOzSy9kPACH+VV05b5cWIntxwm3ptacmuJZPW+9CgiesvFMbL0wor1xpchACz7nd5vvnV8jzfWBjN4DU0Xc6yrNui2WyOTNkIEMnzHJ1OJ3oycPcbdyPPEQUlvZNLW/TJ736jj0O1Ep5/oIxeniNDhhwbC1pvaBzGuxv3BzonzfJIma3dpxbFbCLTWQVIgK0CJcSLWiUsr0rPsgzdbnckPSHdsVjYOCzPBeprTlPciPoZpxXq/LH6sPjR+VgjRl70yuXX7uGQkeR73l6YAl7GoVTFFiPLKFrpW0DYy8uShRjoSm139tZY6VltbuXJ6XnhQ9MyVtoxwBQCVingVFzh8oE8ueYvHOtdKLKWJC9neOivPh+HL5lGnvdOJw600MXKoDocOQ56fRx/73VYuGsZcz/4TFRmpzbyPn4K97z1vbjnL/7eLTfrCy6TAIqpqSnMzMyMfJOGd3ro0bWAEvGOVKvVofH0zofwQAnXqRfGCpcSJ4VispzCk25r2VVkTT1LWHmuDa7oO2vnJW9S4MWs8q/Bs3UgXaVSQafTSV703j+1iNW7TqB17pwLSrIMuPt/vXOkfFmWIc8y/PLtfbztRIb/cKiKcxsVdCdm8Pm5S3C0OYfS2tqQZ72sQR92aA0IdH2PS4UAyete9zr8zd/8Db70pS+h2WziSU96El7/+tfjQQ960DDM+vo6fv7nfx7veMc70G638cxnPhO///u/j/n5+WGY2267DS95yUvwoQ99CJOTk7jqqqvwute9bohGi1CKIrcMZcjg6a9aCoVGx54htowJGwQL2Hhph/gJKXpvF4vuOPzeG8nq+BYQs+pV37PHxSqD/t8pULIdpak7n+6QEpfrUZ7zfUpeRUjqideGxEaPmjfPEKXyyTLjpSP5hRbyemQtGJR20PfiEREgIjskxDMiPMgHz7IsQ7/fH34iHgDu93PPwv5nPOw0T6N8TJa7yPsZ1gcbhumuv/gAVm+6E8f/9F2oXjAP5EDn9iMo5RtbdTXHob6t5UTAiHhG5ubmMDExMXJ4mYTXQERPz8i6EV63JPXk1XuKbuTdQVaZrHYOgcxxKQXc6p9eYGp5cDTxej4Aw7Nc5L+Izpb8xTPXaDRQr9eH8qkXz0rdiVfPozt+/Y/wwN/7RdNTkufA8k13Ye36z7g6+et5DW/uzmF+bh6HDh5Cq9VCOc9H5McCG1p3sDyEPIEpVOhbNh/+8Ifx0pe+FNdffz3e//73o9vt4hnPeAZWVlaGYX7u534O//f//l/81V/9FT784Q/jrrvuwnOf+9zh+36/j+c85znodDr4+Mc/jj/7sz/DW97yFrzqVa8qwsoWsgrsKTGrgrXbVyNlr5PKvffFTDbyHo9F41hhOKzugNZx9NxJWclxR7R48fjQaVj1J8+1q9Oax90OEBHajvLz8tejBD0NwXXjLRjk33ZIyqd3kkj+ntwwj9aIjNtP78bgsBYwYOXEZQ3Jo7z3eOdj3DVI1CeuCjDhNVoyIpUv4Oppm+rBKex/5sNHpmBGywq0sg4wGOCrr/xTrN/yjY26yYH+bUfRu+0IyhjtW/rHC0q5T8h0S7O58W2a2dnZkePgeaegnpqR79YwGJF68/SM1S9j+ojj6Hd8HcrT02Mcz5MJlg0hLoOuX73OJuYtybLNs0MajQaazSYajcaIp8Uj7VWx2lpkUKcr24J1e3sGP89zLF17PW5+xe+i3+kN5VP+Fz57C2563s+ZbVAqlVCr1TA9PY2DBw9ibm4OzWYT9Xp9y/oir01CchFqxxgVcklcc801I/dvectbcOjQIdxwww341m/9ViwsLOBP/uRP8La3vQ1Pf/rTAQBvfvOb8ZCHPATXX389nvjEJ+J973sfbrzxRnzgAx/A/Pw8HvWoR+FXf/VX8V//63/Fr/zKr6Cmzt6PkWe8rQYQshCcbnSvg+n3qZXrTWWE8ihKKQojRcBSedGK1uNFL9i16tRSJkXq9d4mzZc1WvfC8nWKW7woT+yBCE0dxf6FGJyHymvl5/FrtbHm0RupWsqY8+NDz7rd7lBGtadA0hPAIvJZr9cxednFLv8bPALlDLjjFX+C1Y98AdVqdUsdpU5HSVhdL7JuZN++fcOP5IkBtAyb/iCejNqtvsn5WnzFrjVZOtTrv96ibL4OeW48nlPkzUvP0nuWt1jkjo87iA122KboqRt5rj0k+oOG4vWSdSUhOvWua/Hpd12LuRc8B61HPRiDpVUcf+u70bvjiBR0i01oNBqYmJjAoUOHMD8/j0ajMQJIeVBqeZMsuxni9YwAEqaFhQUAwNzcHADghhtuQLfbxZVXXjkM8+AHPxgXXnghrrvuOjzxiU/Eddddh4c//OEjUzjPfOYz8ZKXvARf+MIX8OhHP3pLPu12G+325oeGFhdH9/SHKkN3Xr1OwzIsbHisk/i88Ewxw2B1RJ1eiovTyl8fvx4DVAIerPJ5fFkd1uJLeNfveRQGAJ1OZ/ip850EapLfdogVFT8X4kXQnrJLJUvuGHjoOubzdDwgwu89ntjrocvGbWq5akOubH0GUCj/FJJ85UwH7RmRNLjP6zLoI+Hr9TqqE7XTw8yw7E3mZfRnZ7G2tjZcn8IeM4+0gpff4XoJD2xkQKWMr9cmMDc3h3379qHVao2MlPVPwIiMrD2vpldvOmzMcFugUa5FT+q02AizLvEGavxcr03yAIWnf/iEay8vjqfTFtKgWOrPS4Prh3WulkGZOgQwckKwTCPyGTqS/lC2JxuYfuglQA4s/N9rceId7x3hn8shXrVGo4HZ2dnhYmlt61gmRF973ldtRz1vsf6P0diAZDAY4GUvexm+5Vu+BQ972Mac65EjR1Cr1TA7OzsSdn5+HkeOHBmG0WBE3ss7i173utfhNa95zZbnlpBazzUgYaGzOofce3OOOj4QnreXTqvjaMWsFwPq9Lx5c86Dy8OucX3NQEXPEVodyMrHAjpcb/zPIztdHr3I1SvbOBRqW4/n1HSBUaNpzbMWTVcTd3KtDKzRCMuKHrl45L3T7cRrZVLT8oyH9rwweWtLNODhfMRgtdvt4ZoRASPaPc/1JmtKer0e1tbWcOLEiY1BzrGWeRT8CD/9ARorOfqTkyiXy8O8BRBZeoINmbw/UAFeeQ7wtKkBNo6F6GMVx/CRygT+pXHxyBSPHj3L1Iy4+FkXpcq7pz+98Jp/XSYGQ6G+5xksS9ex3GvSunkcCpWVDWyMZ5ZNT2/q9AQcCKCWNUBaD+v2H5kerlfxwF/8jzj/+U9HubExo9Bfa+OOt1+Lr/7mO5B3eyP5i72RY+tnZ2dx6NAhTE9Po1arDQEcgC2eOC4H2wmdh1X2ojpwbEDy0pe+FJ///OfxsY99bNwkkukVr3gFXv7ylw/vFxcXccEFF7jhtQLSo3Legx+Kz+nEOi83Do9yGbVqgMCjXw0QrNNkudFT1iRYYEgbCcnDyocFznNdStq6E1r1pa/5tMjtgBCrzDtJnqKNeVCK8qTT88CFlhOLN/1Ot6s12vV4tMJwuTm/UJoa5HAf8fIKjWT1bhrt7dB9ghd0aj5lZ0273d6Q3a+eRL7YRjZZA6yj2vs5Bp/5BqqdHI1GY4shkjQtA8VtNVMG3npJjnOqgD6jqoUBnrl0M+aP5LjmoitGysJeERlkecbQytuqx9gzlnmWLf2uCDDy8vXOneH7UB7emicrDYt/KyyDFYuH0CCIbYnICR/jz2mN3JdLeOybfxH7Hv8gZMr7V27WceFV34nJB16Af/ux1wOD0bNPZK3K/v37cfjw4eF0oPAjfUXC89oaLq+2G2xDvDpOobF82ldffTXe85734EMf+hDOP//84fPDhw+j0+ng1KlTI+GPHj2Kw4cPD8McPXp0y3t5Z1G9Xsf09PTIjymEUPUXLdn4adASEuBUg6PTtfKxFpxZ4fl5LE1v8arFE/OS0jF1PH7PnhWvPSQf/q7LTgOHnSar3kM/oSId0crPy1MMX4hPXc8WeV4HTs+aZgsZMn5nGeeQLMfe6bLJWhFeqK49ohqUyE+8KouLi1hZWRnubmg1mtj3vjuBHMCA6mWQI1vtYvCer4x4KUTR80JEVui6HKVSCT98IMO51Y2zH7bUJYBHn7wF57RPDcNrj0i9Xt+yhVXnp/Ph65Cu8cJ4be7JwHbieIbNi6t1Ej9PydcK6+m+kOxa6bG+tdpBrxmRd+wZ02ne78e/E+dd8QBMVPuoZT1A7eXKyiXsf/LDcfA7Hjd8JrLTarVw8OBBnH/++Thw4AAmJiaGHhoAW+Q2pJvH0depcQoBkjzPcfXVV+Nv//Zv8cEPfhCXXHLJyPvHPvaxqFaruPbaa4fPvvzlL+O2227DFVdcAQC44oor8LnPfQ7Hjh0bhnn/+9+P6elpXHbZZUXYMfnTxJ1NG1NWtEUMeUhp6pGZblhvhb2XL5eB+bHysNIW16DutHqnSMgocYf3lBmwec4Lt0NIEC1DdTZTipL0DAW3ccgbVpQPHuWHwqaAkVA5LWVtKW6rX/BzWcQq4FbvONG7UjSQ6ff7WFpawvHjx3H8+HEsLCwgz3NMTExgfn4eF/dm8Ojr+zhwTzbU9Vkvx+QXF1H7w09jcHJtpE/rD6bxdkmrDaU/fv9sjsDp3egjw6NO3DoCRBj4aE9nys+SIR6YePF0PimUZdmWdQlFiHWw975I2iHvRVG+PJDB4Nfruxo063Nj+IA7Sa860cBT/uileOIrvx+tUhfNrIfpcgdz5TVUs83BSd7r44If/Pah3LRaLezfvx8XXHABzj//fMzOzg7BLPOoZdfzXIdsI2B/R6uIrio0ZfPSl74Ub3vb2/Dud78bU1NTwzUfMzMzw61qP/7jP46Xv/zlmJubw/T0NH7mZ34GV1xxBZ74xCcCAJ7xjGfgsssuw4te9CL85m/+Jo4cOYJXvvKVeOlLX4p6vV6EnRGSSmGEbY3opZJ4ZBVzYev0dB4WHzqepYQ5PoflEZ83b+cBBE5Tz/Xq9EMLdz2lwPWhO6I35WPV03YN678X0uVLkaHU9LjOvfYJTWtYzy2XuyYBq94pvToNaT+9zipUB97UI+fPZZY+IGc2ANhyvggDAU4vzzfm65eXl7GwsDD0rsgIcm5uDlNTU6itVnDo8xX0qxnW0cPqsQUcP3IMd6/0ht6RPN9YlAgAy8vLQ7AjdSZTSHpR7XCbbrWC/eU2QlRCjpne6nArppxboUfSlks/VrdWfwu1g6WXUnQl6xupk5R+oMFT6ITkosAiBHJS87DCaRks8l7qSICtTMXpDz/K+yf83k9h/imXneZJ8QdgutTGqX4DfZSQVcpoXnBoCEbm5uYwPz8/srWXB+ehoyG4LrgsbKcsQFJk+q4QIHnTm94EAHjqU5868vzNb34zfuRHfgQA8D/+x/9AqVTC93//948cjCZULpfxnve8By95yUtwxRVXYGJiAldddRVe+9rXFmHFJAEZFjEQMOfnnHh87Qm2BxpieVkgRcfzgI0WKmvahAEJnyoYc4uyIFmAykPAHjjSdW+Bxd1EIWC5U+l54WLTLZpCHhAvPKcfAqtspMYth9dPdNoix7IbQefHClHLvYCZpaUlLC4uYm1tDQCG50zMzc1hbm5uZF69gQqydh/rg80D/cS9LjQYDNBsNlEqlYa7fPgwNhltyqLCRqOBVSygBf948DzLsF6fGG7JFM8PrxkpKntFgXHIq6DbPWTAhPTAykpLx9Ft6oGSFI9HqjGMAW8urxdXA6+QLpe6EP0p3y1qNBpot9sjoHPfIy7COU99mMP3xsawVqmDpUEDeX+A7omlkW29cp6NyC0DErYTHjBh2+Z6uKkqLdnwqBAgSVFmjUYDb3zjG/HGN77RDXPRRRfh7//+793345BUsscjK6vUOcrY+5hwMoiw+Ap1disv77l0dgYIqVMjFppN9ZqElENoZGLx9++V7m3eQnW/3XRTlHmR/GIj51hcL3/9Tm+BlOfaA8H86qnJXq+HxcVFHD9+HCsrK8N+MDExgf379+PgwYNDYCE8dbtdrKysYG1tDYPBYOjB7fV6Q+U+GAyGuyNWV1eHZ5PkeT6coy+Xy8N1cLOzs5iZmcGn1/p44qlbUIKjr/IcX7/wEZiYmBhx78v3WEJGz6rHUL2n9HceUEg46+Rlz7MS4zVV18TieWGKgjFO39KPHJ71r45rGXH510fIC0ipVCro9Xo479mPxaDbR6lqf+wxy4AaBgByoJRh5QOfxgUXXIBzzjkH+/btG/m8gNb/GhRJntaA2qNUG1pEJ5xV37IJdUhpdOtYeAmT8iyFB0/orDStkabFe2hEzCMKna5WHDHyeGReLMVvxfcUShHEvBsopNC9cLH0xjHsRSjGszelaQHlGKV4CEN9UgywXjMiClSH43S08VxfX8epU6dw6tSp4TdDtGek2WwOD2UUANNut7G2tob19XUAGPk2jHhA1tfXh8d7y7x9r9cbghCZapmYmNiYDqrVMDMzg8+W9+HRy3eg3utsASUDZDg6f3+sXPAg1NR8P6+BswYfIRrXU2CF0YMfBjZF0/DeeffbJQssWe+1sU7t1+zxEy+enoLS8VhvS5563V99ppVQJgC9Prp3nsDkjcdw/sUXo9VqDc+o8dYKWW2nAYoOx/WiZyRCfbhI+501gES7Zpl0w4cAQWy06I0qY6OTVAMcykO7O9lFai0+0uR1AivsuGs6PCBllQHA0IXOI9yUkfsexT1NoWcpaRWhEFAOgRGthOVUSi2bej2GTNHIOg2tFBm0a10gcZeWlrCwsDAEF41GA9PT05iZmRl+K0a7yXu9HtbX14fnm8g8uyh2CZNl2XD7sawtkf7WbDaHR79PTk4Of/V6Hd1KBX/zqO/DM7/0ARxYPo4cG57uQZbhtoseiS888bmoVaruaDU0Wj+THkfuo2caOKdQahmLjP4lTIoXyeIl5Km3Dm3TsqUPt6xWq2jfdQqZtQ1d0SAH1j93Gypv/TjOOziPmZmZ4RonBiEWkNd9iBe8emVMfV6EzhpAYpFUrq5gYNNFFvI6WILFQinCxVNFlssyBgRC+WojzmlZUz5ep+D59SI88IiG42seLfRtlT20YG230m4rSwxMC1kjKZYHPk2S5VauLaPGssn58MFn8k7LvwYj8mu32zh+/DiOHj2K5eVlABvrRvbv3z88EVXWhWgF3O/3sbKygk6nAwBbdibkeT48CE3nKfqm3++j2Wyi1WqNeEj27ds3TKczOYl/+LYfw/zKcRxYOgZU6zhx/oPRmZhBBqASUfwalIwDQsaRVZ2fF7+ITk15b8loUd4ZzMWmtZlYl3F/8OLzNIhFWnbkUwAiI9VqFQvXfhF48ZVu/HyQY+lDX8HkX31+5IRf4ZXPndJgg7/dxoBEl8FrA66DDJkbJka7HpB4KI7f8xysh5BZsEIAQY/WGBh4AjqOsrDcZToMgx4ehaYAkRBwshZAeWnElA0jcev9Hm2fUkd0OzXCDU0JapkVxatlxVqMLkZe7+Lh6RqWRwYH3W4Xi4uLOHnyJBYXF4cejampKezfvx9TU1PDaRhJR76F0263h4BDRq56eyaw8WVzWV/S6XSG0ziyKFE8JK1WC5OTk5ienkar1RrqIyl7e98DcLTykKF+quX5CPBKGZHrfhWagghRrO8xGIl5Mz0jznlaoMoqn/XMM/Ypzyxw7endGHn5MY96ICuyKjLX6/WGU4myhXwwGGCwuIivv+kDuPjqZyAfDEY+/pj3B+jfvYzGtV/HzOHDI9+lAbYuYOVrCzAJiPEGkjL4YKBiAWPv0yQenRWARP9776QBrCPbLfJGhFYenLcFGkLXMQql4QEJ7akQnmIKRMJ7QMcDJIymvdEux4nx881C2wEFXj3GnsXy9N4zMJefKCeeHgRGR1ipwFPSEnDAX+3V8shp60Wsx44dw4kTJ4brRiYnJ7Fv3z5MTExs+YaSeD0EjEieeg5e1oQIH9PT08iybHh8/Pr6Ovr9PqampjAxMTE8qrvZbI4AEj1q1esFRo4Ij7RTyDNrPfeAiTfACXm2JJyXpnWvfzwwYUDKesvzmqQCD9ZRRQALX6d6hxiQaVnVYFNAiQyURS4FLJdKJdzzns/hK/cs47yrnoKJiw8CAAadHlY/djPqH7oDs/XJkY8sapuXor89inmrtAxa27pFrlOXAuxqQDKuIfMMLxD+6BejeS1kQlrQijY+88dKVpPncowp/FTUbwEdLz0JZ9VbrD6sPP+9UspIdSfzCbmpLQORYvC1Utwuj9y2ltxa1zHArL0c8mFNPR0SMxx5vjHFs7CwMFw3Ih6LAwcOYH5+fgQYaIUqoEJ/l0ZAQ6vVGnGHa7e68Lq+vo5qtYrp6WlMTEwMz2dqtVqYmZkZOZ5b+rTesWMZbd3Oqd6PEJgJyQb/6/7NBscDTQwQY4MhS0YsAOOVO0XPhnZgeuTVoVcGK06KPtaLtoHN82qazeYQrK6vr2P1+q/hSx+7CeVDk6hNNNFcz3DO3EHM7t+PZrM5ArjkP/StMM03l2+ctSMpA50Y7WpAYlGKYdCKiBWjIFUrLcu1xc9CAsyuaiush945vZCAxToogwzLoxETPo+nmMJJSWuPNshT4qmGRsuI/pR5CijZjnLx0rdG9NqFLO+0C1s+Wif9Qp9Qqr8bIz85Fv6ee+7BqVOnsLy8jH6/P/L5CRmBirLXa046nc5wZ40sZpUdM61WC81mc+jObjQamJqawuzs7NDjImnLkfJTU1OYmZlBo9EYgiDd/xnU8ag6Vrc8SAp5qvS17vOhKWwLKHhtrsOF3lvPQ3lYz6xF/qE4sbRT9V0IWHkkBl7ahfkSj56Uq1wuD3d9SVgBKP1+H5X1EqZrdew/tB/79+8fTtPoupE89fNQf04ZhKfqje3QrgYknhG0OjaH5XhWR9XEo08PtOhG80CABSashtbl8LbXWWWw7j1+rDjec16dbY0ELD4sio0c9mh8UGABZFZMWsHsJHHfi11bcQVYyJoMAQXWvLY2SnIo2dLSEk6ePImlpaXhaZeybmRycnJkgaqez+/3+8OpF33omgYjMl2j8y+VSjhw4MDQkNTrdQwGgyEAkgOvNADSfHvTFZ6RZ10kfGhPiwy4rLT1T+rXIvZYeenosBZxO1nvAXstkacnPCBh6VlN1tqjkA4KAajQ4MtqI76W8HoQLAf1ySF4nU5npG0rlQqmp6exb98+TE9Pjxzkxz89ABZ+dB2n6G/L26XD6fuQVyVVz+9qQKKJK6yoorXmv7iRWMisxuHOZzWI17gWGre8OF54i0+rLDq+Nb/v8cj5hgATxz+bAchOl4mNt5dHaGQn7/meR2chHiSM7BpJjWuF8bwC1jkGeiQpozz9s0i8G3L42fLaCroX1dGanUGjV8G5/QPYv29uqOg1wJb4+uvBkrfsetDfkdF1I1NJcqZJq9XC1NQUFhYWUKlUhq50ycsaqRcB+Nzm3qJEfm/JggVKrXbV8XUcD5BYgNh6N857DpsihwyeUsnTybE4gK8PdXrW2TqVSmX4bSTdHwSQ6wXZtVptC9DhaU3rX08Xsq4JgTwNaIrUPZc7RGcNIBEFEnofczcxecJodWR+zouJUtLXeVhgxcszlm7snp9ZgIOVkiZvbpnTsv6/2SlkDFI6vdcmXrxYmhqkenE9oBEjjsf3euqEF7HyR/K0d0GuV1dXN3bUXAjk33o/tJobJ6UOsgy3dYDyrQOcc2ITjOj0+v3+yJHvMqUjgITBCNeV9jIcPHgQk5OTaLfbIx9IY965DqxBh1dv2kCEFuh7RsaaJghRrL9a/d+TZ12uouAshbcUnWjxxeHGWXdipalJy2232x05X0fv5qpUKsOdW/JNJPGOTE9Pj3jqdJ78s3hhYKF5s9IRbxuDnz1A4hALtlRiiiB7lWqBG2uthVbI3GDS8PLMyt/iJdQZrTxiZQqhXp0f88PvPSOlF1F5ncAqyx7FaZw64/biNuF/Jm3kPHCawjOnH/KYMCCx+rLImbV2JM9Pf6vmwgz9bzsEUNn7VeCmB62g/NUyDp9obek7AkgkXxmRyumrenuwV2d5ng9HttVqFevr60OwwoDEqzdvjQnXmR6lxsAAv9e7gbIsGwKuGCjxeNb/PB1gyZ6Oy/JoxY3lHXueMiiydJ+lAzUVAU0SXuLohawafIsMiBxosCIf4OOvt3Nczy5Y5Svap+Wap4DHBW5MuxqQ6NGCplDD6HgS1orLzyQeP7NI7732eEihFOTvPWdehRdvd46+5lFYDODo99qQeaBpt9KZLEcIvI0D7LhddTtaCx51mvq917/4nQdW9bvQAECUr/aOAP4HGHWaw+mdSgkrT5gweUYGIAduvWgZh0+2tgAEyzhoQMLrVyxdoKdPxLvCU10e8YDK6nNSR/qLx8KffDfHqlddH91uF6urq1hdXR2WVTwleqeGjis8CV8e4LUGhhY/IUqRFYs/TXp6jIkHdB5f/J6Nd4outupKrrW86QMi5Xh3YOvZO3KAn7fTjG1eSE94YDBE29VJKbSrAQmTrlwP/e4UkuOOYwm6JRyhTpw6MtAUAhhWOOtjWLHRW2hkIfc6Hq9LSRX4b3YKgWQOk5IGH8euw2gFxmnKiCwVEKd8J4njS/56FBj6Vo1lQLRcra6u4mh9CXmzwR8bVQwAndoAC9NdHFjd/C6NeFckf8lTpmtkEazk65EGJHmej9SvNjrCe8ibwQZMG7H19XWsr6+PtDF7hHUe2ijq4/eFp/X19eHaBT2YsjwaOn1vhK0X7m5pAicNSw5DYDf0PGaQrT7BtsOTfR1O+okOo+VEt5vmWa8d0TInu7lqtdrIOwEjsu1crx0ROeNyFQEY3Cbe2SVyrQ9FSwGOReisAiQWWeCgCIIvihytZzEAoJWWN9VkjXg9Jc18WKBIBI8X24XKKD9roRzXs2cE9yidQnVYZNStDa/XfmxQrGsrXZEjT/Ys0koUwJZv1PCgIgSaJd7q6ioWG6sAGtF66dVyYHWTF31apvAmJ7KKd8Sq+xTwbg2Q9HOvHr12FiAh7Rg6JsCKK8/01BB7dHmAxeXzDJGlD7x68ojzDxm90HPOO0U/paz702nxFJc1ZS3GWw8Eef2IvGs0GsOvO7fb7eECa1nP1Gq1hlM2XNchEGXVgzyz+pXX9yzwbNnV7dCuBiSeMPKPO26o41gN5ymTcQysla6kV6vVtigN/V7zZikja/421MFCZeRwLKAewOA0xwF438wUqi/PIIYALI/QOA1r+7q0W4oRYBm2ysDxdPrimej1eiOgiWWOt4RK/H6/j7W1NSwvL6O9vAJgbkveTI3u5uJUay5fDIBMu6SsR9N8Wc84Pm+/9ACAXs8jaevPYMhixxgPumwST/RHrVZDq9Uyv4TuDTo8MDsuhWQqBDpCXpKQh8cKb8mdllWvn3nAWfPBHhM5Z0c8ggKAK5XKsD/o3V7y5Whpa0te5NoaRGhApMvF761yhkApv/f0V2r/2dWABLC9BdxIoe2C41CoQ1oKWr/zGkYUi3zMS6fF6WVZNqJQQsajaLm3C7Ss/z3vSDqNU1+eAfIUj3WtAUzIwIRkzeOF47FXQoMRnZ517g3vwOn3+1heXsbCwgLad59E9eQhlGZqgPV11BxodirYt9bYAop4ICAGgj/ZnmIAtfHxwnIcXU9cbwzm9FdcvR0ynjEvl8vDNSdSxwK8rF0lMY+NLo8Oz6BZl3dcgGFR7OwSy0CGBmjW+5Sys4xowCukz7vRgETOHpGvRWvvieh68Y5434aRtvTAg2cDefCr9Yb+heyLdwifdR+jXQ1IdGV5pw3ysxia587vvQNG3Z6pIyMvLd3xLaTLCtNTkpZ3JFZuKw39zEL7lpB64GcPjKSRNdJKiWMBYu1dY+DKXpNYn/Bkm3lO6Vt67YIeBXJa4pVgoMQGT84eWVlZ2XBvX3MXSs+/GMiBkcUkp1m77M79KJc2Dw0T9zmvXRHDzYtZQ2WNPfcMpFVHmhet2/RR4CHjGmoDATT61FvPQANbQYY8i+VjhfcOlLTylXixeg0Nulg+Wcda4XlQFdOd3iCMrzUYEdCR5/nQGyeeLzmgT6bw5WA9/RVgtnkMOCyQCGw9H8UDj1ZcC1yadWRUbaqM7npAoq9jQq47eEzIrGcewBAlwnxYhsIjEdbQSMATct3RJJwWXEs4dX1Y71gI2ZhZIwh97YG6PbIpZBA0WTIVimsZrZj8cz5FwvEgQT/XsqSnSrSytAytNaqWqYz19XUsLCxgbW0NeZ6jensHhz7axdLjm1hrbZ7P0Vqv4MF37sOhtQkgw4gs6ykbASPe+owQ8LJAi3UAlVVXXJ+cL49kdXxvwOXpHx0vZPA9fZACPkN1pNszRW9bejfGQ2p/8t5b+THvFv+hPDQgYXlj8Kvf12q14QnDOj/tGbNAkeQZe2fJYEi3WPWWqi9itOsBCSteL5y4Jsc97IY7pE6bG9BCynJv/Xtp84l4WhmwQtHp6TQt8AKMunulozCvKeBtj7ZHRTqzN5LxjJelyC3jbuVjKV6PnyJpC/GZIzzKC53HIbx0Oh0sLS1hZWUF3W4X5XIZ09PTmF+dxEM+P4eF+jq6daDeLWN6vYZqpbpF4/F8vYARfVS9R+bo0KGY8Qy9t0CKBhW81kwvetU7IkLpezrLIuvocYtfnd64RisF/IXIAmweWLHAcChvrYdDPOn+1O12hweeSXwta51OZ+jtGwwGqNVqww81eju9Qvzq3TAS1uq3nk3SaXh6ZwsoExdJFh40WXTWAJLYWglRdt6co4cWAVvxWrzofw7vIX0dx1I8LAiWUfKEyUtHlJZ2U4d488pvCeQeeDnzFBvRssxoheKNoL10pG21EQpt8/XSZ5nhA9DknQX2mS+R3cXFxaF3BAAmJiZw4MABTE5OIh/kmFqtobR++pTX0lalrdexaEAu67n0p9xDFDJEoXsvDV7wqu/ZAFrrR7Qe0x7TVL3l6QNP5vS1Z+xCefOzIjokBhys9vOeWe8sXSrPeVcN60At4yK3Ml3T6/WQZdnwsDPxfsjHHfXOG30yq8iBAE0G8ZK/BV4ZQEk6XFaLuB3HBZgx2tWARChFaWiyRpCMenU4HS8UPpanFSdmyL1Rp47ndRpOVwukCJSl6PieeeQDePbo3qcUhaAVo/6PkZYp7/wM4UHzYoFlfe2dvyBpeDLFfWAwGAxHkktLS8Mj2uWjY7JGAti63op5EQMh3hF9Mqs+pCqVigBzqw214dD9lQGFPLPisNFIGaVaA5xYGTl+qrEaF/xYacSAjgc8YmGl3vlMnpCnQIMUPa1tgRFZH9JoNIa7ZwaNDHj4HObOOYx8kGP9KydQ/2p7+DVfPjBQTy+GyuiVj8sS6oMsf6E6GD5D3HvEdFYAEiFvNG9VvBefyUKbOi/Ok9F1aPRkIXGPUkYY1ijUEhRv8ZO8E+UspK9Di6di/O3RzpFWep5R01RktB4LExrxWn1CjxA1KJGwolitUaZFg8EAa2trOHnyJNbW1pBlGVqtFmZnZ9FqtYb5MxjxDIQAI5mvbzQaqNVqW3b5aL5S+rWlG1IUOb+TOvKmT700rXbw4kkbaRe/9++RrmOL15jeterHnKZQyXiy6BnpmAHmZ5bscLt4ZdS2R39JWrwjss23XC4D95vA4DmHUCtnw/K1HnEIWSdHfu0KslNbPTHWOifLXgkPVv8qAh7lXg9SNE8xGU6hswqQAFsFj91mOoyEs4QulHZI4XD4FDeYxVMoHy+c9dyLp8tsjbysDgj4YGYPfIxHKcDAixcbtXhzzkCaIop5PSzZEIWlw+q09FShAF+9tZbT14ZN3q+vr+PkyZNYWFhAr9dDtVod8Y7IUegiywyiNS8yWhX+9fkeWuGnjNgtA6x3yVhgLYUknVQvhmUotQ609IPWl9ZiXmCrkee1JLyzK7Qd1NPFofJZ+sYCSWworbgMRKznWbZ1SsxK27oX3gR8t9ttrK2todPpIM83dtYI8M3mauh99zzyDMh4u3oVWPiOSbTes4bSammkrj0PnlVPnp3RNsADnAxmtFzLfQysptLOHc7x75A8gBFCdFYanmBzmBReipBnDCxwZZU1BKxioxVR2EXi7NG9Q9rbwPIgpNvRem+F1fehdGMjSw0AJC1erzEYDEa2OrLh12XUgCHPN446l8WsnU4HjUYD+/btQ6vVGuFVgxFP0Vr1wAbdM2BWPKsu2dPihbPieOXxiHWVNhYhQ22BT8uYWenwICZk/L068ECzqXdzfzDG8pnCsxcmVmbvp+sxz/ORqRot+/JZgvxRs4AFRgCglCGvACuXVof5s1zrcvCzFJ699taU0j6eXKfYR6GzxkNiITTLkANbjW5qZY1reD0QY7nSipKMRviZzsPt2Am8snIJhd8DJsWpSJ2x8ogB6tRRPYdLiWddc1wtf/JOH33OilOnwZ4RUcD9fh9LS0s4efLk8ANzExMTmJmZ2XKktlbOli7Qx9ULP9o7wtsqrboPtR/XE3tI2CBzGI7Pnl4rDYsn1gOWftBTNR7fVh4WqIvJqI5rhfHy2gxgp6fvPSAheVrn3MT0pLUwOAQORdYFkMjZIlmWDc8VqVarWL1/yz7Ib7NAWD2vjMnPjvaFEK8xPa49YpadCBG3Gbenle83HSBJIRZw3cH5BDyrI6W6TDWlhLOUj9fRrQaXTqFHlCwI+t9CxJw2l1UL60655/ZoPPLayXoW8piFPGgh5cLXkpaXPp/1ISCaPQdW32MjLcfELy4uYjAYYGpqCrOzs5iYmBgabZ0mGyRZL6JPiAU2j4uXeX1POXt1w2Espc1ep5CL3MpTl4ev+d7qv/ody4U2tt6UbQhAWO+0nvX49J7pfLeUE9kWmbHS47ZnAMaAhPPy+GQZ0OnraSzxjsjHEGXxdL1eH34kr1wuh8GIUAkj66zYu2NtzdVgyQNOuh41KAnJv6R3Jo6E2NWARBvjWDjdWDzHXWSEGFJG3oigCFneDlEYuhyaB2txI3c84c/jmzspj06tvPeAyZklb+QYChsCILHnOh/L2LERsO6190E8Hda3OazplJAhFlCzsLCAxcVFdDodlEolTExMYN++fajX66bcWov+ZNSqAUmWZSOAhNcObLdfWyAlRCHPggeKQmmzMdY7nCRuyAAx4NC6wOM1VAa+Dsl6DBhmWQZe6OrFZZuRAjC9dHR6ev2MyJjsBmu320NZK5VKaLVawy8rl0ol5N9YQ36/FrKyY8sGOer3jH78z1qs7YFF+bfKKAts5Z2cj2KVO0UnbJd2NSAR8oTJQrH6nTwXAdJuSystC01bjZTSOBZvFuLkhXXCqy43j7xCIyer/DofLgMj5j0Q8u+PvLMErGvvP0QxQ6jzEPnUYER/uI4VKi+85RHeoNTGauVmtGt3YYAeKqUMtZOr6N/eR71ex/79+zE9PT2yeJS3ROq+k+ebh1PpHTa1Wm346ffYF7dTlHDKICcGAPi5VfcpvIT0FI/sLb4YhITKxGkzaLF4CR205i3M5nQyZFveWQMxfmeVVz9j3kNhGJC3222sr68PAYmAXn3uSL/fR+e6I6g94NKtFTmshAzTN2/KNvchi6zysf1jcBaqCysNrp+doF0NSEIVpZ/xvzWi89aScMOGkHwqENG8W51Fh/FOa42RlX4sLJeHUTjnnZr+Hu0MWUY7y7Lh9y9SPX1sYMYZ4VojZp2+HiXyFl/51/P47H3Lsgzd8gLuaX0UOXrDEXB9H/CQK8uYPDyJO/61jrm5OTQajWH+AkLEy8GjOPGO6NMy5ehu2fVgxdN9wDJQrFOs+iuiuD1dkzLoCAEFL/0i/dganPG1Jz9F+A/xVPSdp6tSnsUADO82GQw2vo+0urqKlZUVrK+vI89z1Gq14XdpxAPXbrex8sXjWHrPAPu/64HI+zmy8un0BzlQyjD3mT4ai0CJPvRYpB68cgofvJWX44w7EC0KWHY9INH/XhgL7WrSi+w8g5vq1ksdNUmnFsVsLVjT4VLIi8+nPvIWSN7FkGUbJwhqRSzl26MzT5aSTwWWEocBLMuFJ286fqqy49Ehp2OBcO25sDyDg7yPE82PI0d/1B1/WnTPf2gD1X4TU82p4RSEyLbs3rG27GqgJHIvB6LpxaxW2XU/4vLv5CjRI+YpBIK8tTRMMVmL6RMrjMXbOBTz+hRNN6bLrWfsNffAiFwLAJcTV9fW1oafNWg0GpiZmRl6R3q9HpaXl7GysoLVd92NwR2rOPCdlwIXTCBDhubdwOxX+2gey5E5O8aK6AUul/As1+ItLKLni9rgGO1qQAKkVQgwesaAd2KlJaihUco4vHIn1h4Qa4RhGRkdhvlkEMWjYY8sw+F5ZbZTB3uUTt7oU9+H2sEa9Ugc/oXS1vJk8egZLe9oeG9njc53vfoNDEptt2xAhoMP6qF5vAnko1tiQzt32GvDfHnnOsTqO+ahsPRUyuAlBPxiHg+r/r08Q94c69qrj1j9WNPCfGaJTidm6GL5xYCH5w3RgzErjFUnsmZEFl7LBx8rlQqazSampqaGR8ADwPLyMk6ePIlOp4NqtYr6HV3MvX8V+/c3N7YD5zn6/Rx5hhGgbAHQlMGy9YwHCfq7Olx+Kw3Nh9UeRQezux6QAP7UjCZr5GN5RKzr0DNP+bAi4LA6rdgq9BQwwfciYPp9SMA8FCtHHFu87XlMzixZxku3dego+FSD4sUt2rYio7JeRK8dsUCJNqyWfHfL92DjpCjP4ADlRg8DtFHC5mfZGZRwHnwwG4dn/nRdWds+pUxSZ1abaGPsgXwmaz2b5lv3YcnX0jPy3Np5Z/HoUWwAkjJAYY+Vxa+lI4vya9kDa3G/BTQ0YIrpOvYyyo6atbU1rK6uotvtolQqYXJyEtPT05icq2D2ojXUJgfotgdY/uwyVm9dQZ7naLVaOHDgAPbt24dKZdMsixxYcq3rywJJXh2xHRAdL3Iiu8xia1Q4j3H0BtNZAUgAH+l6CpCVhCV8osC8hT8ewNDCEmpUDm+BglBnTfVSeIjf67DAVpevV0d7dO+QNyrzFJK0KQOCkNdFU2o4jsOeCB2XdyN46ReRrSzLUMpGwYiWcz29IPzwehtW9jylmcKb7ktWm0i6mo+Yd8HSaQxGGOR4gw+rDVO8JRyGd+jE9JBl5ENxrDIOy5GNhvN4ZD0VAyOhM1isdC3eZW3S+vo6VlZWsLa2hn6/j1arhVarhXMfDhx48BLyATbKked4zEWTuPixFXz4L49ibmY/Dhw4gHq9bvKXZdnIyawsP1yulDbRQEeee+1l1YF37eWZQrsakHgVZhl2YGtHY0Mtz/Vox1IcFsrnfPS6FCtuDOFrxTUuaWXFHdziuciq7e3wtUdh8jo3j4xCMlgkvZ0AlxqE8CI/uWbZ47BCeZ6j1juA1fqtfoY5UOq3kOU15Nj0CPKUjZWf7LABNtaPNJvN4SFV4g1MUexbWHKAiEUxgKkptAvF4lW3a2gw5cWTZ5ZHyPMQWWE0H5bx5IGhlJVBnJWHPnfDemfFsXRgyOhyHG4vXVeyUFo8JLKmaWJiAocfWMGBB298kVrWQOF0urPzdTz1hefirutnMT09jXK5PFIuKYs+sI/z5rq36kSH98BZpVIZ8cJZts+jkC4pol92NSABfAMLbP3Sp1Sa59mw0gbCngn2uoS8HZyup5Q0hRRDCu+Wm7SI0IRGDHt071NKx2flY8mv51nhtHR6Fh/aIyJTNGxULKXvASp5Vu8dRqnfxKC0Bj6ZcyMSUF26EBm2ekYYkEhe/X4f3W4X7XZ7uNCwXq9jcnISrVZr6KrmMqcC8JR6DQEPry689/oZD6q0vrPi8zbfFCAUKp8FMlL4tuK60ybYqotC02isuzwg4gEXIQZ1Oj+R93a7vbE4dXUVnU4HWZad9o40ceBBbeT5EIOMpl3OsO/cKvoXT6I6qI54NaU+ZDrFOu/EG2Qzr5beENsiIGhc2dMeJk/eUm3HrgYkISPK7+RgplA8y5ugn/M1YJ8BwaMyLz9W3NqzopGwpcSyLNsyneQhX4+4g4bexxTtHo1HsbqMgWduIwYCWlEwMA2N4i3wzmG1fLJ3hEdiIUBipZ/lJUycfAwWZq5HVukDp70g+WBjlFlduQDllXO2gBCr7wt/Akj0AW3iFdGL+YqQNyK16sq6DpG0AU/LyDtdVm/nXChtLRdaF8V4tXRA7F7ijfPMupffFv2a2XrNAiCWvFj6kNuR+5xM1SwvL6Pdbg/XZExMTGBytoba5OqWMmrKB8DU4T76Rze8c+zZ5qlEzz5ZnskQhcpq9U8GujEdEnru0a4GJDFiwQ15CVhx6zR0GP0fypPTtsJZJIKgR3lFUKfV6ax4VllTiDvkHp058rwIuh09Q+AZfB1Grq22TPWaaI+IBiYMSCz+QjQYDLC2ANz5uQNoN+7AwfuVUGuUUR3MYF/2IJTasxiUNoyRteha8ydGY3V1dWgwsmzzZFYNRHTfsEZ9nL5loCyKeU888GKBSs2njqt3q4TysHSilbeVDvPhGS6PQrpLP081dEMglsmfr+8sz4sVxpNdKTt7jtrtNlZXV7G+vj48AK3ZbGJiYgLlatr3a8uVDHlp83A/XT4G3KG64vZgWfbqknUCl/He0vdnFSAJVZrVmLHw+tqax/WUdshQWGnHhMwDHvyvBZfzFoH0RpL3tuDtURqF5KYImNhu3jp/bexlN438tKx5ICplO+rq6ipuu+023HnbEZw8uYRjX5rBAx5wP1x88cWo1uvIsxyVWmXLKE3nqU+M5Q+cydkj8vPOeOB+4QEEzj8EOCyPR4gkL2tBMIfTC2eFQjKk31s6wAND2juWwn+R51ZesbgMRDy9aAE6fc/ARfPE/MgBaIuLi1hYWMD6+joAoNVqYXZ2FpOTkxh0+uh3M5SrAZBWAvK1jS9VWwtMYzaA680CJsw7l4s9bHmemzMKnt4ZeSavsm8yD4knYCHwwWGsRrMqm8nqxFY+EiYVpOhwrCBDSs6618+80bZVLguw7NG9TzxS8qjIKJXlPsWoCA98CBRv8ZWw3jbxED8M+o8fP46jR49ieXkZADAzM4MDBw4MD5WSuW8Jz99m0WWTqRo9bSuARE50TV3Q7T0P1SH3O8vIe6DBSitkDHTbWgbFi+/xz4somUdPh3KZOI5+xsbe090WWfnrvmINwBh8aLBn8WiVSeRqbW0NS0tL6HQ6yPN8eN7IxMQEKpUK2u0+7v5qhvkHD5AZH9HLcwD9CvKl2RHgybtfRuOM1pG3U5T7uDyzPhNgeb20HdJ2wfKseUCF2yNGuxqQCIUUhgdSQnE9RVGEQh4O3Rms7bUh/ixQoTuY1+lFKFK2IVv5xp7v0XhkAUb9PLW+PaNiKaXU9Lx05F+DEU/erXhCXt8DgLvvvhsLCwtYW1tDq9XC/v37N1zg5c15ds8o6roQwyEfN5Pncporf0QvhSwj6pXRi8+G3jJ4TJ5XwtpNp/PS8eW/aD+29JC1rdyL4+leLS8iU1y/mvcU3jzdyO+sbd4cjvOVKUlZN7K0tDQ8Gr7RaGBychKTk5NoNpvo9/tYXl7Grf+8hMpUGfvPq494DfIcQJ6hf9slQF5Cnm/aAr3Fl+XbO+uGPW9655Iuk3c4KHvgQvbTAtTDZ9gqK6l0VgASTZ5i9hR/KB3rmaUEPdBhNbB+1+/33RGSp9hDeXlb/Ji8DhwygntA5L6j1LoXmfQMFxAHvBZxetbBZ3pkp0dSFliwjMRgMEA37+LO2hEcw3EsPXIJhy+bxnm9KdSzJubyKdQbowegeSNCzVOe50PPiP52jXxTpF6vB0G61Qc5Pw4n5QmlBWDLN4hS2s16HzpiQMf3jGwMJHojdN2WFoVkzdKPOkyoLB6fVhqebvPO2giVRe8mW15exokTJ7C4uIher4dqtYqZmRlMTU2h2WyiVCphaWkJJ0+exKkTi3jfn67g4U8+iAc8YQb1SQCDEvKFfejfcwhoNwGMboGWHV8hW+aV3QIK/FwDcfaayDN9bfHiAXOr7lJpVwMSazQRQ9axygkJQGh7UyiOt/7E4pvT4NEDP9d5acGTONZoQa75vRiVIp1/j84sxepe2tpyrUsb84Jufh/KlwGOeBy63e7wXuSKP0oXUmI8Kj1ZXcBnD3wR/ayHZr+Lg3MA0AByADmwWPo6+qtLuGjh4SijvEVuLdnX3hGZspG6kg/p6a2+qQabPTBeWOve6m+8KNgy9B4gscACv2OdFevfKbqNQUkof/0uFbwwpQ62dFivnHqKjo11CMSJPK2treHUqVM4deoUVldX0Wg0MD09PTwaHthY6LqwsIBTp06h3W6jXm3g1K1NHOnN4+DBA6jVNo6QzwDwziDL+2e1nwU+Uu2TRV47ipxattXrMxvFKj7LsKsBCWALDb+zRiCxQ8dSG5/Dcp6hue0QINGjB6tj6Wey/ZcBjJc21w93SEvg9ujMUEiBx0YdbMA4Pb5nmQvxpOVYK2NgdLGoBkQy6vX6o0VZlmGtvI7PHLgRg2yA+qCHZt5VAU7/AKw0T+Cu7Eu4aPERZvmYvzzP0el00G630W63h98MaTQaW74GbI30Lf5Do0QOo+PHRphWvViAIjRgYr49HWABLC+9VHBi3fNzaxAV4sMLG3vO77h9LV2u/3V4rb/7/T5WVlaGi1jX1tZQKpXQarUwNTWJ+nwJlQvWgNoAnVMdLB/dACPlchmzs7M4dOjQ6Y/r1Uxe9CFo/M4DgF69WrLitY3kaYEZq3+EZMlqh28qQJJKVgeNIXav03gd2EOnGvkCNkjRYcX1Le5c/W0B60Aja7pGlyPluafULLCzR/c9pYx2U+TdalOJZx2Mpc8akfihaZSYwcuyDLc378IgGwDI0ex3kQPmWWjIgFONozhnZR2NvBUEDDKdJCdntttt1Gq14SfgNSCxRt9cVykA34tv1b3UpRWXf94hZlznXNchA89peOeWxAyKju8ZUK98+hlfx4BgaKGkB0b0mgrhOXVNnYDbpaUlLCwsYHV142yRZrOJ2f0zmH4KUD68dvpo+BwTh0t46EPPwV2faODkx3s477zzcODAgeF0juZT9x/re2vyz8BUv7PqTu6ts4F0OGu6Rvd5T764zosCSot2PSAJKVhuKE1sbC1ixMwN56XrCUuoYS1erU43LmK1RgSxUYseae2BkfuGrDbj9mNvRWjEmtqOPMq2jvTWit5SdrFRmoQ72rgbyIDyYIAS4qOppfpxNNsXmf1M8yreEdkBUa/X0Wg00Gg0hjtsrPN+dL16fdXzVHjvPL3A8TktYOt0XOhMJdY3MQOi84gdpuaRZbRYTq1yefqJ30mZrTJYzyzdy8CN41gASNd3p9PB6urqEJD0er3hjprpJ5RQmt/w6m0cDZ8Bp5donPOEWcw2Spi8ew6tVmuLTtfTRxaPVl9OMf66PsX7IWTZMW4rq+/G6kzHj4XzaFcDklDn5jDAaCN5ClyH1fFjrqmQJ4IBBCsYraxYGYbI4tGL441edXx+5qW1B052jlJHZ961AGXPmFiKNmasOLwFSlnZWwDEA0acdj87vT01ys3peCXfszBMs9/H+vr6cO1IlmXD6ZparTb8do2Ow7xxGbxyxHjh9EMgIKTQ5d8bqFj86mde2l7bcJwio2MvTx2niB4tsnWUdVgItHn0/7X35+GWVtWdOP55z3znW3OBVBUqKKKCCoqFGqMSUYkmys82fjVqYkxrY2Ji2jZ2BjN8DSbdPzMSOrFttFsTE2M0jkGjAlFBBEUGFRDQghpv3fGce+Zz9vePW+u966y71t77vVVQ3uqznuc85x32sPbea6/12WsPL6/zdruNWq2GhYUFLC4uplN/ExMTGN8yivyuDqykkiTB6LnA6K0rnyaQoF4uBJc88al73lYhvSHPFeHyYa2FDOmFLMBivbShAQmRhs4l+Tqqj0JIkfNgdUL5PNRJs3Q+HpcTreDPEk8zKFk6wZBOHFkKnq4JiPjkOkax+Ebz8uOSmhzzH3cNWwCerz/p9XpotVooLOXRme6gl+Ts6Zo0MWCkO+49C6HX66UfOmu32wBW+kOpVEoPQtOOief1KusmBFy8LCuggr+zjL4csFB4jbR0Y4j3bVl2jW9NP/j44LxYU2NZ+OVxtUEVv7bCWO9lGWh7b71eHzj8jLb3Tk1NYWxPHkmu62e26ICpDpJ6eSBPvoYpZtqQgwoJMvhzra54OK0upK7nv/XYo/XSKQFIAH9H1VBgFsPPt9VpgsI7laUYLGGQ3pHYudEQ8LLAiBYvNAqMBSWWERpSPFlgIWSoZBgiqXx5WKmM6FpbtMrTkErSkvvQqL3b7aJarQJHe8DFCRyAdpJHyfV0UOKAUn8E4+0tapmJj06nk4IR2lpPUzU0XRNr2NcbRvLkAyU8rHy/3mmUGL4oLykjnE+f98Mqj6YH5HSEdehjFsClgQnKi+tULV257VcrA300r9FooFaroV6vo1gsYmxsDJs3T2NqugAU2+j3nXro2QC/+dV8qfy+A/k0XSvthkZaPfrAKveY+L4qLcGjtAmWrMj8QrShAYklkPKdVKxSWCVpYWMopKisjpulwWLC+oyIHG3FjrAtgCKfDenEkGwDbeFZzAfU+LXWZtYo17fgUjMmmkzxs3a0PLrdLpaWFjHamsNp99VQnACahSLqlRK6hcIgKHEJckhw5tJ5gNNH6jR11el00Ol00jLwU1n5uhHJlwQEGkDICkZiwsT0Qw1AWgOkJNHPQeFxeHh5cJbGu88IhoAn51E7wl0b7Wv3EjhZOjzEJ5df65j4fr+Per2Oubk5zM/PY2lpCUmSYGp6Auc+OcH20x9CobAy6OsczWF+bBwLo6OAxg8AtAsDefOpGqu8mh7XysR1hBzQ+urCahuLLJ58A1ZZzyHa0IAkhrjSlcrXB2YoLldq1sl5FDaGD5mP1YghlEpp+pSmr8PGKBjtPna0N6T1ERkTaYxkPVuu9aykgQ+tbTVjFZJny9D0+300Gg0cOvQjTG+/DY+a6MA5AA0ACZDUgEPjk1gcG0OSAIlLsKl1GrYvn4lKb9wEzLT4kLb5kmezVCqlh6Dxo+IfKcraPr4Ru283nQZA+Xv+jj8L8WbpKeuZ9l6C1lB+Mm9JfGehb/2UNPgcVPP3/DwY8owsLCykYMQ5h6mpCTz94hYmp5sDuKPQ72N7dQmlbhdHpqYGeQDQSfLoVBwqnRWDT/IXK4OyDFYdWUCTh7PiaelavDycOn/DA5IYhZwFPFgjPyLeCbT3Wnq84/uMveRP40UbQVAeIQOi5WHxS52Wx7cQ+pBODGng2QekffKXpT/43mtGjoMYa4cNkfw8QpIkqNfr2L9/Pya33o6RsWO7E4QY7awtYU/jPOTzj0bSzwFuUMYtMNJsNtFqtdIt88ViMT2VlT6iZ4GrGEWrjRIlPzJ8bDhZtxrw1/qi1IF8tEtrdWRdSX6sw/Nk+vydb+HkekkDUpbesYASD6OVh8eXdezc6vbe+fl5VKtV9Pt9jI2N4azHlzA5vbhGVul2ulHH0sgImqXSSnoA+kiwXChhS37td5O0+vLp2CxAjgMU3uZWO2k2RMvbGoRYlFUWNjwgAfxuLnLjcsUeOhSNN44EE9boxWpIi89Q3to95RMalTjn1P3sltLV0LWluC2FGCrXkMLk3Mr6By5n0oAAa88HIbJAgXwv5UkLJ0eXHKDKvHz5Sm/P8vIy9u/fj7n5H+CMs1q+6kC7dztG3GNXDmt1+tdr6VouZOVgZHR0FJVKRf2UQywQofAaWOB1IAFEjAKXu+5ChuqR7GcxepKutec8Da5TfbqFx7MMnlwDovGl1VPoJOpOp5NO1VSrVTi38sG86elp7Dh91qwLYAWATNWX0SyV0AfQyhXRzBeBfg7jrTHk82vPGZH5SzAvydf21mmqdO8b3Gj5nwx9vqEBSUzl0o9/Gp3ehUgaBt64VgNrPMaSHE1qZfGRtctBPtMUglQuGkjhcS1go/E+pDhKkiTdGkhA2nK7SoVvAWANWACD7lyfguQjScsAyfwk+KcReqvVwkMPPYQf/vCHmNpyFP0+4PNaOyyh119CLplI05f5UfpyIWsul0O5XMbo6Gh6EJpvhBhLMbKtGWAfUOHX1g4f3wDA1y5kpOQi9yxl93ljQuEt0nRQFsDoa0Ofx0TTafx9s9kcmKYZGRnB1NQUJicnUSofWOMdGYgPINd3mCuytSQO2Da7CaWkCORtXcv5kwOQULm09xYQ4eWnvGSf0MCxlpdWBl+4GNrQgEQSryC5f9tq5FA6ViOHTje0FDZ/Z4EpX0P70pb/mvKgZ/xQOKqf0JymxgtPU+YxpDBpo2UuV7JdeJv6lIA2gudghNqc3kkefKNzKU/EE4F+Agm87zUaDRw4cAAPPvggFhcXsWlbZP2gt6acdE/TNOQZ4dt8S6VS6hnhJx1znnnZjgekyLog3uQzXxzL40LP+RoHrd/FyISPZJqyfnwDEO1ZSP9JPWZdU9k1Csm/vLe+cUMy2mw2sbS0hKWlJXQ6HZRKJUxOTmJychKFQgHdboK8x2I6AL0ktwJGHIAEmFwax6NmdpjeRe1e8knl1L5XxdOQ8sDj8sPRtHAyjtb+UtZkHhb4yGILNjQg0UYbUnnzLzTSM7nNiShUcXLEKfOWZCkZeU3pyhECD+szEhKEyGdaObjR0r4/IoFcqG5oVGqBlCHZpI2WtdEvEU1P0A4W/u0LOmODEx910XZDC1zKa+1e410aYPrRl1GPHDmCAwcOoFqtrizoS7YglzsaqJkiEoybMkyLD/m3anq9XjpVww8/swYCsi9oZBlr7Znsg9p6DSuslj7nQeoAiydZR7EgKxTOp1M4n753VA7NIGqkrVGK4VWG9XkcnHPpVE2tVkOz2USSJBgfH1/xjJRKK+ue9uVx5lld5IwtvgmAarIFleUyyp0Sti1sxlR9QvWCcDvl29HGy2oBFwlUVd6MgYQGcnzriR5u2vCAJAQQuGEFBjt2zB5/LhQEbCgN2tZoxbF4lv+8DNoJsrHfW5Cggo+utVGPBcjIgFlATwMoQ/CRjbjR5qNbTenL53SgWLvdHpjWoQObtK2V/EeAxNru6RuJWWWhf75mq9/vY3l5GYcPH8bBgwfTT7Vv3rwZ0xO7APcNILHWkSTI42zA6fPiVAfkFaG6yOfz6Y4aWsS6JmU2wouRWw0wWuXnzzTgwd9n3SmlAR/LE5HFYFPaXA41A+bjR4bTBifaqaQxnlmL35j60nijd/S+2+2iVquhWq2iVquh0+lg06ZN2LRpE0qlElqtFmZnZ3HvDxbxqN0VFEtrpxqdS9DvVbDpoXOxrVBWt/Va9kYDmr529elfq500snjSQKwP8MQMQmPtw4YGJES+jgmsGl45b64ZaU4yDF+pTMSFifPhG3Fawkdubh7GEjzLXaa954pGe6fVi5WPRjLOkHSSCp7amzwa2smo0rg4t3J+BzfGnU4HvV4P5fKqIuSHf/ERmVTKVrtK4+QjAuYE2AmUtNvt1DOyuLiITqeD6elp7Nq1C9u370Q5/3w0+18AsPJhvYH8sRl5d97AM94PW60WqtUqGo0Ger1eOlAYHR3F2NjYwFRNaHRsUcggy7DaT0vPB2qs5xbA4e1j5SvT0wZwFq9EUof45EbeSznTDNh6DWoMUJKAXJaz1WoNnMQ6OTmJTZs2oVKpoNVq4ciRIzh8+DDq9Ta+ft0ELv5Jh3KlB+eId4d+dxzt2tNRKJTVA8+ccwODQ1lmDZRo5ffJsiyXLL+0AdKOyVOUY3VFjFzEAuQNDUg0hSk7KlUwbwifktIq1hrBhpQdvyajIHnl/GiggfOigR3OnxRq8t5YC/qsEQ43YFZ+GsUYryGtErV3p9NJF7OSvPri0Bds6WjrVmvFy0CfR+fnHPC25IDHWj8ieYshDkLoYLJ2u42ZmRk88MADWFxcRKFQwNatW3HmmWdi27ZtxwDDJAr9V6DVuwOd/n0AOkgwgTweh5w7G4lQT8RTq9VCvV5Ho9FAo9FIQV25XMbY2BhGRkZQKpXM00B5vwt5SWPrIDa8pq9CAxaNLA8GpcXLpYFaX3rH04813WfpGPnMSk8LH/KQWMBITum3220sLS1hdnYWy8vLKBaL2Lx5M0ZGRtBqtTAzM4OjR4+i1+thx44d2LxpN47u34KJ6WUUS0tIkhx6na1I3Fbk84V0SlQrO+noWH0aIq0Off2Zx5F6XoLdLCcEh9oiC21oQMJJGynI96FRAOBf96HlEXJXaXE4UtZcmzEjs9CIQgq/nOPnHV0DQTJ/CZ60+hmSn2Rd0feGyDBqoFFLo1AooFKpoFarAVhZwJnP5zE6Oorx8XGU6ByEY/H5aalZgAZRaGRECoxGVs1mE3Nzc3jooYcwMzODXC6HHTt2YM+ePdi+fTtGRkbSNPO5KYwkz0LFXTy4PR9uTT69Xg/dbheNRiOdsup2u+lJrLSIVVtHoylka1Qnn4cMasiY8nxl265Hmfv6XMxoVeM7VjZ8MurzZFh5a4AjBNhiyNKjPL1+v59+vbfRaCBJEkxNTaWekfn5eRw5ciT17O3evRtbt25FuVxGrz2GfmdHCj7yhfwAGPF5adZDXG+H2p/nZ7WrNYjlaUiyZNUa3K6HNjwg8RlTHkbOi/FFrjK+1ngAVOTrMx6aYtA6tHUOAbnRuBCGFplyxSfD+fiR9yEgJK+HFCZZpwRMuXvUGmHSL5/PY2xsDIVCAaVSKZUlOhZdfjhOKiQCP3L9guRTKnAJZIHB3Tp03W63MTc3h/379+Pw4cMoFovYs2cP9uzZg23btq0ZofqMJ39Gnheaqmq1WnBu5ayRUqk04BkpFNaqtZidGrG8yLAWQNGMICdLiUsgo7UBveP/MYAiBL6scBZZAMUCGiH9kcUTEqMDtfYFVvpcvV7H4uJiusV3bGwMk5OT6PV6qFarmJmZQbPZxPT0NHbu3IktW7agVCoNeFuSZGVNFv2so9tjPULyvcW/Vi8yD+u8Io0sACNB0In0hmi04QEJUagzysPRZPiYkQH9a0dt84biCptIE1TfR424sHMwwsNz4MKFJqTkKK3QUfgSbfuUy5DWR9TGEpRYQJnC04fiKIw1CtXkXJNXGcY6D0NLk8BCo9HAwYMHcf/996dTRzt37sTu3buxZcuWFCjItCk9zU1Mz+mcEZqqoV1dBEj4sfBUH75pqRgDnCVMSP/EGn6r78o68xlurY00HqwBSixfvjgaGOOy5zPQJ0KvaEApSZJUbjqdDubn5zEzM4NqtZqC2eXlZTSbTRw9ehT1eh1btmzBrl27sHXr1jU7tvgH8iy+11MWrZ5iwC2PL6+1eJae0fiR4R8uOiUACQcAsXvWs+6w4fF8Ash50YADT5uEhC+61QQodFx9jJDIMtCvWCyuKS8nyttXZp6+1TGG4EUn3sYaKKF7DjoJzFI4WecaAOF50XONNMDuA6sA0FuYQ+GGa1E6dAD5Th9LY1sxvnUbzjjjDJx22mmpMnfODXgmNWMun/FDz2jNSKvVQj6fT6euSqVSOm0VkjMfcOB9VxtdW/UhyfI6xvJBcay8pHGx0tIMiVUOmWaMZyLWOPH8tDayjKJPf/jeaaN53p/4mSPNZhPj4+OoVCrI5/Oo1WpYWlpCr9fD1q1b8ahHPSoF0wRCyBvJvSRam/N8s3p+QnIcAhdyDRH9W2BGKwOP+0jRhgckoY7vc2dm3XJG8fmZG3y6hSs03yhEXsudOpJ3yT/lz+NyYYtxr9GiR77PXx49LH+SD9/ISON/CEp0orrXzhPQ2lQb+fsMEr+X6WkUWmFPfOZzOYz84wew9R/ej6TbgUtyeKzrY2+hhHte8v8AF1+MiYmJgbJpYMd6Jqdp6vU6ut0ucrkcKpVKOqqln6XILWWrUSiMVO6WIdfaRpbTlx9PQ6sj6X3lOoA8ARSX9JwF/LQ8eVnktSRNT8X0dak7s+gHix8LnPB6o2/V1Go15HI5jI2NpYf31Wo1tFotbNmyBdu3b0+3/pI3hIAwASvfmSq+ey4/Vnnkey5bUqbpnvqZPPXbql9uRzR9/kh4RThtaEBCq/uBVUWtAQxNEWjk63DUsX0KhIeX8YHVkZf1zQGpHEiI+Bw9CY+Wj6YIpZBJAZbxpcBrZZPpykPRZJ1Y6QxplaSxsOqSK1bZTtqoiJNlOH2jIs3YkCyO//OHMP6Rv14Ne+x7M8VuG0/61Acxf9ZZqF98SRqH86gZR/682+2mO4harVY6TZMkCUZGRtKDz2jdDPUrIunl8MmiVU88rJaWlbbmkeB1F0u+/Hg/5TqQeODrk3zlDdWHVQf8nca3Zfw0PaEdfuYbXIby4bLNQQ9522q1Go4ePYparYbx8XEUi0V0u10sLCyg1Wphenoap59+errAlYMRvoNNTpXz/DX9SXxJMOsbQPoGdRrQ5ssAZNvJeBrPlq5/pPT1hgYkEiD4KpnOKgiNSKRysUiCHK6ouQAQUiWwZJ1bEsor9E4DNpr3RAMTMr7Gm6wfS+HztDTeh0BkhWJHHRoAlMrOJ0fS2GtKXRpJPrrieQwY1toSRv/h/TrPAByAiQ9fheWLngfHAD3nSSpnekYn0dKv0Wig3W4jSRKUy+XUM1IsFgfOGtGAFVfOMX1aLU+yOk1GaWm6R+szsqw8vI8HGc8aDPE8LeCfdeRrgWFfHpysD8hx0GgZYTLyslyaQbW8E9aRBUmSoNFooFqtotlsolgsolwur+TVWMZ5992OXUtHMTY5iXqxg4XtFyF3DOySZ4Ty5WuVrDrk8kL1ok27W/LLSeoAS54k+NHqWKsXmdfJog0NSAAduWsjLgIkNMqy0gqRtqBV5sWfyU9/Ey8xQijz8YElTdnwvLWOawksL4dPWKXRshB5LMj7v4FCyl0zMNa1NFqaQZbp+/jQwlhGNf+NG5Br21/rTQAUjhxE4d670DrrXBWQyHz4OSZ0lkmr1RrY2kvbemk3keVqlnxbZZdhQ6SBtVA/thbrxg56tClgX3+25EHKUGinlYzrkxmtDUJh5TMOWDjw1rzesQtJ+TX3uAHA2NgY8vk8Nv/gLrzk3z+JUm9l2hEJkLvrZjSv/Ud8/82/jc5pu1PviAWsiOSAQYISuZGBh6U6tmwZz0+Wka8zlHXEQbnMM9Q3ODi0ZFwb+EqK1f2Zzuy9+uqrcd5556UfHNq7dy8+//nPp++bzSauuOIKbNmyBePj47j88stx+PDhgTT27duHyy67DKOjo9i+fTve8Y53oNvtZmHDJN8oQhtlaDtftM5v5aX9tEV7XEi0rZI+g6IhafnTyhvzTL7jyk6OPjTQIuuKQBC9kwdmxZR5I1KoLNLgagrHWnckO7mlVGQc62AsWfcyLPEi49IZIHT42uKDP0JUyy0tpAMB2f5a3yEwQr9+v49CoYCRkRGMjo4OeEbkoU5af9DAXiwQ0NpVyq9Wvzwv4otoPaDc6n/AoEchBMIkmJEH58m0LT58AEPKpDZS9/UV37S4xo+P5Do5WofU6XRWD9I7eggvveHjKPY6K1/rdX3kjsl/eXEO5/71H6DY7ajTND59zMvMgbP07tC1lb68J4rRnTwNeVii1COcb7mWTZPj2PbIKu+ZAMkZZ5yB9773vbj11ltxyy234PnPfz5+5md+BnfddRcA4Nd//dfx6U9/Gh/72Mdw/fXX48CBA3jFK16Rxu/1erjsssvQbrfx9a9/HR/60IfwwQ9+EL/7u7+biWmNeKeVDeUDGVm8JZZS53OMvNF5p6TnPL6WlyV4Upnwa02Bxhh8LZymZK243JDwqR/+jBuh0LTZqUBW/fM66Ha7A3WntZUEMJYR5KNcCXZ5vvx9jIxo/He7XSwtLWFmZgYHkwJiVE17644BWbAAEQc+HIyQ4aDdNOQd0U5htcpkgQdfvFiFL+tMqzvNgIdGkhrwtAYfvuk1H19aflp5LD5D5dAAs8xX5mf1Ay19q424Iad/AtLLy8tIkgSVSgVJkuCJd96ExDnVECb9PoqLc9h8yw3moEDjPwTaKGwIIPAyWNNTvH9q04g+YMPTsTyNGu8PJ2WasnnpS186cP+e97wHV199NW666SacccYZ+MAHPoC/+7u/w/Of/3wAwDXXXIMnPOEJuOmmm/DMZz4TX/jCF/Dd734X//Zv/4YdO3bgKU95Cv7wD/8Q73znO/F7v/d76QmTWUgKiuZassBIDMXE5Y2sfWnV4i92C3FsZ7Z442GoI/DOzAEU8UVxQ2eVEBCRrlZNscSMTk814m3IF2FTncvFnkQacAsZUv5eG8kDumLU8pZh6FetVlGtVtF54gXoXP8JFJYWkGiAJsmh+Zhz0Nq5Ky23HI1rYI0ACS2UpkWrtLNBjjZ9xA2E9o6Hsd5bpBmFmHihPKS+IP5p/YEcXGnrcmQeFjDS8o7Vd74wmqdAPufpWOlJeeakbQzg73h5nHNoNBqYn59Hq9VKwUin08E5B+9D3tdmSYLp79yEhee+ZIAvzrvVjlZ5JY/WO1kGGd7q19ZzC0BTnjJNnieXRfmO63yNsuj97J9ZPEa9Xg8f/ehHsby8jL179+LWW29Fp9PBJZdckoY555xzsHv3btx4440AgBtvvBFPfvKTsWPHjjTMpZdeiqWlpdTLohF9/Ij/iGIUgHMu/TR5zMhHU+TA2ikTIq4krDCkcENgSTM6kifLEFnGST7TFlbxMKGROTcgMqxE45JONUAi66XT6Qx4QHgYS2lo88m8rWjHCU2ZaO3D06V2kB4Y/t6SFZmOfFepVLBp0yacsXs3jv7SfwGQwElFl8vBFQqYed2vqrKjeW2onFQ+WkTIf3weX9Z9qD9wgxYyqDHp8vdaWrLuYpWyry1i4/p0CPFCJL0rMfyGwIpmiOXgxopr8RmTj+SRZKvT6aQHnlGcbre78g2pnn+5QOIccu2W6VmQINtXfi2MNYgOlZd7TjR9y+vctyOI1xfnRbN9FC5GFtdLmRe13nHHHdi7d296oMwnPvEJnHvuubjttttQKpUwPT09EH7Hjh04dOgQAODQoUMDYITe0zuLrrzySvz+7/9+kDcLqTnnUmUX6ky880pUSGHovaWMNL54XJmn5F/Gk/mFRha+fC2Fx8naveScG0DScoEWMDgvzeNpHfBUAydUbjK65C2zFIFcYE11Tmnk8/lUdrvdLpxza45FlwqNX1P+Wl37ZJc8dxIo5XI5TE5Opgq4e9FzMfPbf4apj1yF8v13p/Eb55yPmZ/7j2jseiwcm6rReKAfn9Ypsp0NcjpUpqG582W5OLjzvdfqJdS3YsLysmrxjye89lzqMfrXBkmaXFp6lNKO4V/GsfKx8g7FDQE84r/X66HRaKBeryNJEoyNjaWyNjo6itrOXZje/4Dq5QNWwHVz92MHPDIa8R00HHiFwJfk10cUh/MiPY78zB+ZjwSiNGiR7W2BEX5t8Xq8Oj0zIHn84x+P2267DYuLi/inf/onvP71r8f1119/XEyE6F3vehfe/va3p/dLS0vYtWvXQBhtVEP3clTJ42iCof3La56XNvVCCly6v7W8OC+aMtCUU1bFoMXR5g1lPlQ2bfTCy0q/Xq83YIhlWmRoTkUwQgqBKwVZ19oqe5lOkiQp8KBrnj5fMS/zBwbbkkCNz7gNtBPW9iNSXLwcFKb5pAtQf8//RO7APuQX59Ce3or25m0r5WfeD2kQNWDPpzs1YCv5zTpSy9q3ZFztOoaXWDBhDXa0XyxxneIb5WYFYjH9V2vvUJqhQZvvGX9HZex2u6jX6+j1eumW8Xa7jVKphC1btmDueS/Dpv/zZ3Za/X46XWOBeg4UYvjTeOXXlq616kHqEnmuC//XgAx/Zw1GQ3zzfI+HMgOSUqmEs846CwBwwQUX4Jvf/Cb+/M//HK961avQbrexsLAw4CU5fPgwdu7cCQDYuXMnbr755oH0aBcOhdGoXC6jXC6veS7Rv0UaSOHPOYUEXWs4bmytNLhS8AmahpS5YdB4kmHlO/6M3Pj8pEFZR9pIipdVE2ZKW6tnmcapRFp7Uh0lyYprOEYpa+kSyfldzThqBkcqDO3Zmnyx2pYyD+seALo7z0B7++krwJ+tlZGARAMfVEYuWxyMWLtAtPoIjTRlHM6njCu3+IbqgD/3Gf8QbxqvMW3A8+X3Wjpa/NAzqRMskvospBd9aViGWBpceU1yR97FJFnxjtDi6LGxMUxNTWH+wudg7q5bsOlbX12JT+knOSSuj0OX/yJap+9R+bJ45no0i33R0vOBVg1o8HBW3fE40sOi7XLSbEysjg8BXEnrXkNC1O/30Wq1cMEFF6BYLOJLX/pS+u7uu+/Gvn37sHfvXgDA3r17cccdd+DIkSNpmC9+8YuYnJzEueeeu678ZWezVvJTmCw7DbSOxRWlZrT5T1urwdOyFI6Pfx5fi2sJolVmzS1vxePP+E4afq8tvON8y2mMLB30x5V87aF9AZSTz9BYYJM8JjL/9dSnFkd6QbisUJvzdS00nSQVmrZehGSEplDlNGpPgBnZx6y1TTFkxZGyyu99gN/KQ+a3HsqqyLX8pcHy8WOBEVkfseE4xXhKpH4FBr/ftF4wT1OfmzdvxqZNmzA+Po5t27ZhampqZZCby+GHv/B2PPiqN6O17fQ0Xv0xj8cP/9Pv4OiLXmnWmea9I7KMe4hv7SfLZN376ssCUJod4/8Wn1qY45F3okwekne961148YtfjN27d6NareLv/u7vcN111+Haa6/F1NQU3vjGN+Ltb387Nm/ejMnJSfzKr/wK9u7di2c+85kAgBe+8IU499xz8fM///P4kz/5Exw6dAi//du/jSuuuEL1gJxIshSFzwCE0rJGrTEKgOLw0aO2AyGWNOXJ33FeyDuiARlKRxrZmPyJrHJQeeXo+FQmrbPLuqZn0hBaHd6ncI5XIWhpUbtJgMHf0dSQDCfD86kfuqf8CLAQD9xr4lOaPq+Ajyzlq/Ulqz20fkTvshojyZdMR9MxvnKFwvn4iKkTLS9JIVDB5V+2cyyQ0Yh0HB2ox8OPjY0N1EuSy2P2uS/B7E+8GPl2c2VRdmnlyPjEDU6la7zKf9mPQ3bFVy/ymcxD1heRT4Z9ssa358t8eT+NpazylwmQHDlyBK973etw8OBBTE1N4bzzzsO1116Ln/qpnwIA/Omf/ilyuRwuv/xytFotXHrppfjrv1791kU+n8dnPvMZvOUtb8HevXsxNjaG17/+9fiDP/iDLGxkJtmhpbKwlKWl8OgZNa62gn89PMWSD3j4RnS8g1gKhncuKpsETVrH43Fp3YllUHn9Zx3R++jCv70Qh2r24ugfd9JGlta79aZp0eH64fT/qf/nqWrcNffsaLQEycp9ILv1ynkqc0YGWytb8bFLPrauPKypGas9NN0Rins8FOPZiAWkGgCOCS/zzDIC93kIfeF8QIZPY1q88I/hOedSb6UEF1QmJAncyNhK+hHGXQNPcmDm07VW2j6SOpyXgedrta0cLEoAzT2UsfxwHkL1FKJMgOQDH/iA932lUsFVV12Fq666ygyzZ88efO5zn8uSrUkSifrC8JGbFl/7B9Yu/uHv+QJFOjdBnvFhpctJNho32lzwrOkUuahJPqM0fApIK7McBWvCJcFFzAjxRCprTodqh7C/uv9hSfv/Fuq7Pg4uHzzZbKyLLM+CJLlQG8g+kuP5+RTuetOka55Hlv7E9Z4GrGLArhYmBET4vVbHlFbISGmbBLS0NJ2kvZM6jd5b3lyfRybWc5N1cErXsq25/pbvLECgyYsVloMcbictmeODcMmbRbF1saG/ZWM1knxPyI9/y0aO1Hkc3mCyYeSBPNzgU9r8VFZO2nkTvFNID4KvXPQv12topLngaDul5u1wbu3haVr6EhBxgMaFVnb6GAE+HsolOZw2ftrDknaITkS5Hg7AFkrzcP0w+se+1ptLhOw+PPjxhFAfKzwfbRzFCz77gvR5jLfm4aKT0X4/znSi+vlsazZNL5Sm5r2g6xjgwd9Z/8Cgkee6UzPU2mBOo5AHSgOYFu9WmtLDIm2Kjz8rvs+jFUsbHpBYz/k7Qnx0YJUWTo7urXx80zPkKdEaRKahLYyV+8ctj4Tk34eStfLwuJYHKGZkRqCDgycNxFk8h5TsegX7tPHT8OCvP3jcacp6jRmJdrvd9IRRLT8pB9KjZMmeVBgcMCZJ4j30jwNHq87P/9D5qWeEgMlGoj76ONw4HA44pFOOfB4SIh8w4LrLSjukU3l8yxtjpR0DNkMePM2DJXWEfK8NRuUsgs8DJPO1ypgFTJ+SgISIo1QCJPyeH2lN84whg0DeBe4B4PFCoEQz1vyaFvXRIkHLoGkGxvKqyDwofasufUKkCbh0/QEYOIOFT/dQGZ1z5mFXIdAQI+BaB10vacCK7ulHW6mLxWLaqfmZIbxO5RytlDsCir785GJQjT+Ld+16++j2wXjHPAxW2a1ngH+Nk8VTSPFZ7482j6KPPnLIYWtlqxrmkaIsivdkpG0Z3UeKjjdvir+5tHldZbHiZBkYaYMUea3Jtcwjq6HmcXzllvxY5ZD5a/ZEAiwtHZmn9i5m8Em04QGJ1gCagudbFelUPf5lXmkktAbj/xwFy50EfKcANxoynOVh4TzI8lK4LA0tw3IDSSCNwvmMiYa+uYeEe34046N1bOfWnj4aKgv/J1qPcsoSTysPr1N+IBrfQULl5gAjpFT4HC7Py1r9LrdaayTrXsr2v77iXwfyo0+1N5vNAfDOy00eRw6iqR74Ti7qf/SdmlarNbBdWH7aPUmSgV0SuVwOpVIp/cIvV5CXXnspjjSPYGtlK6699Fqz7XyGKAvY0sJZQFO+twYLkmRbWscHcLkg4nVu8SC9r3IgoekWHl8ubJdr2Og5P7Kc0vaVWw5Q5Kib34eAKi+Xr69beik0SNLKTPF9tiSLV0QjjVdJ2nPLg8HjUD/luiekH2PSjUmHaEMDEiIJSmSHsjoyryxtEagvP6vD032v10sNrWZMeGfhAsA7rVxI6isP8R4LUDgY4+XWwmpl17Z/AvACKpkO9xZRG4QE3Jee7574CYWL6VyawpZrbmRbd7ur38ywRjoa6NG8H5pnJAaMcDm3ykTpyvNE+HkhVB7+7RmSKQIXvHwUv9vtotlsDnxXivPBgQgZNAIsnU5HNaCceD+SZLW7rFfLCFtp8ToK9T35Xi4M9KWv8WrlYcl+6Jo/00AMl/tYT4DkzecJ8/U9i2eSE6KjzaN40RdeZKajJ86ZXH2WRH3P+jiJsvCJTkJ/x+o4dn1Uwi8NeWFp9fsr3+LqTHbQH1//tG2j0FjNVwHIPjolAElW8oEV7ZnWWbgxtZQRX9xJCls7dEqOCPh7aeCl94H/tO3KMi+6lgYydMgP59ECKfKUTe4h4ILJ8+FTXPw8C8to8/LRtTUy1QyJpTizGDKtg3FwIL0bMh+p0CkOxZPeJzlikYZS8iSVt8WDxjvdW7vROK/8kDRqOwADHi/6sFm73U4VHp/S5H2Itz33NtGzVquVekmAta5k7p0KjSRlf/CFt5SpJgexbaCBTu0dJykz8l7my+Npz6S+kflb5NupqJGvbvlATPOA8OuYPtpHH0eaR9RwQ4qk/LHfCaAsYATY4ICEOnPoGwKyUjRFbu2M0dLi/1x5y3wkSNA6vuSLh5HrCCz3IDcUXIHL0YOWJ5FvIZM2StNAEQcdmptZ1je9I6BGfPA42iFulFYWT4rGNy+rpSi1XUyW8dfSl88pjmXkJWgKGU0e3yq3ZRB4vlKGNV54WL4+iEAJhS8UCumarcXFRczPz2NhYQH9fh+jo6MYGRkZcOfTj3/OgF8TL4VCYWCXHK374nzTomLNsyjrhZfZtysuRD5gooWV9ayFt/oNPY89O0VSbJ/R0uFyG7Pw0UrLBzrke5m2db+lvMXLQ2Z6JDwkkclzPhy5M2JtfCJvhb5iHhKaUpWbPzT9axEPV+6U0/ixtKEBCSfLQPEORGH4T06XxCghH8DhvHDjqxkFa7TL32mjIK1clA9XcLJ8Vl3xtEKuV2kk+TPt/BItHL/m+UkvSqy7T2s3rRzcAGgeL84HX7isGQtNyVpgRJJWL3wKwzf1IONZoIXXHedHAg+qE75Lhz/jP1JUFIZP1/T7fTSbTdTrdSwtLaHb7aJarWJpaQmLi4uo1WopINm0aRMmJiZQKpVSzwgBFKp3vuiaZKtUKiGXy6HdbgMARkZGBspGXj+tz1GdyPazAGoMZQUh/F4DHKHwlixr6VheEV43vrJaRsjqVzEDQcmXz/OheW984f/3s//3mndyoCrLpOlciqedxKrxp91rfdAqu5U+JwtQazxp5ZM8ynD0ReQjR47g1ltvxX333YdarYZOpzMwBcv7OudBDkQHdAzidCLRKQFIfJ2QKxwNbPCRMH8vF3fFdnQrH86PFAi+dsLyokgB1hSYBjSkAtLehzw3lGeIB152/pVZzVhTWMsLpJXJt1hM8hvyGMSAJm37ng90aO989c3rTDM6IXm2+ODlt/jkyoNPKXJ++EJwGjlR2vSej6parRaWl5dRr9cxMzOD2dlZJEmSxqf0Z2ZmsHPnTmzevBmFQiEFGiSn3FvGv3dTKBTSBem5XG7NguxOpzPQbzVPSWjErclBaISo9Rdf2/A2suLKtEPtrcWzDF0MGAHsEz+tZ1ZdynQ5D7IvWiDB0rs8ngbk+BSeZoxjgZWPtGlCX91b5Ypt25j0+T2vS23JAA8nZU37WaTZPj6wjqEND0gs4ZKKyKpQGgWTK5grRKvytec+pUUjPgl6eFqhTk7XPmWiCbfmdZDEARHPU6YXUubaWRihTs6n3DRjzEGLnLv2Cbn0ePB4PuUpAZNWHp6O5F0DtVZeEohYsizJiiMVos+7oz0nnuQ0IL+Wu206nQ7q9Tr6/T6Wl5fx0EMPYX5+HrOzs2i32wOjVAIX9XodjUYDjVBp6wAAk1ZJREFUtVoN27dvx+joaPrBQF6Gdrs9sAC2UCika0ioP/H2oK+68sXk0lMSM70bqn/5XosjDasvfuz7UJ48X0k+w25RCIjFxCMPmKZ7pH7m+sfnTbAGRxqY4WGtsnOvaQis8fQBDJTNd2S75MMCZDJ9bRCppc2JA3r+3moD/gFQrh+kDeVpSVnU3nPbE0sbHpAAfmPJK8NS0PTf7XYHFuRpFRpTwdYZElqakj+ehuZypHKRQpbv5FoMy2AR5fP5Ne56yt+qQ42nGAPp44PXk1SEHCxyfqy8JC8h5W6NDuRWXV4HdC/Xv8g8tI6u8cDLIOWZwIWvDDx+zChPghrn3JppGQIfBCTkGhICJI1GA/1+H4uLizh8+DDm5+fRbDbX7KRJkiRde0K7bXK5HDZt2oTR0dEBmSaPS7vdTuujUCigWCyiWCyu2SVGvPDpH9/obD0jYR/54krAv558YoGSRllACDdEmqyGDLZ8L6c+NMPF28inJ3w6Xqat8euTB15uqWu19DWAIfPW6karI86XD3BofBBpa6A0z41MV7YxtxuSX5/sUVz5LKvcbmhAQpVgoTi6poqRoz9LKfOFdDxdnrbWWfk/v+Y7Zfh7XwNSXpqhlflqCFqGszqAJaw+YdI6rYznAyacHy28PMvEZzxkOvJ9bDx6zkEeyZZmzPm1BExSJi3AxnkpFAoDO5+s/LSyAlgDfHjYkEGS/YOAhAZEuJdEhqUzSzqdzgqQaOex/cCl2HngMpSb29EuLuDQzs/jwM5Po12uI0kSVKtVjI+Pp/XX6/XS808IHBHQpn8qq9yK3G63US6XvUY/pj6s+g/JQih+1jDcQBDJ6d0sACVm0BaKI99p/VwD1j6wrIXRjL6VjqZTtLi+ciRJMnAOiqbbtHtN91phrHR52pauDRn2kD7XgJ5mt7RBO49nyT/vc5pNiKUND0ikwgd0YaW57k6ng0qlksaXc+1ya66GIi3lwd9z3iyApKXFDSONGKVAWgBM44G/10bZvvUu1pobjQeZv/VOllUCH+li18pq5S/T5tcS4FjgUT6TZad/AizUPnw3kDWi0WSDG1e5KNlXrizAWPKkgXLpFZHgXS5g5fd0Ki8HJ/1GCU/+1n/HxPLZK/wih0J3Ao954Jdx+oGfwbfP/xW4kfrAqb0EyrrdLtrtdgrQaIErnXNC03hSPjudzsCn5mPqjz+XxlUzDlY9W/n4+oKmA3j/59cngrg8an2MrqXcUBtr5ZODG2uw4qtvC1j5gAp/7gMCMeDTAjnaIn3Os3VUghyYSP4lWbZA5k1pWKDFR7LNZXzqt5pXM4bWA5A1OiUAiRyRygpNkrUL6ygs/yfibnhKQ1McdG8JpnOr89o0stM6Ow/PSRuhSwPDy8jTIIPCpx2kUaZ/7irnactyyy3FMk3OH3/P64DnzU+0pX/iOQQ4ZD1bzy2QIUc2kmctnKZYtNMlpTxI4y55pDbQPCSWfIQMq2XsJBjhu2iof8gy8rAcnNA9LTCt1WpYWlpCs9nEmd//NYwvn4UErB8duy63tuEJ3/ttfO+Z/2WgD7fbbSwvL6PRaKDVaq1pLw0oceJ9WzMyvFyUpwzrAxm8LnxtY5Gmd6y0LD54/wwBphBpesMydjHGxgIiVhuEwsk4RBwA+fiwjnHQ+NGeU15aPEsv+PLRyAIjXM9KOZV5WHXsy1N7RgN2CxxpdCJACKdTApAAg0jdUsTc6HLFRs8oHS0fuaBSggpppPloUkP4liKShkwu6NQUrgQdFFcDa1oZKawlxBzkEO++Y6ApXxo58/rho11eZuvbOpbAS2CpGWYql1wAGSKu8KRi43XJwQevI3ov203yy2XVMjRa2/mUmMxbA2VyZw15JXgf0RQcgSbNk0PbfZeXl9FfruC0oy9EzjhdKYcCNlWfguLco4Bdq/2Fduk0Go0Bz4kFsEIK3zKEWp1pcibvpQ6wwvHnlnL3taHW1336ySKfkZIy5zMsst44aLXAAZdTX537eJZ9gufH0+HxeNq+9LO2tySt/qxyynsJ9KUtkP01BMC0MoZ0OScqJw00shLlxZc6ZAE1nDY0ILFIAhV6xt9bX0fVDIQvD4liNSBEfMjvOkhQJDscEHc8uLXg0RJm+YzH53P2Mh3imQyINKpypOADZby8FFZbiCjBEBkobTQsy8/LJOsji2HzyQIHaby9+XtA/76Rxa9UTFY4/kzKiabsOBjhgITXsQRWEsjxNOgo+Hq9jrm5OczPz6NWq2F89jzkXGDqBA4TC+cil7sHzjnU63V0Oh0ASIEP1ZlcJO7rB5rXI6TMffVsGZEQWcZGvpdufSmXFmCSfSQEWGIBgwQAsj5kXB+FAIFGMh8ZXvLEBwvamryYPLW8pc6SZdDK7tOHsr6surPACV1nAaQa7zIv/uPTsLFpS/4s+Y2lDQ9INEOohZH3PoGwlJoP+fkqXsYjg64pe1/5tHDUKX0AzCozAQ8tPc3AyY5lTUNpZZaGUQorVyYEOvhzXk4rv1B5tXiSOG+8fqxyyGvOj/W9Fi5P8p3Mg6fJ60Pz2GnP5HttHYh26JmmpGQ6vV4Py8vLOHz4MPbv34+5uTk0Gg2Uui21bjklSNDtd7C0tITx8fHUS8OnVvlH+ySQ0jyclkKVba1N+fj6tDWo0WRRMyRau2p6RuatlYN7M31yIuNZgxJ+zd9bOwW57GYF9jxtjUcfvzwMH9TJe23qWeOVl0krnxZPA0g+HSTLKwetWnirbn3hJR+S9xi54DLps2U+0vpT1rQ2PCABbEPDR+r8F5MeX9waO7qyhJO7ssgTINdtyDStRWSW8dP4CfELDH4Mj6evjY7kqIELMOeJK1vrudbxqc5l+UMAIkbRWMrVSlOWn8uNBhCINIAX0ybaeiFOWntIkKCBJp6+ZtDJ08GnbHgYHpYDBdqWOzs7i/vvvx+HDh1CrVZDt9vFbPl2dJMGCm7ELjP6eKj07ygeqAEAtm3bNgAqqO9RfvwL3fQvp/n49BOda6LJs9YuPqNiGQZe/1ZcKx7PkxOBDo0nn8KXfcbybsi8fP3Ax0MIVFtpxeQF6KBF5sPz8w3aNN5kn+ZxfGUKyQzXkRQmC3DkZQvVrcw/BhBqIDJ07laIQsAqC50SgITI1zCkeOU8ma8RpCDJNSh0bRkLnjeAAUVp5cOVsYzvI58x08CMfEfxCRTItRGcv9DIQ8uPj17kCEfGs0a5crTC0w95m0KGnqfDvSJk/DRFJA2eVD4+Y8Xf819Iwcrw/FlMeGpj/tOACMWVQIfu2+02FhYWMD8/j7m5OdRqNbRarRWAm+vgB5v+Ho+fez0SZR1JH10cGPsKqvl9KNVKWFhYQKlUSgGGc6unskrQRe1B65M4EaiSXh2t3iXFggqtbjXyGUjLmGY1CJpMSoPI8wNsQx/qyz7+QkZbps37i69ssQNBng8nbQAVayC1sNK+9Pv9AZnlfdg6f4jzEQJR0jsr+eL58Hrw/fP4vF2o3/A1fzyeVUe+dpDXMbShAUnWDsw9JD5jwZ9Z0xdSyDTPjGbMY4wH5ceFnacd6swyXZkHpSPfc4Ml8+B8UXy560Yz7jJf7nXicWXnsHY5SUWhgRfOqwXUtA7Kw3OAFup4Mh1ZZksOZL1TeXgb83x8UzIyH0ncoHMFJGXS6gs87263i6WlJTz00EM4ePAglpaW0uPjKa1vb/rvGG+eiQs62/CU0f+D6cJ+1Hpb8PXyq/CPzzsD9z26CbTfhbHvfxmjC/eiOD+PSqWCcrmc9jkJDMl7oHk7qa34x8G4weDhtDbR2tGKlwW8EPkGDPReCxsbR/Im09L0E48nr33gw8eDFk72qZCXxPJMyzR9fGnvrHq10o0FMBrg0oDhekkrq9Sb2jsLJPJ06TnfZZfVrobCZ6mHDQ1IAF1RWB4FzXBJRMhJm+PVAIlza7+boSmgEGqma24MeZq+DuJTsNq/RaTArdE/N5haR7S8BBSHRrca2NL45Pn4Rn8yHW1BZAzqD42KLD6155IvOaLxyakGbqTMWWXX7rX1IRygWABHKwd5R2ZnZ7G8vDww/QjQav0ufnLzq/BatNFzOSRw6Cc5PB0fwuNmnodfuOSv0cyX0Dz/p7F86Ht43JeuRAX2egf651M2cnqs0+mg1WqhVCqhWCwOrEWy2kSrNx8o8Rn9EFkjRm00a+mGULohkmlZshZblhAwiAEj1gDLp+80nazFj2lrjY+sYMT3LtR+GvnARMyANLbeuC6XX/kNka9+10v2RN0GIAscaBUlXVKactYUt29EJv9DDaQtIJQKWCsLD7ueeuA88YWo0nXPw/OpLc4DkRzFc154XWuLELW61sqrtQMfhRMfllG20rKMCgEGfhqoVsZQ/WtrlXi98vrNsohMK5Pl4ZC8yDzlVA2wKp+SX173rVYLc3NzmJmZQbVaTb81I+XnLf0GXoOVr/Lmkz5yiUMBK2m/8J7r8d8+/W4gvzIeam5/HH7w3Lej1WqtWctCpJVZ1jEtiCWepJxZpIHch4tijAmfMpWeMi73SbJ6nD5NcclzcSis7wRSX7+wSMpZKJxFobqw9JmWbpY+qsXN0ud9+WkDn+ORKU3Xam2qhfGR7Pu8/2t5W3ydSDqlAIk0fjycVMSAve6A/3PjKk+y0zqLNDJc0Pl6hBhXuVbGmPe+NC3jzetDGznLskoQRWWldKQRlGlJgyzrUL6XwJC/95HGu0a8g8s6lHzzLalStnxAwwIU/N7XyaWi8XlYZD1qYMQCapr8dzodNJtNVKtVHD16FHNzc+mXfelsEuKrDOBNvQYsVZV3fbz6W/+EHUuHVx7k8qif8RTMjZ0+8DE9XlbJl3ZmglY2DqpD5Bupa9exQNWXnvacZJGDCw2McINE4QmYSN6kfFN+Uv58ZBmnWE9CDMWAAMs4rpc0MCLT1fSrFtYHRkIDSu25772WdwwY0XQi1w2xbfVw0IYHJPSvKXtS8FI5ywbWFLJlLDXjaOWt8SuNNLD2nAwiSzmG8tDCWzxr4TjxDslBhtyiGeJFAxyWF0Uadjn6prxlfK3cknernahcBDRkvhyIaOBW1rGsO8sFy2WASDv5VxohGV+TFQ28SWOvpSXltN9f2WGztLSULmKtVqvpB/T41KKbOB3nPeanMAm/Uiu4Pl5w7w2M2S6O7jgP7XYb/b59BgTnTwMkEghr8TS9kYXWa5QpT+2Z1vdjR6CaDPjiaSPqWL0TKosVNoZi61CrG8sLFgOyZLoyvq/NLN6sfCSw1EgDPzFlyUqy78slAllk8ETRhl5DwkEH3Wv/wOopdNzohIy7phS1PDgP1joR/i+/wyHT6vf7A+nIMlsoXobjxtIn/DKMvOblIGNB/1yZ+TquNerQ8iDDy8uquSQ5uAAAh8H21r5FIb8dkyRJ2hF5XEqbyirrSTNsmjKj8mj1DKyucYnZYs7rLuTJ0WSbt5dmUPnJutITSF/1nZ+fR7VaRaPRGABoLsmh9YxfRe8J/wGFH10HfPcj3nI4AGV+Xolz6CX5NVNyvC45UErrLr/WmyMBKx8Bx5CsW95+oX4kryXvVhwejvPB9YSmh7iusMrB4/j6ocYTf+br5774Gi/0zAc8tWfck5EFIIXSt8BIjKfD5xU5XgrJm6UzrDJL+aLPPvA+b+mzR4I2PCDRrrVwzrk12341b4FsMM0VRtfSHSyNJTdq9IyUjHX0uoWgZQeUQiP5s5SIrxNLISTeeV6aIpQKW9ZnqFNZBp+v5aDn2iFDaRnSv7V1L8uklUHjT/MYyTJqdSmf+cJSmqEtmTweBxY+Ay5lmH8EkC8K5YpI9gmS2Xa7jWq1inq9jmazmXrH+v0+muf/MnpPeBWQJLhn67noJTnknT2dlgC4/fQnrj7IF1Ga+YFpvGVdpp4odywxIAUg9E0O6u+0uPVEKFjNEEri5w5x3mU/8oFY/k5Li7c/98iRHEm9Q/eh8z1k3lK+Yo1ujCEPEZff40lHhvfVQcyA4HjKw6+twYXWpzXwFALJIT64fPG1jfxdzCDpRNKGByQh5cDDchc/MAhIeDhgUCh44/g8IPSeGpHykoDEOTewm0XmBwweNa4pGM6vptQshR4yjByEUN6++pHPfOQbeUreNO8O95pI3gfyYasXrE7N86G24jyFysSfW/Ugy0Zl8oEgTtKIyXc8jHZNZJ33IvOgPiLX7lC4VquFWq2Wekf6/WOHquVH0D331cAxHo+M78QXH3MpLrn/Cyi4wUVyANBN8vjejsfh1jOesvKg30OusYix+74GNzmhgl6qL22QQCSn/HhZJPgK1btGcrChxZH8aQMey6BZ7WfxKMERrxPNO0ggVAIVLT8L6Gp5hUBKDBjUwlj3sTrf4pffW/3Liq+VWwJEyaPWTlq5eDwLcFj1myU9LT4tWLf4lHri4aINDUgAvWMCaxtOrk3gwhiqZJkHjbg0MEML/IBV178mpGQAi8XimiPReXrEu2+UIBdk+QSPd8AQsJE8c+XGlTwZdEvRyrC8DjU+KI1+v78GuGllW6PYobcpeQc4X1p8Xh/aOw1YaVNwlAc3Shz0UVxNgVBczZDRPV8foikPziv3LPGREaXDeZQ7cPr9lQ/fNRqNNYCk1+uhc8ZFQKE8UEe/+7wr8cSZO3F69QDyDJR0c3lUy+N406v+YgXA9LpAv4stn/wdoKcfXy/rSTu0j0hbmyTr1WccfODPupZ8UF/g9Rsy7hK0xpCsH4sfnm632x3Ycs9lV+qnGDAuy2DdS3nU+lBsXlqasuy+Ph2j97S4BOh4nhooke+tdtf6rS+8pis4T7LcscCh1+uh3W6nXxvnwIu81NbA/UTThgYkmoG1FHM6mjumSC0QwOPya96R5AJJGY+vUbH44XzzY661/DXDyfmRSlt2UC60lpLW0rcAhsWX7JySPz5dYE0HaaBF8ijBwppO7NZ6WDQ3rWa4rfrQ4lppceKyIMNbho/LlnTH03u5SFUqeClL8l4aSeofEpxQHhSGtuYO8Jhfe0T80bHt+Jmf+zx+8dvvx6vv/DC2NGZRSwr4+51Pwl++9N3Yt+NxQKeF0h3/ivFv/B2KCw+iPzJiLpDmbc13oMgw1D/lN3F8nyKwQJ9sG97fLeOhtWGs8pYAQaYZSz59JnWWJhsaWQDCCi/7FjdwofL42kCTc6k3fBQCI1Y6vJ9Y61i4XvLJlAakuOxqFFM2mXYoT65HyDYSEeinfsZ1zcNJGx6Q0H+o4YHBbYEhQELEBcR3aisnaTxkeP6cBCKk3Kyyy5E2z5fXi/ZcS5PXFfEkD0uzeNVGsASGiAd51DI3qFqZQusqrNFySNFmGbVKnqSy4eXgYbX20xS5BhZ4GaQi5rIjy2vVgwQxcncTPeNgQ8aVZUifLTyg1uHCyGa87+J34n0XvxP5fhfL11yK5KEfAFf/PEZzeSSuj0I+D+Tz6BYK6pSq1ga0vTWXyw1Mz5EskIJtt9tot9urYZmx0HSFTw9wcCHl1WrfGPkissrK87f0GyfN2yGvffKoGS6LBx7HWoQfC/R5vnKqTgun8WyBDV9+Gm/WO0tPWelpfV3ziFjxY9e6kK7met5HmuxpU4uW5+XhpA0PSKwOTO+tOJZr2BdfjnZ9gEQTNssYaa477d9KS+bHvRCaEuECWCisiIC2RoWUgwQ1PH/rn8eX61I4H5x/2UkkL7KD+Dos9ywQYOD3Gq8cQHGerQXI/J4bbcsboyk7PoKW366w6siqJy7bcsTF3/PV9Byka6Ozfn9lMWu9Xken00GlUsHo6OiAHOUO34be/APA1G4kubXfrnH9LtoP3QK38CO4Y+VM+r01ZXLOpYd7AVhzyJdU7LlcDmAscxmlxa31en1gtCfbX/Zlrfyy/vhzTrw8PmPF33PwxcP69IGWLrW1z7sgyyxH+lL2OB9Sz2hllPxoekv2ES19CkftqX3TRSsb779ZDahWP1r5SEZlXOKf+JZT1FZekn9JFj8+ssJxQC7JOj5B8uyzlyeCNjQg4WR1ENkptIrVOo68JqK5VU2ILADCSRNCTXAlb9Joh4SUj35pO6dWP9rUCTfmFFdbp2KNNuUIh9cRuQV5OTQFIo2xVmYKQ6BK8sDLwJ8RWUaD6kTWjS+u1jYybSkr8lREC/DGjHq1UY7kj8AHn7r0HVDHAQztWikXJrGl+xRM9oGjyR2Yz38f+VwOnX/7r8i9/Bo4OCS51fZw/S7QXkbnS7+7pt74qI68Hvw9gROpRLmhgsAFVE6asuEghT7gx+VWa0dr4GDVLb+25IWH1cC/71q7l880r4Kla6TxjzH28lnsNmqtTq3BC7+W+kPybJHUIyHj7Asj9Q0H+RyUhNKyeAoBDCtcKJ4FhPg7KTt8q7ymW+TzmLZYD214QKIZbKloeNiQUpH3IUUg87dQfoh/K5+QEMgOroElido1MCPjWP++vPm9NP5SYUoPRkzdyPUUEkBZvPFRGZXf2nlijVpCbSg9DD4QBUD12PB7KpdlNKRCkSN5LQw/3I0DPrkjhcLTyvvZ2VnMzy2id+cLseX+S5D0KwCARwFYKNyKOwq/heah76D7D69C/qK3Ao99AZIkB9froH/359D5+p8DSw+qHi4CIuVyGZVKJQXAmgLkZfcBL/p1u91UVvg6Lfl5AK09Zf3JvDSPhg/MaM80LwKfTtb48hmBGINh8RiKZ+UVY/h8BpLnS//a4CQmf5mGlBkNOFigSCuLxZ81sPHp/pj6sOpNA3WxdaZNrVFf4Wd10Xs+mLPWt5xI2vCABNArX1K/3x/4Eig1eGgtQWxemtLRhFgTXm10Qzxp0wAWD5YxoziWSy4EyPi9tWVVKlgLGMl3mkHmcbSRlTTqa7Y4Qm8LbkQ097Y2co6VC5kGEW9by8Bp4EmCEe2fyqAd9MeJe8v4Nf1zAMCfdTodLC4uYmZmBrWvvRD5H/0EkACNCYd+Dig1gKn2+bio+/e4ofBS1GbvQfczbwXKE3DlKaA+B3SWAUVJ53K59CN4xWIR5XJ54KN4VK4kSVAsFtfUsU/ZE8jifYbvZiOPmmVYLPm36le2TaiPWWBE6/PWIISnw+Nz7wIHBJp+sigESnj9hwyu7L8xIIE/j+kzWUfrIWMu84vV2VZ9aLoyBmxo9760Y8rBZYyu+dk90lMq1zdmAcVZZI5owwOSmFEAV7L8OyRcQT8cvGiIXTYoFw4u0NoiI14ejUJeB5mW5l3QlB8ZBzlyyzKS4fdah5Sdn7tEJSCSxntNp41oTlmP1ohEU+qSKI424raUkawDeR0CJKHOrgE6zbDKfKif0Ef0Dh06hKX9I0h+9BNYOM1h7gyHHuEDB4zNJ9h6/wQe17sC38n/FgCg36oiaVWpAtbUAYGRSqWCUqmEcrmMcrmM0dHRFDiQbBI/cqF0qNzk4aH8Wq3WgOxIo2oZP54erzuf7Gt1L59boIfy8hkXyzDTPU0phPSalH9NFiVJAGEZYGkofeDFisP/ebk56JLhtXRjnms6xTcNpvGp6TMezgKWGmW1SSEwQtcSaPDpTW1hPrC6vuSRoA0PSIg0QykNmXOr30Hh7nsCKHKxUtb8ubKSBs4njLyzWa7kGGGm/HwKi8e1AJRmqEmIAQzslOF5UNgYb45mZPnITm5T1oyHBvCscvsMRswOAV5+GSZGeWtll3z6QIZliLV7Dkwt4mCEp8u9I81mE41GA70fXoyFPX3MP0oWEFjeBDTPS3DGd16OQvL7cK6lgj36p3YmQFIulzFa2YZK/kyU8kBS2I9cTp9KDIFCWY9UPurv/MNz/AN01iJFrc60a42XWLLStICPLy/ZJ3wgi8u+ZWQlTzEDEAuMWGn4ymQZ8Sz1G5sXpS3f8fy0QYtPz/ie+SiUh6VTYkAs17H5fH5goM51LQfFMWAkaxkt2tCARKJmqxNyBUXTNqVSKVXK6/nehSQ50tRGYpJ3LSznmfMujb5Mh9eBVT8aheqN58nT0njh/Fu7IzRjTM+0dRVaPVnpSAoZMJ6/zFMaKgtYAmvXtmg8ajyF5NXXyS1wEkpLAhG5boR21bRaLbRarZWts51tmN9lMJIAvSKw+KgSyoen0O3PeEfNuVwOxWIRo6OjmBg9HZvdFSgv/ySSY6rI5WfQn/oXuKnr4RJ9ukGrH2nAeZ8HVnfdyB03vnMxNBAUo3ilTrJ4tvKTi6npGa9L7VrjQdMjmi6R15KkjgqVVRuUxZCmN6SO4cYyax6y/FK/aaAkVE6tH/p40+LIcp4I8uk+CeD5Uga56NrHq2Wrssg9p1MCkMQgQ4kErbkyGT8rP8Dazq1tAePCzBedSj5kGG30YDW69VyuA/F1amlsuWK0piiIX025yDhS+PnaBp6vLIdcVKgtouVllDxqYSk9XhfUdjKMNnrg5eWGTrr7+S9GkTo3eNgef65da/Fl/vxEU84/AZLl5WUsLS2h0WhgaXIC/JsxaygBFnf00T3cXH3E2owrwHw+j5GREUyOn47t7auQ6+9AghXg108cOsUtyC2/EUl/M/qbP7FGnnzTrDIvWXZa5Dpwjklif1sqZmToA6pSbmT6oTR9aWSJH8pfC2MZTS1eTFifnFuDtlAaPgAVSsfSK/xe65+y7x+PVz0LaXWdZQCt2RRKl9tGLY4FSh4O2tCABNA9BL7RMik0zV3N0zueBuA8kPKXIwzpdeBKl5MGPLS4Pl5kehZY0PLQysWRtCwvEbkD5XO+EJWXg4ej9rE8FDH15qNQGWM6oDUK4NM/mmxp/Ms4VD+8fDIf/h/iW6svUkLaGiIK0+12Ua/XsVye8wMSAC6fIFesIOnWBsCYHIkWCgUUCgVM9n8uBSP9nMPsGX0sbu+jf0wjFRsvxXR9GVO9LyB/7AN5clFuaP0Tf07lcc4NbCfWgLZloDSlrsm+D6T4yOel0HQA3WueDqmDLH4so8vTkLrLMo6aF0VOCXF5p37LebAMp6wnrY5iyQd+tD7LAYgsm4wr77U68/Hlk2EejusHKQtaur68QmAkC2m2JAttaEDChYeDEasiSClZO21CB/D4KKZzSN64ouDCpK3RsJQJ/5fP+b0UWulxAdYuiuX14stTAkEtbc0rwTuWDKMBDc04A/6RgsanJA3g0TMOJil/WT+cd21VOv+Xi5WTJFmTBw/H85Xyze8tRcrfaTtruCHma6loZ0u9Xkez8wBK5acD8NVzB71eXQXe9L/qnShipP2SFTCSODz0hC6a4xgAPJ0KMDPyc+jWKtje/Myazw6kgER0O+mN4e1C9dNut9ccuqYd1MXv+bOYaylPoTStegvlY+kU654/D3kJeN+3wHuoHDIc1z2aXrOMaog0oMaJ88/1igXGZDxZHi6LgD7VJOvMZ5tCZZL9iEgbXMYCNuKfvmMjAb4su0+WNHn3xbHolAEkfBeI1hDcRc1XE5NypjDrrUxpWLUOoRlorTElCNDWp2hGT+Obdx6Zp287qjTCvh02EhzIOV5ZP/w530Eh0yNAIEfcGg8aWa54eU33XCYofQmaeHtZ9ezjx2or+VwuKtN2eVh88DToX/tgngQ8tAONh+10OmgsfR6liZ8zy+VcD/WFz6Hfaw081xRpkiQo5ieQcxMAgIWd/TVgZCXCyt/8+M9iunMrSm5W7Sc8Hpc1ftAagT4i+pgYlzt5KJtWn/K5NTLlANCKK+tJ63eynX38WOlq1774UpY0L4ami/i9vLb443lqxpfzzsOtZ62f5E2zFz6AJMvMy+Mrs48fGVd7L4nC8zN2Qmnxd5Ko3/OP6/HwPmDs0z/yWawt3dCAhEgiX9lAZFQInUtFLA3Resi3ZoEEVetIcurIMvpamvzfesa9FVIoaPeBplSkEtS8AVY9ECjh4Tkf2qgjlCYvI2/XAcWNwXJzo2B1XJmvNPwy3BqDyPj3hedkAQgr7HrkkssTByGhaRo5JeKcQ7d1D5rVL6A8fgmSRLqpe3D9JuYPvz9NSwOf3Ph3+3W4pIMERSzuCJTN9bA48hxMdP4laJQlACoWiwNrRcgQSRmiNDW59OXH38X2WytdDdDIUXgMPzJcyAPCdaYGfLQ+bxkb3s9JB2i7eCRv/F6WnZMGVqz0rDC8LLK8Vr4x+Vm2R8vfl45V3z4QxClW9ohn6hP8DBJfOqE+qIWNDQ9scEAiOwN1sJAhl/PPIZdUFn6sTsN5lWsGOCixgI2Fen2AxBIwSyETaUZZ1qslmGsOKWP5S8MErK414bxQx9bmpTU+tfqOBVDSiGllklMqmtKyFEMIfGgyLI26ZUwkaQqWy7v8qJ4WjtZZkFeBfosH342J7UsYmfpZJEkBzvWRJDl02/tw5If/Ba36fdEKvdttYbn4FYzieeiUsdY7MkA5dPI7gY7fdQ2sfvuG1qnQ1BPdO7e67Z+vI+H1p4FVWaaQspbXIZLt6WvfmGeWodfCxAAeMloSaPh44FOMPE8p6xSWp5tVD8u6iwEmctAVE36972PSzxJW8yBlBWNE1oLWEFltqV1bg3GNNjQg0chSHNSpuDuaV5hPOGM8Ffw+lI4Govhz3zQGXWs8c3BjxdMMnibgPC7/DwElue6Ev9d293BlpBlxOT9qKR/nHMhB4uAGXPQ+T4xPacpyWWWW6Wn5cP4lcNDy5t9MWo9xongcVNCUDD9zQHpOCJR0Oh20Wi225qqN+QPvQXXmb1AZfxYcSmjV70G9+s01ZQpRv9/H0d412J1/LpJ+Ds67UaGPnGsM1JH06tFzAiHlcjk9AZYOWuNfrCbgwg9h0+pR60e+vkH/MYAlJk1N5mIMn5Wujxffc/5MDlZCAEJ6aK2+yMseGlQCg7vYtLqPBSWcfAMuCu8D3FZZswCWkOz4wLGlL3hfkW1JfT3khY3t2+sF5USnDCDROqxsHJoTJ8VMBtHXGDEjPsuoZOWfCy53d8py8Dz4v88wWMCJ6kGOBjkfMbxL0uKRspHrY2QnlotiLe8MpbUGfbtBr4YPnVN+/D62nL561hSjBIxWehJwZpE/Ke/SO6J92Zf4kqCEf/OGwvd6h9FsfNwEuhrJuuh2u6j178aP8m/B+NE/R3V7xfaSJHlMd7+Vxte8bETkHaGTX2m6hre/PBjK8o7JfuxrK14HVO88TRk/Ji3+zlevoXdZRs4Wj3LgFuNV0vLg6fF/Hk4La8XX8sti/DXS8rMAnuUN0OrKx1fIZlj5cR0hN0LEknMO7XZbPVZgvXQ8aWxoQGIZBGosKQBSKXNAIjtgFqHWFIil6LS4vCPyEYK160dTgj6j4BuVODc4ytGmXLS6kR1Cpi3LrAEO6kgSlHCwRN8d4Z08lqSB0EZzsrNLxREasUilw8tGefJ61IyP5gGyjJMEjhZfFsm8tIWuzrn0w3pSUfEwsly8/ni9cS8Y/2zDfPtm5H/wWoxs/TsgVwASCSx7qPT2Y7J3VxCMAKu7g8gDwsEIB6ck86HdWVb9htpIi5P1mU9fUHl8YIWHsZ7F9inN0IZIC6v1qxidYZG2KNUCKBqYsPjW4st3mq4ABgdPGijh5NMzVr35eM9K1P+5xzRLXO13vLShAYkcbWqjeik4mjKx5lB9I2wfUOBxuaHVyKfgZFxNCWmdJIZ4OO4l4aelaukD9vH2Vhks/gic+A6Ek2WX+STJiqtezoFaitiqH342AsmLb56cu6412eLgS6sX7Zkmh9qCXg00hmRDpml5TwisdzqdgZETB/CazEqvgOYhk2DIOYfuwt3o3fxLGH3a+5Arb4Xrd1bCJQVUOvfhzOb7kc+vghE+XZMkCfh3i2itiAxHeUtQ4xsNW22k1bXWVyx5lc9ijIsFLGLd7LIPamFkOC0tmY7PqGt6KwQCQ7xrlIUn7d6nxzXbYvGk1Q+FjW1nThZAkWA/BNrk4JGnx72icm2ZzFejEwVCOG1oQMIVNgk8kTQE1CDSI+KrUEojZtpCpqWNEonk3n4uLHyqgXbBaFM3PlCi8RVC6zIc8cfD8ve9Y4dVSc+Glo+sBx6Ht4s2spDraSzF7OtIlJbFE/HOPVIaX7wMMpyvHmU+Fo/0r60dsORVGnktDFc+2tZerpR6vR4ajQYWFxexvLyMTqcDYHA0yvuW7GeSZF3IOM45dGa/hcV/uwSV01+A/NQTUSoAm5O7saU8g0K5jCRZXQcyAEYU0trYMiDaM5/syzC+tHxk5a1dWwYnlKfsZ5SWj6cYQ+czvL765mFkHfqMr5WGfAbo3+XR+OdxrPJo/SxUfxRuPQBE5qWlbaXLB3Y+vnjdc/3K15D4wOsjQRsakACDo1HecNookhQyX0MSi/IsJeEbZWnCTkJAcXnH4IaO7vliPC19+YyvVLcACDfA8hmRNb3By2utirdAjYbWtXrUAIGlsENfotTKTeWT5dJ2n2hp8PeWktBkUqal8WnlxUkzNrKM9COZb7fb6Rc9pXeEf+2z2+2iWq1ibm4O9XodnU5njVKzFKZVPimHGoCF66B14AsoHPkySuPjKO3cCZQn0nwJkMidMVY9EmUFJZJHrQ1lfNnHfPVjAR0fWX0jVm9pMs/fx7QpN3pWHVvPNH2s8cDjWcDSB0i1MhwPMOB5yjwsUKTlGbrX3lmAkEjLR66F4/G5HpW6jX/p1wd6HgmAsuEBCZEl6PS/uihv1TVN6xeyABJVmSr5awpEpsPfa/H4f2i3Cb/mO0w0JckBj0yTDBU/v4GIr8XggEGWUVMamtKWPGh88ny1MskFsFqd+NLg7yzgI/ngxkrrqBYItCgEgrT06F4zTrwdCYDTYm45XSOnbFqtFmq1GqrVKlqt1pr2InAg+42vfNyYSX61eJQHV87ace9avBjZk3WrlYP3IStubJtmecfD+No9RJq+kf2dh9WmTTV+fKBL67tWGTQ+Qx4Oa/QeAh0a4JFyLcPL+g/Vi+95TN1YoFaTVaorXx1IGyUBCbB2TaXVtjJ/qx5OBGDZ0IDE1ylkY/ERsESJMi5/pl3LOKGGCHVGIt/OFEDf6mYhX04SEGkGlAsj/4YMlZHqTgIAC0BRWrIsmkGSowK6p+kaLT1KI5/Pq2WSHYyDCamctc4qgRr/+UZzdO8bjWrE25mTJZOWEqWfBBva1AyF46ClVqvh6NGjqNVqqXckJD+Wh0rWiwX8pDyRG1nKJD/uPV2P0BtMQ9sSzPPyjSKtetVAi8+gSdLkPhTeMmIyPcsYan2Mx5F80AAthrcQGJHv5DVvHwtcaulrvGv38tqXfmz7W7zIcvl4l3xpMmQNcmL41uJq07/83tp5Z8XhlMvl1pxuLuNZ+sqiDQ1ItEWtlsBIhWoZR0k+UBIjOBbJ/EOGSgMd2oJJi0drJCjz5td8waksv+xMmpIgQCFXw2epH14ureNqoz5p3C3lzLfKaR2Ijy6sdxzsSP6tcvF6CoWR4SzjyIEIfy53z/B4PGyv18Py8jJmZmYwOzuLer0+oKQ0QC/rwtcX5EiNx+WKlPPMw9GUjbZgVdaTZkC0eg31d98zLd+Yd1Y/1OJpeilWwWu6LaYPWmF8xla7t0CsNeVmtREvB9cjsh9JOfKVL4Y0MKiVz9KdwNpPV/D0Yvq+ptcs4n1JS8si7ZTWGDmxALEmc7G8ABsckISUICeuEKVr0jISWuWu5zsKkg8t31A5skwtWYjeykd7RmnwKRnOizYnzIGSHPFaXgOeD92T14Mf883BRxbDopVdA6rcOPP32lSPJSOWcpbhiOTOHi0cV8SxZaY0tNEPN/o0hUNh6vU65ufnUa1W0W63BwCIFl+CIaveiTRDok3N0AFnPC2+jZcbH0kWKAnVne85T08DY6E0LGAR6s+xhiuUBpEG8GW/pnD8XZaBhJY/10Fy8ToPQ9fS6Ms+JvsoL4sFnHz8aWBI0xWaEeb32vSxZTe0viHT9Hk3QuWJCc/XkMm2z5rviaJTApCEhIaHlx/X4+9CqE9TIjEKKdTAVhoaQpajSS2eD2BZfGgKSuMtlJ+MJ1dvyymYkNHSplMoHaucFm8cfJC7kechw1nlpWur/mU9WqM/DYhoht03vyvDk9KXC7glgEiS1RX23W4XzWYTi4uLWFxcTBe58QXDEpz5wIjknxOXAzmy5Seo0kiagxCZnpa2T/Fb8ho7OOFkgXLrXhot30JsHscCOKF+w/kIGakYneYz9jGARYIS7inx1SPVE/dm+kBH6L0kOWDR+rC0DRIE+dqb9zXJlyy3T95kevJdaIeNRVw3yP5o8fJw0oYHJPQf6hQUVu7K4B4T2XEJwEgDCdhzfTGKgr8L8S+F35eXpqh4+j5AIp9xA8AVKOWhoXdLafFwEqBY4I+nxzs0BxWAfQqrZcQluIkBmjEgQJIcYfoUl2bcLV64PPB73j58TQgHF/Sdmn6/P7DrptVqYX5+HvPz81hYWEgPRJN8xQCQGLJGswRKKpUKSqVS6imR4MU6z0LKFG9/S97lYtsY3nmaMX1SPvfJjYyrpS/7nmY8tP/YMmq8yfKGdK5PD/FySHDG06Q+nNUrnUUWfQBG6ooQ+LDS4/E02ffVo8zT0puhbwtpxL2jVM+PJPjQaEMDEiAbQpcHQMmTUDWjbRkJwF6EykkKnuxwITTvAw4abzEdxpe25E1+tl1bRCoBk0/5c6PC48ozZGTn8JUDWNtBra+6WtvBfWBKKtIY4nUUcyKolS5/p63hoDC8vfg3a+R3bPg2XwIofGcNn0/mecjpG0sGJQjUlCkn2T78tFUuK9ox71ZaIdKAla+fyfQtUBabrxZP8hA7WOHXseUPxbF4lCBb8hrDiyW/HLTLfhtTrpAe9YX39T3reWxd83JYa58kP/I+xKvve0xamlyf8G/ZWIBeI58sa+A1ljY0IJEVwitCc/WTMuXbfnkH4Y1qoUVtpMHvswACutfy4gtKfQIsG14afp5+LHCzBEmLL3dOWCNf51ZG7sVicYBHvo5CgjVg8Khvaew0Hq3nVoeVa3O00TW/18JqYTSg5ePP914qcc6z9FzJdSHatAt/1+12sby8nHpICKRwyucSPOupO/HMp25HsZDD3ffP4zNf/iEWqivbgnv9PnpnnY/WM1+M/vgUcjMHMPK1T6Fw5EG17okXbZRXKpVQKpXWyLf2k2S908Cb9NTJdDTSgIvVP7W4vueaXiBeNFmw0s4C1kJAxzKSsaBEixcC/jy8PPZf6qYQCPG1y3pAhVYvsjwStPGyZAWMMXnTO26XfLJNzymMtYThZNGGBiRANvRF4bmSJuXOG5F7TrSRSwhk8GehTqHF58CCrjmo0LwHPB6/luCMA5UslAWkaOV0zg0sVJTvZEfjHUxD3D7FpJWNe100AGm1seYF05SE1h6cP5+MWKTxZqVDsqIdA08/7iWho+FpZ838/DyazeaaqZodWyr47+98OnadNo5utw8kwKXPPgNv+X+eiN/502/gC988gqW3/DE6T/1JoNsFkgSAQ+Nlb8LIp96PsX/+K+Tg7zfkASkUCqhUKumH8ZJkdZGrZQSTJAFceATK60l7bvVnX1uH5C4GTGjxtHexo1ZJoXJZ8Xl7acbUMozWO7r2gcZY0KT1KflM63MxuljyqvHjkzEtLx9wW887qcdJrmP44sRtIaUR8ubKe8uGrZc2PCAhkoIoG5D/JAjh7yTq1PLxCf56GkjbukrPtWvKV+NV8m2NOnykKRZZrpBCiTW2fLpEKj4rjSyAympTeqbtopGAVObtI2s+nKfP/61wsn219pdTOFy5SFDCgQhfOzI7O4uZmRksLS0NnP0BAIV8gv//bz4DO7eNrNwXVmWnWMjhyv/8TNxz49m45ZznHIswqE4aL3sT8oszGPvKx9YASPnL5XIYHR3F2NjYwPdo5IJWX/1roCWWLIMYG1e79j3jFANYQ3rFAgO+vHl9hdL1xffx4RswaAbXFy9EGkCxAOR65UiTXS0uXccuyA6RzItfc49fjOeP1xNN2WhglNOJAhwh2tCAhC9ItQTZGu1oIxdpsDgC5WlIIyfzCY2ENMGQnccHALgSt0Z9Mn3Ju8UjkXYgWdYRhw9caHlLTw6F4yO19ZJVr1Yd+eJLA6mN2KyyS+BoGYVQ+8h2p3s5JcN/tG6k0+mg2WyiVqthbm4O8/PzqNVq6Ha7A/X+7At24oydY2r+uVyCbs/hl55ewC3LxoFazqH+07+E0es+jgT2dF4ul0O5XE7BCH8uQaqsc5mWNpLU6jPGmMo0eJgY0sLGgmkuH1Z4a2G5/I8dHGjPuK6QBybyfEKyagGDEPG4lu7V2kcLm8WLIHWNJoM+kBATxpe+TCcUH9CPaJDxeH3wnXjaujEK90iBEWCDAxIg7D6nZ3wxo/X9E81YWehee8/jye1qVhoynu8dJ7n+xZdHbJq8TFrHl3F5Z7HAVIxh5u0jvzacxRhY7+l5DHCzOjQvrw/ESLBnyaVVjxbApX/6aVOO/Cc/nkdgpNvtotFoYGFhAUePHsXc3BwajcZAnyCg+6ynbUev10c+rwPBQj6HF+buBZYdAEVRJgn6m7aju+cclH70vYH6IuJgpFKppN4ROpmVf8IgpJi1qQUuTz7vVUi2YoFEbLhYyppWCMTwcD79JvuzBrjpOXf1WwafpyF1iw9EWgZY67OaztK2qsu1KfI6xFeIrDrQdJr2XgsTyns9wKHfXzl7SH4m4WTShgckgD4qsBqQlDRX7nK0yRdcWiMr2UllB7EMukxDC2+RBDwa74C+r5/noS3E08Jy5UThtXlGCksgyRpdWMpSGmR+LxVdqBy+MnFerfxlXfmIp+U7KC4mTR+Y0wAJlwH5AT0a8fAFruQZaTQaqWdkdnYWS0tLKSCR+ZSK4YV4paSPHBz6GiChshVLA+nwkWOhUECxWESpVFozTcO3/fq2+Wr3UoatPijr2ixDBmXvAwSWfPjy8IHyEGjQwvC4GhiWYTW+NP1nGVSNR65btPg+kudmaHzJvGV8rlc02ZD88fT4ejSZTgzY8L3z6QcL6MXIjZWmXNAqdcwjTacEINHIpxC4EfeBFxkvFq0DgwYq62gsxIdUtFrnthSxVCQ+ZcR5l18d5p2Sf6Ke3nGyOhK90wCLpqg0l6sFcrR25muGfDxp4UMnLdJ0h1QuVv1racmwPH9eJlIi9M/PHOGeEDldQ4ef0Y6aarWaLmTl4I7y/cG+JTz7gh1QvR8A+n2H+9xW9LEWLEz0Gnj54i3Y2Z5DfWIGXyjlUOv4p1mSJNG/V3OMtPn4JEkA0Z2s+pTEP4vAw5/I/rpeCoEVH09an4gdBFigRKZh9dNYYBHSp77+KcG9xlco3Zj64+GsHT8SlHA+NPKB6Cz1oQGGrDt5NCCigfpHkk4JQCI7g0+g+cgxVNn0XjNkscd5y+cxox0rDf7T9o1zsoyhjzQFRoaBP5M8FNhiRt9oQ/IqD/PhnZ0AjzXS4v+ap0RbrKr9S1618LJjxrazVZ9a/BCIlAuw5RoRAiLaWSOtVgvLy8tYWlrC4uIiZmdnsbCwgEajYZ75AgCf/vKP8IaXn232pyQBPnjksUC+B7Cpybce/QLec/AfUXFt9FyCwnM2oXXxNP7kxiO45vaFNA/el+TXfLUD0einrS3hmMmqRwtoWIbYB9pjRsQxchITRzMcGj8SVFjl18BIDN8Wv5qR9pEF0jkwsvi3yh8DhHztr/HI60vqQDmNqMmplbb2PAsw4fpAtremNyV/XG/6QPjJAOLH92GWHwOyjK5mSIDB8/tD7iq+elmmbwGamM4oO1UsOKKf3M6plVlOS8n1BbKsvjz5KZ+Sd02pWwpUu/eRVbaYdpLlJB6tQ4RkPtpZFSHeZb1b5Zf58m25ElTwo9/5j3tE6MfXk9Aumnq9jlqthsXFRRw8eBAHDx7E/Pw86vX6wDcstLIcmW3gj//2NgBAt8enNB36fYd//+YBfPqPPwB020CvCwB40+yX8acHPoxR10YOQDFZUaKVQg6/+5ydeO2TpgfaAgAKhQJGRkZQKpUGvmXDj4/ndad5T4BsI0TZLpZsyHbT+oBFlqHTflpaln6J6UNW+lzmtPCSb59OCekNLZxWRl9/scps8RDiTQM7vjgSKFi7v2JIC8/BC+fHqmtf3fL4PG2LP34oWoyOjtXbx0OnjIfEOf82Lx6OK3iJunlYbcRsjXwlxaBxq7Nacfm1nHLSjg7WlJ2ML4/NlnXJp0l4GN8oy6oXOaKSPGgUAgCUXkz7WeWQ6XF+LX5Cnq5YJeVbC6SlT/KrnTeiPWs2m1hYWEgXsS4vLw+8l+WRyunTX9mHhw7V8PqXPx5PP387ckmCA0eW8fefvgf/8Nl7ke87TL/7VWhc9ovoX/RC/P6hj3vL+xsXbcPHvreELgbXkBSLxRSIFAqFgcWssr6pHorF4sCz2DrXKKQ3fGS194lS4KF0LM9HTLohT09W3pzTj3q3dETIGyCfWbo3phwhT4gMJ425lMUsvGs8+MCCfB87ANa8NBaPfGBu8WnZj4eLjstD8t73vhdJkuDXfu3X0mfNZhNXXHEFtmzZgvHxcVx++eU4fPjwQLx9+/bhsssuw+joKLZv3453vOMd6Ha7x8NKSj7kyxU/ByOh8Jpht96vh1eZjvR+hEYIPJ52bfEueYkpu+99KF25JsJHskPxfKVnyKpb/rOAR6j9pWdCaxtf/qF05bcktG2u3EOgeVI4IOl0Omi1Wpibm8ORI0dw5MgRHD16FEePHk2/USM/tgcAu7aO4MVP24oXPW0btk6WBspxy50z+JU//Cou/g8fx8Vv+Fe89JplfKT6GPR2n4UkSVA89CNMfuDdeNkfXYptvaq3XacreVy8axRJMjgtw6f/CIxwOeDAW60nz6JaX/vK/iLpRBlq/sxnrCwDqPV3aZApvLU9PmSkfGUJ9XtePi1cKLzMx/dMex+j44i0OogBBzJd3p6++BrgyZKf5I/0mVUOmYeWH+mRdrut5qfRww1GgOPwkHzzm9/E3/zN3+C8884beP7rv/7r+OxnP4uPfexjmJqawlvf+la84hWvwNe+9jUAK9vwLrvsMuzcuRNf//rXcfDgQbzuda9DsVjEH/3RH62LFy4gvmPSaRGbHPVryojSkAIuR1Ly2uc10HjW7n2jb1ley3BbcX18WCN0WQ/aaFKrJ5kPNzwyLt/ZRLysZ6QaG162m1U+yb9W3pB3JwR+tOkhi8der4d2uz3woTwCIjRN02w2cfTo0XThaqPRwPLyMtrt9sBR0c45bJ4o4g9efTaefe4m5I7l3es5fOrmQ/iDj96NWuPYF5GTHNo/9yb0f/pVQLmyWsh77kL+z38fyUM/xKZy3PhmqpxPAQmdykrtTkCDpmw4ALFAilZfIYoJKwFBKHwW+ctK1HcsfcD1jw8k8/RkXCt8rNeFj+ZjzwySaWq8+Mqk8eprK83LwfWV5f2wwmgAQHtnpS1thvZeK5cWNhZsyinimMHdI0Xr8pDUajW85jWvwfvf/35s2rQpfb64uIgPfOADeN/73ofnP//5uOCCC3DNNdfg61//Om666SYAwBe+8AV897vfxYc//GE85SlPwYtf/GL84R/+Ia666qoUrR0PceMihYSU3fGc3W8ZfDmCCPGnGan1htF+MfO93KDJ9RacNG+Ahrhlvj7y8aQZcm60rTL76pB3Qq1uZL5aulYevvJpIMQ3VcL/iWQYbpwJpNDi1WazmXpHDhw4gNnZWVSr1RSM8LzHynl88FfPw8XnrIIRAMjnE7zsop34myvORz638rz7y/8Z/Vf8/CAYAYDHPh69P/6fcNtPx48W4/rvg0udVH6KxWIKSmjNCD+DRC5uJYpZLxLyRGjGI5ZC4S3wGRs/K2lryiQ/ljzH6MJQf4uJqwFIHxCy7jlZgxaZl6++tWMKtN/xtJ8FZqwBnXbNB2xWuvJDlBYfzrkBT2sIcD9SoGRdgOSKK67AZZddhksuuWTg+a233opOpzPw/JxzzsHu3btx4403AgBuvPFGPPnJT8aOHTvSMJdeeimWlpZw1113qfm1Wi0sLS0N/AC9kwGDla4ZADLARD6jwtPhzy1DJuP7wqwnTUt5xgKb0E/WIVFI0WkUKovk3QcO1ptvqJwx4Xh433QNyZo0DqEpnlwul66h4OsofOdvcCVJebRaLczPz+Ohhx7C4cOHUa1WUa/X0Wq11oyEnHO4/OKd2L21gkJ+rTzlcwme8bhNeN55W9E/40z0X/izQKKoi3wBGBlF/5VvwO1Hmrh7toleX2+XXt/h/oU2bjvSUsvMQYg8EE0qclK8kmTbxYCNkIGV/ULqFx7Gl5bl1QnJnQ8EaGFDgNuKF1P+EMWE1UCEpR999Wp5ynx8ZZGLE0mWbZD3IZsDDHpIfOXRQBUReVrlotYYwBgqYxbZkpQZkHz0ox/Ft771LVx55ZVr3h06dAilUgnT09MDz3fs2IFDhw6lYTgYoff0TqMrr7wSU1NT6W/Xrl3pO1/ntToiHyn7dtpIium0IcUSqxw1xcJJQ+1Z+YvhQSuLthZE8m3tTrLiaUZcqwO5/oGHiSlH6J2mOGQcXxl8fFjPLEOlEV93IteD1Ot1zMzMYG5uDvV6PVU22nqkfr+Plz9zO3xLL7q9Pl5x8eno/eSL0100KuULcD/5Yrh8Hu/68gH0nENXgJJe36HngN+6bmU9GU3XjI+PY2RkJPWSlMtlFItFtU4IiNBzDZCEaD1GKMboZVXeIfLJrhZWxolJN2teofJZafo8I5a+k+DZRz7D6+NVS1fzWITus8hUyD7xNCUv1j8PH1t2PmVj8eF7rl1r77L2iUyA5MEHH8Tb3vY2fOQjH0GlUglHOEH0rne9C4uLi+nvwQcfTN9Z3gLL2JBRs1YXhyhGMWjXMcKTtfFCIzWLP00ZxLzTdoT4OpaPB37P0+QGV/MqWFvUfOlKZRfqeL560crD60Mrk49IYfM8fAZGq6N2u41Wq4VqtYqZmRksLi6mYEQueuX1unWiNDBVI6mQz2HndBluajPk4WNrqFRGMjqOWw428MqP/wi3HqwPvP7WoQZe/cl9+OahZjoNU6lUMDY2lgKSYrGYekr4ll8NlFAaseQbQRJpsuFT9LEGIGb0yvPXZJHiWLRekCHTsHjMSiGDL/PT9ECIR6tNslDstE6IFx4/CyCS7ct/XPZ9dWmd1xObt6Wns8jOiaJMi1pvvfVWHDlyBE972tPSZ71eDzfccAP+6q/+Ctdeey3a7TYWFhYGvCSHDx/Gzp07AQA7d+7EzTffPJAu7cKhMJLK5TLK5fKa51onDDWgNc/qq3TpichCMUaZ8qB3Ps+HFHrZwUOC6AsrDSLPwxfel58PMIZGAXxxHB8Vh8pBdLyAz8qD86g9t/ixZI6+eBzikepNnktCi1kXFxdR6C7jzEngMHI4vKSPfuj6yGILEyMF5HI6v91eHwfnm0gWZ72eFABAq4mkWUeSJLjtcAP/4Z9/hEeNF7BtNI+jjR7213op0KBtvnztCD8cLXTolAQtRNa11i6+/h/SIzwtnrZP3rX8tTwsUBQy7qH8jpdk2UJ6Ruozul6vztDCaPlYedM98W6BCSL5cdEs/VxLNxRWA96cV03e1tO+FIfPDsTI8HpoPfo2EyB5wQtegDvuuGPg2S/8wi/gnHPOwTvf+U7s2rULxWIRX/rSl3D55ZcDAO6++27s27cPe/fuBQDs3bsX73nPe3DkyBFs374dAPDFL34Rk5OTOPfcczMXIJaskZBl/DWh1cCClkdIMcXyafEgw/d6vSCa1sjqlLEdzhdOGm0CGLGjLqvclqKTRKDTGp1a8TQ+nHPmIspYwCl5tqa0rPS0KRd61mq1MNqawVvPreEpP7k99Xrc9MMq3veVQ7jtoZoq4//0tYN45+VnmWUv5HP4+NcPIj/zefRe8TozHHpd5K77PHKiTPtrXTxUXf2sAG978ogQEKHzSLSRngZIaP2JVoe838QYTg2MaIMALS8Zz0c+uQhdy/tQXjHg4UQYH8mTrD/5TCtDTLm0fLQ0tLw5ae9Dh5xpfMvvnMly+uyExQ9/5qsbH7AOEdcfcspGhrPiy/dy0LMeIEKUCZBMTEzgSU960sCzsbExbNmyJX3+xje+EW9/+9uxefNmTE5O4ld+5Vewd+9ePPOZzwQAvPCFL8S5556Ln//5n8ef/Mmf4NChQ/jt3/5tXHHFFaoXxEfrLbgUJO0jejFGxeLHZ/BiRl+UhtWB+b22nuNEIlzZYeR2XN6pQ3VDnoAsJx1KYMPztHiU8WLqOeT+19IJfVBPe8bLoXlGtFNJOYiRgKTb7WK6N4c3PPYQCgkGpmAu3D2OD//8Y/GGD/8A39xXG6gTAPjHr+7HK599Gs7cPoqC+KJvr+/wjbvncf0dR1FwQO/zH0fvRa8AZFv1usByDYWPfwiTxQRTpTzm2g71bl+VyVwuh1KphPHx8fRHUzV8p40Wj3tGrAW/vrq3KARgQkYtS/ryyAH5nqe5XgUfA0aykkyHA3X+zBrRUxox9aYZZEtnrgcY+splPeM2wnfOi/ZvpW2FC9WNj0L1xXUJ/9wE1VtM3Wk2x+IjK53wo+P/9E//FD/90z+Nyy+/HD/xEz+BnTt34p//+Z/T9/l8Hp/5zGeQz+exd+9evPa1r8XrXvc6/MEf/MG68gsZHCuOdu7DiSILcYaMZMh7YHVGDl6y1IelZHz3/DnVo/ZcPqORLQcjvKyyo/oMg/VvkeQrtq58YX2jtFBevE6AQW9OqA4oPLlc2+02XrLlAAqJS7foEhVyCfK5BH/00t0qH/VWD6/979/Cl79zFH22CLXT7eNjX92P/3jVt9Hrr4QvXvNnKPzTNUCzMZBHcvedeMYfvQn/5/E9fOenT8NXL92Bb79kB/7kadM4bWR1HQg/Z6RcLmNkZASjo6NrtvfyD+tx2aCfDDPAS6RC1cJqdR/bD3l8HtfXvyy50/pEiJ9YHuU7nx7SeNJG5ppsa/lbfcAC8D5dJGVZ2yVitQcvg7WFVt5z2SsUCmqaFhjhsuyTDZ8+1O55HKu+NKJwdEyAPF7A0ne+tgpRFpt03EfHX3fddQP3lUoFV111Fa666iozzp49e/C5z33ueLMGoKPyEPEFfr5RljRAWfiR8bX3Vl6xRGmFFk76+JOjMysOsOqR0bwhMi0t3VBbWQpPvg/VMc8/pmw+njQQYsmCVmY5daR1cv6el1PyqMWbRhW7RtuAscgjn0tw5pYKLtg1hlsUL8lcrY23/s3t2LmpjPPOnEK/73DrwTbmax24NlNW/T5K//i/UPjkR9B/4lOBUhnYdz+e0z6EDz5rC3Iop96Zcj7BK3aN4Hk7yvjZ62awv7GSTqFQwOjoaLpbrlKpDByExusx5meRJXta3cr32n0ori8deqYZbeKTjzipj4VkzGdwrHutv1pkybrWv50b/OKt7FPHo0c1j2yIH185ZV+0+rzWX31gwQIZGg+xckSywfngA2mpAy39q4EOvobEAjWcD+3eB2wtYBqiDf0tG17oLKMHfuS2b+tglo5kCX4oTixZnVxTcr7RTFYepIBzACSP/dYoRoH6lIKMS+1llZ8oK0jTnoUMhVQIXImEZJOXy1JcPB9S+vL9BJajyrhnUxnf/FF1TXnp+tB8CzNPfwqKr3wlco9+NEYB9O67D92PfQzdL35xVRm3msjd+nUAQD4B/vRFO5FPgLzgq5BLsKmUw++eN4U33TibrvsolUoDo0y+Y4bvnOFGgXtE+LklsaBEKy+Fkc+0+6xkGW0uTwTquVGQvPvANqUTWwbLYIfKYL3zDWo0UMLrQILvEPCgughNC0uyAIJ20J4cGEjDy98RaemQnMq60PiRFGpDCRokQJJ1q6VB7/juO97HQiTrJARgstKGBiSADgQAf+M759DpdMzGWm+l+owPz0NTkKF4PH2fcef58GtrikpTaNYIU1MwWngNvFjG2yJp0C3Fa5HWOa2O6hvJWIAnBO4s2eLXpGC1MJIorJyzX2zEfQNqsdn1grjiW9+K0stfDseAXO7Rj0b5N38TubPPRufqq9ek+dwdZewYsQF9IZfgp06rYMdIAUsooFQqpdM1tICVDkCTu2vklA3fnUNgJMsg5ERTSA4571pYrV9IA6PpBl8fjzXW2uBFyq6lJ+i91S8tMGLlH6trfYM96zrLvQRInE+Nd+4RkunKdUIa+Iopo/RsUHpyispXf5oOdM4NfOk3pi/FtNHxAnngYVhD8kiShtZiK8631x2ImwvOkqfkWZLcjiw7fKwC1njKwqPWwSTvvFPE1oHFU5Y28/GcddQUUjy+NLUzWHwnsfL0pHLQFCJPW/INYGBNxr0LOSw0/fW33OrhunvmzbNd8hdcgNLLX76SDx/dHbsuXn45kvPPX2PIHjtRXHMAmqRckuCs6QoqlUp67sjY2NjAN2wsMMK/YSO/Ahw7orOI64D1DECyAuRQWnKXnOz/PKylF7T68OkQCzRbMirDaWlYRlwDwVa9a33K19Y+8M/z8NWTlj+Py7fZa/lYZdV0ZagMGhCVZLV1iIgfAiS0ywZYu/V4PTbkePvDKQFI6DrL6Fme4c+JK8bjUXyacHK+pcDKMsWky3mmslmroEOG2zKOMm/JR4xSCpE8G8Y3cpE8n4jRMm8L7WTTLLLGDYw0MjHgxwdqgNUdJ/1+H7lCEf9wt7++/+K6/Wh21m4zpl/hZS+D83xt23W7KLz0pWtkeLnbh3GEyQC1kzxKpRIqlQomJibSaRv+UT0qnwZG5FSNNV0TMgCxRiEGLK9HxkOAIWvaPqMUO5ix+rwWP6afhQZy9B/TBvK5lq5z+leyY/jjz/i/xZdPr4buNQArwZIGeHyDGJ6e/DilRbR2hOxfDMjS+NWeh34xdEpM2QBrK1KiatkZfGBES9uH0iUPvsqndLIAEBnfSoOjebk+hoe3+Af0lecx9aQpf1/n4HWqARF+7dsi7AMqMeE4vz6PWWx78XL55CEkU1YZ6BswdMpwqVTCv+8ro9uYxS9dOI5iPkGvv7Ljptd3+KvrD+DqG/YP5Ckpf/bZSAq2KkgKBeTPWj2vhNL4t4NN/L/nA9ahaX3nsL/Rx32NHMYnyumprBxYSM+I5hWhtSdyuuZ4QSgvS5a+yymkE4joS+MW+fpBiDS++DMrLW0hqsWbVT/Wt5Z4upIvH1nA3Kr3LCDEiq/pHB+vcp2Txb+Vh9QPvnJodR+jk60ykN3rdDoD37HxnbOk6faHi04JQKJ1Fs1g8vuYL/7ydK0OEDIqIZLKICYtiaq1dxy48LwsHoC1pxNSOiFlbb3XyiXTtYAjf9fv99XzKUJl08JYCD8UhpPWeX1KLatxkWnIvLmhph0rn77vKP7u5nvx/MeMYMdkETPVNj575xxmqi21nPyZazTWPNfCyLIcbvTw4QeW8fOPGVOPoM8lCf7q3gZK5ZXpmtHR0RRsEMCQYISDDQpXqVRQKpWCYETWndZXrXogCsm6NsLVzqMJtbmlR3h6IeILEmVavgWOVr1I3jSDSv1R69vceMk1FqGj4LV+5+vv8pnV9r6yWn1W40WWR5LGqw+Q+QYpIRmQactrnpfmnSD7F1q2IHmWzyygoj2PBTEbHpAAg4ohZAAorJyf9BmiGCOl5eNLS6ZpgQctLC+HxZ8UghDAWA+o4oaD38s0NUUSMzLVAAvPh3dgLW6WcvieWx1ee2+V4URRkiQDO1a63S62b9+O+fl5VKtV/POdC+mCbfmtJouPznXXIbd7NxJrx1m/j+7116tp/d5t86jkgFc9ehzdvkPfAfkc0HfAH3+3hs/NAJs3j2BiYgITExPpd2u0NSP8nu+84btqLDAiyQK6sSTl0hc/BNQ1fk4UWf3D2j0ogUQMYJcUMvYh/SvDaIMQLT/5zApnpRcLaqy8LIBH1xoo0QaGkrgOkzo5pFuyEskF32EjeZE8+fRsiGL6D6dTApAQZen0NK1B8QA/0jxRykR2EN+oINbIhQx8qMNJvizSjLG29Y3eW8ZbG1lxPrSy8jNjOIi04vl4D4Xh/75TGUPKXAOfvrJzssAmGZt+v596SSYmJrBt2zbk83kcOHAAMzMzaDabXtDG79uf+hTKr3wl3MjIGlDiej2gXkfns59dE9c5hy6A/3zrPP7q7ip+dvcYNpdyeLDew6f2NzHfATZv3oxNmzZhampqzUf0ZH0kSZKuFaEwfJomhkIyEZITn8G07mMMKH/u40Frbyvv2HwpHs/f9+0U7V/mw/nk/ZGDSuq3Gu9amaRutMgHRrQya/zFgCZeT1o7aB5lHz8yfR9pZ2TF6IoQUZr847JZwXto8Hs8dEoBEqIYRGp5SKwOeCJHuVr68j92pBJSXj4PS0iAQ6BHUx5SCfh4D5E0yr6D7NbbPpYi0YCTlmfM6Crm3ufRstLlC0BPO+00jIyMoNfrYXl5eWBLH+eXU+mJj8fkq16GynnnwnWPoptsRTsZQ6+zoqiSQgFucRGNd74TbmFB5YPoh7Uu/vLu2oChy+fzGB0dxejoaLp2hK8R4caLylQoFAbWi9CZJTG0nr6rGWWfgrVAjq9dOR+aMV8vnahBUkwelhxpIELqV3pm1V2sh8lHVhvI9vUBFykrFmiz8ogBU1r8kCdCG7DxuubxfGGB1fNHut0u2u32GjCopaPxGtM/1iPjpxQgkR3CNw/HPSQ8vs9DYjUShbFIAgMNWfs6n9ZZsuQr09VGJDEUA2Ck9yKkJLS0tbgh0Gjx6wMY2vypr90tI8PD+njh177Re0w6SZKk53IUCgWUy2WMj49j06ZN2Lx5MzqdDhYXF808pt7wKmx52y/BdbvpgtZir4dKfxkLN9+O9uE5dL/9bXRvuAHodNaUXRsdcx6Jp9HR0TXfqtGO0ubfqqFwMnyItH4T8mb44vtIGoSHY9CSdYASm2aoLmV5rPBW/XK9E1o7Yt1befF6t74vw+XKoqz9VbsOxZP5yTDEIz/9mryfMg4/jJKnQ4ulKZ1QPdIH9ejoeMnjeuRs6CER5BMCfvgUF1SrI2mGQ/vnefCwPsrq9dCMucxfHq4lhTjLAsxYojhW2hoIoPA+Pnx8SkPIR9mherVcq7zuYpC/lIPQQjeLNGUcs7iMl1Uubu12u8jn85iYmMDk5CTm5uZMvja98sU44x2vQyHXBAC0uw7NbgHI54EkwfTeJ+LBy16L/sKSyoOkIhxeurWIV2wrYUsxwYNt4JPVAu7MjWHz5s2YmppKp2rIS0Ltzb9hw6dqtG3BvjqU73xyEQNKfEaY/kkGLPAdyounY+VhhdMGJNJg+/gPka9fSB0l+7uvL/H8QzJP5bFORI0FatpAQrvXrq0pmxD5+NLsEM+Deyks4CZBvbR1Pr56vV56Bgl/frygQmv3rGmeEoAECI8qZSXRoh5OWoPKdLXTNWPmSSX5gI4FSuSogJ5Z++OpnMS3VSb53KeUpfKRI0UtjBZfS0++jzEcMR0wy44DaQCtE259JzWuh0Kr6jVPBL2juPl8HuVyOTX+Gp32pldg1zveAOd6SBLAOaBQ6mKk1MVis4weckC5hPGXvhCL//tja9pX1tuWYg5/f+4onjCWR8855JME54w6vHiTww2dDv5xchKjo6NrFqbK7b3cIyK9KJJi+liMJ02+jzHoVnzfQMKKGwswYsFVyAj6eOck60F6mzU9QzxaAEn7j2kjyaNV3pi0LBnWBqKx/Fh5cP6krtTS4d+ssXSbpWu09vPx3e/30W6319itWIDH41hAxgJSIdrwB6P5KoWH4UQHo3W73ejTWrV5bzkfnoVvyX9WyjLS4Xkcj+GUHUI75TLWQGcxNtoojMKG6o86mXb4HI+rbTtNkmTN5+65sZRtn0WpyTrTflmIeB0dHU3P+5AKZuq5F2DXO95wLDzS/yQBEgBT5RYAByQJRp5+/ho+Nf7/8uwRnD16DBAdS7Rw7P/ZxQZ+uv2QOUUj+xPVtXY+Sda64HyGZMoqm5W2BcpD7aaF94XR0qZ6o2eWXHKZ5XWtXWtf4Y4pj1VGYPW8C03Oef6yrLKvxuqRmPbkAzT+zoqXRcfKe1+/0YCAlpa0bz5Q6mszCS7JQ2KBBgsI+Tw21n3ouaQNDUiIsjQa7zB0bK4FKELKQUP96+Hbolik7sufd/AQnzHgyKcArHc+YxsCF7HK0deJrLAWEPApKPlOKs4sSlzjOUYB839Zjlwul3pJSqXSQF473/hyuG5vTZoraQC5HFAu6O8pDd5GjxvN4znThRSASMoBeO7ygyjBDRg/bjw5CKFpGu0DehrIiAGi2nNLgVuyGJILH/n6ihZGAwYUxgLN9JPAQgMjMh8eR/IlR+uWgeRGVr63yhDjseV1w3niZPFj6ZIkSVSPshY2ZqCppaHZCZJH+mnrPeTg2NLnWQaXMgzZPQIkvlNaLZmPBRcWwPHRhp6y4ULPG0kbsfNntOWQu60sBSYpZBR7vV60IGsNxjuxjx+rjFo5eEfgiieLoFiAzXrHebEE2xffAn88/RieZV3y/xD4yFI/6yVLbrWREn/u4210dDQ996PVaq0sXsvlMPH0JyHxnPXuHFDM99Fs59C89fYg78+ayqPvnHooGtGI6+K09hIOlral5eRGkdaN8H/NIEt5j21/Xl9y2kHrZ9x4rAeAxPLD8+I8WPqLv/MBJJ83ydILVv+TfcVqA5/e0dKVwE/qL99aMyt9q854nrHA0hcmJBeyfbTwmk7i4a1wMp8s9orbgHa7jeXl5YFtv74pOaucFj8xcmDRhgck9O9rTM2YEVLM8pl6LX/faE0iduLL13A+hSTz5GEI+WtpEBKORfuW8ZfAL/a9RhYY4fehRbBZjRK/twye5F+WTRtJSYrt0Dw9MsJy9GZ1biuPJElQLpcxNTWFiYkJ1Go1tNttJAm8YIRlANdqo/ovXzCDUN0VkgQxqqaYz62Z9uJHwpdKpRSc+MB8FvnicdYWUa9DWp/lA8oy3Zgw2jNZjpDRstKUfUKLnxVYc54orlyHlQUga+FkuXnaHLCG9CEPb633kuGsMHIQpb3LSj5gE9susTYgJFP0vtVqodPpqOsoZdhHmjY8IAkJK4Xj/9QI/PhcTVHw51zgeTiZj9ZhLSRvPeOdwjfK0Dq5BkYIkEiFq6Xr40t2WJmW5EOj2I5tjWa0jmelSeWWPGnpxihWDcxY4UMjHeIt66iC4vHzBPj3KIi/Uqk08EXdXqeDxvfvx/Q5Z4DOPmv38+i6HMA+RtNpOxz+9Xejv7C4pp64ocjn8/hev4x8oCk7SR6zY1vS80RoKobOGqEP7dH0TIx3JIYsw+8jno/Pm6IpfNp+yfMP6aP1UgyYl89jPnlPZc6y44uXW8q3NoCwAJZWFl+b8X7I5dJXtiwyECM3sQvRfYMKKVe+gQbVn7yWaWn3RL1eD41GY+A7Nhpvkk+NNGAUq8cs2tCABNBduT5jwSuw3++nHxmiERwPZwmVVul8pEvP5NoNi28JfCwh04yDVIJaR5KgxCqHjzQQIPOR+YU6iE8J+UYzGg9ZRi/rNRQhJWClox0SFZu3BmJIbpvNprp9j5Qz//7LaT/zLJx27nYk+dU1IpV8D91+gmq3jL4DXKeH+//Db6D9gx8CWNsGlGaxWMTo6ChmN03jfjeLPUkL2kHlfST4zubHol8eQekYMJLni8gFmbIcnLKCEeu5BfLlNf/3jT5906Ax8qHxEptOVtnX8pLEd/L5+l5MPyLQor3T4vv0H09DGxCFrmP6K6UrdaWWn6UL+EDIGrRpwMdnYyx+LZL80n+v11uzwyYkP5ru9wERn30M0YYGJJqSp0rzTVFwoaMfBxJScflACRc+jT+NNyuc5FHyw3m3SHZoyad2NklIQfN8ScFYilDyHQJXlkLU+LB4sijU0UKGRr7PivaBtZ4QehbDn+zI1H788+Hk5ZPp83qfvugJePL7rgCQQGaZTxzG800s1nO4983vScGIXBhZLBZRLpfTtSlTU1OYnJzEh4tn4m1zt2Ki30KCFV9L/9j/gdEtuOGMpw2sD6Hj4MlzQ56TGAAq731yqxkQHsZqS02utf4v+fDJcwxpcugz+hbvWpohebPiZZV5DXCHwLcPUMWCn9ACWQ6utLbUQAYHJb68fXpUqz/fwCO2vq2+Lt9LGXLOpQMZbdsvv5bveNtqP61sWYAI0SkDSJJkdYpF+1F4IgpvbQcNdW6rEWT4EP/Ei9Vh1msIKS4ZFRIwC6hpowS65s9jyiQVCaXl+5gT8enLJ+tok8ofO0+qKUdZfg7MJP+hUUIsaTJGSoIDEa4QpJeNPA/OOex588vg+g65gvapeKCYBx787b/A0te/MyAzBB7GxsYwMTGBsbGVg87Gx8cxOjqKcrmMbqGAv93yIlw4fz/OX3gAI702Fkrj+M62x+H72x+HfKk8cKIsgRBaWK65vS1jod1r76RR5+n6gANvf+kl8IEbKw2NiB9K38e/BSg42JdATobh03kxX9yVz33v+L98Lp9Z4MJqE7oOnbitpWmFoXQt/a7pLfrSeEy6kvjZIlY+Gg+a/Gu6RcaXnjqSBzr9lTyr7XY7BSQyfe7NtQCG9e5E0CkBSCwkKjssf07CxkGJT1i0fGU4qQRiEbIFRoi0zuzrCFa6IdLylXFjRoUWsOBtYXlqLABmrX/xGRgKT8pYdvSYkZn2XCNase7jz5Ip3zsuZ+QdabfbA54R+mlneRTGKth88bkoJT3k0YED0HZ59NmO/36niy1PPQtzn7spnVIpl8uoVCqYnJxMP45XqVRQqVTSaRvantvP5fDN8afilt1PG9jCO8pOXdW2o1pllW1gGUMf+eozpi9IitELWdMKARztvfQG+NLS0pb8W4cG+oxRCHz7+Ld4kromRr9Z+VsDIi1PHt/S/xog5O9IpmUdWjxo+Vllsd7xa3lui0yDe1JzuRyazab3MM3jWVsibaMFYCw6JQCJfMZHssDaBWrUGDT/nrXSrG8GWN9GscCLxrdUypqSlvGyEJXfckWGFLYUfAt0aKM2q/NZ5ZZ1F1LWWtq0DVurb2sRnE8Z+fKSoNYCJ1qbakqL/zj44IuxKQ4BAQAoFotpmbc974nYnG8glwCU/Bg6aLk8av0ScGxBa2ViDJs2bUpBx9jYGMbGxjA9PY2xsbEUiMhzQ7TzRSQo0s7PsOqQ6sICaTGG8HiIDyqyhPcp8Bi5jennMeDDMpY+g2XdW6BEyqzVX2TdWB6I0OJQyYumRzS9rxGvE+lV1MpMssAHRFr9c50qAVZI78eWW+oZCsd1HQdHMv9+v49Wq4Vms5naPSvtLO9i7FssbWhAQmR1CPppc2U02vS5qLhQWGhPQ9laGrHlCCkjzTCHFLcvnAyTZcSlxfflL695mbhS0tpLKlV6Jk9e5GlQula5fQcRaaNGq2zaM5ITzVsklSGPr3V4mp6R2/T4uidu8AmYlM/ZgT2/8xIADhDrR8roAbk2av0yknwOpdk69uzZg0qlgpGREYyMjKBSqaTH0NOPe0e48pOHnclDuDTZ4n3xRJLVR/l7+TyrvMvyyP4fAhiStAWU8v16ALkMb/UFqWNCaVttKfUTT4t7Y3g4awGpjCPTlOlwYCL/OW8EhGR+Pt1rgQyNVyvNEBiVZZT39JM6QAJFDZRSvEajgVarNXAGCf+3BtsWaXUr7a4F4jTa0IBEAxGyY5BC5OEpDB0fzxW9tmKe4mjGL6uh8pHWcKHRZChf653WCXykdRBLGH3KzdepfaBJe2+5HYlCW/18dSA7uRZP4903IuLKSlM2dM2VBf24rHLFpK3LSZKVLwFPv/JpZvmSBKgkPdS7PfR6DlvuXUBl9+70hFe+5oOfokqnwBLo4cCf17dlQDVwYAH8kAKP6Qf8uQ+YaNcxpJVLxo9ZB+FLWwMAkmJ4jgUcPvK1i7YQUlt/oYEtK48QsLTAjU+naGCK2wutn/pAkMaTlCktrFUvFrCQg2cy+lJ/UBrc7lHd8PUjMp60j5I3Cfj4c56PTMdKU6MNDUhIIXOykKW85wsEJfKUo2r+zjeayzrKsuJaHU8rn0UhQEKLtWIExQIjsR1Tey7jys4o49HUC28bn6BL5eILp937vCdSSfrkTZZZxrc6LikMklM6yEjWFbUhAZMUuFTyGHni6WqZV/MGyrkeKv/yfYxPb0m3CGtHufNnNHXDyxYiC1Ba8skpBpRbefL4scBE8ukzLlrfiCHfaFy+50bdAkxZjJ9VPp9u4zqSZITHl4MDaZRl/jJdCsfT9fVZqUdkG3IQGDNYCFEIqEp9pxlveq4BMP5c06sSMFjGHxicpqbBDT8qoNlsprqEp6GBFA18WLxo/GSpY2CDA5JerzdgVKWhswwiNZRsFN6x6JmMF6IsoCRrY2Wh0AhDdtoY0jqKtZ/dZ8AtJcLDcoXGFaDWyU8UhdK0AIUWlyskn5GwOi+BbQIY/AA0abQkUOt2u2jnehEfqnKYvPcoNu+rw42NpUqR77Ah8CFPWZV9LVRn1ruQwaJwWUf3WhvF8roekJHVuMlrC4xovPt4Wy8gkv3Ol7Y0tlaeIfngOiRGX9EzCzTK+JZh9XlBqA9ooMkqSwg8cJ4l/zwOn+KQO3R4elwP0CBElkVOj9EH9er1ejplw3U3HwDF9GuNYm2kjzY0IOEIUJJlaHnDdrvdtHG4kGQBJaGGs977DCt1Ch5Ovo/Nh88xyvghZcLTlh1L44N3Zi0+D6OV32dE5LxpqN61BWuSpNKRfGoKQcuH86uF93Vwrgg4L9wzIhdeS6VJz0jpNBoN1JcWMNbpISnaWxYTABOHWygWiwOKiLbm0mmv5Dmhd7JeY2TIF06LZwFWno4vfR8I0fqwJa9WGhqfWUmmq+WzHsOgkaVnQs/kPV/gSYsorXRi9RZ/HvLc8vx5fB/o4PcaAJRxLB3vqxtr8CuveR+TzzkgkflKfSKfa/VN6yTz+XzqHSFAoqXFeeV6Wvtp+fvKHNtHNjQgkaNzDe36KqLb7aLRaKDRaKgjPu2e8oohX7jYEaYUdIsH+VxTdrI+5HYxaysm59MHIihNuatCgitSZJZysEYnEqit4RWrHYBvw7UAjDVa4XxoSsgayfo6uSarwOpn2uUuGnnOiFSW8kRg2gq8vLyM5eVltKp1JF+7H6PPeSySvALOnUPScxi7twowfugQNPrJBaqyLiyyZDukmGIUl9WvQ4bR13ekgdNG4usFLhpvsv/7QE9M+pasynsfgNKmbKy+boFCno+Pfykf3COgGWZeX3ydCB+Q8rNWJHDm8mvdS558IFzmQfzLxbncC837Luebyizz5v2fdAInbbBG4QmMkPek2WyiWq3i6NGjaLVa6SBH5sPXpWkLX+W0Drchvv9Op4MY2tCAZGlpCcVicUBw+LwhPbMOtqGKajabAFYqt1AopPH5P5EGELR7KVz8fYg0w0NxYxWUDGPlGwOKfEBM44d3MK4kfPXG32v5+BawpvXNXvH0pftTpmkpaet5yFBRGN6BVX6xCp74ji/+L3cZaAu0yTPS7/dRr9exvLy8snjt47ehcv6jkJusDIIS54AkwaavziDX7sMlSeoF4aeqal/ejZWV0Dvi3QKYllEL5XG8JNvWZ1x9/UJLNzasRrEDGC1Prd7p2gdQZFoyvRgAZukMzZjSO20nmSTNO8Pz1HbnEIXkSgMovnuehzbY4jv+OG8yX37NDT7/aeWQQIUGM7RGstvtYnl5GQsLC6jVagPgRvLJZwvouQQs0g5JUKJdWzMZkjY0IFlYWEjns4FVQZJnJfBRnkS7tVoNY8fmz7vdbjoy1ML7zu4g0kZjlkG2SIurxZMKPTSCsvK2hIu/tzogT19T3nKdigbgOEkAowELeifbB0rxZeeQbeWrM226Sz73bSvW8qRyyHt5toiPT163PF6tVkOtVkvPGcgvOnT+7OsY+/+dj965W0FfwivNdzB18xwq9y0NlIkvWOVeLqts/N8XxnomyxmSDR852PP80jiG0g2FD4FRST6jbAFhK65P1uR/qG61ODxfq40lmPSRpUt89aYZaF84X7trXotQHrJcfJpI04USwHCvA6VP1zQdZZWL63wOEuT6Es47gYh8Pp8CEL6Atd1uo1qt4siRI5ibm0u/9qvVG/eccB4kMJH173uWBURvaEDSbDbV7YWkXOWhTQRWSGi63W66lbHT6aTuapo7l8qZCySRRM2++c+YUaYUBh+SlsZaO7xHGndLEUpEbOUpy8D5k+CIC7AEEpwnzoMc2VB8vs2UpzOQHvS6kgpC1rdVDxYY1HjU4lqARHZebdqGpxMCIzQCWlpaQr1eR7fbRalUwuTkJLaVJjF9wzwqd3TQHssDrR6S+SZ6TPHwBawc4PO21Hi36s4in3HRQHsWSjQ0GuBDk2cNaFigQBvtruFLiUvxNYMuQXhMOWSZZD4yDDd4PlCg6ZvjIakXOE/aoIPurTVnWtpau/H+ygc8GmkDSEpTW/chAQ9/T9dSD2hTLJJfrgskv9zDwRe+t9vtFIwsLCygWq2iWq2iVqtheXkZ1WoVc3NzaLfba/Qjr29tGkbyKt9lDWvRhgYkfJcMEVVup9NJhUWeIknhnHMoFApYWlpKj9N1zqFUKqFUKmF0dDS9LhQKKRrlCwDpACm5FZKICxv9pHGNJTlqcc4NIFre8SxjKdOylJ+v41vlkuG0vOlaTgXI8NKTZSlEno6vg/jKwnkLGVpeflJEcn0IVzzS46SNUqXCsoyhlKter4dWq4V2u41arYZqtZp+uXpiYgJbt27F5s2bMTY2hqSXIDe3snW4K/iRB575DJ3k3WdAQ/W/nrM5fKTVlVV/3I0u39F9aCSvpevjJRQvqz7QQEhMnj5woXkQTiRxXcXzk/zxd74pDs63BfSkfIY8tpq+6vf7KBaLankonNSpcsE6f8ffyx/3cvBvV9F3aGjbLg1K6HmtVkvjLCwspKey0pEB9GV7knvtCAU+bWOV1WoD/m69YHZDAxKNpMHgjSwFnRpyeXk5XdzaarXWuK+5opYLQcfGxtKPjY2OjqJSqax834MtvJKeGT4K1RZf8Y7lG4FJ9GyBg9iRq09ZWUbViievJV9ytTxPB0AKHimOnDPmYMBnDK3RLX9nKXbNIEmFQzxwonfyGxJW/fE8+VyxFY6USafTSYEIKaNcLpce+T49PY3R0dFUjvhPgj+a2tQMEm8XzYiux4hlUVK8jdcAB1rIDP9Kf62tZf/h4Xh8q//4ZCsE1mS5NCNopcvjx5CMI9tfDnToeaiNfGuwstSjxqdWH5aOkWF53ct26Ha7awCLjK/xSX2aT/9LUKvx6Zwb2CXHvaGkH8i7QWCj1Wqh1WqhXq+nHk8OTOgIALn4nR8PQD9+XADFAZAuZyAe+TsJrmTbPFxgFdjggMTqMFSZvMPQymYSTLqnvdnUoLQlii+Q1YiEr8g+IDY2NoaRkZF0oS2BmpGRkTXrWvjn2Dk44d4T7qrk4XgZtTrhq6MlGOK8a6iex4ttA945rdG9BGiWR0MaZjKW2uiDl0GmQ4rH8khQfWt5WzIleebx5bwvKQOZrlWv8jMG1P7aQVHtdntla++xBay0iJWmHMfHxzE9PY2RkZEBcMSNEU+frrli9RkjzfDy51Z8Dkytdz5QLPNeebD672s/ix8iC4j5wEhIMWeNrwEYWSa5uNsqq7yXQMwXn3jxvQ/lGRuG5MHyashBmU8ufACQP9PykgCNx+Fpcl65sadwfIE592xwoME9IO12O31OUy4URp4VwvktFAqoVCoDyxFKpVLKw8LCwkB/1xbGkm6lsnAdxOv64QQgkjY0IAHWKhrZmXkH5kqgUqmg31/52JA2cqS48p5TkiQD25loka088VJ+jIwAyejo6Jq1Lvy4br6wlnY9AKtbM3lelpElkttwecfjI3h65zMKsg40g6spBjpBkOdDaUhgwRWw9oE8XxxgdcqOSMqBtj1PglguM7xeLBBE+UgAwN9Lwyv5pnsJpiitpJxH03WxWF3E0sISqtVqqhgrlQq2bt2Kbdu2YWJiInUx89X2XPHy01g5yNN48gFUy2hq8UNgQxoHGccCvKG0NX7pOgQupNzxvqaBbi2+ZeR42tp6CfpJOZLpUnx6rq1/8gEy7d6ikGz42kiGo/cSeGtpWnlpIMLiV15LPa99toHXKwEFDioajcbAei4CFvScAxPuweD9UdNtclBMg1yakt21axfGxsYwOzuLubm51ENCuk/qJOLLOTdwwCHfVScPDKV8ffeyro8HwGxoQGIpAE68grjh58ZYNqCGxqUR4wLL56FJ+VMcDVlzj4U8BVN+IZXzTQKUz+dRKpXSOHR4VS6XS9e88PToXaVSGVjwS2nxtSfUAanjyFMAZZ37jKq8TpLVM0ioXNLzwetLUxg8LV/bE98anxaY4XlJQMLlQisjJwlwefmk0eVklck5h9bWHFoXTKK/uwIkCUYap6N1ww8x9/E74Y6BkS1btmD79u0YHx8fACPa6IdkitZHEd+yTkJlDo2mYkfaWh1bcSwjZyn3kHH0GVitDrR3Eozx/mSlp8XXAKsFRjTefQDGx78WPra+JODSymWlYwEzzpfs83yQBqwOdPh0CB8M8H7IDa7WNxqNRnrN12kQAOGeCw42pCeCt4+mv6S3ktc3lW90dBRjY2Po91d20JFOy+fz2L17N37qp34KExMT+OpXv4rZ2VnU6/UU+EjvKgdFAFLgRPaLbAqAdI1KTHseLwCRtKEBSQyRYiCvBE2xcIEgIxkCNz5Fwt8T8cbiI1BqbLm1UjOURNK9Ltek8HMjCEXTs/HxcZTLZVQqldSdPzU1hYmJCYyNjaUCTMJH28VoWoCmsYrF4sp20mPby+gwLmAQHHIFIOvGN1Km0aE21cIVpmUkeKfXUD6Pz9uFGw+ehm8UwL0qmmLnCkoDQ5K0UR6B5e6jR9C8dBMFXKmrkQI2XfIYjD55Ow78yU2YLI1h+/btmJycTL1/loLmYFhOBVpl5fVr1blPMWUFJhZg4yN/H1jy8erjI4u3RLa9xTN/56trnqaWnqVfpCzLcsSAAvnLwj/lxdfsaTz5eJF9F1jdQcK9FtxQk5zT1Mfi4mK6FoNPidAUCv8mFHkMaJGnrHttUBGikMzxZ7KdpeHP5XKYmJjAmWeeCeccfvjDH2J+fj5dx0IeErJrNIDkHg+yL/V6PX3X7XaRJAna7XZqSwjoaOWM7a/reabR/xWAhHYRkOeAnsuOJ4GBJInoNSPCUbw0nHLxk5Y+/+ekeSv4j4wLgBSQ0MihUqmki2+73S62bNmC6elp7Nq1C1u3bh0AOZR/r9dDtVrF/fffj4MHD6JcLmNychKdTgflchm9Xg/z8/MpWKEdH4uLi2g0GgMdXXpE+Boarfx8FKTVkTZ1JsPw45Fl+3CgwL1RmsdEI817o4EmajfNsEjwyZUUtUO/30er30Hv+TuBRHFX53Mo7xjHjsufiPxXjmJsbCwd5VCb8CkknjcpLOkV8dVraJTEAQ9/xw1VCNxwmZHyI6fdfPHXS1pbyXT5tdWnuX6wBhohzwv9ZD+QnjeNb/IYhMon5dQqp2w/mYY06lpbcnnUjL1zbsBjwddjULhSqYQdO3Ygn8+jUqmg2+2i2WxiaWkJ+/fvx8zMTDo1wsGHBOS8zHI6WNZ1qB3l4Ef2f0kxA998Po+pqSmcfvrpaDabOHToUAoecrkcGo0G7rvvPvR6PRw6dCg93JN7j/hW/l6vl3o+OIAsFAopSLHaLgsdb/87pQGJ9I74phq40eTx15OfpngkcLHI6vRaOP5PHZhQMPeg0FoZ2k1Ur9cxPz+P/fv344wzzsCWLVsGPitPP66EaIFupVJJF0smSTLg4mw0Gjh69Cjm5uZSVC5H5bT+pVwup1NQEgxwg0mdUwN8vhEyjQCIuFeJLxzm6ctRJfcyaIqaeJEGgn68XDHGkt7TaCeXy6H92BLyJfuAsiSfw+hFp2Hi+zmUS+WB9SvSncx55qCZGy55qrEFPjjJtSdaHA2IyAOftPwGyiraSNaJLGcoPZm2Rr5BApWB8yb5pLJbMq7JM+8rPmOoGQ6+joGOCCd5kmWSYJoPtjTAbQEP7sng0718aoQv9JR9i3tAOHiQX2NPkpWFm0tLS1hYWEClUkl3plSrVSwtLaX6iK/R4DKmeQTloMEH9DQ5sUCaRjF2IEmSVEf2+/30SApgFWjOzc3h3nvvRaPRwKFDhwZOiOWy3+/3MTIyku4mLRaL6WCx1WoNgBPe3ushX1+JTfOUBSS84fk6Ct5xNGMp42pGT0O9nLjwWEbTJ5C88Sxh54qZx6F/8o4kycq6jXw+n67gXlpawsGDB/GDH/wAW7duTbeHTkxMrPnU/OzsLJaWljAxMYHl5eX03BUA6bY0QuedTgdzc3OYn5/H0tLSwKiIK2a+TgbAwAJeKrPckUT1SXOd/AReeidHWrxdc7mV7bDkHaJt3JqxICI3sDTsXBHTc9rjT9NYXIlSW/X7fWw+Ezj93BzGtwHoAwv7HQ7c5VA9MrggNl0DM70N+Z4DCh4FVsqhMF2GW3YD/PA5bmls5By2NOaW4dPIeifTk+H4lugsAELrlwDSNtd4kH2K91F65stPGyjINLkO0YCCc25AricnJ1EulwfW/NCaMG485RlKRLyP87x4Pzxy5Aiq1SoajcaAB4L6Jl8bweVZAgZprKj+5DoMqVuJT76A06pDuqf24TxQ+aiPLSwsDJRB41NLW64t03jh8qHxSKQt0LfCx8gZD0vh6HwR2nxBgKTVamFmZgYzMzOYm5tDv983Pc6kMyuVCiYnJ7G8vJwCRL4gVzsfiw9q6X69gCWGTllAAqxFo9yNxyuVd3QNmMg0Y4RK8nCiSRMKKTR8zQr3ntBW50ajgaWlpfRQLFKE5MorFArpQqmpqSlMT0+n61CKxWI6B0tTNL1eL92CKkdEmrGjf9+UCX9HSlmuB0qnH8b7QG4lLpWLgEuxWMTExAQe97jH4VGPehRKpVLqjrVcqFQeqei40iWXcb1ex+LiYmoAaCs5N7RnPgM4/Yl59PsOuVwC5IDpXQ6bdie499+7OPj93kCdtdtt5GoNlGNEqLtaZ8QTPyKaPFP0ZV8CkSQ37XZ7wEjz6RxN2fpG9PI9b2uZTki5aXlrAMa51Q94+YC6licHjjJPX//l/UrzYpAXhIj62NjYGHbs2IE9e/ZgcnJyYFt7qN5iacuWLWsOxyIPAl+wzn8cBPB+y6fKZP1xTwTdS/C23nUYEgRRHdFUDukzvnuPA+0spMkz50fKBm9rOfjgcWPlSuO30+mgWq2m0+DtdhulUikt/8zMDI4ePTqw09MaRJMOHBsbQ7lcHlhrA6xuAuCDPNmn+bIBi2ernw09JMcqkUYZckU0J5+xDI2ceHwrTIzb70SQz02oKRVy4y0vL6cghDoR1RspGzK2SbJyOm2pVErfUb0CSI8pbjabA94EyY/sOJqXgtqNr/Og0Rb9CJzk8/mBr/2S4uXrh/r9frq4Vx6RbtHIyMhA/WqyQgZ9cXERd911F+65557UCFDnfvRLxnH6mVRWXu6V67OfXcC+uxfRqiMFc+12G+2v1TD1ksea/Lm+Q3KkhdbRGsa3bEE+n8fy8jK63S6q1Wo6sqI60kY82hevfWsjZBtyT5fcJSYNLY8jp0e5nFjrhzRjR//VanWAR/5eeob4KF7zlmjySelI74AEZBSWPkvBP1w4OTmJqakpPOEJT8DY2JjZrla9aKBdhiUe6YNqR48exezsLBqNxsDJnXL6RPNy+MAdH/honjbpTYn1svFrPp3IBzAElvlzCWBCZIE/uaYkq12Q6WWNB6zqZ9KnHHSRrllaWhoAZHzQRvmQvqQdNTQoofU3XBaIqG8SkJGnlMv+w3k+XjolAQk1Bq17oFGjJqxapw4Jj2Y4Y/ixnmUFKDFAiN7LfOjHzzGha6tc3K3unEtH34Sw+ZTA8vLywOetNSUm65hG8JrhIiVHIIl3FD73nCQJ6JAs6sw0kqCDhpxbOVCoWq1iYmIiNdR0cB25LcnjQgYHWAVMJEMEdHK5XJoHhSXFT0rgiVeciTMmOnD9nvnFFQdgx+MS3P6l+UEP02wP87c8hOmnnj74xV4qdy7B0ufvR3O+g61bt6bKmk+naavoefvI6UveTppMcWDgW9+jjRY5KNLC8byl7EiAmyQJept7QG5FRg8fPjwQHlh74JxUqBLkaPxoU7SazuAymsvl0m9iEailNAkg0MFWmmeEkzao4bxxo9NsNlGr1XD06FHcfffd2LdvX7r4nHbGkdeM14k1LSnrXpad8yF1qS+uBiYsXWGFo+dy0XQWfazpPEsPWnYhtEh1vcQ3CVAfJm8IeWBJF/INA6Ojo6kXlHQW94RQnZEXnMAGb3OSBTmgIP3ycNEpB0i4waXRCUeAGiCRC/8onUeKQvmuR1HJuHJkSkiabw8mgEICSJ6GfD6PkZGR9BhymuIitx9XcuTFkCMsLsS+Ts+v+Sia0iVFz7cG8o5D9bm8vJzGc25lCmdubg6HDx/G97//fYyPj6eAhL5ZRHJD3oRWq5Xyw4EKP6KdFtYtLS1haWkJhw8fxsLCQrqmZNdlOzC9vYjictP7+bdcLsGWM8qoVqtrDMN9f/bvOOs3fgLTT30UXPdYPeYSoO9w9B++i9ZthzE+Po7Z2Vnk8/l09ETASLYFH+VbI1NO1vMkSdQRLNU7xY1tb61vEhi28nfTLgUkc3NzKu/ciPFyxwwKqM9oJMERsOolorohLySlQwsy5+bmcO6552JqamrN2hCZB02L8u9t8enCVquFRqOReiYbjUa6jou8ZQAGdq3IuuEeE0D/4q0PkPjkyCc/PtLAC9dh9Ix7uOQUj4/koCgU5kRQLG8AUh3CvVrSVnBdTXqKFq6SLuNghAMSWT5eD7zfcXnhA44T4RGRtOEBiWxgmiunQ8BkR7Pi+1x9PiHKImAyni+92DQtACNHF/yngRIAA6iY//jCNb6OgxtqmhpotVrpt3yoPFJxy1GpLDcRd8sSoqfn9Iw6nZzy4fPicp6fT9kQ/wQy+Om3PG9+TeWgsADSuXo+P9/r9fCoC8+Bw4oHxNeazjl0272B7cppe3QS3P3/fgnjZ23F5ov3ID9SRPtQDcs3HwQavRRE0pqZ5eVl1Go11Ov1dG0IByG8XWJkjHtQYuNQHVlGP2QMQiBFjtYIPPtAhnQzy/5h5SdJ89xoxPsb56VUKmHfvn24/fbbMTExMTAtKIlkiXv5yAhTXRCg4Cfy0poDufVVm7KmMvhGvqGpENk/Ykh6pmRaMn1uDDng5TtEtHaUesaSw0eSYvQ8n5r2DZq5lyyfz6efkeCeE+dcKg8ayOb1Jgd3Fr8aOLLKGQteNjwgIeJuKBrthsBIlrS10dDxkk8Rh/hZTxiZn+yY0oBwYaL5RmAVPfOFndbIRApqFoMm4/HRm1QkMk1SVPSOK3D++W0Jvnq9HsZcDxfP78cFCwdQ6XXxUHkcN2zZjXtGN60AC6EcnXPpAtLdZ5bxlAsnsW1bGfVGDyPdOpYKI2jnCyj3ul5Qct/tswO8cTDX6XTQvPMhzN61f4BvPu3mnMPU1FQ6qiJAonkHpYs2FghnaT9Kyyd31g4nKw8N3AKra0g02eBxtXnvLOWxFKxVRi1t8t62Wi1Uq9X0I57EO99VQ32P+hg/mZkbD/Jw0roqkiO5JkSCy1gKAQ1t1BzrGbGAa9a28eUhn4dkPEv4GPL1AWswJg92s2SXdBoB10ajkZ5HRDqNvgROOjykPzmQ0XZOxXrBstKGByRcMfMv9GqI0iKu5CRCfKSJd0LpjuNhAL9Qc0+HTEsTphDapQ7C8+RgL1RfWQESv+fPOQBKkgT1en11l83mPpBfdXNrvNFHqcrlclonfEHYztYyfuXuf8d4Z2WKJQGws7WMi5YO4Ybtj8E/7T4PSFa3JZJCSBLgp1++HU9+yiR6PYd8PkG/75AsLWFzbRkHpqdR7nVVT4lzQHO5i+/ccGBgt4sczUrwlCQrJy5yjxXtoFpaWkrXsWj1bU1hauQbOYcUP+XB25KvF5LbZGNoIG32tV++c0jjTZOH9QISXz78ufTC8MXZdIIyebZoqpDaUwMkuVxuwPvLPwZK6wZoNwYZKWnstTrX9EJWigUgWdN5OLwXMYNLS7+uZ0DlS896L4+qtzwQ3FPCPWpE3W433XFF/V5bwM3z595fvgOLyOLneNtqwwMSAAOdmE/T+MhqXPrFImjfs6wkXWEcjIRcZj6SnU8bgWvh5TM+asuyyjoW4PmUBOVJ3ghZFr4ynlA9j2t1PL4mJp8k+I/3fB1jnRb4eDB/zOj9xJH7cWRyM27a8dgU8FA9XPjMSTzp/ImV8HlyX6/8F/o97FhaxKGpKUy0W5A10e318Xd/8m1UF+sp79J7IQ2JXBxKa1sWFxfTA/D4x7S4JyGL0pDhueeJkwYeJciX7+neAt2aPPAyO+fAv/bLdzXJdub0cAw2pGxp/+T9oG32ExMT6cJXvo6Le0k4ICEjw72TdM138vBpOo0nqoOYQUIsxerMLLKneU1ONPmARqiOQno4NMiz8iPZl2t+LJJAAVgFHByg0Fk0kviAjHjqdDoDGx0kAIn1kMQOWIk2NCAhEEIjDOqcITCi0fG6ck8EaYLOBVkDLDKuTE+e2SB3F2hGg6fHw8jFZJoBscCHBoasMNYzWsMiRwUUjqdNi3HpHQcelBZ5NyjME5eOYGtreQ0PaRkB/MRD38cN02egc8xIrNRxggueMWHGSwBUul3kncPcyBgq3Q6KvR5cArRyBVzzG1/G4qGa2i7EmzbK5Wdc0D2doktGjO9AIHnIYhzkqnrNSGQZGWveCcmTDyhbwJLecxn0eS7Wa3i1NK1pIguE0TM5z6+1M7C6I4r0HS2kJo+l73tEvsHTiQZmPgMeY4zlew3I+oBmLI9a/qHwkjffe+2d1I1yazER93LQvdUfNF4oH+qz5Nnm95p3g9LRdslJGfWVm7fZeuRrwwMSDkasBVsxRMaaK2BLkVgCqTWyT2nGdIr1NrA2IuaAjYy7PLiM805xKB0NvFgd5XhI68jEEzesnE9Zl9oCXYrX6/UGvttD4R63NIMuEhTW+DBWKAGwvVlDeXkJNaxuO96ytYTxCX9XcgBGWm00SmXUS+VjeQPXvu9mzB9YiqpHbsRoNM3ryHLDUtmlXMeCbt8uEMuIpuUOAANfuqGwPmNrKXyN1jv4WE8czbho4JPamJ8/QQu4+Te5tO26Ml3r2YnutzIv+dwHmkK6VhswHQ+okM+ztKU1UMzCh2WnkiQZmAq32jaGP4onT22mMFp6ctec1DlyzRe3E1y21wsaNzQgoQOvgNUDYLg7OYTK6Zn0FFhhrTAxAq+9C41qjgdpUnw58gRWV2XT2RshrwUXNgonlZwG4o5X6Wmj3Vhhl1MN8hwRTakkcCuoI8Byr9NBP888MqEIAPp9h9pCC43CKPp9hwfvOIKv/e0tWD5Sj1aYXEHIaRPnVlfR04iItx3VBVeE2tSLBQZDpAHDGDCtAfMQgLEAks+ASZJ1m0VOtTx8sqrxMiB3yeCuB7mbhANNDshpgMFBp6/PxY7mHw7i5fPpVq3ueBjfvZZeKD9fGB+P8tqS86z1SmXiU+PraVO+mJn/W7reyoN0Jq1nodO8+Y/CWWlx+xCiDQ1IaHspVXSWLWeSqNK4stMETT4LCZxPEfveybxCaVudTsuHKy7n3MAoTCoG7uKT7kNyB/K8SIn6DBGvN8vbJOsj62hEpimnHTQ35H3jW/C8I/fbaQGYK1ZQLY2gwBRRbcmhvtzD6JjtScjnEnz6T2/B9+6aywyuNDnTFACVS34VmufFjZiWzvHIngUsNEUu+ZOjLi193pbrHYFZZck6QpZ8csNklZfHpWv6kS6T/YuH5YudicjjJ9uRvniruec14HIiAElMHYbC8LrT1i9RPXAQxt/JetPaIAaghPiLCecDEaG+lWV9XiiMJYMxAJWHoTUt5J3lu8Jo2lD2gRgbyWlDAxK+y4HTepSV1gCyYrN6LGLBijV6yjLfn6XRnXPpnnXyLBHJfDggkV/f5AvreHwJZiR/2iid4mnvQsDQKr+vE2so/85Np2OxWMF4p5UuZJX0pW2PQYFtqaPy3HrzIp713E3gx8IT9Xp9zM40cff35lU+YtqOgONKer2BRY9kkLhSoOca+Is1HDKedm8BCs43L6eWtzYStniKoRMJVmIo1I/lM95/uKeS9yc+/UxtKfuT3HHGQSktSOTpaoMKLh/HA0p4P7JILjwPhZXEF2zyeuBAlXsBtL5lTeVl1Z88fQtA+9I/EQDQSkcDaRzIZYnPieSJFsvzY+UBrAElofUvGm1IQEKFk9tQgbAy0hqE0uMHaPniZXHvyeusRiGmo8cQ917wTknKKp/PD+xS4CS3otKcIv/IFR3G0263B76ZIUd5Gl/8X9aVdFnzd3Ixn2s5wAEttHD9k68368BXp9fsdRjttNTzQjq5HOr5OQBfXfPuf9/jUNmXQ6GYwDkgSQAqinPAcq2N3lNF+SmTLLopARKs1pnDSpl5PWZVAhuZOugAzZW259+VsTxA2rUMF0tSHkN9lWSW+ky9Xk9BBRkM2v5Lo0/+NVb+ng8ICKjSwXz8ZFf+kUt5JtN6DMbx1lGWeNT/5TMOcrl+4WW0QLFcpE3vrPNwQqTVHddLJwLsZSUOPglAcBAR8l74AAm3lZ1OZ+Bjp3yrsJSt2GMGErcBtdb999+Pxz7W/uDYkIY0pCENaUhD+vGiBx98EGeccYb5fkN6SDZv3gwA2LdvH6ampk4yNxuDlpaWsGvXLjz44IOYnJw82exsCBrWWXYa1ll2GtZZdhrWWXY6mXXmnEO1WsXpp5/uDbchAQm58aampobCmJEmJyeHdZaRhnWWnYZ1lp2GdZadhnWWnU5WncU4Dx6e7yYPaUhDGtKQhjSkIWWgISAZ0pCGNKQhDWlIJ502JCApl8t497vfjXK5fLJZ2TA0rLPsNKyz7DSss+w0rLPsNKyz7LQR6mxD7rIZ0pCGNKQhDWlIpxZtSA/JkIY0pCENaUhDOrVoCEiGNKQhDWlIQxrSSachIBnSkIY0pCENaUgnnYaAZEhDGtKQhjSkIZ102pCA5KqrrsKZZ56JSqWCiy66CDfffPPJZumk0Q033ICXvvSlOP3005EkCT75yU8OvHfO4Xd/93dx2mmnYWRkBJdccgnuvffegTBzc3N4zWteg8nJSUxPT+ONb3wjarXaI1iKR46uvPJKPP3pT8fExAS2b9+On/3Zn8Xdd989EKbZbOKKK67Ali1bMD4+jssvvxyHDx8eCLNv3z5cdtllGB0dxfbt2/GOd7xj4COFpxJdffXVOO+889IDlfbu3YvPf/7z6fthfYXpve99L5Ikwa/92q+lz4b1Nki/93u/t+Z7U+ecc076flhfOu3fvx+vfe1rsWXLFoyMjODJT34ybrnllvT9hrIBboPRRz/6UVcqldz/+l//y911113uTW96k5uennaHDx8+2aydFPrc5z7nfuu3fsv98z//swPgPvGJTwy8f+973+umpqbcJz/5Sfed73zHvexlL3OPfvSjXaPRSMO86EUvcueff7676aab3L//+7+7s846y7361a9+hEvyyNCll17qrrnmGnfnnXe62267zb3kJS9xu3fvdrVaLQ3z5je/2e3atct96Utfcrfccot75jOf6S6++OL0fbfbdU960pPcJZdc4r797W+7z33uc27r1q3uXe9618ko0sNOn/rUp9xnP/tZd88997i7777b/df/+l9dsVh0d955p3NuWF8huvnmm92ZZ57pzjvvPPe2t70tfT6st0F697vf7Z74xCe6gwcPpr+ZmZn0/bC+1tLc3Jzbs2ePe8Mb3uC+8Y1vuPvvv99de+217gc/+EEaZiPZgA0HSJ7xjGe4K664Ir3v9Xru9NNPd1deeeVJ5OrHgyQg6ff7bufOne6//bf/lj5bWFhw5XLZ/f3f/71zzrnvfve7DoD75je/mYb5/Oc/75Ikcfv373/EeD9ZdOTIEQfAXX/99c65lfopFovuYx/7WBrme9/7ngPgbrzxRufcCgjM5XLu0KFDaZirr77aTU5Oular9cgW4CTRpk2b3P/8n/9zWF8Bqlar7uyzz3Zf/OIX3XOf+9wUkAzrbS29+93vdueff776blhfOr3zne90z372s833G80GbKgpm3a7jVtvvRWXXHJJ+iyXy+GSSy7B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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n", - "superanimal_analyze_images(superanimal_name,\n", - " model_name,\n", - " in_image_folder,\n", - " max_individuals,\n", - " out_image_folder)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "6VEjHu-00Z4Y" - }, - "source": [ - "### Zero-shot Video Inference Without adaptation\n", - "Independent of the use case (i.e., zero-shot or few-shot fine-tuning), to\n", - "optimize performance on unseen user data we also developed two\n", - "unsupervised methods for video inference that help overcome differences\n", - "in the data SuperAnimalmodels were trained on compared to\n", - "what data users might have (Fig. 3a, and Supplementary Fig. S5a).\n", - "These so-called distribution shifts can come in various forms (e.g.,\n", - "spatial or temporal; see Methods). For example, a bottom-up model\n", - "can not performwell if the video resolution or animal appearance size\n", - "is dramatically different from those data which we trained on, and the\n", - "animal datasets are particularly diverse in size, which can pose challenges\n", - "(Supplementary Fig. S5b, c). Therefore, inspired by45, we\n", - "developed an unsupervised test-time augmentation called spatialpyramid\n", - "search that significantly boosted performance in three OOD\n", - "videos (Supplementary Fig. S5c–e, Supplementary Video 3, Supplementary\n", - "Table S19; and see Methods). This is unsupervised, as the user\n", - "does not need to label any data, they simply give a range of video sizes.\n", - "Note that in practice this does slow down inference time depending on\n", - "the search parameter space, and this method is not needed with topdown\n", - "posemodels as top-down detection standardizes the size of the\n", - "animal in both train and test time before the cropped image is seen by\n", - "the pose models.\n", - "\n", - "Secondly, to improve temporal video performance we propose a\n", - "new unsupervised domain adaptation method (Fig. 3a). Others have\n", - "considered pseudo-labeling for images but they always required\n", - "access to the full underlying dataset, which is not practical for\n", - "users33,46,47.Our approach is tailored for pose video adaptation without\n", - "the need for the ground-truth data. The method runs pose inference\n", - "\n", - "on the videos and treats the output predictions as the pseudo groundtruth\n", - "labels and then fine-tunes the model.\n", - "First, we used the animal’s size (estimated by convex hull formed\n", - "by animal keypoints, see more details in Methods) as an indicator to\n", - "measure the improvement in smoothness of video pose predictions.\n", - "Qualitative performance gain for SA-TVM is shown in Fig. 3b–e.\n", - "We also use a jitter score (see Methods) as the indicator to measure\n", - "whether video adaptation mitigates the jittering that can be seen\n", - "in pose estimation outputs. Overall, our method had a significant\n", - "effect on reducing jitter (F(1, 23286) = 190.03, p < 0.0001; Supplementary\n", - "Table S20, in all but the dog (p=0.36, d = − 0.03) and Golden\n", - "lab (p=0.62, d = − 0.06) videos; Supplementary Table S21, Fig. 3f–j and\n", - "Supplementary Video 4).\n", - "To quantitatively measure the improvement of video adaptation,\n", - "we define adaptation gain and robustness gain (see Methods) to\n", - "evaluate the method’s improvement to the adapted video (a subset of\n", - "the video dataset) and to the target dataset (all videos in the video\n", - "dataset). We used Horse-3016 where 30 videos of horses are densely\n", - "annotated to evaluate video adaptation (Fig. 3k).\n", - "We compare our method to Kalman filtering and so-named selfpacing33\n", - "(see Methods), and find that it significantly improves mAP in\n", - "terms of video adaptation gain (p<0.003, Cohen’s d>0.785) and\n", - "robustness gain (p = 0.0001, Cohen’s d = 3.124; Fig. 3k; Supplementary\n", - "Tables S22, S23, S24).\n", - "\n", - "Notably, video adaptation outperforms self-pacing by 4 mAP in\n", - "terms of robustness gain, demonstrating that it not only adapts to one\n", - "single video, but to all 30 videos in the dataset. This is important\n", - "because our method demonstrates successful domain adaptation to\n", - "the whole video dataset rather than to a single video.\n", - "Our method does not take extensive additional time, and practically\n", - "speaking, can be run during video analysis. For example, if a video\n", - "(of a given size) can be run at 40 FPS, our video adaptation would slow\n", - "down processing to approx. 12 FPS, while self-pacing would be closer\n", - "to 4 FPS (thus slower and less accurate)." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Upload a video you want to predict" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 94 - }, - "collapsed": true, - "id": "PK3efA0I0Z4Y", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "59dc148a-6546-4946-b645-6a82595a2639" - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " Upload widget is only available when the cell has been executed in the\n", - " current browser session. Please rerun this cell to enable.\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Saving dog-agility.mov to dog-agility.mov\n", - "Uploaded files have been moved to: /content/uploaded_videos\n" - ] - } - ], - "source": [ - "# Step 1: Upload files\n", - "uploaded = files.upload()\n", - "\n", - "# Step 2: Define upload directory\n", - "upload_directory = Path('/content/')\n", - "\n", - "# Create a new folder for uploaded files\n", - "in_video_folder = upload_directory / 'uploaded_videos'\n", - "out_video_folder = upload_directory / 'processed_videos'\n", - "os.makedirs(in_video_folder, exist_ok=True)\n", - "os.makedirs(out_video_folder, exist_ok=True)\n", - "\n", - "# Step 3: Save and move files to the new folder\n", - "for filename, content in uploaded.items():\n", - " # Save the file to the upload directory\n", - " file_path = os.path.join(upload_directory, filename)\n", - " with open(file_path, 'wb') as f:\n", - " f.write(content)\n", - "\n", - " # Move the file to the new folder\n", - " destination_path = os.path.join(in_video_folder, filename)\n", - " shutil.move(file_path, in_video_folder)\n", - "\n", - "# List contents of the new folder\n", - "print(f\"Uploaded files have been moved to: {in_video_folder}\")\n", - "\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "JoA-RATSICj_" - }, - "source": [ - "#### Choose the superanimal and the model name" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": { - "collapsed": true, - "id": "OiRAP9XD0Z4Z", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "superanimal_name = 'superanimal_quadruped' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", - "model_name = 'hrnetw32'" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "93xGQKr90Z4Z" - }, - "source": [ - "video_inference_superanimal(\n", - " videos=[\"/mnt/md0/shaokai/tom_video.mp4\"],\n", - " superanimal_name= f\"{superanimal_name}_{model_name}\",\n", - " video_adapt=False,\n", - " max_individuals=3, \n", - " )" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Zv3v0QgSJNOg" - }, - "source": [ - "### Zero-shot Video Inference without video adaptation" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 0 - }, - "collapsed": true, - "id": "poqynL0UJTBp", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "c4877231-0aff-4cf2-9ea0-b20ef230549f" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "running video inference on ['/content/uploaded_videos/dog-agility.mov'] with superanimal_quadruped_hrnetw32\n", - "Using pytorch for model hrnetw32\n", - "Task: None\n", - "scorer: None\n", - "date: None\n", - "multianimalproject: None\n", - "identity: None\n", - "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - "engine: pytorch\n", - "video_sets: None\n", - "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - "start: None\n", - "stop: None\n", - "numframes2pick: None\n", - "skeleton: []\n", - "skeleton_color: black\n", - "pcutoff: None\n", - "dotsize: None\n", - "alphavalue: None\n", - "colormap: rainbow\n", - "TrainingFraction: None\n", - "iteration: None\n", - "default_net_type: None\n", - "default_augmenter: None\n", - "snapshotindex: None\n", - "detector_snapshotindex: None\n", - "batch_size: 1\n", - "cropping: None\n", - "x1: None\n", - "x2: None\n", - "y1: None\n", - "y2: None\n", - "corner2move2: None\n", - "move2corner: None\n", - "SuperAnimalConversionTables: None\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 250\n", - "device: auto\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 39\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 39]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 10\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 200\n", - " pretrained_weights: None\n", - " seed: 42\n", - "metadata:\n", - " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", - " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - " unique_bodyparts: []\n", - " individuals: ['animal']\n", - " with_identity: None\n", - "Processing video /content/uploaded_videos/dog-agility.mov\n", - "Starting to analyze /content/uploaded_videos/dog-agility.mov\n", - "Video metadata: \n", - " Overall # of frames: 183\n", - " Duration of video [s]: 3.10\n", - " fps: 59.03\n", - " resolution: w=1128, h=630\n", - "\n", - "Running Detector\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - " 98%|█████████▊| 179/183 [03:43<00:04, 1.25s/it]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Running Pose Prediction\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - " 98%|█████████▊| 179/183 [00:35<00:00, 4.98it/s]\n", - "WARNING:root:The video metadata indicates that there 183 in the video, but only 179 were able to be processed. This can happen if the video is corrupted. You can try to fix the issue by re-encoding your video (tips on how to do that: https://deeplabcut.github.io/DeepLabCut/docs/recipes/io.html#tips-on-video-re-encoding-and-preprocessing)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Saving results to /content/processed_videos\n", - "Saving results in /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32.h5 and /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_full.pickle\n", - "Duration of video [s]: 3.1, recorded with 59.03 fps!\n", - "Overall # of frames: 183 with cropped frame dimensions: 1128 630\n", - "Generating frames and creating video.\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 179/179 [00:01<00:00, 96.79it/s] " - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Video with predictions was saved as /content/processed_videos\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "data": { - "text/plain": [ - "{'/content/uploaded_videos/dog-agility.mov': scorer superanimal_quadruped_hrnetw32 \\\n", - " individuals animal0 \n", - " bodyparts nose upper_jaw \n", - " coords x y likelihood x \n", - " 0 563.710938 338.242188 0.944513 557.664062 \n", - " 1 561.429688 338.039062 0.880739 561.429688 \n", - " 2 437.992188 360.476562 0.826738 432.382812 \n", - " 3 436.710938 360.632812 0.808081 431.164062 \n", - " 4 654.023438 337.070312 0.335497 654.023438 \n", - " .. ... ... ... ... \n", - " 174 -1.000000 -1.000000 -1.000000 -1.000000 \n", - " 175 -1.000000 -1.000000 -1.000000 -1.000000 \n", - " 176 545.460938 83.882812 0.881381 542.445312 \n", - " 177 545.460938 83.882812 0.882614 542.445312 \n", - " 178 542.195312 96.273438 0.590828 542.195312 \n", - " \n", - " scorer \\\n", - " individuals \n", - " bodyparts lower_jaw \n", - " coords y likelihood x y likelihood \n", - " 0 350.335938 0.887882 551.617188 356.382812 0.572390 \n", - " 1 350.570312 0.885944 548.898438 356.835938 0.514241 \n", - " 2 371.695312 0.488187 432.382812 371.695312 0.278069 \n", - " 3 371.726562 0.451573 431.164062 371.726562 0.266889 \n", - " 4 343.242188 0.291900 573.789062 546.914062 0.423012 \n", - " .. ... ... ... ... ... \n", - " 174 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 \n", - " 175 -1.000000 -1.000000 -1.000000 -1.000000 -1.000000 \n", - " 176 89.914062 0.783046 542.445312 89.914062 0.540425 \n", - " 177 89.914062 0.784810 542.445312 89.914062 0.544063 \n", - " 178 96.273438 0.610255 538.335938 103.992188 0.712697 \n", - " \n", - " scorer ... \\\n", - " individuals ... animal2 \n", - " bodyparts mouth_end_right ... back_right_paw belly_bottom \n", - " coords x ... likelihood x y likelihood \n", - " 0 533.476562 ... -1.0 -1.0 -1.0 -1.0 \n", - " 1 530.101562 ... -1.0 -1.0 -1.0 -1.0 \n", - " 2 443.601562 ... -1.0 -1.0 -1.0 -1.0 \n", - " 3 442.257812 ... -1.0 -1.0 -1.0 -1.0 \n", - " 4 623.164062 ... -1.0 -1.0 -1.0 -1.0 \n", - " .. ... ... ... ... ... ... \n", - " 174 -1.000000 ... -1.0 -1.0 -1.0 -1.0 \n", - " 175 -1.000000 ... -1.0 -1.0 -1.0 -1.0 \n", - " 176 536.414062 ... -1.0 -1.0 -1.0 -1.0 \n", - " 177 542.445312 ... -1.0 -1.0 -1.0 -1.0 \n", - " 178 530.617188 ... -1.0 -1.0 -1.0 -1.0 \n", - " \n", - " scorer \n", - " individuals \n", - " bodyparts body_middle_right body_middle_left \n", - " coords x y likelihood x y likelihood \n", - " 0 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " 1 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " 2 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " 3 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " 4 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " .. ... ... ... ... ... ... \n", - " 174 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " 175 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " 176 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " 177 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " 178 -1.0 -1.0 -1.0 -1.0 -1.0 -1.0 \n", - " \n", - " [179 rows x 351 columns]}" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import glob\n", - "videos = glob.glob(os.path.join(in_video_folder, '*'))\n", - "video_inference_superanimal(\n", - " videos=videos,\n", - " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", - " video_adapt=False,\n", - " max_individuals=3,\n", - " pseudo_threshold=0.1,\n", - " bbox_threshold=0.9,\n", - " detector_epochs=1,\n", - " pose_epochs=1,\n", - " dest_folder = out_video_folder\n", - " )" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Display the processed video" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 171 - }, - "collapsed": true, - "id": "ObMlVSHAdAcR", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "eac63ca5-c66b-4ece-dac9-79788a3a4949" - }, - "outputs": [ - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from IPython.display import Video, display\n", - "import glob\n", - "import os\n", - "\n", - "# Path to the video folder\n", - "out_video_folder = '/content/processed_videos' # Replace with your video folder path\n", - "video_paths = glob.glob(os.path.join(out_video_folder, '*.mp4'))\n", - "\n", - "# Display each video\n", - "for video_path in video_paths:\n", - " display(Video(video_path, embed=True))\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "qF3C-L19fItR" - }, - "source": [ - "#### If the video is not displaying correctly, it could be due to the video encoding. Run the cell below and try again. Or you can download the video from the processed_videos and display using your computer's video player" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 0 - }, - "collapsed": true, - "id": "iiz-Yck8e67g", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "f5078d12-1cd1-4e92-a2c3-43254d244048" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "ffmpeg version 4.4.2-0ubuntu0.22.04.1 Copyright (c) 2000-2021 the FFmpeg developers\n", - " built with gcc 11 (Ubuntu 11.2.0-19ubuntu1)\n", - " configuration: --prefix=/usr --extra-version=0ubuntu0.22.04.1 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --enable-gnutls --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libjack --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libpulse --enable-librabbitmq --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libssh --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-libzmq --enable-libzvbi --enable-lv2 --enable-omx --enable-openal --enable-opencl --enable-opengl --enable-sdl2 --enable-pocketsphinx --enable-librsvg --enable-libmfx --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-libx264 --enable-shared\n", - " libavutil 56. 70.100 / 56. 70.100\n", - " libavcodec 58.134.100 / 58.134.100\n", - " libavformat 58. 76.100 / 58. 76.100\n", - " libavdevice 58. 13.100 / 58. 13.100\n", - " libavfilter 7.110.100 / 7.110.100\n", - " libswscale 5. 9.100 / 5. 9.100\n", - " libswresample 3. 9.100 / 3. 9.100\n", - " libpostproc 55. 9.100 / 55. 9.100\n", - "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled.mp4':\n", - " Metadata:\n", - " major_brand : isom\n", - " minor_version : 512\n", - " compatible_brands: isomiso2mp41\n", - " encoder : Lavf59.27.100\n", - " Duration: 00:00:03.03, start: 0.000000, bitrate: 6874 kb/s\n", - " Stream #0:0(und): Video: mpeg4 (Simple Profile) (mp4v / 0x7634706D), yuv420p, 1128x630 [SAR 1:1 DAR 188:105], 6872 kb/s, 59.03 fps, 59.03 tbr, 59032 tbn, 7379 tbc (default)\n", - " Metadata:\n", - " handler_name : VideoHandler\n", - " vendor_id : [0][0][0][0]\n", - "Stream mapping:\n", - " Stream #0:0 -> #0:0 (mpeg4 (native) -> h264 (libx264))\n", - "Press [q] to stop, [?] for help\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0musing SAR=1/1\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0musing cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mprofile High, level 3.2, 4:2:0, 8-bit\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0m264 - core 163 r3060 5db6aa6 - H.264/MPEG-4 AVC codec - Copyleft 2003-2021 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=20 lookahead_threads=3 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00\n", - "Output #0, mp4, to '/content/processed_videos/output.mp4':\n", - " Metadata:\n", - " major_brand : isom\n", - " minor_version : 512\n", - " compatible_brands: isomiso2mp41\n", - " encoder : Lavf58.76.100\n", - " Stream #0:0(und): Video: h264 (avc1 / 0x31637661), yuv420p(progressive), 1128x630 [SAR 1:1 DAR 188:105], q=2-31, 59.03 fps, 14758 tbn (default)\n", - " Metadata:\n", - " handler_name : VideoHandler\n", - " vendor_id : [0][0][0][0]\n", - " encoder : Lavc58.134.100 libx264\n", - " Side data:\n", - " cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A\n", - "frame= 179 fps=0.0 q=-1.0 Lsize= 800kB time=00:00:02.98 bitrate=2198.1kbits/s speed=3.46x \n", - "video:797kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.359801%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mframe I:1 Avg QP:23.34 size: 13947\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mframe P:63 Avg QP:23.52 size: 7640\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mframe B:115 Avg QP:25.05 size: 2785\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mconsecutive B-frames: 3.4% 27.9% 15.1% 53.6%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mmb I I16..4: 35.0% 61.4% 3.6%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mmb P I16..4: 13.8% 35.9% 1.3% P16..4: 18.9% 2.1% 0.5% 0.0% 0.0% skip:27.5%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mmb B I16..4: 1.7% 3.5% 0.3% B16..8: 21.4% 1.7% 0.2% direct: 1.4% skip:69.6% L0:45.3% L1:53.5% BI: 1.2%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0m8x8 transform intra:69.0% inter:86.0%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mcoded y,uvDC,uvAC intra: 22.5% 34.9% 5.5% inter: 2.9% 5.8% 1.1%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mi16 v,h,dc,p: 23% 51% 10% 17%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mi8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 27% 29% 33% 2% 2% 2% 2% 2% 2%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mi4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 27% 21% 20% 5% 7% 6% 7% 5% 4%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mi8c dc,h,v,p: 55% 26% 16% 3%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mWeighted P-Frames: Y:0.0% UV:0.0%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mref P L0: 77.2% 7.5% 11.6% 3.7%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mref B L0: 86.9% 10.3% 2.7%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mref B L1: 98.4% 1.6%\n", - "\u001b[1;36m[libx264 @ 0x573686f25380] \u001b[0mkb/s:2151.78\n" - ] - } - ], - "source": [ - "!ffmpeg -i /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled.mp4 -vcodec libx264 -acodec aac /content/processed_videos/output.mp4" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 826 - }, - "collapsed": true, - "id": "epM2uJg4e-1A", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "8841ab20-5062-4f9e-cd1d-dc45a8e50c8a" - }, - "outputs": [ - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from IPython.display import Video, display\n", - "import glob\n", - "import os\n", - "\n", - "# Path to the video folder\n", - "out_video_folder = '/content/processed_videos' # Replace with your video folder path\n", - "video_paths = glob.glob(os.path.join(out_video_folder, '*.mp4'))\n", - "\n", - "# Display each video\n", - "for video_path in video_paths:\n", - " print (video_path)\n", - " display(Video(video_path, embed=True))" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Z8Z5GSti0Z4Z" - }, - "source": [ - "### Zero-shot Video Inference with video adaptation (unsupervised)" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 11353, - "referenced_widgets": [ - "8f396755637f4a779f3e77bf8e4c5f2d", - "67677e7c5e284682ae8aae107833e702", - "255dee8feaf74901b7a412fce53e0beb", - "765e9d1889f3472c9e050d96ca1c0e24", - "7b5f401de8f647bbb1f241d0ae61a106", - "a5cf5d546d11442f86cb3b048f6e1b51", - "30df28e721be4dffa0271edad4cd5ce3", - "cfee5f910ac14a95b770b77fd6f9629e", - "ff5459e8346a48edbb9fc6bdfcdeb690", - "1066fb18c9d045bea6568909a49a4a7a", - "aec1058e27ab48d0bdeaa934f12a2a04" - ] - }, - "collapsed": true, - "id": "5mhOmtzw0Z4Z", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "d830c849-91e8-4597-f388-cec2cd3b4eb0" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "running video inference on ['/content/uploaded_videos/dog-agility.mov'] with superanimal_quadruped_hrnetw32\n", - "Using pytorch for model hrnetw32\n", - "using /content/uploaded_videos/dog-agility.mov for video adaptation training\n", - "Task: None\n", - "scorer: None\n", - "date: None\n", - "multianimalproject: None\n", - "identity: None\n", - "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - "engine: pytorch\n", - "video_sets: None\n", - "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - "start: None\n", - "stop: None\n", - "numframes2pick: None\n", - "skeleton: []\n", - "skeleton_color: black\n", - "pcutoff: None\n", - "dotsize: None\n", - "alphavalue: None\n", - "colormap: rainbow\n", - "TrainingFraction: None\n", - "iteration: None\n", - "default_net_type: None\n", - "default_augmenter: None\n", - "snapshotindex: None\n", - "detector_snapshotindex: None\n", - "batch_size: 1\n", - "cropping: None\n", - "x1: None\n", - "x2: None\n", - "y1: None\n", - "y2: None\n", - "corner2move2: None\n", - "move2corner: None\n", - "SuperAnimalConversionTables: None\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 250\n", - "device: auto\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 39\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 39]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 10\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 200\n", - " pretrained_weights: None\n", - " seed: 42\n", - "metadata:\n", - " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", - " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - " unique_bodyparts: []\n", - " individuals: ['animal']\n", - " with_identity: None\n", - "Processing video /content/uploaded_videos/dog-agility.mov\n", - "Starting to analyze /content/uploaded_videos/dog-agility.mov\n", - "Video metadata: \n", - " Overall # of frames: 183\n", - " Duration of video [s]: 3.10\n", - " fps: 59.03\n", - " resolution: w=1128, h=630\n", - "\n", - "Running Detector\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - " 98%|█████████▊| 179/183 [03:43<00:04, 1.25s/it]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Running Pose Prediction\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - " 98%|█████████▊| 179/183 [00:37<00:00, 4.80it/s]\n", - "WARNING:root:The video metadata indicates that there 183 in the video, but only 179 were able to be processed. This can happen if the video is corrupted. You can try to fix the issue by re-encoding your video (tips on how to do that: https://deeplabcut.github.io/DeepLabCut/docs/recipes/io.html#tips-on-video-re-encoding-and-preprocessing)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Saving results to /content/processed_videos\n", - "Saving results in /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32.h5 and /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_full.pickle\n", - "Duration of video [s]: 3.1, recorded with 59.03 fps!\n", - "Overall # of frames: 183 with cropped frame dimensions: 1128 630\n", - "Generating frames and creating video.\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 179/179 [00:01<00:00, 96.18it/s] \n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Video with predictions was saved as /content/processed_videos\n", - "Task: None\n", - "scorer: None\n", - "date: None\n", - "multianimalproject: None\n", - "identity: None\n", - "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - "engine: pytorch\n", - "video_sets: None\n", - "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - "start: None\n", - "stop: None\n", - "numframes2pick: None\n", - "skeleton: []\n", - "skeleton_color: black\n", - "pcutoff: None\n", - "dotsize: None\n", - "alphavalue: None\n", - "colormap: rainbow\n", - "TrainingFraction: None\n", - "iteration: None\n", - "default_net_type: None\n", - "default_augmenter: None\n", - "snapshotindex: None\n", - "detector_snapshotindex: None\n", - "batch_size: 1\n", - "cropping: None\n", - "x1: None\n", - "x2: None\n", - "y1: None\n", - "y2: None\n", - "corner2move2: None\n", - "move2corner: None\n", - "SuperAnimalConversionTables: None\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 250\n", - "device: auto\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 39\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 39]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 10\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 200\n", - " pretrained_weights: None\n", - " seed: 42\n", - "metadata:\n", - " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", - " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - " unique_bodyparts: []\n", - " individuals: ['animal']\n", - " with_identity: None\n", - "Video frames being extracted to /content/uploaded_videos/pseudo_dog-agility/images for video adaptation.\n", - "Constructing pseudo dataset at /content/uploaded_videos/pseudo_dog-agility\n", - "\n", - "Running video adaptation with following parameters: \n", - "(pose training) pose_epochs: 1\n", - "(pose) save_epochs: 1\n", - "detector_epochs: 1\n", - "detector_save_epochs: 1\n", - "video adaptation batch size: 8\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Downloading: \"https://download.pytorch.org/models/fasterrcnn_resnet50_fpn_v2_coco-dd69338a.pth\" to /root/.cache/torch/hub/checkpoints/fasterrcnn_resnet50_fpn_v2_coco-dd69338a.pth\n", - "100%|██████████| 167M/167M [00:00<00:00, 218MB/s]\n", - "/content/drive/My Drive/DLCdev/deeplabcut/pose_estimation_pytorch/data/transforms.py:68: UserWarning: Be careful! Do not train pose models with horizontal flips if you have symmetric keypoints!\n", - " warnings.warn(\n", - "Data Transforms:\n", - " Training: Compose([\n", - " HorizontalFlip(always_apply=False, p=0.5),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "\n", - "Note: According to your model configuration, you're training with batch size 1 and/or ``freeze_bn_stats=false``. This is not an optimal setting if you have powerful GPUs.\n", - "This is good for small batch sizes (e.g., when training on a CPU), where you should keep ``freeze_bn_stats=true``.\n", - "If you're using a GPU to train, you can obtain faster performance by setting a larger batch size (the biggest power of 2 where you don't geta CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` for the backbone of your model. \n", - "This also allows you to increase the learning rate (empirically you can scale the learning rate by sqrt(batch_size) times).\n", - "\n", - "Using 17 images and 10 for testing\n", - "\n", - "Starting object detector training...\n", - "--------------------------------------------------\n", - "Epoch 1/1 (lr=1e-05), train loss 0.07259\n", - "Loading pretrained weights from Hugging Face hub (timm/hrnet_w32.ms_in1k)\n", - "/usr/local/lib/python3.10/dist-packages/huggingface_hub/utils/_token.py:89: UserWarning: \n", - "The secret `HF_TOKEN` does not exist in your Colab secrets.\n", - "To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.\n", - "You will be able to reuse this secret in all of your notebooks.\n", - "Please note that authentication is recommended but still optional to access public models or datasets.\n", - " warnings.warn(\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "8f396755637f4a779f3e77bf8e4c5f2d", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "model.safetensors: 0%| | 0.00/165M [00:00 #0:0 (mpeg4 (native) -> h264 (libx264))\n", - "Press [q] to stop, [?] for help\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0musing SAR=1/1\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0musing cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2 AVX512\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mprofile High, level 3.2, 4:2:0, 8-bit\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0m264 - core 163 r3060 5db6aa6 - H.264/MPEG-4 AVC codec - Copyleft 2003-2021 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=20 lookahead_threads=3 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00\n", - "Output #0, mp4, to '/content/processed_videos/after_adapt_output.mp4':\n", - " Metadata:\n", - " major_brand : isom\n", - " minor_version : 512\n", - " compatible_brands: isomiso2mp41\n", - " encoder : Lavf58.76.100\n", - " Stream #0:0(und): Video: h264 (avc1 / 0x31637661), yuv420p(progressive), 1128x630 [SAR 1:1 DAR 188:105], q=2-31, 59.03 fps, 14758 tbn (default)\n", - " Metadata:\n", - " handler_name : VideoHandler\n", - " vendor_id : [0][0][0][0]\n", - " encoder : Lavc58.134.100 libx264\n", - " Side data:\n", - " cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A\n", - "frame= 179 fps=0.0 q=-1.0 Lsize= 810kB time=00:00:02.98 bitrate=2226.3kbits/s speed=3.07x \n", - "video:807kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.353296%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mframe I:1 Avg QP:23.44 size: 14043\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mframe P:65 Avg QP:23.60 size: 7621\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mframe B:113 Avg QP:25.09 size: 2803\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mconsecutive B-frames: 2.8% 32.4% 20.1% 44.7%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mmb I I16..4: 33.7% 62.7% 3.6%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mmb P I16..4: 14.0% 36.1% 1.3% P16..4: 18.6% 2.0% 0.5% 0.0% 0.0% skip:27.5%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mmb B I16..4: 1.5% 3.1% 0.3% B16..8: 21.4% 1.7% 0.2% direct: 1.3% skip:70.4% L0:42.6% L1:56.3% BI: 1.1%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0m8x8 transform intra:69.0% inter:85.0%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mcoded y,uvDC,uvAC intra: 22.7% 35.2% 5.7% inter: 2.9% 5.6% 1.2%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mi16 v,h,dc,p: 22% 51% 10% 17%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mi8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 28% 29% 33% 2% 2% 2% 2% 2% 2%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mi4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 27% 21% 20% 5% 6% 6% 7% 5% 4%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mi8c dc,h,v,p: 55% 26% 16% 3%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mWeighted P-Frames: Y:0.0% UV:0.0%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mref P L0: 77.4% 7.3% 11.7% 3.7%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mref B L0: 86.5% 10.4% 3.1%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mref B L1: 98.5% 1.5%\n", - "\u001b[1;36m[libx264 @ 0x56ae1c0e3c80] \u001b[0mkb/s:2179.49\n" - ] - } - ], - "source": [ - "!ffmpeg -i /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled_after_adapt.mp4 -vcodec libx264 -acodec aac /content/processed_videos/after_adapt_output.mp4\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Display the video adapted video" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 1684 - }, - "collapsed": true, - "id": "GDJye14Fhthb", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "77377c77-ca47-49e7-a7be-a7c16fde7bec" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "/content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled.mp4\n" - ] - }, - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "/content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_labeled_after_adapt.mp4\n" - ] - }, - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "/content/processed_videos/after_adapt_output.mp4\n" - ] - }, - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "/content/processed_videos/output.mp4\n" - ] - }, - { - "data": { - "text/html": [ - "" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from IPython.display import Video, display\n", - "import glob\n", - "import os\n", - "\n", - "# Path to the video folder\n", - "out_video_folder = '/content/processed_videos' # Replace with your video folder path\n", - "video_paths = glob.glob(os.path.join(out_video_folder, '*.mp4'))\n", - "\n", - "# Display each video\n", - "for video_path in video_paths:\n", - " print (video_path)\n", - " display(Video(video_path, embed=True))" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "br3pwGf40Z4a" - }, - "source": [ - "## Training with SuperAnimal\n", - "In this section, we compare different ways to train the model, using and without using SuperAnimal. \n", - "You can compare the evaluation results and get a sense of each baseline.\n", - "We have following baselines:\n", - "\n", - "- ImageNet transfer learning (training without superanimal)\n", - "- SuperAnimal transfer learning (baseline 1)\n", - "- SuperAnimal naive fine-tuning (baseline 2)\n", - "- SuperAnimal memory-replay fine-tuning (baseline3)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "L2wxevEn0Z4a" - }, - "source": [ - "#### Uploading your DLC project into Drive. Note you have to zip your DLC project and select the zipped file" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 129 - }, - "collapsed": true, - "id": "visacW8i0Z4a", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "db60ca6e-b38d-44c7-bc69-a327b40c8d4a" - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " Upload widget is only available when the cell has been executed in the\n", - " current browser session. Please rerun this cell to enable.\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Saving daniel3mouse.zip to daniel3mouse.zip\n", - "Contents of the extracted folder:\n", - "- __MACOSX\n", - "- daniel3mouse\n" - ] - } - ], - "source": [ - "uploaded = files.upload()\n", - "for filename in uploaded.keys():\n", - " zip_file_path = os.path.join(\"/content\", filename)\n", - " with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:\n", - " zip_ref.extractall(\"/content/dlc_project_folder\")\n", - "\n", - "print(\"Contents of the extracted folder:\")\n", - "extracted_files = os.listdir(\"/content/dlc_project_folder\")\n", - "for file in extracted_files:\n", - " print(f'- {file}')\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Change the path to your project in dlc_project_folder" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "collapsed": true, - "id": "nY7Sv9pslaMh", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "dlc_proj_root = Path(\"/content/dlc_project_folder/daniel3mouse\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "BPvoL9uZ0Z4a" - }, - "source": [ - "#### Comparison between different training baselines\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "eVmpaLdB0Z4a" - }, - "source": [ - "Definition of data split: the unique combination of training images and testing images.\n", - "We create a data split named split 0. All baselines will share the data split to make fair comparisons.\n", - "- split 0 -> shared by all baselines\n", - "- shuffle 0 (split0) -> imagenet transfer learning\n", - "- shuffle 1 (split0) -> superanimal transfer learning\n", - "- shuffle 2 (split0) -> superanimal naive fine-tuning\n", - "- shuffle 3 (split0) -> superanimal memory-replay fine-tuning" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### What is the difference between baselines? \n", - "\n", - "**Transfer learning** For canonical task-agnostic transfer learning,\n", - "the encoder learns universal visual features from ImageNet, and a randomly\n", - "initialized decoder is used to learn the pose fromthe downstream dataset.\n", - "\n", - "**Fine-tuning** For task aware\n", - "fine-tuning, both encoder and decoder learn task-related visual-pose features\n", - "in the pre-training datasets, and the decoder is fine-tuned to update pose\n", - "priors in downstream datasets. Crucially, the network has pose-estimation-specific\n", - "weights\n", - "\n", - "**ImageNet transfer-learning** The encoder was pre-trained from ImageNet. The decoder is trained from scratch in the downstream tasks\n", - "\n", - "**SuperAnimal transfer-learning** The encoder was pre-trained first from ImageNet, then in pose datasets we colleceted. Then decoder is trained from scratch in downstream tasks.\n", - "\n", - "**SuperAnimal naive fine-tuning** Both the encoder and the decoder were pre-trained in pose datasets we collected. In downstream datsets, we only finetune convolutional channels that correspond to the annotated keypoints in the downstream datasets. This introduces catastrophic forgetting in keypoints that are not annotated in the downstream datasets.\n", - "\n", - "**SuperAnimal memory-replay fine-tuning** If we apply fine-tuning with SuperAnimal without further cares, the models will forget about keypoints that are not annotated in the downstream datasets. To mitigate this, we mix the annotations and zero-shot predictions of SuperAnimal models to create a dataset that 'replays' the memory of the SuperAnimal keypoints. \n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": { - "collapsed": true, - "id": "AgIsUu6v0Z4a", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "imagenet_transfer_learning_shuffle = 0\n", - "superanimal_transfer_learning_shuffle = 1\n", - "superanimal_naive_finetune_shuffle = 2\n", - "superanimal_memory_replay_shuffle = 3" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "kuKcxM8F0Z4a", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "bb09b5cd-bd25-416b-8b94-aa25a5ffd1a7" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", - "Creating training data for: Shuffle: 0 TrainFraction: 0.95\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 152/152 [00:00<00:00, 3254.90it/s]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" - ] - } - ], - "source": [ - "config_path = dlc_proj_root / 'config.yaml'\n", - "deeplabcut.create_training_dataset(\n", - " config_path,\n", - " Shuffles = [imagenet_transfer_learning_shuffle],\n", - " net_type=\"top_down_hrnet_w32\",\n", - " engine=Engine.PYTORCH,\n", - " userfeedback=False,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "_6RncQbr0Z4a" - }, - "source": [ - "### ImageNet transfer learning\n", - "\n", - "Historically, the transfer learning using ImageNet weights strategies assumed no “animal pose task priors” in the pretrained\n", - "model, a paradigm adopted from previous task-agnostic transfer learning." - ] - }, - { - "cell_type": "code", - "execution_count": 63, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "H2z8kM340Z4a", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "bd322c11-20e4-4b75-da4d-fdae1b7e5937" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Training with configuration:\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " rotation: 30\n", - " scaling: [1.0, 1.0]\n", - " translation: 0\n", - " collate: None\n", - " covering: False\n", - " gaussian_noise: 12.75\n", - " hist_eq: False\n", - " motion_blur: False\n", - " normalize_images: True\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " rotation: 30\n", - " scaling: [1.0, 1.0]\n", - " translation: 40\n", - " collate:\n", - " type: ResizeFromDataSizeCollate\n", - " min_scale: 0.4\n", - " max_scale: 1.0\n", - " min_short_side: 128\n", - " max_short_side: 1152\n", - " multiple_of: 32\n", - " to_square: False\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " variant: fasterrcnn_mobilenet_v3_large_fpn\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0001\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [160]\n", - " lr_list: [[1e-05]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 0\n", - "device: auto\n", - "metadata:\n", - " project_path: /content/dlc_project_folder/daniel3mouse\n", - " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset95shuffle0/train/pose_cfg.yaml\n", - " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", - " unique_bodyparts: []\n", - " individuals: ['mus1', 'mus2', 'mus3']\n", - " with_identity: None\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " freeze_bn_stats: False\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 12\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32]\n", - " kernel_size: []\n", - " strides: []\n", - " final_conv:\n", - " out_channels: 12\n", - " kernel_size: 1\n", - "net_type: hrnet_w32\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " gpus: None\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0005\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-05], [1e-06]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 3\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 64\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 3\n", - " seed: 42\n", - "Loading pretrained weights from Hugging Face hub (timm/hrnet_w32.ms_in1k)\n", - "[timm/hrnet_w32.ms_in1k] Safe alternative available for 'pytorch_model.bin' (as 'model.safetensors'). Loading weights using safetensors.\n", - "Unexpected keys (downsamp_modules.0.0.bias, downsamp_modules.0.0.weight, downsamp_modules.0.1.bias, downsamp_modules.0.1.num_batches_tracked, downsamp_modules.0.1.running_mean, downsamp_modules.0.1.running_var, downsamp_modules.0.1.weight, downsamp_modules.1.0.bias, downsamp_modules.1.0.weight, downsamp_modules.1.1.bias, downsamp_modules.1.1.num_batches_tracked, downsamp_modules.1.1.running_mean, downsamp_modules.1.1.running_var, downsamp_modules.1.1.weight, downsamp_modules.2.0.bias, downsamp_modules.2.0.weight, downsamp_modules.2.1.bias, downsamp_modules.2.1.num_batches_tracked, downsamp_modules.2.1.running_mean, downsamp_modules.2.1.running_var, downsamp_modules.2.1.weight, final_layer.0.bias, final_layer.0.weight, final_layer.1.bias, final_layer.1.num_batches_tracked, final_layer.1.running_mean, final_layer.1.running_var, final_layer.1.weight, incre_modules.0.0.bn1.bias, incre_modules.0.0.bn1.num_batches_tracked, incre_modules.0.0.bn1.running_mean, incre_modules.0.0.bn1.running_var, incre_modules.0.0.bn1.weight, incre_modules.0.0.bn2.bias, incre_modules.0.0.bn2.num_batches_tracked, incre_modules.0.0.bn2.running_mean, incre_modules.0.0.bn2.running_var, incre_modules.0.0.bn2.weight, incre_modules.0.0.bn3.bias, incre_modules.0.0.bn3.num_batches_tracked, incre_modules.0.0.bn3.running_mean, incre_modules.0.0.bn3.running_var, incre_modules.0.0.bn3.weight, incre_modules.0.0.conv1.weight, incre_modules.0.0.conv2.weight, incre_modules.0.0.conv3.weight, incre_modules.0.0.downsample.0.weight, incre_modules.0.0.downsample.1.bias, incre_modules.0.0.downsample.1.num_batches_tracked, incre_modules.0.0.downsample.1.running_mean, incre_modules.0.0.downsample.1.running_var, incre_modules.0.0.downsample.1.weight, incre_modules.1.0.bn1.bias, incre_modules.1.0.bn1.num_batches_tracked, incre_modules.1.0.bn1.running_mean, incre_modules.1.0.bn1.running_var, incre_modules.1.0.bn1.weight, incre_modules.1.0.bn2.bias, incre_modules.1.0.bn2.num_batches_tracked, incre_modules.1.0.bn2.running_mean, incre_modules.1.0.bn2.running_var, incre_modules.1.0.bn2.weight, incre_modules.1.0.bn3.bias, incre_modules.1.0.bn3.num_batches_tracked, incre_modules.1.0.bn3.running_mean, incre_modules.1.0.bn3.running_var, incre_modules.1.0.bn3.weight, incre_modules.1.0.conv1.weight, incre_modules.1.0.conv2.weight, incre_modules.1.0.conv3.weight, incre_modules.1.0.downsample.0.weight, incre_modules.1.0.downsample.1.bias, incre_modules.1.0.downsample.1.num_batches_tracked, incre_modules.1.0.downsample.1.running_mean, incre_modules.1.0.downsample.1.running_var, incre_modules.1.0.downsample.1.weight, incre_modules.2.0.bn1.bias, incre_modules.2.0.bn1.num_batches_tracked, incre_modules.2.0.bn1.running_mean, incre_modules.2.0.bn1.running_var, incre_modules.2.0.bn1.weight, incre_modules.2.0.bn2.bias, incre_modules.2.0.bn2.num_batches_tracked, incre_modules.2.0.bn2.running_mean, incre_modules.2.0.bn2.running_var, incre_modules.2.0.bn2.weight, incre_modules.2.0.bn3.bias, incre_modules.2.0.bn3.num_batches_tracked, incre_modules.2.0.bn3.running_mean, incre_modules.2.0.bn3.running_var, incre_modules.2.0.bn3.weight, incre_modules.2.0.conv1.weight, incre_modules.2.0.conv2.weight, incre_modules.2.0.conv3.weight, incre_modules.2.0.downsample.0.weight, incre_modules.2.0.downsample.1.bias, incre_modules.2.0.downsample.1.num_batches_tracked, incre_modules.2.0.downsample.1.running_mean, incre_modules.2.0.downsample.1.running_var, incre_modules.2.0.downsample.1.weight, incre_modules.3.0.bn1.bias, incre_modules.3.0.bn1.num_batches_tracked, incre_modules.3.0.bn1.running_mean, incre_modules.3.0.bn1.running_var, incre_modules.3.0.bn1.weight, incre_modules.3.0.bn2.bias, incre_modules.3.0.bn2.num_batches_tracked, incre_modules.3.0.bn2.running_mean, incre_modules.3.0.bn2.running_var, incre_modules.3.0.bn2.weight, incre_modules.3.0.bn3.bias, incre_modules.3.0.bn3.num_batches_tracked, incre_modules.3.0.bn3.running_mean, incre_modules.3.0.bn3.running_var, incre_modules.3.0.bn3.weight, incre_modules.3.0.conv1.weight, incre_modules.3.0.conv2.weight, incre_modules.3.0.conv3.weight, incre_modules.3.0.downsample.0.weight, incre_modules.3.0.downsample.1.bias, incre_modules.3.0.downsample.1.num_batches_tracked, incre_modules.3.0.downsample.1.running_mean, incre_modules.3.0.downsample.1.running_var, incre_modules.3.0.downsample.1.weight, classifier.bias, classifier.weight) found while loading pretrained weights. This may be expected if model is being adapted.\n", - "Data Transforms:\n", - " Training: Compose([\n", - " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", - " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "Using 456 images and 27 for testing\n", - "\n", - "Starting pose model training...\n", - "--------------------------------------------------\n", - "Training for epoch 1 done, starting evaluation\n", - "Epoch 1 performance:\n", - "metrics/test.rmse: 58.034\n", - "metrics/test.rmse_pcutoff:57.757\n", - "metrics/test.mAP: 1.523\n", - "metrics/test.mAR: 2.222\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 1/3 (lr=0.0005), train loss 0.00532, valid loss 0.08804\n", - "Training for epoch 2 done, starting evaluation\n", - "Epoch 2 performance:\n", - "metrics/test.rmse: 24.018\n", - "metrics/test.rmse_pcutoff:4.871\n", - "metrics/test.mAP: 41.487\n", - "metrics/test.mAR: 48.519\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 2/3 (lr=0.0005), train loss 0.00385, valid loss 0.00458\n", - "Training for epoch 3 done, starting evaluation\n", - "Epoch 3 performance:\n", - "metrics/test.rmse: 14.797\n", - "metrics/test.rmse_pcutoff:4.767\n", - "metrics/test.mAP: 64.759\n", - "metrics/test.mAR: 71.852\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 3/3 (lr=0.0005), train loss 0.00298, valid loss 0.00334\n" - ] - } - ], - "source": [ - "# Note we skip the detector training to save time. The evaluation is by default using ground-truth bounding box.\n", - "# But to train a model that can be used to inference videos and images, you have to set detector_epochs > 0\n", - "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, batch_size = 64, shuffle = imagenet_transfer_learning_shuffle)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "J-udMck7nDbG" - }, - "source": [ - "#### Though the evaluation was also done during training, let's just do it again here to double-check" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "TDHMdKz4m_16", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "091082c1-0c61-4967-9750-092541dad0ae" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", - "100%|██████████| 152/152 [01:20<00:00, 1.88it/s]\n", - "100%|██████████| 9/9 [00:04<00:00, 1.86it/s]\n", - "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle0_snapshot_001-results.csv (pcutoff: 0.01):\n", - "INFO:root:train rmse 54.74\n", - "train rmse_pcutoff 54.74\n", - "train mAP 0.58\n", - "train mAR 3.46\n", - "train mAP_pcutoff 0.58\n", - "train mAR_pcutoff 3.46\n", - "test rmse 55.73\n", - "test rmse_pcutoff 55.73\n", - "test mAP 2.78\n", - "test mAR 7.04\n", - "test mAP_pcutoff 2.78\n", - "test mAR_pcutoff 7.04\n", - "Name: (0.95, 0, 1, -1, 0.01), dtype: float64\n" - ] - } - ], - "source": [ - "deeplabcut.evaluate_network(config_path, Shuffles = [imagenet_transfer_learning_shuffle])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Transfer learning with SuperAnimal weights" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "ZGhAuyqs0Z4a" - }, - "source": [ - "#### Prepare trianing shuffle for transfer-learning with SuperAnimal weights" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "wOSdZQtOp8qa", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "5c76dbca-4706-4f9d-a70d-0d7763cdcda0" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", - "You passed a split with the following fraction: 94%\n", - "Creating training data for: Shuffle: 1 TrainFraction: 0.94\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 152/152 [00:00<00:00, 7673.55it/s]" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], - "source": [ - "weight_init = WeightInitialization(\n", - " dataset=f\"{superanimal_name}\",\n", - " with_decoder=False,\n", - ")\n", - "\n", - "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", - " from_shuffle = imagenet_transfer_learning_shuffle,\n", - " shuffles = [superanimal_transfer_learning_shuffle],\n", - " engine = Engine.PYTORCH,\n", - " net_type=\"top_down_hrnet_w32\",\n", - " weight_init = weight_init,\n", - " userfeedback = False)\n", - "\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Launch the training for transfer-learning with SuperAnimal weights" - ] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "W60UgRQWqghn", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "1ebd95f1-830a-4ba0-9f4b-71ac749ef50e" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Training with configuration:\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " rotation: 30\n", - " scaling: [1.0, 1.0]\n", - " translation: 0\n", - " collate: None\n", - " covering: False\n", - " gaussian_noise: 12.75\n", - " hist_eq: False\n", - " motion_blur: False\n", - " normalize_images: True\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " rotation: 30\n", - " scaling: [1.0, 1.0]\n", - " translation: 40\n", - " collate:\n", - " type: ResizeFromDataSizeCollate\n", - " min_scale: 0.4\n", - " max_scale: 1.0\n", - " min_short_side: 128\n", - " max_short_side: 1152\n", - " multiple_of: 32\n", - " to_square: False\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " freeze_bn_stats: False\n", - " freeze_bn_weights: False\n", - " variant: fasterrcnn_mobilenet_v3_large_fpn\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0001\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [160]\n", - " lr_list: [[1e-05]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 0\n", - "device: auto\n", - "metadata:\n", - " project_path: /content/dlc_project_folder/daniel3mouse\n", - " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle1/train/pose_cfg.yaml\n", - " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", - " unique_bodyparts: []\n", - " individuals: ['mus1', 'mus2', 'mus3']\n", - " with_identity: None\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 12\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32]\n", - " kernel_size: []\n", - " strides: []\n", - " final_conv:\n", - " out_channels: 12\n", - " kernel_size: 1\n", - "net_type: hrnet_w32\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " gpus: None\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0001\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-05], [1e-06]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 3\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 64\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 3\n", - " seed: 42\n", - " weight_init:\n", - " dataset: superanimal_quadruped\n", - " with_decoder: False\n", - " memory_replay: False\n", - "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=False, memory_replay=False, conversion_array=None, bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", - "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", - "Data Transforms:\n", - " Training: Compose([\n", - " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", - " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "\n", - "Note: According to your model configuration, you're training with batch size 1 and/or ``freeze_bn_stats=false``. This is not an optimal setting if you have powerful GPUs.\n", - "This is good for small batch sizes (e.g., when training on a CPU), where you should keep ``freeze_bn_stats=true``.\n", - "If you're using a GPU to train, you can obtain faster performance by setting a larger batch size (the biggest power of 2 where you don't geta CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` for the backbone of your model. \n", - "This also allows you to increase the learning rate (empirically you can scale the learning rate by sqrt(batch_size) times).\n", - "\n", - "Using 456 images and 27 for testing\n", - "\n", - "Starting pose model training...\n", - "--------------------------------------------------\n", - "Training for epoch 1 done, starting evaluation\n", - "Epoch 1 performance:\n", - "metrics/test.rmse: 45.606\n", - "metrics/test.rmse_pcutoff:nan\n", - "metrics/test.mAP: 1.715\n", - "metrics/test.mAR: 5.556\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 1/3 (lr=0.0001), train loss 0.00603, valid loss 0.00577\n", - "Training for epoch 2 done, starting evaluation\n", - "Epoch 2 performance:\n", - "metrics/test.rmse: 47.635\n", - "metrics/test.rmse_pcutoff:nan\n", - "metrics/test.mAP: 0.216\n", - "metrics/test.mAR: 2.222\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 2/3 (lr=0.0001), train loss 0.00542, valid loss 0.00507\n", - "Training for epoch 3 done, starting evaluation\n", - "Epoch 3 performance:\n", - "metrics/test.rmse: 35.083\n", - "metrics/test.rmse_pcutoff:nan\n", - "metrics/test.mAP: 21.118\n", - "metrics/test.mAR: 27.407\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 3/3 (lr=0.0001), train loss 0.00476, valid loss 0.00445\n" - ] - } - ], - "source": [ - "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, batch_size = 64, shuffle = superanimal_transfer_learning_shuffle)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Evaluate the model obtained by transfer-learning with SuperAnimal weights" - ] - }, - { - "cell_type": "code", - "execution_count": 44, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "jpO3aIAIsWbz", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "51ab747e-bf7f-4cf0-bdb5-02774439a08b" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", - "100%|██████████| 152/152 [01:22<00:00, 1.84it/s]\n", - "100%|██████████| 9/9 [00:04<00:00, 1.90it/s]\n", - "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle1_snapshot_003-results.csv (pcutoff: 0.01):\n", - "INFO:root:train rmse 34.03\n", - "train rmse_pcutoff 34.03\n", - "train mAP 21.50\n", - "train mAR 27.52\n", - "train mAP_pcutoff 21.50\n", - "train mAR_pcutoff 27.52\n", - "test rmse 34.55\n", - "test rmse_pcutoff 34.55\n", - "test mAP 22.24\n", - "test mAR 28.15\n", - "test mAP_pcutoff 22.24\n", - "test mAR_pcutoff 28.15\n", - "Name: (0.94, 1, 3, -1, 0.01), dtype: float64\n" - ] - } - ], - "source": [ - "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_transfer_learning_shuffle])" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "_Es6RR-_0Z4b" - }, - "source": [ - "### Fine-tuning with SuperAnimal (without keeping full SuperAnimal keypoints)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "6oo9oJ8XyZrn" - }, - "source": [ - "#### Setup the weight init and dataset\n", - "First we do keypoint matching. This steps make it possible to understand the correspondance between the existing annotations and SuperAnimal annotations. This step produces 3 outputs\n", - "- The confusion matrix\n", - "- The conversion table\n", - "- Pseudo predictions over the whole dataset" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### What is keypoint matching?\n", - "\n", - "Because SuperAnimal models have their pre-defined keypoints that are potentially different from your annotations, we porposed this algorithm to minimize the gap between the model and the dataset. We use our model to perform zero-shot inference on the whole dataset. This gives pairs of predictions and ground truth for every image. Then, we cast the matching between models’ predictions (2D coordinates)\n", - "and ground truth as bipartitematching using the Euclidean distance as the cost between paired of keypoints. We then solve the matching using the Hungarian algorithm. Thus for every image, we end up getting a matching matrix where 1 counts formatch and 0 counts for non-matching. Because the models’ predictions can be noisy from image to image, we average the aforementioned matching matrix across all the images and perform another bipartite matching, resulting in the final keypoint conversion table between the model and the dataset. Note that the quality of thematching will impact the performance\n", - "of the model, especially for zero-shot. In the case where, e.g., the annotation nose is mistakenly converted to keypoint tail and vice versa, the model will have to unlearn the channel that corresponds to nose and tail (see also case study in Mathis et al.). " - ] - }, - { - "cell_type": "code", - "execution_count": 46, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 1000 - }, - "collapsed": true, - "id": "vEHeuKSKyjA6", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "f85fa523-910a-444d-914f-4a67730f1bc7" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Before checking trainset temp_dataset\n", - "Before checking testset temp_dataset\n", - "Task: None\n", - "scorer: None\n", - "date: None\n", - "multianimalproject: None\n", - "identity: None\n", - "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - "engine: pytorch\n", - "video_sets: None\n", - "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - "start: None\n", - "stop: None\n", - "numframes2pick: None\n", - "skeleton: []\n", - "skeleton_color: black\n", - "pcutoff: None\n", - "dotsize: None\n", - "alphavalue: None\n", - "colormap: rainbow\n", - "TrainingFraction: None\n", - "iteration: None\n", - "default_net_type: None\n", - "default_augmenter: None\n", - "snapshotindex: None\n", - "detector_snapshotindex: None\n", - "batch_size: 1\n", - "cropping: None\n", - "x1: None\n", - "x2: None\n", - "y1: None\n", - "y2: None\n", - "corner2move2: None\n", - "move2corner: None\n", - "SuperAnimalConversionTables: None\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 250\n", - "device: cpu\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 39\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 39]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 10\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 200\n", - " pretrained_weights: None\n", - " seed: 42\n", - "metadata:\n", - " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", - " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - " unique_bodyparts: []\n", - " individuals: ['animal']\n", - " with_identity: None\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "keypoint_matching(\n", - " config_path,\n", - " superanimal_name,\n", - " model_name,\n", - ")\n", - "\n", - "conversion_table_path = dlc_proj_root / \"memory_replay\" / \"conversion_table.csv\"\n", - "confusion_matrix_path = dlc_proj_root / \"memory_replay\" / \"confusion_matrix.png\"\n", - "# You can visualize the pseudo predictions, or do pose embedding clustering etc.\n", - "pseudo_prediction_path = dlc_proj_root / \"memory_replay\" / \"pseudo_predictions.json\"" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "sA8yyLgs0zoO" - }, - "source": [ - "#### Display the confusion matrix\n", - "\n", - "The x axis lists the keypoints in the existing annotations. The y axis lists the keypoints in SuperAnimal keypoint space. Darker color encodes stronger correspondance between the human annotation and SuperAnimal annotations." - ] - }, - { - "cell_type": "code", - "execution_count": 52, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 406 - }, - "collapsed": true, - "id": "luDxpD9H0zYZ", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "d6420e08-3e9c-40dc-8a13-92bc0d8c220b" - }, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "\n", - "confusion_matrix_image = mpimg.imread(confusion_matrix_path)\n", - "plt.imshow(confusion_matrix_image)\n", - "plt.axis('off') # Hide the axes for better view\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "i0QWikYmy_Mj" - }, - "source": [ - "#### Display the conversion table\n", - "The gt columns represents the keypoint names in the existing dataset. The MasterName represents the correspoinding keypoints in SuperAnimal keypoint space." - ] - }, - { - "cell_type": "code", - "execution_count": 51, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "CeA-NzDMynYV", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "ae27fb36-223c-4aa2-f63f-42adadb95f02" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " gt MasterName\n", - "0 snout nose\n", - "6 rightear right_earbase\n", - "11 leftear left_earbase\n", - "14 tail2 left_antler_end\n", - "15 shoulder neck_base\n", - "20 tail1 back_end\n", - "21 spine3 back_middle\n", - "22 tailbase tail_base\n", - "23 tailend tail_end\n", - "24 spine1 front_left_thai\n", - "31 spine4 back_left_thai\n", - "37 spine2 body_middle_right\n" - ] - } - ], - "source": [ - "import pandas as pd\n", - "df = pd.read_csv(conversion_table_path)\n", - "df = df.dropna()\n", - "print (df)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Prepare the training shuffle and weight initialization for (naive) fine-tuning with SuperAnimal weights" - ] - }, - { - "cell_type": "code", - "execution_count": 54, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "xEeM_hrOu6k8", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "4a5f4d5f-d1c5-42f8-f4ed-e8c208a1dc10" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", - "You passed a split with the following fraction: 94%\n", - "Creating training data for: Shuffle: 2 TrainFraction: 0.94\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 152/152 [00:00<00:00, 12111.90it/s]" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], - "source": [ - "from deeplabcut.modelzoo.utils import (\n", - " create_conversion_table,\n", - " read_conversion_table_from_csv,\n", - ")\n", - "table = create_conversion_table(\n", - " config=config_path,\n", - " super_animal=superanimal_name,\n", - " project_to_super_animal=read_conversion_table_from_csv(conversion_table_path),\n", - ")\n", - "\n", - "weight_init = WeightInitialization(\n", - " dataset=superanimal_name,\n", - " with_decoder=True,\n", - " conversion_array=table.to_array()\n", - ")\n", - "\n", - "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", - " from_shuffle = imagenet_transfer_learning_shuffle,\n", - " shuffles = [superanimal_naive_finetune_shuffle],\n", - " engine = Engine.PYTORCH,\n", - " net_type=\"top_down_hrnet_w32\",\n", - " weight_init = weight_init,\n", - " userfeedback = False)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Launch the training for (naive) fine-tuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": 62, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "c3XAr6uRyXOD", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "03740373-f9cd-4708-d140-0127033bfdc8" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Training with configuration:\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 0\n", - "device: auto\n", - "metadata:\n", - " project_path: /content/dlc_project_folder/daniel3mouse\n", - " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle2/train/pose_cfg.yaml\n", - " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", - " unique_bodyparts: []\n", - " individuals: ['mus1', 'mus2', 'mus3']\n", - " with_identity: False\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: False\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 12\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 12]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "net_type: hrnet_w32\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0005\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 3\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 64\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 3\n", - " pretrained_weights: None\n", - " seed: 42\n", - " weight_init:\n", - " dataset: superanimal_quadruped\n", - " with_decoder: True\n", - " memory_replay: False\n", - " conversion_array: [0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]\n", - "eval_interval: 1\n", - "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=True, memory_replay=False, conversion_array=array([ 0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]), bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", - "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", - "Data Transforms:\n", - " Training: Compose([\n", - " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", - " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "Using 456 images and 27 for testing\n", - "\n", - "Starting pose model training...\n", - "--------------------------------------------------\n", - "Training for epoch 1 done, starting evaluation\n", - "Epoch 1 performance:\n", - "metrics/test.rmse: 11.625\n", - "metrics/test.rmse_pcutoff:5.703\n", - "metrics/test.mAP: 71.971\n", - "metrics/test.mAR: 75.185\n", - "metrics/test.mAP_pcutoff:36.845\n", - "metrics/test.mAR_pcutoff:40.370\n", - "Epoch 1/3 (lr=0.0005), train loss 0.00387, valid loss 0.00279\n", - "Training for epoch 2 done, starting evaluation\n", - "Epoch 2 performance:\n", - "metrics/test.rmse: 9.842\n", - "metrics/test.rmse_pcutoff:4.464\n", - "metrics/test.mAP: 77.846\n", - "metrics/test.mAR: 80.000\n", - "metrics/test.mAP_pcutoff:33.924\n", - "metrics/test.mAR_pcutoff:35.926\n", - "Epoch 2/3 (lr=0.0005), train loss 0.00210, valid loss 0.00188\n", - "Training for epoch 3 done, starting evaluation\n", - "Epoch 3 performance:\n", - "metrics/test.rmse: 8.163\n", - "metrics/test.rmse_pcutoff:3.807\n", - "metrics/test.mAP: 82.317\n", - "metrics/test.mAR: 84.815\n", - "metrics/test.mAP_pcutoff:49.699\n", - "metrics/test.mAR_pcutoff:53.704\n", - "Epoch 3/3 (lr=0.0005), train loss 0.00167, valid loss 0.00154\n" - ] - } - ], - "source": [ - "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, eval_interval = 1, batch_size = 64, shuffle = superanimal_naive_finetune_shuffle)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Evaluate the model obtained by (naive) fine-tuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": 61, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "VXfdKS-H2yqw", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "53b1b8fa-6aa3-4dad-a5be-153e96eb0323" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", - "100%|██████████| 152/152 [01:25<00:00, 1.79it/s]\n", - "100%|██████████| 9/9 [00:04<00:00, 1.84it/s]\n", - "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle2_snapshot_003-results.csv (pcutoff: 0.01):\n", - "INFO:root:train rmse 48.46\n", - "train rmse_pcutoff 47.76\n", - "train mAP 10.08\n", - "train mAR 21.36\n", - "train mAP_pcutoff 10.07\n", - "train mAR_pcutoff 21.29\n", - "test rmse 47.00\n", - "test rmse_pcutoff 46.74\n", - "test mAP 12.16\n", - "test mAR 22.22\n", - "test mAP_pcutoff 12.16\n", - "test mAR_pcutoff 22.22\n", - "Name: (0.94, 2, 3, -1, 0.01), dtype: float64\n" - ] - } - ], - "source": [ - "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_naive_finetune_shuffle])" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "_nUAMlbZ0Z4b" - }, - "source": [ - "## Memory-replay fine-tuning with SuperAnimal (keeping full SuperAnimal keypoints)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Catastrophic forgetting** describes a\n", - "classic problemin continual learning. Indeed, amodel gradually loses\n", - "its ability to solve previous tasks after it learns to solve new ones.\n", - "Fine-tuning a SuperAnimal models falls into the category of continual\n", - "learning: the downstream dataset defines potentially different\n", - "keypoints than those learned by the models. Thus, the models might\n", - "forget the keypoints they learned and only pick up those defined in the\n", - "target dataset. Here, retraining with the original dataset and the new\n", - "one, is not a feasible option as datasets cannot be easily shared and\n", - "more computational resources would be required.\n", - "To counter that, we treat zero-shot inference of the model as a\n", - "memory buffer that stores knowledge from the original model. When\n", - "we fine-tune a SuperAnimal model, we replace the model predicted\n", - "keypoints with the ground-truth annotations, resulting in hybrid\n", - "learning of old and new knowledge. The quality of the zero-shot predictions\n", - "can vary and we use the confidence of prediction (0.7) as a\n", - "threshold to filter out low-confidence predictions. With the threshold\n", - "set to 1, memory replay fine-tuning becomes naive-fine-tuning." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Prepare training shuffle and weight initialization for memory-replay finetuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": 64, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "BKEF76AI0Z4c", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "bf107c7b-6e3c-4ece-f680-067e4d7641f0", - "vscode": { - "languageId": "plaintext" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", - "You passed a split with the following fraction: 94%\n", - "Creating training data for: Shuffle: 3 TrainFraction: 0.94\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 152/152 [00:00<00:00, 11984.40it/s]" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], - "source": [ - "weight_init = WeightInitialization(\n", - " dataset=superanimal_name,\n", - " conversion_array=table.to_array(),\n", - " with_decoder=True,\n", - " memory_replay=True,\n", - ")\n", - "\n", - "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", - " from_shuffle = imagenet_transfer_learning_shuffle,\n", - " shuffles = [superanimal_memory_replay_shuffle],\n", - " engine = Engine.PYTORCH,\n", - " net_type=\"top_down_hrnet_w32\",\n", - " weight_init = weight_init,\n", - " userfeedback = False)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Launch the training for memory-replay fine-tuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": 65, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "Ru8tIFmD2Mkv", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "81a0ec13-6ba7-4089-bed5-f19c6bae0bcb" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Training with configuration:\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 0\n", - "device: auto\n", - "metadata:\n", - " project_path: /content/dlc_project_folder/daniel3mouse\n", - " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle2/train/pose_cfg.yaml\n", - " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", - " unique_bodyparts: []\n", - " individuals: ['mus1', 'mus2', 'mus3']\n", - " with_identity: False\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: False\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 12\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 12]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "net_type: hrnet_w32\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0005\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 3\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 64\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 3\n", - " pretrained_weights: None\n", - " seed: 42\n", - " weight_init:\n", - " dataset: superanimal_quadruped\n", - " with_decoder: True\n", - " memory_replay: False\n", - " conversion_array: [0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]\n", - "eval_interval: 1\n", - "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=True, memory_replay=False, conversion_array=array([ 0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]), bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", - "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", - "Data Transforms:\n", - " Training: Compose([\n", - " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", - " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "Using 456 images and 27 for testing\n", - "\n", - "Starting pose model training...\n", - "--------------------------------------------------\n", - "Training for epoch 1 done, starting evaluation\n", - "Epoch 1 performance:\n", - "metrics/test.rmse: 11.625\n", - "metrics/test.rmse_pcutoff:5.703\n", - "metrics/test.mAP: 71.971\n", - "metrics/test.mAR: 75.185\n", - "metrics/test.mAP_pcutoff:36.845\n", - "metrics/test.mAR_pcutoff:40.370\n", - "Epoch 1/3 (lr=0.0005), train loss 0.00387, valid loss 0.00279\n", - "Training for epoch 2 done, starting evaluation\n", - "Epoch 2 performance:\n", - "metrics/test.rmse: 9.842\n", - "metrics/test.rmse_pcutoff:4.464\n", - "metrics/test.mAP: 77.846\n", - "metrics/test.mAR: 80.000\n", - "metrics/test.mAP_pcutoff:33.924\n", - "metrics/test.mAR_pcutoff:35.926\n", - "Epoch 2/3 (lr=0.0005), train loss 0.00210, valid loss 0.00188\n", - "Training for epoch 3 done, starting evaluation\n", - "Epoch 3 performance:\n", - "metrics/test.rmse: 8.163\n", - "metrics/test.rmse_pcutoff:3.807\n", - "metrics/test.mAP: 82.317\n", - "metrics/test.mAR: 84.815\n", - "metrics/test.mAP_pcutoff:49.699\n", - "metrics/test.mAR_pcutoff:53.704\n", - "Epoch 3/3 (lr=0.0005), train loss 0.00167, valid loss 0.00154\n" - ] - } - ], - "source": [ - "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, eval_interval = 1, batch_size = 64, shuffle = superanimal_naive_finetune_shuffle)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Evaluate the model obtained by memory-replay finetuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "collapsed": true, - "id": "sfMcK3gq8WxZ", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_memory_replay_shuffle])" - ] - } - ], - "metadata": { - "accelerator": "TPU", - "colab": { - "gpuType": "V28", - "provenance": [] - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.19" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "1066fb18c9d045bea6568909a49a4a7a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "255dee8feaf74901b7a412fce53e0beb": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_cfee5f910ac14a95b770b77fd6f9629e", - "max": 165432914, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_ff5459e8346a48edbb9fc6bdfcdeb690", - "value": 165432914 - } - }, - "30df28e721be4dffa0271edad4cd5ce3": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "67677e7c5e284682ae8aae107833e702": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_a5cf5d546d11442f86cb3b048f6e1b51", - "placeholder": "​", - "style": "IPY_MODEL_30df28e721be4dffa0271edad4cd5ce3", - "value": "model.safetensors: 100%" - } - }, - "765e9d1889f3472c9e050d96ca1c0e24": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_1066fb18c9d045bea6568909a49a4a7a", - "placeholder": "​", - "style": "IPY_MODEL_aec1058e27ab48d0bdeaa934f12a2a04", - "value": " 165M/165M [00:00<00:00, 250MB/s]" - } - }, - "7b5f401de8f647bbb1f241d0ae61a106": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "8f396755637f4a779f3e77bf8e4c5f2d": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_67677e7c5e284682ae8aae107833e702", - "IPY_MODEL_255dee8feaf74901b7a412fce53e0beb", - "IPY_MODEL_765e9d1889f3472c9e050d96ca1c0e24" - ], - "layout": "IPY_MODEL_7b5f401de8f647bbb1f241d0ae61a106" - } - }, - "a5cf5d546d11442f86cb3b048f6e1b51": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "aec1058e27ab48d0bdeaa934f12a2a04": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "cfee5f910ac14a95b770b77fd6f9629e": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "ff5459e8346a48edbb9fc6bdfcdeb690": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - } - } - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "view-in-github", + "colab_type": "text" + }, + "source": [ + "\"Open" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "5SSZpZUu0Z4S" + }, + "source": [ + "# DeepLabCut Model Zoo: SuperAnimal models\n", + "\n", + "![alt text](https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1616492373700-PGOAC72IOB6AUE47VTJX/ke17ZwdGBToddI8pDm48kB8JrdUaZR-OSkKLqWQPp_YUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8N_N4V1vUb5AoIIIbLZhVYwL8IeDg6_3B-BRuF4nNrNcQkVuAT7tdErd0wQFEGFSnBqyW03PFN2MN6T6ry5cmXqqA9xITfsbVGDrg_goIDasRCalqV8R3606BuxERAtDaQ/modelzoo.png?format=1000w)\n", + "\n", + "# 🦄 SuperAnimal in DeepLabCut PyTorch! 🔥\n", + "\n", + "This notebook demos how to use our SuperAnimal models within DLC3. Please read more in [Ye et al. Nature Communications 2024](https://www.nature.com/articles/s41467-024-48792-2) about the available SuperAnimal models, and follow along below!\n", + "\n", + "### **Let's get going: install DeepLabCut into COLAB:**\n", + "\n", + "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, + "collapsed": true, + "id": "AjET5cJE5UYM", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "1fc34e5a-6bf3-4866-95fb-264ca42928da" + }, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Collecting deeplabcut==3.0.0rc1\n", + " Downloading deeplabcut-3.0.0rc1-py3-none-any.whl (2.0 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.0/2.0 MB\u001b[0m \u001b[31m7.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting albumentations<=1.4.3 (from deeplabcut==3.0.0rc1)\n", + " Downloading albumentations-1.4.3-py3-none-any.whl (137 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m137.0/137.0 kB\u001b[0m \u001b[31m14.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting dlclibrary>=0.0.5 (from deeplabcut==3.0.0rc1)\n", + " Downloading dlclibrary-0.0.6-py3-none-any.whl (15 kB)\n", + "Collecting einops (from deeplabcut==3.0.0rc1)\n", + " Downloading einops-0.8.0-py3-none-any.whl (43 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m43.2/43.2 kB\u001b[0m \u001b[31m4.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting filterpy>=1.4.4 (from deeplabcut==3.0.0rc1)\n", + " Downloading filterpy-1.4.5.zip (177 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m178.0/178.0 kB\u001b[0m \u001b[31m14.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n", + "Collecting ruamel.yaml>=0.15.0 (from deeplabcut==3.0.0rc1)\n", + " Downloading ruamel.yaml-0.18.6-py3-none-any.whl (117 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m117.8/117.8 kB\u001b[0m \u001b[31m9.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting imgaug>=0.4.0 (from deeplabcut==3.0.0rc1)\n", + " Downloading imgaug-0.4.0-py2.py3-none-any.whl (948 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m948.0/948.0 kB\u001b[0m \u001b[31m42.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting imageio-ffmpeg (from deeplabcut==3.0.0rc1)\n", + " Downloading imageio_ffmpeg-0.5.1-py3-none-manylinux2010_x86_64.whl (26.9 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m26.9/26.9 MB\u001b[0m \u001b[31m38.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: numba>=0.54 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.60.0)\n", + "Collecting matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3 (from deeplabcut==3.0.0rc1)\n", + " Downloading matplotlib-3.8.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (11.6 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m11.6/11.6 MB\u001b[0m \u001b[31m59.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: networkx>=2.6 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (3.3)\n", + "Requirement already satisfied: numpy>=1.18.5 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.25.2)\n", + "Requirement already satisfied: pandas!=1.5.0,>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.0.3)\n", + "Requirement already satisfied: scikit-image>=0.17 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.19.3)\n", + "Requirement already satisfied: scikit-learn>=1.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.2.2)\n", + "Collecting scipy<1.11.0,>=1.4 (from deeplabcut==3.0.0rc1)\n", + " Downloading scipy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (34.4 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m34.4/34.4 MB\u001b[0m \u001b[31m27.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting statsmodels>=0.11 (from deeplabcut==3.0.0rc1)\n", + " Downloading statsmodels-0.14.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (10.8 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m10.8/10.8 MB\u001b[0m \u001b[31m60.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting tables==3.8.0 (from deeplabcut==3.0.0rc1)\n", + " Downloading tables-3.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (6.5 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m6.5/6.5 MB\u001b[0m \u001b[31m64.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting timm (from deeplabcut==3.0.0rc1)\n", + " Downloading timm-1.0.7-py3-none-any.whl (2.3 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.3/2.3 MB\u001b[0m \u001b[31m84.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: torch>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.3.0+cpu)\n", + "Requirement already satisfied: torchvision in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.18.0+cpu)\n", + "Requirement already satisfied: tqdm in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (4.66.4)\n", + "Collecting pycocotools (from deeplabcut==3.0.0rc1)\n", + " Downloading pycocotools-2.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (427 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m427.8/427.8 kB\u001b[0m \u001b[31m40.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: pyyaml in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (6.0.1)\n", + "Requirement already satisfied: Pillow>=7.1 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (10.3.0)\n", + "Collecting cython>=0.29.21 (from tables==3.8.0->deeplabcut==3.0.0rc1)\n", + " Downloading Cython-3.0.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.6 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.6/3.6 MB\u001b[0m \u001b[31m99.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting numexpr>=2.6.2 (from tables==3.8.0->deeplabcut==3.0.0rc1)\n", + " Downloading numexpr-2.10.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (405 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m405.0/405.0 kB\u001b[0m \u001b[31m30.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting blosc2~=2.0.0 (from tables==3.8.0->deeplabcut==3.0.0rc1)\n", + " Downloading blosc2-2.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.9 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.9/3.9 MB\u001b[0m \u001b[31m98.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: packaging in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (24.1)\n", + "Collecting py-cpuinfo (from tables==3.8.0->deeplabcut==3.0.0rc1)\n", + " Downloading py_cpuinfo-9.0.0-py3-none-any.whl (22 kB)\n", + "Collecting scikit-image>=0.17 (from deeplabcut==3.0.0rc1)\n", + " Downloading scikit_image-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (14.9 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m14.9/14.9 MB\u001b[0m \u001b[31m56.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: typing-extensions>=4.9.0 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->deeplabcut==3.0.0rc1) (4.12.2)\n", + "Collecting scikit-learn>=1.0 (from deeplabcut==3.0.0rc1)\n", + " Downloading scikit_learn-1.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (13.3 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m13.3/13.3 MB\u001b[0m \u001b[31m61.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting opencv-python-headless>=4.9.0 (from albumentations<=1.4.3->deeplabcut==3.0.0rc1)\n", + " Downloading opencv_python_headless-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (49.9 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m49.9/49.9 MB\u001b[0m \u001b[31m19.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: huggingface-hub in /usr/local/lib/python3.10/dist-packages (from dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (0.23.4)\n", + "Requirement already satisfied: six in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (1.16.0)\n", + "Requirement already satisfied: opencv-python in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (4.10.0.84)\n", + "Requirement already satisfied: imageio in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (2.34.1)\n", + "Collecting Shapely (from imgaug>=0.4.0->deeplabcut==3.0.0rc1)\n", + " Downloading shapely-2.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (2.5 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.5/2.5 MB\u001b[0m \u001b[31m82.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (1.2.1)\n", + "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (0.12.1)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (4.53.0)\n", + "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (1.4.5)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (3.1.2)\n", + "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (2.9.0.post0)\n", + "Requirement already satisfied: llvmlite<0.44,>=0.43.0dev0 in /usr/local/lib/python3.10/dist-packages (from numba>=0.54->deeplabcut==3.0.0rc1) (0.43.0)\n", + "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut==3.0.0rc1) (2024.1)\n", + "Requirement already satisfied: tzdata>=2022.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut==3.0.0rc1) (2024.1)\n", + "Collecting ruamel.yaml.clib>=0.2.7 (from ruamel.yaml>=0.15.0->deeplabcut==3.0.0rc1)\n", + " Downloading ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl (526 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m526.7/526.7 kB\u001b[0m \u001b[31m35.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: tifffile>=2022.8.12 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->deeplabcut==3.0.0rc1) (2024.5.22)\n", + "Requirement already satisfied: lazy-loader>=0.4 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->deeplabcut==3.0.0rc1) (0.4)\n", + "Requirement already satisfied: joblib>=1.2.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->deeplabcut==3.0.0rc1) (1.4.2)\n", + "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->deeplabcut==3.0.0rc1) (3.5.0)\n", + "Collecting patsy>=0.5.6 (from statsmodels>=0.11->deeplabcut==3.0.0rc1)\n", + " Downloading patsy-0.5.6-py2.py3-none-any.whl (233 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m233.9/233.9 kB\u001b[0m \u001b[31m22.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: filelock in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (3.15.1)\n", + "Requirement already satisfied: sympy in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (1.12.1)\n", + "Requirement already satisfied: jinja2 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (3.1.4)\n", + "Requirement already satisfied: fsspec in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (2024.6.0)\n", + "Requirement already satisfied: setuptools in /usr/local/lib/python3.10/dist-packages (from imageio-ffmpeg->deeplabcut==3.0.0rc1) (67.7.2)\n", + "Requirement already satisfied: safetensors in /usr/local/lib/python3.10/dist-packages (from timm->deeplabcut==3.0.0rc1) (0.4.3)\n", + "Requirement already satisfied: msgpack in /usr/local/lib/python3.10/dist-packages (from blosc2~=2.0.0->tables==3.8.0->deeplabcut==3.0.0rc1) (1.0.8)\n", + "Requirement already satisfied: requests in /usr/local/lib/python3.10/dist-packages (from huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2.31.0)\n", + "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.10/dist-packages (from jinja2->torch>=2.0.0->deeplabcut==3.0.0rc1) (2.1.5)\n", + "Requirement already satisfied: mpmath<1.4.0,>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from sympy->torch>=2.0.0->deeplabcut==3.0.0rc1) (1.3.0)\n", + "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (3.3.2)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (3.7)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2.0.7)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2024.6.2)\n", + "Building wheels for collected packages: filterpy\n", + " Building wheel for filterpy (setup.py) ... \u001b[?25l\u001b[?25hdone\n", + " Created wheel for filterpy: filename=filterpy-1.4.5-py3-none-any.whl size=110458 sha256=6a53d4b22046f248c777d11ff05cb8fc91ec43300fe46f247501b451462c33b3\n", + " Stored in directory: /root/.cache/pip/wheels/0f/0c/ea/218f266af4ad626897562199fbbcba521b8497303200186102\n", + "Successfully built filterpy\n", + "Installing collected packages: py-cpuinfo, Shapely, scipy, ruamel.yaml.clib, patsy, opencv-python-headless, numexpr, imageio-ffmpeg, einops, cython, blosc2, tables, scikit-learn, scikit-image, ruamel.yaml, matplotlib, statsmodels, pycocotools, imgaug, filterpy, dlclibrary, albumentations, timm, deeplabcut\n", + " Attempting uninstall: scipy\n", + " Found existing installation: scipy 1.11.4\n", + " Uninstalling scipy-1.11.4:\n", + " Successfully uninstalled scipy-1.11.4\n", + " Attempting uninstall: scikit-learn\n", + " Found existing installation: scikit-learn 1.2.2\n", + " Uninstalling scikit-learn-1.2.2:\n", + " Successfully uninstalled scikit-learn-1.2.2\n", + " Attempting uninstall: scikit-image\n", + " Found existing installation: scikit-image 0.19.3\n", + " Uninstalling scikit-image-0.19.3:\n", + " Successfully uninstalled scikit-image-0.19.3\n", + " Attempting uninstall: matplotlib\n", + " Found existing installation: matplotlib 3.7.1\n", + " Uninstalling matplotlib-3.7.1:\n", + " Successfully uninstalled matplotlib-3.7.1\n", + "Successfully installed Shapely-2.0.4 albumentations-1.4.3 blosc2-2.0.0 cython-3.0.10 deeplabcut-3.0.0rc1 dlclibrary-0.0.6 einops-0.8.0 filterpy-1.4.5 imageio-ffmpeg-0.5.1 imgaug-0.4.0 matplotlib-3.8.4 numexpr-2.10.1 opencv-python-headless-4.10.0.84 patsy-0.5.6 py-cpuinfo-9.0.0 pycocotools-2.0.8 ruamel.yaml-0.18.6 ruamel.yaml.clib-0.2.8 scikit-image-0.24.0 scikit-learn-1.5.0 scipy-1.10.1 statsmodels-0.14.2 tables-3.8.0 timm-1.0.7\n" + ] + }, + { + "output_type": "display_data", + "data": { + "application/vnd.colab-display-data+json": { + "pip_warning": { + "packages": [ + "matplotlib", + "mpl_toolkits" + ] + }, + "id": "6acdb4d198fb491cb7f8ba13f542ed20" + } + }, + "metadata": {} + } + ], + "source": [ + "!pip install deeplabcut==3.0.0rc1" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "5h0vq6E50Z4W" + }, + "source": [ + "### PLEASE, click \"restart runtime\" from the output above before proceeding!" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "collapsed": true, + "id": "LvnlIvQm0Z4X", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "import deeplabcut\n", + "import os\n", + "from deeplabcut.pose_estimation_pytorch.apis.analyze_images import (\n", + " superanimal_analyze_images,\n", + ")\n", + "from deeplabcut.core.weight_init import WeightInitialization\n", + "from deeplabcut.core.engine import Engine\n", + "from deeplabcut.modelzoo.video_inference import video_inference_superanimal\n", + "from deeplabcut.utils.pseudo_label import keypoint_matching\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "UeXjmtu40Z4X" + }, + "source": [ + "## Zero-shot Image Inference & Video Inference\n", + "SuperAnimal models are foundation animal pose models. They can be used for zero-shot predictions without further training on the data.\n", + "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another dest folder." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "FvFzntDMxPoL" + }, + "source": [ + "## Zero-shot image inference\n", + "\n", + "- If you have a single Image you want to test, upload it here!" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "NbDsZQfsxPoL" + }, + "source": [ + "#### Upload the images you want to predict" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 92 + }, + "collapsed": true, + "id": "c4yfTj7r0Z4Y", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "b86aaca8-4238-4020-e0b0-bfb4a6da278c" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " \n", + " Upload widget is only available when the cell has been executed in the\n", + " current browser session. Please rerun this cell to enable.\n", + " \n", + " " + ] + }, + "metadata": {} + }, + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Saving zebra.png to zebra.png\n", + "User uploaded file \"zebra.png\" with length 1291107 bytes\n" + ] + } + ], + "source": [ + "from google.colab import files\n", + "\n", + "uploaded = files.upload()\n", + "for filepath, content in uploaded.items():\n", + " print(f'User uploaded file \"{filepath}\" with length {len(content)} bytes')\n", + "image_path = os.path.abspath(filepath)\n", + "image_name = os.path.splitext(image_path)[0]\n", + "\n", + "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n", + "# manually upload your video via the Files menu to the left\n", + "# and define `video_path` yourself with right click > copy path on the video." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Jashzdjb0Z4Y" + }, + "source": [ + "### Select a SuperAnimal name and corresponding model architecture\n", + "\n", + "Check Our Docs on [SuperAnimals](https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/ModelZoo.md) to learn more!" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "collapsed": true, + "id": "uH9LXig90Z4Y", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "superanimal_name = 'superanimal_quadruped' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", + "model_name = 'hrnetw32'\n", + "max_individuals = 1 #how many animals do you expect to see?" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000, + "referenced_widgets": [ + "d00216a2601747129cf3fdfa7c083097", + "05ecceef10ef43e5b011d5c3141ee4ac", + "d847ebd070b44f72ae5b8ea4e9346525", + "6b86e9cd93f1452bb774f2253232f91d", + "574f2fc10c794b93866b0728b4b82974", + "92c2b0759d6148baada3ba7aa00364a9", + "28119e084d06416e9c6fcb7167fc1eec", + "6023cb183d9a4d6495a180c6ad25088a", + "0ec583db59074e738fd1545bdeacabdd", + "f79fc9e32f344ab8a0cee579bcfaf303", + "9415c05ae7eb46089d9c77dfdd7a014f", + "6fd9f5ca19464368bb357f14b600b4df", + "6986e059de50418193dfbadb0f371aad", + "58147217c725435e8ae7545437ee9f0c", + "a78de7722c2649ca8e6d2ea26d305ce8", + "692db8a5e6be4cd58762b057edbfe753", + "1744c5f9765f49a78938a670cdf07514", + "439c929438204468b3b4d4c734735789", + "c1cbb03ad13f42a681c615a4abffde86", + "5ce989cb535a4b2eba237bad529e030f", + "f16ff7a640d84bedaf69340d6a4a3762", + "0381efd7cab94a35acba036e1f1ea45e" + ] + }, + "collapsed": true, + "id": "OmJtVmHq0Z4Y", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "d6b01e32-46ad-4243-cb1b-2a126ce99b7d" + }, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Loading.... superanimal_quadruped_hrnetw32\n" + ] + }, + { + "output_type": "stream", + "name": "stderr", + "text": [ + "/usr/local/lib/python3.10/dist-packages/huggingface_hub/utils/_token.py:89: UserWarning: \n", + "The secret `HF_TOKEN` does not exist in your Colab secrets.\n", + "To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.\n", + "You will be able to reuse this secret in all of your notebooks.\n", + "Please note that authentication is recommended but still optional to access public models or datasets.\n", + " warnings.warn(\n" + ] + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "pose_model.pth: 0%| | 0.00/160M [00:00\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m superanimal_analyze_images(superanimal_name,\n\u001b[0m\u001b[1;32m 3\u001b[0m \u001b[0mmodel_name\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0mimage_name\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 5\u001b[0m \u001b[0mmax_individuals\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py\u001b[0m in \u001b[0;36msuperanimal_analyze_images\u001b[0;34m(superanimal_name, model_name, images, max_individuals, out_folder, progress_bar, device, customized_pose_checkpoint, customized_detector_checkpoint, customized_model_config)\u001b[0m\n\u001b[1;32m 135\u001b[0m \u001b[0mconfig\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"individuals\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mindividuals\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 136\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 137\u001b[0;31m predictions = analyze_image_folder(\n\u001b[0m\u001b[1;32m 138\u001b[0m \u001b[0mmodel_cfg\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mconfig\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 139\u001b[0m \u001b[0mimages\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mimages\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py\u001b[0m in \u001b[0;36manalyze_image_folder\u001b[0;34m(model_cfg, images, snapshot_path, detector_path, device, max_individuals, progress_bar)\u001b[0m\n\u001b[1;32m 330\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mprogress_bar\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 331\u001b[0m \u001b[0mdetector_image_paths\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mtqdm\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdetector_image_paths\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 332\u001b[0;31m \u001b[0mbbox_predictions\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mdetector_runner\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minference\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimages\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mdetector_image_paths\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 333\u001b[0m \u001b[0mpose_inputs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mlist\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mzip\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimage_paths\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mbbox_predictions\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 334\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/torch/utils/_contextlib.py\u001b[0m in \u001b[0;36mdecorate_context\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 113\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mdecorate_context\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 114\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0mctx_factory\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 115\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mfunc\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 116\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 117\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mdecorate_context\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/runners/inference.py\u001b[0m in \u001b[0;36minference\u001b[0;34m(self, images)\u001b[0m\n\u001b[1;32m 102\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpreprocessor\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 103\u001b[0m \u001b[0;31m# TODO: input batch should also be able to be a dict[str, torch.Tensor]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 104\u001b[0;31m \u001b[0minput_image\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpreprocessor\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0minput_image\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 105\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 106\u001b[0m \u001b[0mimage_predictions\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpredict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0minput_image\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/data/preprocessor.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, image, context)\u001b[0m\n\u001b[1;32m 120\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m__call__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mimage\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mImage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mtuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mImage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 121\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mpreprocessor\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcomponents\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 122\u001b[0;31m \u001b[0mimage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mpreprocessor\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 123\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mimage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 124\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/data/preprocessor.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, image, context)\u001b[0m\n\u001b[1;32m 134\u001b[0m \u001b[0mimage_\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcv2\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mimread\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimage\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 135\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcolor_mode\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"RGB\"\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 136\u001b[0;31m \u001b[0mimage_\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcv2\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcvtColor\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimage_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcv2\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mCOLOR_BGR2RGB\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 137\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 138\u001b[0m \u001b[0mimage_\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mimage\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31merror\u001b[0m: OpenCV(4.10.0) /io/opencv/modules/imgproc/src/color.cpp:196: error: (-215:Assertion failed) !_src.empty() in function 'cvtColor'\n" + ] + } + ], + "source": [ + "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n", + "superanimal_analyze_images(superanimal_name,\n", + " model_name,\n", + " image_name,\n", + " max_individuals,\n", + " out_folder = '/content/')" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6VEjHu-00Z4Y" + }, + "source": [ + "### Zero-shot Video Inference\n", + "- Without Video adaptation (faster, but not self-supervised fine-tuned on your data!)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "qGoAhxZOxPoM" + }, + "source": [ + "#### Upload a video you want to predict" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 92 + }, + "collapsed": true, + "id": "PK3efA0I0Z4Y", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "c5a9050d-3d28-41ce-fc78-6bc9fd111296" + }, + "outputs": [ + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " \n", + " Upload widget is only available when the cell has been executed in the\n", + " current browser session. Please rerun this cell to enable.\n", + " \n", + " " + ] + }, + "metadata": {} + }, + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Saving zebra-dancing.mov to zebra-dancing.mov\n", + "User uploaded file \"zebra-dancing.mov\" with length 21553881 bytes\n" + ] + } + ], + "source": [ + "from google.colab import files\n", + "\n", + "uploaded = files.upload()\n", + "for filepath, content in uploaded.items():\n", + " print(f'User uploaded file \"{filepath}\" with length {len(content)} bytes')\n", + "video_path = os.path.abspath(filepath)\n", + "video_name = os.path.splitext(video_path)[0]\n", + "\n", + "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n", + "# manually upload your video via the Files menu to the left\n", + "# and define `video_path` yourself with right click > copy path on the video.\n", + "\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "JoA-RATSICj_" + }, + "source": [ + "#### Choose the superanimal and the model name" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "collapsed": true, + "id": "OiRAP9XD0Z4Z", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "superanimal_name = 'superanimal_quadruped' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", + "model_name = 'hrnetw32'\n", + "max_individuals = 1 #how many animals do you expect to see?" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "93xGQKr90Z4Z" + }, + "source": [ + "video_inference_superanimal(\n", + " videos=[\"/mnt/md0/shaokai/tom_video.mp4\"],\n", + " superanimal_name= f\"{superanimal_name}_{model_name}\",\n", + " video_adapt=False,\n", + " max_individuals=3, \n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Zv3v0QgSJNOg" + }, + "source": [ + "### Zero-shot Video Inference without video adaptation" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 108 + }, + "collapsed": true, + "id": "poqynL0UJTBp", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "3fb4aacd-c862-43d4-b1a2-5d11ae2cd500" + }, + "outputs": [ + { + "output_type": "error", + "ename": "SyntaxError", + "evalue": "positional argument follows keyword argument (, line 13)", + "traceback": [ + "\u001b[0;36m File \u001b[0;32m\"\"\u001b[0;36m, line \u001b[0;32m13\u001b[0m\n\u001b[0;31m )\u001b[0m\n\u001b[0m ^\u001b[0m\n\u001b[0;31mSyntaxError\u001b[0m\u001b[0;31m:\u001b[0m positional argument follows keyword argument\n" + ] + } + ], + "source": [ + "import glob\n", + "videos = glob.glob(os.path.join(in_video_folder, '*'))\n", + "video_inference_superanimal(\n", + " videos=videos,\n", + " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", + " video_adapt=False,\n", + " max_individuals,\n", + " pseudo_threshold=0.1,\n", + " bbox_threshold=0.9,\n", + " detector_epochs=1,\n", + " pose_epochs=1,\n", + " dest_folder = '/content/'\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Z8Z5GSti0Z4Z" + }, + "source": [ + "### Zero-shot Video Inference with video adaptation (unsupervised)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 11353, + "referenced_widgets": [ + "8f396755637f4a779f3e77bf8e4c5f2d", + "67677e7c5e284682ae8aae107833e702", + "255dee8feaf74901b7a412fce53e0beb", + "765e9d1889f3472c9e050d96ca1c0e24", + "7b5f401de8f647bbb1f241d0ae61a106", + "a5cf5d546d11442f86cb3b048f6e1b51", + "30df28e721be4dffa0271edad4cd5ce3", + "cfee5f910ac14a95b770b77fd6f9629e", + "ff5459e8346a48edbb9fc6bdfcdeb690", + "1066fb18c9d045bea6568909a49a4a7a", + "aec1058e27ab48d0bdeaa934f12a2a04" + ] + }, + "collapsed": true, + "id": "5mhOmtzw0Z4Z", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "d830c849-91e8-4597-f388-cec2cd3b4eb0" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "running video inference on ['/content/uploaded_videos/dog-agility.mov'] with superanimal_quadruped_hrnetw32\n", + "Using pytorch for model hrnetw32\n", + "using /content/uploaded_videos/dog-agility.mov for video adaptation training\n", + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: auto\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 39\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 39]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n", + "Processing video /content/uploaded_videos/dog-agility.mov\n", + "Starting to analyze /content/uploaded_videos/dog-agility.mov\n", + "Video metadata: \n", + " Overall # of frames: 183\n", + " Duration of video [s]: 3.10\n", + " fps: 59.03\n", + " resolution: w=1128, h=630\n", + "\n", + "Running Detector\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 98%|█████████▊| 179/183 [03:43<00:04, 1.25s/it]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running Pose Prediction\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 98%|█████████▊| 179/183 [00:37<00:00, 4.80it/s]\n", + "WARNING:root:The video metadata indicates that there 183 in the video, but only 179 were able to be processed. This can happen if the video is corrupted. You can try to fix the issue by re-encoding your video (tips on how to do that: https://deeplabcut.github.io/DeepLabCut/docs/recipes/io.html#tips-on-video-re-encoding-and-preprocessing)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saving results to /content/processed_videos\n", + "Saving results in /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32.h5 and /content/processed_videos/dog-agility_superanimal_quadruped_hrnetw32_full.pickle\n", + "Duration of video [s]: 3.1, recorded with 59.03 fps!\n", + "Overall # of frames: 183 with cropped frame dimensions: 1128 630\n", + "Generating frames and creating video.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 179/179 [00:01<00:00, 96.18it/s] \n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Video with predictions was saved as /content/processed_videos\n", + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: auto\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 39\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 39]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n", + "Video frames being extracted to /content/uploaded_videos/pseudo_dog-agility/images for video adaptation.\n", + "Constructing pseudo dataset at /content/uploaded_videos/pseudo_dog-agility\n", + "\n", + "Running video adaptation with following parameters: \n", + "(pose training) pose_epochs: 1\n", + "(pose) save_epochs: 1\n", + "detector_epochs: 1\n", + "detector_save_epochs: 1\n", + "video adaptation batch size: 8\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Downloading: \"https://download.pytorch.org/models/fasterrcnn_resnet50_fpn_v2_coco-dd69338a.pth\" to /root/.cache/torch/hub/checkpoints/fasterrcnn_resnet50_fpn_v2_coco-dd69338a.pth\n", + "100%|██████████| 167M/167M [00:00<00:00, 218MB/s]\n", + "/content/drive/My Drive/DLCdev/deeplabcut/pose_estimation_pytorch/data/transforms.py:68: UserWarning: Be careful! Do not train pose models with horizontal flips if you have symmetric keypoints!\n", + " warnings.warn(\n", + "Data Transforms:\n", + " Training: Compose([\n", + " HorizontalFlip(always_apply=False, p=0.5),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "\n", + "Note: According to your model configuration, you're training with batch size 1 and/or ``freeze_bn_stats=false``. This is not an optimal setting if you have powerful GPUs.\n", + "This is good for small batch sizes (e.g., when training on a CPU), where you should keep ``freeze_bn_stats=true``.\n", + "If you're using a GPU to train, you can obtain faster performance by setting a larger batch size (the biggest power of 2 where you don't geta CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` for the backbone of your model. \n", + "This also allows you to increase the learning rate (empirically you can scale the learning rate by sqrt(batch_size) times).\n", + "\n", + "Using 17 images and 10 for testing\n", + "\n", + "Starting object detector training...\n", + "--------------------------------------------------\n", + "Epoch 1/1 (lr=1e-05), train loss 0.07259\n", + "Loading pretrained weights from Hugging Face hub (timm/hrnet_w32.ms_in1k)\n", + "/usr/local/lib/python3.10/dist-packages/huggingface_hub/utils/_token.py:89: UserWarning: \n", + "The secret `HF_TOKEN` does not exist in your Colab secrets.\n", + "To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.\n", + "You will be able to reuse this secret in all of your notebooks.\n", + "Please note that authentication is recommended but still optional to access public models or datasets.\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "8f396755637f4a779f3e77bf8e4c5f2d", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "model.safetensors: 0%| | 0.00/165M [00:00\n", + " \n", + " Upload widget is only available when the cell has been executed in the\n", + " current browser session. Please rerun this cell to enable.\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Saving daniel3mouse.zip to daniel3mouse.zip\n", + "Contents of the extracted folder:\n", + "- __MACOSX\n", + "- daniel3mouse\n" + ] + } + ], + "source": [ + "uploaded = files.upload()\n", + "for filename in uploaded.keys():\n", + " zip_file_path = os.path.join(\"/content\", filename)\n", + " with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:\n", + " zip_ref.extractall(\"/content/dlc_project_folder\")\n", + "\n", + "print(\"Contents of the extracted folder:\")\n", + "extracted_files = os.listdir(\"/content/dlc_project_folder\")\n", + "for file in extracted_files:\n", + " print(f'- {file}')\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "b5UqfHcnxPoO" + }, + "source": [ + "#### Change the path to your project in dlc_project_folder" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true, + "id": "nY7Sv9pslaMh", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "dlc_proj_root = Path(\"/content/dlc_project_folder/daniel3mouse\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "BPvoL9uZ0Z4a" + }, + "source": [ + "#### Comparison between different training baselines\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "eVmpaLdB0Z4a" + }, + "source": [ + "Definition of data split: the unique combination of training images and testing images.\n", + "We create a data split named split 0. All baselines will share the data split to make fair comparisons.\n", + "- split 0 -> shared by all baselines\n", + "- shuffle 0 (split0) -> imagenet transfer learning\n", + "- shuffle 1 (split0) -> superanimal transfer learning\n", + "- shuffle 2 (split0) -> superanimal naive fine-tuning\n", + "- shuffle 3 (split0) -> superanimal memory-replay fine-tuning" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "WofR2jytxPoR" + }, + "source": [ + "### What is the difference between baselines?\n", + "\n", + "**Transfer learning** For canonical task-agnostic transfer learning,\n", + "the encoder learns universal visual features from ImageNet, and a randomly\n", + "initialized decoder is used to learn the pose fromthe downstream dataset.\n", + "\n", + "**Fine-tuning** For task aware\n", + "fine-tuning, both encoder and decoder learn task-related visual-pose features\n", + "in the pre-training datasets, and the decoder is fine-tuned to update pose\n", + "priors in downstream datasets. Crucially, the network has pose-estimation-specific\n", + "weights\n", + "\n", + "**ImageNet transfer-learning** The encoder was pre-trained from ImageNet. The decoder is trained from scratch in the downstream tasks\n", + "\n", + "**SuperAnimal transfer-learning** The encoder was pre-trained first from ImageNet, then in pose datasets we colleceted. Then decoder is trained from scratch in downstream tasks.\n", + "\n", + "**SuperAnimal naive fine-tuning** Both the encoder and the decoder were pre-trained in pose datasets we collected. In downstream datsets, we only finetune convolutional channels that correspond to the annotated keypoints in the downstream datasets. This introduces catastrophic forgetting in keypoints that are not annotated in the downstream datasets.\n", + "\n", + "**SuperAnimal memory-replay fine-tuning** If we apply fine-tuning with SuperAnimal without further cares, the models will forget about keypoints that are not annotated in the downstream datasets. To mitigate this, we mix the annotations and zero-shot predictions of SuperAnimal models to create a dataset that 'replays' the memory of the SuperAnimal keypoints.\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true, + "id": "AgIsUu6v0Z4a", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "imagenet_transfer_learning_shuffle = 0\n", + "superanimal_transfer_learning_shuffle = 1\n", + "superanimal_naive_finetune_shuffle = 2\n", + "superanimal_memory_replay_shuffle = 3" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "kuKcxM8F0Z4a", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bb09b5cd-bd25-416b-8b94-aa25a5ffd1a7" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", + "Creating training data for: Shuffle: 0 TrainFraction: 0.95\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 152/152 [00:00<00:00, 3254.90it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" + ] + } + ], + "source": [ + "config_path = dlc_proj_root / 'config.yaml'\n", + "deeplabcut.create_training_dataset(\n", + " config_path,\n", + " Shuffles = [imagenet_transfer_learning_shuffle],\n", + " net_type=\"top_down_hrnet_w32\",\n", + " engine=Engine.PYTORCH,\n", + " userfeedback=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_6RncQbr0Z4a" + }, + "source": [ + "### ImageNet transfer learning\n", + "\n", + "Historically, the transfer learning using ImageNet weights strategies assumed no “animal pose task priors” in the pretrained\n", + "model, a paradigm adopted from previous task-agnostic transfer learning." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "H2z8kM340Z4a", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bd322c11-20e4-4b75-da4d-fdae1b7e5937" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Training with configuration:\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " rotation: 30\n", + " scaling: [1.0, 1.0]\n", + " translation: 0\n", + " collate: None\n", + " covering: False\n", + " gaussian_noise: 12.75\n", + " hist_eq: False\n", + " motion_blur: False\n", + " normalize_images: True\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " rotation: 30\n", + " scaling: [1.0, 1.0]\n", + " translation: 40\n", + " collate:\n", + " type: ResizeFromDataSizeCollate\n", + " min_scale: 0.4\n", + " max_scale: 1.0\n", + " min_short_side: 128\n", + " max_short_side: 1152\n", + " multiple_of: 32\n", + " to_square: False\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " variant: fasterrcnn_mobilenet_v3_large_fpn\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0001\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [160]\n", + " lr_list: [[1e-05]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 0\n", + "device: auto\n", + "metadata:\n", + " project_path: /content/dlc_project_folder/daniel3mouse\n", + " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset95shuffle0/train/pose_cfg.yaml\n", + " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", + " unique_bodyparts: []\n", + " individuals: ['mus1', 'mus2', 'mus3']\n", + " with_identity: None\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " freeze_bn_stats: False\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 12\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32]\n", + " kernel_size: []\n", + " strides: []\n", + " final_conv:\n", + " out_channels: 12\n", + " kernel_size: 1\n", + "net_type: hrnet_w32\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " gpus: None\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0005\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-05], [1e-06]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 3\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 64\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 3\n", + " seed: 42\n", + "Loading pretrained weights from Hugging Face hub (timm/hrnet_w32.ms_in1k)\n", + "[timm/hrnet_w32.ms_in1k] Safe alternative available for 'pytorch_model.bin' (as 'model.safetensors'). Loading weights using safetensors.\n", + "Unexpected keys (downsamp_modules.0.0.bias, downsamp_modules.0.0.weight, downsamp_modules.0.1.bias, downsamp_modules.0.1.num_batches_tracked, downsamp_modules.0.1.running_mean, downsamp_modules.0.1.running_var, downsamp_modules.0.1.weight, downsamp_modules.1.0.bias, downsamp_modules.1.0.weight, downsamp_modules.1.1.bias, downsamp_modules.1.1.num_batches_tracked, downsamp_modules.1.1.running_mean, downsamp_modules.1.1.running_var, downsamp_modules.1.1.weight, downsamp_modules.2.0.bias, downsamp_modules.2.0.weight, downsamp_modules.2.1.bias, downsamp_modules.2.1.num_batches_tracked, downsamp_modules.2.1.running_mean, downsamp_modules.2.1.running_var, downsamp_modules.2.1.weight, final_layer.0.bias, final_layer.0.weight, final_layer.1.bias, final_layer.1.num_batches_tracked, final_layer.1.running_mean, final_layer.1.running_var, final_layer.1.weight, incre_modules.0.0.bn1.bias, incre_modules.0.0.bn1.num_batches_tracked, incre_modules.0.0.bn1.running_mean, incre_modules.0.0.bn1.running_var, incre_modules.0.0.bn1.weight, incre_modules.0.0.bn2.bias, incre_modules.0.0.bn2.num_batches_tracked, incre_modules.0.0.bn2.running_mean, incre_modules.0.0.bn2.running_var, incre_modules.0.0.bn2.weight, incre_modules.0.0.bn3.bias, incre_modules.0.0.bn3.num_batches_tracked, incre_modules.0.0.bn3.running_mean, incre_modules.0.0.bn3.running_var, incre_modules.0.0.bn3.weight, incre_modules.0.0.conv1.weight, incre_modules.0.0.conv2.weight, incre_modules.0.0.conv3.weight, incre_modules.0.0.downsample.0.weight, incre_modules.0.0.downsample.1.bias, incre_modules.0.0.downsample.1.num_batches_tracked, incre_modules.0.0.downsample.1.running_mean, incre_modules.0.0.downsample.1.running_var, incre_modules.0.0.downsample.1.weight, incre_modules.1.0.bn1.bias, incre_modules.1.0.bn1.num_batches_tracked, incre_modules.1.0.bn1.running_mean, incre_modules.1.0.bn1.running_var, incre_modules.1.0.bn1.weight, incre_modules.1.0.bn2.bias, incre_modules.1.0.bn2.num_batches_tracked, incre_modules.1.0.bn2.running_mean, incre_modules.1.0.bn2.running_var, incre_modules.1.0.bn2.weight, incre_modules.1.0.bn3.bias, incre_modules.1.0.bn3.num_batches_tracked, incre_modules.1.0.bn3.running_mean, incre_modules.1.0.bn3.running_var, incre_modules.1.0.bn3.weight, incre_modules.1.0.conv1.weight, incre_modules.1.0.conv2.weight, incre_modules.1.0.conv3.weight, incre_modules.1.0.downsample.0.weight, incre_modules.1.0.downsample.1.bias, incre_modules.1.0.downsample.1.num_batches_tracked, incre_modules.1.0.downsample.1.running_mean, incre_modules.1.0.downsample.1.running_var, incre_modules.1.0.downsample.1.weight, incre_modules.2.0.bn1.bias, incre_modules.2.0.bn1.num_batches_tracked, incre_modules.2.0.bn1.running_mean, incre_modules.2.0.bn1.running_var, incre_modules.2.0.bn1.weight, incre_modules.2.0.bn2.bias, incre_modules.2.0.bn2.num_batches_tracked, incre_modules.2.0.bn2.running_mean, incre_modules.2.0.bn2.running_var, incre_modules.2.0.bn2.weight, incre_modules.2.0.bn3.bias, incre_modules.2.0.bn3.num_batches_tracked, incre_modules.2.0.bn3.running_mean, incre_modules.2.0.bn3.running_var, incre_modules.2.0.bn3.weight, incre_modules.2.0.conv1.weight, incre_modules.2.0.conv2.weight, incre_modules.2.0.conv3.weight, incre_modules.2.0.downsample.0.weight, incre_modules.2.0.downsample.1.bias, incre_modules.2.0.downsample.1.num_batches_tracked, incre_modules.2.0.downsample.1.running_mean, incre_modules.2.0.downsample.1.running_var, incre_modules.2.0.downsample.1.weight, incre_modules.3.0.bn1.bias, incre_modules.3.0.bn1.num_batches_tracked, incre_modules.3.0.bn1.running_mean, incre_modules.3.0.bn1.running_var, incre_modules.3.0.bn1.weight, incre_modules.3.0.bn2.bias, incre_modules.3.0.bn2.num_batches_tracked, incre_modules.3.0.bn2.running_mean, incre_modules.3.0.bn2.running_var, incre_modules.3.0.bn2.weight, incre_modules.3.0.bn3.bias, incre_modules.3.0.bn3.num_batches_tracked, incre_modules.3.0.bn3.running_mean, incre_modules.3.0.bn3.running_var, incre_modules.3.0.bn3.weight, incre_modules.3.0.conv1.weight, incre_modules.3.0.conv2.weight, incre_modules.3.0.conv3.weight, incre_modules.3.0.downsample.0.weight, incre_modules.3.0.downsample.1.bias, incre_modules.3.0.downsample.1.num_batches_tracked, incre_modules.3.0.downsample.1.running_mean, incre_modules.3.0.downsample.1.running_var, incre_modules.3.0.downsample.1.weight, classifier.bias, classifier.weight) found while loading pretrained weights. This may be expected if model is being adapted.\n", + "Data Transforms:\n", + " Training: Compose([\n", + " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", + " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "Using 456 images and 27 for testing\n", + "\n", + "Starting pose model training...\n", + "--------------------------------------------------\n", + "Training for epoch 1 done, starting evaluation\n", + "Epoch 1 performance:\n", + "metrics/test.rmse: 58.034\n", + "metrics/test.rmse_pcutoff:57.757\n", + "metrics/test.mAP: 1.523\n", + "metrics/test.mAR: 2.222\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 1/3 (lr=0.0005), train loss 0.00532, valid loss 0.08804\n", + "Training for epoch 2 done, starting evaluation\n", + "Epoch 2 performance:\n", + "metrics/test.rmse: 24.018\n", + "metrics/test.rmse_pcutoff:4.871\n", + "metrics/test.mAP: 41.487\n", + "metrics/test.mAR: 48.519\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 2/3 (lr=0.0005), train loss 0.00385, valid loss 0.00458\n", + "Training for epoch 3 done, starting evaluation\n", + "Epoch 3 performance:\n", + "metrics/test.rmse: 14.797\n", + "metrics/test.rmse_pcutoff:4.767\n", + "metrics/test.mAP: 64.759\n", + "metrics/test.mAR: 71.852\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 3/3 (lr=0.0005), train loss 0.00298, valid loss 0.00334\n" + ] + } + ], + "source": [ + "# Note we skip the detector training to save time. The evaluation is by default using ground-truth bounding box.\n", + "# But to train a model that can be used to inference videos and images, you have to set detector_epochs > 0\n", + "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, batch_size = 64, shuffle = imagenet_transfer_learning_shuffle)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "J-udMck7nDbG" + }, + "source": [ + "#### Though the evaluation was also done during training, let's just do it again here to double-check" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "TDHMdKz4m_16", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "091082c1-0c61-4967-9750-092541dad0ae" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", + "100%|██████████| 152/152 [01:20<00:00, 1.88it/s]\n", + "100%|██████████| 9/9 [00:04<00:00, 1.86it/s]\n", + "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle0_snapshot_001-results.csv (pcutoff: 0.01):\n", + "INFO:root:train rmse 54.74\n", + "train rmse_pcutoff 54.74\n", + "train mAP 0.58\n", + "train mAR 3.46\n", + "train mAP_pcutoff 0.58\n", + "train mAR_pcutoff 3.46\n", + "test rmse 55.73\n", + "test rmse_pcutoff 55.73\n", + "test mAP 2.78\n", + "test mAR 7.04\n", + "test mAP_pcutoff 2.78\n", + "test mAR_pcutoff 7.04\n", + "Name: (0.95, 0, 1, -1, 0.01), dtype: float64\n" + ] + } + ], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles = [imagenet_transfer_learning_shuffle])" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "0GIFWU-MxPoR" + }, + "source": [ + "### Transfer learning with SuperAnimal weights" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "ZGhAuyqs0Z4a" + }, + "source": [ + "#### Prepare trianing shuffle for transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "wOSdZQtOp8qa", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "5c76dbca-4706-4f9d-a70d-0d7763cdcda0" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", + "You passed a split with the following fraction: 94%\n", + "Creating training data for: Shuffle: 1 TrainFraction: 0.94\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 152/152 [00:00<00:00, 7673.55it/s]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "weight_init = WeightInitialization(\n", + " dataset=f\"{superanimal_name}\",\n", + " with_decoder=False,\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", + " from_shuffle = imagenet_transfer_learning_shuffle,\n", + " shuffles = [superanimal_transfer_learning_shuffle],\n", + " engine = Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init = weight_init,\n", + " userfeedback = False)\n", + "\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "3qFxlRHixPoR" + }, + "source": [ + "#### Launch the training for transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "W60UgRQWqghn", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "1ebd95f1-830a-4ba0-9f4b-71ac749ef50e" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Training with configuration:\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " rotation: 30\n", + " scaling: [1.0, 1.0]\n", + " translation: 0\n", + " collate: None\n", + " covering: False\n", + " gaussian_noise: 12.75\n", + " hist_eq: False\n", + " motion_blur: False\n", + " normalize_images: True\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " rotation: 30\n", + " scaling: [1.0, 1.0]\n", + " translation: 40\n", + " collate:\n", + " type: ResizeFromDataSizeCollate\n", + " min_scale: 0.4\n", + " max_scale: 1.0\n", + " min_short_side: 128\n", + " max_short_side: 1152\n", + " multiple_of: 32\n", + " to_square: False\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " freeze_bn_stats: False\n", + " freeze_bn_weights: False\n", + " variant: fasterrcnn_mobilenet_v3_large_fpn\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0001\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [160]\n", + " lr_list: [[1e-05]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 0\n", + "device: auto\n", + "metadata:\n", + " project_path: /content/dlc_project_folder/daniel3mouse\n", + " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle1/train/pose_cfg.yaml\n", + " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", + " unique_bodyparts: []\n", + " individuals: ['mus1', 'mus2', 'mus3']\n", + " with_identity: None\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 12\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32]\n", + " kernel_size: []\n", + " strides: []\n", + " final_conv:\n", + " out_channels: 12\n", + " kernel_size: 1\n", + "net_type: hrnet_w32\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " gpus: None\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0001\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-05], [1e-06]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 3\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 64\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 3\n", + " seed: 42\n", + " weight_init:\n", + " dataset: superanimal_quadruped\n", + " with_decoder: False\n", + " memory_replay: False\n", + "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=False, memory_replay=False, conversion_array=None, bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", + "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", + "Data Transforms:\n", + " Training: Compose([\n", + " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", + " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "\n", + "Note: According to your model configuration, you're training with batch size 1 and/or ``freeze_bn_stats=false``. This is not an optimal setting if you have powerful GPUs.\n", + "This is good for small batch sizes (e.g., when training on a CPU), where you should keep ``freeze_bn_stats=true``.\n", + "If you're using a GPU to train, you can obtain faster performance by setting a larger batch size (the biggest power of 2 where you don't geta CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` for the backbone of your model. \n", + "This also allows you to increase the learning rate (empirically you can scale the learning rate by sqrt(batch_size) times).\n", + "\n", + "Using 456 images and 27 for testing\n", + "\n", + "Starting pose model training...\n", + "--------------------------------------------------\n", + "Training for epoch 1 done, starting evaluation\n", + "Epoch 1 performance:\n", + "metrics/test.rmse: 45.606\n", + "metrics/test.rmse_pcutoff:nan\n", + "metrics/test.mAP: 1.715\n", + "metrics/test.mAR: 5.556\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 1/3 (lr=0.0001), train loss 0.00603, valid loss 0.00577\n", + "Training for epoch 2 done, starting evaluation\n", + "Epoch 2 performance:\n", + "metrics/test.rmse: 47.635\n", + "metrics/test.rmse_pcutoff:nan\n", + "metrics/test.mAP: 0.216\n", + "metrics/test.mAR: 2.222\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 2/3 (lr=0.0001), train loss 0.00542, valid loss 0.00507\n", + "Training for epoch 3 done, starting evaluation\n", + "Epoch 3 performance:\n", + "metrics/test.rmse: 35.083\n", + "metrics/test.rmse_pcutoff:nan\n", + "metrics/test.mAP: 21.118\n", + "metrics/test.mAR: 27.407\n", + "metrics/test.mAP_pcutoff:0.000\n", + "metrics/test.mAR_pcutoff:0.000\n", + "Epoch 3/3 (lr=0.0001), train loss 0.00476, valid loss 0.00445\n" + ] + } + ], + "source": [ + "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, batch_size = 64, shuffle = superanimal_transfer_learning_shuffle)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "XzOWKiOixPoR" + }, + "source": [ + "#### Evaluate the model obtained by transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "jpO3aIAIsWbz", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "51ab747e-bf7f-4cf0-bdb5-02774439a08b" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", + "100%|██████████| 152/152 [01:22<00:00, 1.84it/s]\n", + "100%|██████████| 9/9 [00:04<00:00, 1.90it/s]\n", + "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle1_snapshot_003-results.csv (pcutoff: 0.01):\n", + "INFO:root:train rmse 34.03\n", + "train rmse_pcutoff 34.03\n", + "train mAP 21.50\n", + "train mAR 27.52\n", + "train mAP_pcutoff 21.50\n", + "train mAR_pcutoff 27.52\n", + "test rmse 34.55\n", + "test rmse_pcutoff 34.55\n", + "test mAP 22.24\n", + "test mAR 28.15\n", + "test mAP_pcutoff 22.24\n", + "test mAR_pcutoff 28.15\n", + "Name: (0.94, 1, 3, -1, 0.01), dtype: float64\n" + ] + } + ], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_transfer_learning_shuffle])" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_Es6RR-_0Z4b" + }, + "source": [ + "### Fine-tuning with SuperAnimal (without keeping full SuperAnimal keypoints)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6oo9oJ8XyZrn" + }, + "source": [ + "#### Setup the weight init and dataset\n", + "First we do keypoint matching. This steps make it possible to understand the correspondance between the existing annotations and SuperAnimal annotations. This step produces 3 outputs\n", + "- The confusion matrix\n", + "- The conversion table\n", + "- Pseudo predictions over the whole dataset" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "fRm62Ji_xPoS" + }, + "source": [ + "#### What is keypoint matching?\n", + "\n", + "Because SuperAnimal models have their pre-defined keypoints that are potentially different from your annotations, we porposed this algorithm to minimize the gap between the model and the dataset. We use our model to perform zero-shot inference on the whole dataset. This gives pairs of predictions and ground truth for every image. Then, we cast the matching between models’ predictions (2D coordinates)\n", + "and ground truth as bipartitematching using the Euclidean distance as the cost between paired of keypoints. We then solve the matching using the Hungarian algorithm. Thus for every image, we end up getting a matching matrix where 1 counts formatch and 0 counts for non-matching. Because the models’ predictions can be noisy from image to image, we average the aforementioned matching matrix across all the images and perform another bipartite matching, resulting in the final keypoint conversion table between the model and the dataset. Note that the quality of thematching will impact the performance\n", + "of the model, especially for zero-shot. In the case where, e.g., the annotation nose is mistakenly converted to keypoint tail and vice versa, the model will have to unlearn the channel that corresponds to nose and tail (see also case study in Mathis et al.)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, + "collapsed": true, + "id": "vEHeuKSKyjA6", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "f85fa523-910a-444d-914f-4a67730f1bc7" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Before checking trainset temp_dataset\n", + "Before checking testset temp_dataset\n", + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: cpu\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 39\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 39]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "keypoint_matching(\n", + " config_path,\n", + " superanimal_name,\n", + " model_name,\n", + ")\n", + "\n", + "conversion_table_path = dlc_proj_root / \"memory_replay\" / \"conversion_table.csv\"\n", + "confusion_matrix_path = dlc_proj_root / \"memory_replay\" / \"confusion_matrix.png\"\n", + "# You can visualize the pseudo predictions, or do pose embedding clustering etc.\n", + "pseudo_prediction_path = dlc_proj_root / \"memory_replay\" / \"pseudo_predictions.json\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sA8yyLgs0zoO" + }, + "source": [ + "#### Display the confusion matrix\n", + "\n", + "The x axis lists the keypoints in the existing annotations. The y axis lists the keypoints in SuperAnimal keypoint space. Darker color encodes stronger correspondance between the human annotation and SuperAnimal annotations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 406 + }, + "collapsed": true, + "id": "luDxpD9H0zYZ", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "d6420e08-3e9c-40dc-8a13-92bc0d8c220b" + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "\n", + "confusion_matrix_image = mpimg.imread(confusion_matrix_path)\n", + "plt.imshow(confusion_matrix_image)\n", + "plt.axis('off') # Hide the axes for better view\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "i0QWikYmy_Mj" + }, + "source": [ + "#### Display the conversion table\n", + "The gt columns represents the keypoint names in the existing dataset. The MasterName represents the correspoinding keypoints in SuperAnimal keypoint space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "CeA-NzDMynYV", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "ae27fb36-223c-4aa2-f63f-42adadb95f02" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " gt MasterName\n", + "0 snout nose\n", + "6 rightear right_earbase\n", + "11 leftear left_earbase\n", + "14 tail2 left_antler_end\n", + "15 shoulder neck_base\n", + "20 tail1 back_end\n", + "21 spine3 back_middle\n", + "22 tailbase tail_base\n", + "23 tailend tail_end\n", + "24 spine1 front_left_thai\n", + "31 spine4 back_left_thai\n", + "37 spine2 body_middle_right\n" + ] + } + ], + "source": [ + "import pandas as pd\n", + "df = pd.read_csv(conversion_table_path)\n", + "df = df.dropna()\n", + "print (df)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GkfIo8zTxPoS" + }, + "source": [ + "#### Prepare the training shuffle and weight initialization for (naive) fine-tuning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "xEeM_hrOu6k8", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "4a5f4d5f-d1c5-42f8-f4ed-e8c208a1dc10" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", + "You passed a split with the following fraction: 94%\n", + "Creating training data for: Shuffle: 2 TrainFraction: 0.94\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 152/152 [00:00<00:00, 12111.90it/s]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "from deeplabcut.modelzoo.utils import (\n", + " create_conversion_table,\n", + " read_conversion_table_from_csv,\n", + ")\n", + "table = create_conversion_table(\n", + " config=config_path,\n", + " super_animal=superanimal_name,\n", + " project_to_super_animal=read_conversion_table_from_csv(conversion_table_path),\n", + ")\n", + "\n", + "weight_init = WeightInitialization(\n", + " dataset=superanimal_name,\n", + " with_decoder=True,\n", + " conversion_array=table.to_array()\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", + " from_shuffle = imagenet_transfer_learning_shuffle,\n", + " shuffles = [superanimal_naive_finetune_shuffle],\n", + " engine = Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init = weight_init,\n", + " userfeedback = False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "gZx6nr-ExPoS" + }, + "source": [ + "#### Launch the training for (naive) fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "c3XAr6uRyXOD", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "03740373-f9cd-4708-d140-0127033bfdc8" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Training with configuration:\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 0\n", + "device: auto\n", + "metadata:\n", + " project_path: /content/dlc_project_folder/daniel3mouse\n", + " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle2/train/pose_cfg.yaml\n", + " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", + " unique_bodyparts: []\n", + " individuals: ['mus1', 'mus2', 'mus3']\n", + " with_identity: False\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: False\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 12\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 12]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "net_type: hrnet_w32\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0005\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 3\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 64\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 3\n", + " pretrained_weights: None\n", + " seed: 42\n", + " weight_init:\n", + " dataset: superanimal_quadruped\n", + " with_decoder: True\n", + " memory_replay: False\n", + " conversion_array: [0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]\n", + "eval_interval: 1\n", + "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=True, memory_replay=False, conversion_array=array([ 0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]), bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", + "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", + "Data Transforms:\n", + " Training: Compose([\n", + " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", + " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "Using 456 images and 27 for testing\n", + "\n", + "Starting pose model training...\n", + "--------------------------------------------------\n", + "Training for epoch 1 done, starting evaluation\n", + "Epoch 1 performance:\n", + "metrics/test.rmse: 11.625\n", + "metrics/test.rmse_pcutoff:5.703\n", + "metrics/test.mAP: 71.971\n", + "metrics/test.mAR: 75.185\n", + "metrics/test.mAP_pcutoff:36.845\n", + "metrics/test.mAR_pcutoff:40.370\n", + "Epoch 1/3 (lr=0.0005), train loss 0.00387, valid loss 0.00279\n", + "Training for epoch 2 done, starting evaluation\n", + "Epoch 2 performance:\n", + "metrics/test.rmse: 9.842\n", + "metrics/test.rmse_pcutoff:4.464\n", + "metrics/test.mAP: 77.846\n", + "metrics/test.mAR: 80.000\n", + "metrics/test.mAP_pcutoff:33.924\n", + "metrics/test.mAR_pcutoff:35.926\n", + "Epoch 2/3 (lr=0.0005), train loss 0.00210, valid loss 0.00188\n", + "Training for epoch 3 done, starting evaluation\n", + "Epoch 3 performance:\n", + "metrics/test.rmse: 8.163\n", + "metrics/test.rmse_pcutoff:3.807\n", + "metrics/test.mAP: 82.317\n", + "metrics/test.mAR: 84.815\n", + "metrics/test.mAP_pcutoff:49.699\n", + "metrics/test.mAR_pcutoff:53.704\n", + "Epoch 3/3 (lr=0.0005), train loss 0.00167, valid loss 0.00154\n" + ] + } + ], + "source": [ + "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, eval_interval = 1, batch_size = 64, shuffle = superanimal_naive_finetune_shuffle)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "oXuRshzhxPoS" + }, + "source": [ + "#### Evaluate the model obtained by (naive) fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "VXfdKS-H2yqw", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "53b1b8fa-6aa3-4dad-a5be-153e96eb0323" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", + "100%|██████████| 152/152 [01:25<00:00, 1.79it/s]\n", + "100%|██████████| 9/9 [00:04<00:00, 1.84it/s]\n", + "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle2_snapshot_003-results.csv (pcutoff: 0.01):\n", + "INFO:root:train rmse 48.46\n", + "train rmse_pcutoff 47.76\n", + "train mAP 10.08\n", + "train mAR 21.36\n", + "train mAP_pcutoff 10.07\n", + "train mAR_pcutoff 21.29\n", + "test rmse 47.00\n", + "test rmse_pcutoff 46.74\n", + "test mAP 12.16\n", + "test mAR 22.22\n", + "test mAP_pcutoff 12.16\n", + "test mAR_pcutoff 22.22\n", + "Name: (0.94, 2, 3, -1, 0.01), dtype: float64\n" + ] + } + ], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_naive_finetune_shuffle])" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_nUAMlbZ0Z4b" + }, + "source": [ + "## Memory-replay fine-tuning with SuperAnimal (keeping full SuperAnimal keypoints)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "n6HPu6RaxPoS" + }, + "source": [ + "**Catastrophic forgetting** describes a\n", + "classic problemin continual learning. Indeed, amodel gradually loses\n", + "its ability to solve previous tasks after it learns to solve new ones.\n", + "Fine-tuning a SuperAnimal models falls into the category of continual\n", + "learning: the downstream dataset defines potentially different\n", + "keypoints than those learned by the models. Thus, the models might\n", + "forget the keypoints they learned and only pick up those defined in the\n", + "target dataset. Here, retraining with the original dataset and the new\n", + "one, is not a feasible option as datasets cannot be easily shared and\n", + "more computational resources would be required.\n", + "To counter that, we treat zero-shot inference of the model as a\n", + "memory buffer that stores knowledge from the original model. When\n", + "we fine-tune a SuperAnimal model, we replace the model predicted\n", + "keypoints with the ground-truth annotations, resulting in hybrid\n", + "learning of old and new knowledge. The quality of the zero-shot predictions\n", + "can vary and we use the confidence of prediction (0.7) as a\n", + "threshold to filter out low-confidence predictions. With the threshold\n", + "set to 1, memory replay fine-tuning becomes naive-fine-tuning." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "CSLmjlCIxPoS" + }, + "source": [ + "#### Prepare training shuffle and weight initialization for memory-replay finetuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "BKEF76AI0Z4c", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bf107c7b-6e3c-4ece-f680-067e4d7641f0", + "vscode": { + "languageId": "plaintext" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", + "You passed a split with the following fraction: 94%\n", + "Creating training data for: Shuffle: 3 TrainFraction: 0.94\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 152/152 [00:00<00:00, 11984.40it/s]" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "weight_init = WeightInitialization(\n", + " dataset=superanimal_name,\n", + " conversion_array=table.to_array(),\n", + " with_decoder=True,\n", + " memory_replay=True,\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", + " from_shuffle = imagenet_transfer_learning_shuffle,\n", + " shuffles = [superanimal_memory_replay_shuffle],\n", + " engine = Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init = weight_init,\n", + " userfeedback = False)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "MKwJiIyKxPoT" + }, + "source": [ + "#### Launch the training for memory-replay fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "Ru8tIFmD2Mkv", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "81a0ec13-6ba7-4089-bed5-f19c6bae0bcb" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Training with configuration:\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 0\n", + "device: auto\n", + "metadata:\n", + " project_path: /content/dlc_project_folder/daniel3mouse\n", + " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle2/train/pose_cfg.yaml\n", + " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", + " unique_bodyparts: []\n", + " individuals: ['mus1', 'mus2', 'mus3']\n", + " with_identity: False\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: False\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 12\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 12]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "net_type: hrnet_w32\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 1\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 0.0005\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 3\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 64\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 3\n", + " pretrained_weights: None\n", + " seed: 42\n", + " weight_init:\n", + " dataset: superanimal_quadruped\n", + " with_decoder: True\n", + " memory_replay: False\n", + " conversion_array: [0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]\n", + "eval_interval: 1\n", + "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=True, memory_replay=False, conversion_array=array([ 0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]), bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", + "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", + "Data Transforms:\n", + " Training: Compose([\n", + " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", + " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + " Validation: Compose([\n", + " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", + " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", + "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", + "Using 456 images and 27 for testing\n", + "\n", + "Starting pose model training...\n", + "--------------------------------------------------\n", + "Training for epoch 1 done, starting evaluation\n", + "Epoch 1 performance:\n", + "metrics/test.rmse: 11.625\n", + "metrics/test.rmse_pcutoff:5.703\n", + "metrics/test.mAP: 71.971\n", + "metrics/test.mAR: 75.185\n", + "metrics/test.mAP_pcutoff:36.845\n", + "metrics/test.mAR_pcutoff:40.370\n", + "Epoch 1/3 (lr=0.0005), train loss 0.00387, valid loss 0.00279\n", + "Training for epoch 2 done, starting evaluation\n", + "Epoch 2 performance:\n", + "metrics/test.rmse: 9.842\n", + "metrics/test.rmse_pcutoff:4.464\n", + "metrics/test.mAP: 77.846\n", + "metrics/test.mAR: 80.000\n", + "metrics/test.mAP_pcutoff:33.924\n", + "metrics/test.mAR_pcutoff:35.926\n", + "Epoch 2/3 (lr=0.0005), train loss 0.00210, valid loss 0.00188\n", + "Training for epoch 3 done, starting evaluation\n", + "Epoch 3 performance:\n", + "metrics/test.rmse: 8.163\n", + "metrics/test.rmse_pcutoff:3.807\n", + "metrics/test.mAP: 82.317\n", + "metrics/test.mAR: 84.815\n", + "metrics/test.mAP_pcutoff:49.699\n", + "metrics/test.mAR_pcutoff:53.704\n", + "Epoch 3/3 (lr=0.0005), train loss 0.00167, valid loss 0.00154\n" + ] + } + ], + "source": [ + "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, eval_interval = 1, batch_size = 64, shuffle = superanimal_naive_finetune_shuffle)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "i-2MBRDjxPoT" + }, + "source": [ + "#### Evaluate the model obtained by memory-replay finetuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true, + "id": "sfMcK3gq8WxZ", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_memory_replay_shuffle])" + ] + } + ], + "metadata": { + "accelerator": "TPU", + "colab": { + "gpuType": "V28", + "provenance": [], + "include_colab_link": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.19" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "1066fb18c9d045bea6568909a49a4a7a": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "255dee8feaf74901b7a412fce53e0beb": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "FloatProgressModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_cfee5f910ac14a95b770b77fd6f9629e", + "max": 165432914, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_ff5459e8346a48edbb9fc6bdfcdeb690", + "value": 165432914 + } + }, + "30df28e721be4dffa0271edad4cd5ce3": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "67677e7c5e284682ae8aae107833e702": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_a5cf5d546d11442f86cb3b048f6e1b51", + "placeholder": "​", + "style": "IPY_MODEL_30df28e721be4dffa0271edad4cd5ce3", + "value": "model.safetensors: 100%" + } + }, + "765e9d1889f3472c9e050d96ca1c0e24": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_1066fb18c9d045bea6568909a49a4a7a", + "placeholder": "​", + "style": "IPY_MODEL_aec1058e27ab48d0bdeaa934f12a2a04", + "value": " 165M/165M [00:00<00:00, 250MB/s]" + } + }, + "7b5f401de8f647bbb1f241d0ae61a106": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "8f396755637f4a779f3e77bf8e4c5f2d": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HBoxModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_67677e7c5e284682ae8aae107833e702", + "IPY_MODEL_255dee8feaf74901b7a412fce53e0beb", + "IPY_MODEL_765e9d1889f3472c9e050d96ca1c0e24" + ], + "layout": "IPY_MODEL_7b5f401de8f647bbb1f241d0ae61a106" + } + }, + "a5cf5d546d11442f86cb3b048f6e1b51": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "aec1058e27ab48d0bdeaa934f12a2a04": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "cfee5f910ac14a95b770b77fd6f9629e": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "ff5459e8346a48edbb9fc6bdfcdeb690": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "ProgressStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "d00216a2601747129cf3fdfa7c083097": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HBoxModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_05ecceef10ef43e5b011d5c3141ee4ac", + "IPY_MODEL_d847ebd070b44f72ae5b8ea4e9346525", + "IPY_MODEL_6b86e9cd93f1452bb774f2253232f91d" + ], + "layout": "IPY_MODEL_574f2fc10c794b93866b0728b4b82974" + } + }, + "05ecceef10ef43e5b011d5c3141ee4ac": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_92c2b0759d6148baada3ba7aa00364a9", + "placeholder": "​", + "style": "IPY_MODEL_28119e084d06416e9c6fcb7167fc1eec", + "value": "pose_model.pth: 100%" + } + }, + "d847ebd070b44f72ae5b8ea4e9346525": { + "model_module": "@jupyter-widgets/controls", + "model_name": "FloatProgressModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_6023cb183d9a4d6495a180c6ad25088a", + "max": 159622507, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_0ec583db59074e738fd1545bdeacabdd", + "value": 159622507 + } + }, + "6b86e9cd93f1452bb774f2253232f91d": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_f79fc9e32f344ab8a0cee579bcfaf303", + "placeholder": "​", + "style": "IPY_MODEL_9415c05ae7eb46089d9c77dfdd7a014f", + "value": " 160M/160M [00:00<00:00, 262MB/s]" + } + }, + "574f2fc10c794b93866b0728b4b82974": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "92c2b0759d6148baada3ba7aa00364a9": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "28119e084d06416e9c6fcb7167fc1eec": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "6023cb183d9a4d6495a180c6ad25088a": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "0ec583db59074e738fd1545bdeacabdd": { + "model_module": "@jupyter-widgets/controls", + "model_name": "ProgressStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "f79fc9e32f344ab8a0cee579bcfaf303": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "9415c05ae7eb46089d9c77dfdd7a014f": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "6fd9f5ca19464368bb357f14b600b4df": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HBoxModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_6986e059de50418193dfbadb0f371aad", + "IPY_MODEL_58147217c725435e8ae7545437ee9f0c", + "IPY_MODEL_a78de7722c2649ca8e6d2ea26d305ce8" + ], + "layout": "IPY_MODEL_692db8a5e6be4cd58762b057edbfe753" + } + }, + "6986e059de50418193dfbadb0f371aad": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_1744c5f9765f49a78938a670cdf07514", + "placeholder": "​", + "style": "IPY_MODEL_439c929438204468b3b4d4c734735789", + "value": "detector.pt: 100%" + } + }, + "58147217c725435e8ae7545437ee9f0c": { + "model_module": "@jupyter-widgets/controls", + "model_name": "FloatProgressModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_c1cbb03ad13f42a681c615a4abffde86", + "max": 517814951, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_5ce989cb535a4b2eba237bad529e030f", + "value": 517814951 + } + }, + "a78de7722c2649ca8e6d2ea26d305ce8": { + "model_module": "@jupyter-widgets/controls", + "model_name": "HTMLModel", + "model_module_version": "1.5.0", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_f16ff7a640d84bedaf69340d6a4a3762", + "placeholder": "​", + "style": "IPY_MODEL_0381efd7cab94a35acba036e1f1ea45e", + "value": " 518M/518M [00:01<00:00, 281MB/s]" + } + }, + "692db8a5e6be4cd58762b057edbfe753": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "1744c5f9765f49a78938a670cdf07514": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "439c929438204468b3b4d4c734735789": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "c1cbb03ad13f42a681c615a4abffde86": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "5ce989cb535a4b2eba237bad529e030f": { + "model_module": "@jupyter-widgets/controls", + "model_name": "ProgressStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "f16ff7a640d84bedaf69340d6a4a3762": { + "model_module": "@jupyter-widgets/base", + "model_name": "LayoutModel", + "model_module_version": "1.2.0", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "0381efd7cab94a35acba036e1f1ea45e": { + "model_module": "@jupyter-widgets/controls", + "model_name": "DescriptionStyleModel", + "model_module_version": "1.5.0", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + } + } + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} From ed8a979cebfb20a54b22bf39aade5384dd47afc4 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 21 Jun 2024 00:50:43 +0200 Subject: [PATCH 174/293] Update COLAB_Pytorch_SuperAnimal.ipynb --- .../COLAB/COLAB_Pytorch_SuperAnimal.ipynb | 392 +++++++++--------- 1 file changed, 190 insertions(+), 202 deletions(-) diff --git a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb index 2d48d59042..e4c4103b52 100644 --- a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb @@ -31,180 +31,15 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 1000 - }, "collapsed": true, "id": "AjET5cJE5UYM", "jupyter": { "outputs_hidden": true - }, - "outputId": "1fc34e5a-6bf3-4866-95fb-264ca42928da" - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Collecting deeplabcut==3.0.0rc1\n", - " Downloading deeplabcut-3.0.0rc1-py3-none-any.whl (2.0 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.0/2.0 MB\u001b[0m \u001b[31m7.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting albumentations<=1.4.3 (from deeplabcut==3.0.0rc1)\n", - " Downloading albumentations-1.4.3-py3-none-any.whl (137 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m137.0/137.0 kB\u001b[0m \u001b[31m14.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting dlclibrary>=0.0.5 (from deeplabcut==3.0.0rc1)\n", - " Downloading dlclibrary-0.0.6-py3-none-any.whl (15 kB)\n", - "Collecting einops (from deeplabcut==3.0.0rc1)\n", - " Downloading einops-0.8.0-py3-none-any.whl (43 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m43.2/43.2 kB\u001b[0m \u001b[31m4.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting filterpy>=1.4.4 (from deeplabcut==3.0.0rc1)\n", - " Downloading filterpy-1.4.5.zip (177 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m178.0/178.0 kB\u001b[0m \u001b[31m14.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n", - "Collecting ruamel.yaml>=0.15.0 (from deeplabcut==3.0.0rc1)\n", - " Downloading ruamel.yaml-0.18.6-py3-none-any.whl (117 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m117.8/117.8 kB\u001b[0m \u001b[31m9.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting imgaug>=0.4.0 (from deeplabcut==3.0.0rc1)\n", - " Downloading imgaug-0.4.0-py2.py3-none-any.whl (948 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m948.0/948.0 kB\u001b[0m \u001b[31m42.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting imageio-ffmpeg (from deeplabcut==3.0.0rc1)\n", - " Downloading imageio_ffmpeg-0.5.1-py3-none-manylinux2010_x86_64.whl (26.9 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m26.9/26.9 MB\u001b[0m \u001b[31m38.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: numba>=0.54 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.60.0)\n", - "Collecting matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3 (from deeplabcut==3.0.0rc1)\n", - " Downloading matplotlib-3.8.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (11.6 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m11.6/11.6 MB\u001b[0m \u001b[31m59.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: networkx>=2.6 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (3.3)\n", - "Requirement already satisfied: numpy>=1.18.5 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.25.2)\n", - "Requirement already satisfied: pandas!=1.5.0,>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.0.3)\n", - "Requirement already satisfied: scikit-image>=0.17 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.19.3)\n", - "Requirement already satisfied: scikit-learn>=1.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.2.2)\n", - "Collecting scipy<1.11.0,>=1.4 (from deeplabcut==3.0.0rc1)\n", - " Downloading scipy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (34.4 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m34.4/34.4 MB\u001b[0m \u001b[31m27.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting statsmodels>=0.11 (from deeplabcut==3.0.0rc1)\n", - " Downloading statsmodels-0.14.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (10.8 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m10.8/10.8 MB\u001b[0m \u001b[31m60.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting tables==3.8.0 (from deeplabcut==3.0.0rc1)\n", - " Downloading tables-3.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (6.5 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m6.5/6.5 MB\u001b[0m \u001b[31m64.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting timm (from deeplabcut==3.0.0rc1)\n", - " Downloading timm-1.0.7-py3-none-any.whl (2.3 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.3/2.3 MB\u001b[0m \u001b[31m84.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: torch>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.3.0+cpu)\n", - "Requirement already satisfied: torchvision in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.18.0+cpu)\n", - "Requirement already satisfied: tqdm in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (4.66.4)\n", - "Collecting pycocotools (from deeplabcut==3.0.0rc1)\n", - " Downloading pycocotools-2.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (427 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m427.8/427.8 kB\u001b[0m \u001b[31m40.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: pyyaml in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (6.0.1)\n", - "Requirement already satisfied: Pillow>=7.1 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (10.3.0)\n", - "Collecting cython>=0.29.21 (from tables==3.8.0->deeplabcut==3.0.0rc1)\n", - " Downloading Cython-3.0.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.6 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.6/3.6 MB\u001b[0m \u001b[31m99.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting numexpr>=2.6.2 (from tables==3.8.0->deeplabcut==3.0.0rc1)\n", - " Downloading numexpr-2.10.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (405 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m405.0/405.0 kB\u001b[0m \u001b[31m30.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting blosc2~=2.0.0 (from tables==3.8.0->deeplabcut==3.0.0rc1)\n", - " Downloading blosc2-2.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.9 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.9/3.9 MB\u001b[0m \u001b[31m98.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: packaging in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (24.1)\n", - "Collecting py-cpuinfo (from tables==3.8.0->deeplabcut==3.0.0rc1)\n", - " Downloading py_cpuinfo-9.0.0-py3-none-any.whl (22 kB)\n", - "Collecting scikit-image>=0.17 (from deeplabcut==3.0.0rc1)\n", - " Downloading scikit_image-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (14.9 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m14.9/14.9 MB\u001b[0m \u001b[31m56.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: typing-extensions>=4.9.0 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->deeplabcut==3.0.0rc1) (4.12.2)\n", - "Collecting scikit-learn>=1.0 (from deeplabcut==3.0.0rc1)\n", - " Downloading scikit_learn-1.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (13.3 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m13.3/13.3 MB\u001b[0m \u001b[31m61.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting opencv-python-headless>=4.9.0 (from albumentations<=1.4.3->deeplabcut==3.0.0rc1)\n", - " Downloading opencv_python_headless-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (49.9 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m49.9/49.9 MB\u001b[0m \u001b[31m19.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: huggingface-hub in /usr/local/lib/python3.10/dist-packages (from dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (0.23.4)\n", - "Requirement already satisfied: six in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (1.16.0)\n", - "Requirement already satisfied: opencv-python in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (4.10.0.84)\n", - "Requirement already satisfied: imageio in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (2.34.1)\n", - "Collecting Shapely (from imgaug>=0.4.0->deeplabcut==3.0.0rc1)\n", - " Downloading shapely-2.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (2.5 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.5/2.5 MB\u001b[0m \u001b[31m82.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (1.2.1)\n", - "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (0.12.1)\n", - "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (4.53.0)\n", - "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (1.4.5)\n", - "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (3.1.2)\n", - "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (2.9.0.post0)\n", - "Requirement already satisfied: llvmlite<0.44,>=0.43.0dev0 in /usr/local/lib/python3.10/dist-packages (from numba>=0.54->deeplabcut==3.0.0rc1) (0.43.0)\n", - "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut==3.0.0rc1) (2024.1)\n", - "Requirement already satisfied: tzdata>=2022.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut==3.0.0rc1) (2024.1)\n", - "Collecting ruamel.yaml.clib>=0.2.7 (from ruamel.yaml>=0.15.0->deeplabcut==3.0.0rc1)\n", - " Downloading ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl (526 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m526.7/526.7 kB\u001b[0m \u001b[31m35.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: tifffile>=2022.8.12 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->deeplabcut==3.0.0rc1) (2024.5.22)\n", - "Requirement already satisfied: lazy-loader>=0.4 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->deeplabcut==3.0.0rc1) (0.4)\n", - "Requirement already satisfied: joblib>=1.2.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->deeplabcut==3.0.0rc1) (1.4.2)\n", - "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->deeplabcut==3.0.0rc1) (3.5.0)\n", - "Collecting patsy>=0.5.6 (from statsmodels>=0.11->deeplabcut==3.0.0rc1)\n", - " Downloading patsy-0.5.6-py2.py3-none-any.whl (233 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m233.9/233.9 kB\u001b[0m \u001b[31m22.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: filelock in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (3.15.1)\n", - "Requirement already satisfied: sympy in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (1.12.1)\n", - "Requirement already satisfied: jinja2 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (3.1.4)\n", - "Requirement already satisfied: fsspec in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (2024.6.0)\n", - "Requirement already satisfied: setuptools in /usr/local/lib/python3.10/dist-packages (from imageio-ffmpeg->deeplabcut==3.0.0rc1) (67.7.2)\n", - "Requirement already satisfied: safetensors in /usr/local/lib/python3.10/dist-packages (from timm->deeplabcut==3.0.0rc1) (0.4.3)\n", - "Requirement already satisfied: msgpack in /usr/local/lib/python3.10/dist-packages (from blosc2~=2.0.0->tables==3.8.0->deeplabcut==3.0.0rc1) (1.0.8)\n", - "Requirement already satisfied: requests in /usr/local/lib/python3.10/dist-packages (from huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2.31.0)\n", - "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.10/dist-packages (from jinja2->torch>=2.0.0->deeplabcut==3.0.0rc1) (2.1.5)\n", - "Requirement already satisfied: mpmath<1.4.0,>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from sympy->torch>=2.0.0->deeplabcut==3.0.0rc1) (1.3.0)\n", - "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (3.3.2)\n", - "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (3.7)\n", - "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2.0.7)\n", - "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2024.6.2)\n", - "Building wheels for collected packages: filterpy\n", - " Building wheel for filterpy (setup.py) ... \u001b[?25l\u001b[?25hdone\n", - " Created wheel for filterpy: filename=filterpy-1.4.5-py3-none-any.whl size=110458 sha256=6a53d4b22046f248c777d11ff05cb8fc91ec43300fe46f247501b451462c33b3\n", - " Stored in directory: /root/.cache/pip/wheels/0f/0c/ea/218f266af4ad626897562199fbbcba521b8497303200186102\n", - "Successfully built filterpy\n", - "Installing collected packages: py-cpuinfo, Shapely, scipy, ruamel.yaml.clib, patsy, opencv-python-headless, numexpr, imageio-ffmpeg, einops, cython, blosc2, tables, scikit-learn, scikit-image, ruamel.yaml, matplotlib, statsmodels, pycocotools, imgaug, filterpy, dlclibrary, albumentations, timm, deeplabcut\n", - " Attempting uninstall: scipy\n", - " Found existing installation: scipy 1.11.4\n", - " Uninstalling scipy-1.11.4:\n", - " Successfully uninstalled scipy-1.11.4\n", - " Attempting uninstall: scikit-learn\n", - " Found existing installation: scikit-learn 1.2.2\n", - " Uninstalling scikit-learn-1.2.2:\n", - " Successfully uninstalled scikit-learn-1.2.2\n", - " Attempting uninstall: scikit-image\n", - " Found existing installation: scikit-image 0.19.3\n", - " Uninstalling scikit-image-0.19.3:\n", - " Successfully uninstalled scikit-image-0.19.3\n", - " Attempting uninstall: matplotlib\n", - " Found existing installation: matplotlib 3.7.1\n", - " Uninstalling matplotlib-3.7.1:\n", - " Successfully uninstalled matplotlib-3.7.1\n", - "Successfully installed Shapely-2.0.4 albumentations-1.4.3 blosc2-2.0.0 cython-3.0.10 deeplabcut-3.0.0rc1 dlclibrary-0.0.6 einops-0.8.0 filterpy-1.4.5 imageio-ffmpeg-0.5.1 imgaug-0.4.0 matplotlib-3.8.4 numexpr-2.10.1 opencv-python-headless-4.10.0.84 patsy-0.5.6 py-cpuinfo-9.0.0 pycocotools-2.0.8 ruamel.yaml-0.18.6 ruamel.yaml.clib-0.2.8 scikit-image-0.24.0 scikit-learn-1.5.0 scipy-1.10.1 statsmodels-0.14.2 tables-3.8.0 timm-1.0.7\n" - ] - }, - { - "output_type": "display_data", - "data": { - "application/vnd.colab-display-data+json": { - "pip_warning": { - "packages": [ - "matplotlib", - "mpl_toolkits" - ] - }, - "id": "6acdb4d198fb491cb7f8ba13f542ed20" - } - }, - "metadata": {} } - ], + }, + "outputs": [], "source": [ "!pip install deeplabcut==3.0.0rc1" ] @@ -1098,20 +933,6 @@ "max_individuals = 1 #how many animals do you expect to see?" ] }, - { - "cell_type": "markdown", - "metadata": { - "id": "93xGQKr90Z4Z" - }, - "source": [ - "video_inference_superanimal(\n", - " videos=[\"/mnt/md0/shaokai/tom_video.mp4\"],\n", - " superanimal_name= f\"{superanimal_name}_{model_name}\",\n", - " video_adapt=False,\n", - " max_individuals=3, \n", - " )" - ] - }, { "cell_type": "markdown", "metadata": { @@ -1123,43 +944,212 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": { "colab": { - "base_uri": "https://localhost:8080/", - "height": 108 + "base_uri": "https://localhost:8080/" }, "collapsed": true, "id": "poqynL0UJTBp", "jupyter": { "outputs_hidden": true }, - "outputId": "3fb4aacd-c862-43d4-b1a2-5d11ae2cd500" + "outputId": "b1809362-9a3f-416c-982a-439312003774" }, "outputs": [ { - "output_type": "error", - "ename": "SyntaxError", - "evalue": "positional argument follows keyword argument (, line 13)", - "traceback": [ - "\u001b[0;36m File \u001b[0;32m\"\"\u001b[0;36m, line \u001b[0;32m13\u001b[0m\n\u001b[0;31m )\u001b[0m\n\u001b[0m ^\u001b[0m\n\u001b[0;31mSyntaxError\u001b[0m\u001b[0;31m:\u001b[0m positional argument follows keyword argument\n" + "output_type": "stream", + "name": "stdout", + "text": [ + "running video inference on /content/zebra-dancing.mov with superanimal_quadruped_hrnetw32\n", + "Using pytorch for model hrnetw32\n", + "Task: None\n", + "scorer: None\n", + "date: None\n", + "multianimalproject: None\n", + "identity: None\n", + "project_path: /usr/local/lib/python3.10/dist-packages/deeplabcut/modelzoo/project_configs\n", + "engine: pytorch\n", + "video_sets: None\n", + "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + "start: None\n", + "stop: None\n", + "numframes2pick: None\n", + "skeleton: []\n", + "skeleton_color: black\n", + "pcutoff: None\n", + "dotsize: None\n", + "alphavalue: None\n", + "colormap: rainbow\n", + "TrainingFraction: None\n", + "iteration: None\n", + "default_net_type: None\n", + "default_augmenter: None\n", + "snapshotindex: None\n", + "detector_snapshotindex: None\n", + "batch_size: 1\n", + "cropping: None\n", + "x1: None\n", + "x2: None\n", + "y1: None\n", + "y2: None\n", + "corner2move2: None\n", + "move2corner: None\n", + "SuperAnimalConversionTables: None\n", + "data:\n", + " colormode: RGB\n", + " inference:\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + " normalize_images: True\n", + " train:\n", + " affine:\n", + " p: 0.5\n", + " scaling: [1.0, 1.0]\n", + " rotation: 30\n", + " translation: 0\n", + " gaussian_noise: 12.75\n", + " normalize_images: True\n", + " auto_padding:\n", + " pad_width_divisor: 32\n", + " pad_height_divisor: 32\n", + "detector:\n", + " data:\n", + " colormode: RGB\n", + " inference:\n", + " normalize_images: True\n", + " train:\n", + " hflip: True\n", + " normalize_images: True\n", + " device: auto\n", + " model:\n", + " type: FasterRCNN\n", + " variant: fasterrcnn_resnet50_fpn_v2\n", + " box_score_thresh: 0.6\n", + " pretrained: False\n", + " runner:\n", + " type: DetectorTrainingRunner\n", + " eval_interval: 50\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " milestones: [90]\n", + " lr_list: [[1e-06]]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 50\n", + " save_optimizer_state: False\n", + " train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 250\n", + "device: auto\n", + "method: td\n", + "model:\n", + " backbone:\n", + " type: HRNet\n", + " model_name: hrnet_w32\n", + " pretrained: False\n", + " freeze_bn_stats: True\n", + " freeze_bn_weights: False\n", + " interpolate_branches: False\n", + " increased_channel_count: False\n", + " backbone_output_channels: 32\n", + " heads:\n", + " bodypart:\n", + " type: HeatmapHead\n", + " weight_init: normal\n", + " predictor:\n", + " type: HeatmapPredictor\n", + " apply_sigmoid: False\n", + " clip_scores: True\n", + " location_refinement: False\n", + " locref_std: 7.2801\n", + " target_generator:\n", + " type: HeatmapGaussianGenerator\n", + " num_heatmaps: 39\n", + " pos_dist_thresh: 17\n", + " heatmap_mode: KEYPOINT\n", + " generate_locref: False\n", + " locref_std: 7.2801\n", + " criterion:\n", + " heatmap:\n", + " type: WeightedMSECriterion\n", + " weight: 1.0\n", + " heatmap_config:\n", + " channels: [32, 39]\n", + " kernel_size: [1]\n", + " strides: [1]\n", + "runner:\n", + " type: PoseTrainingRunner\n", + " key_metric: test.mAP\n", + " key_metric_asc: True\n", + " eval_interval: 10\n", + " optimizer:\n", + " type: AdamW\n", + " params:\n", + " lr: 1e-05\n", + " scheduler:\n", + " type: LRListScheduler\n", + " params:\n", + " lr_list: [[1e-06], [1e-07]]\n", + " milestones: [160, 190]\n", + " snapshots:\n", + " max_snapshots: 5\n", + " save_epochs: 25\n", + " save_optimizer_state: False\n", + "train_settings:\n", + " batch_size: 1\n", + " dataloader_workers: 0\n", + " dataloader_pin_memory: True\n", + " display_iters: 500\n", + " epochs: 200\n", + " pretrained_weights: None\n", + " seed: 42\n", + "metadata:\n", + " project_path: /usr/local/lib/python3.10/dist-packages/deeplabcut/modelzoo/project_configs\n", + " pose_config_path: /usr/local/lib/python3.10/dist-packages/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", + " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", + " unique_bodyparts: []\n", + " individuals: ['animal']\n", + " with_identity: None\n", + "Processing video /content/zebra-dancing.mov\n", + "Starting to analyze /content/zebra-dancing.mov\n", + "Video metadata: \n", + " Overall # of frames: 458\n", + " Duration of video [s]: 7.82\n", + " fps: 58.59\n", + " resolution: w=1840, h=1032\n", + "\n", + "Running Detector\n" + ] + }, + { + "output_type": "stream", + "name": "stderr", + "text": [ + " 25%|██▌ | 116/458 [02:23<06:18, 1.11s/it]" ] } ], "source": [ - "import glob\n", - "videos = glob.glob(os.path.join(in_video_folder, '*'))\n", "video_inference_superanimal(\n", - " videos=videos,\n", + " videos=video_path,\n", " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", " video_adapt=False,\n", - " max_individuals,\n", + " max_individuals=max_individuals,\n", " pseudo_threshold=0.1,\n", " bbox_threshold=0.9,\n", " detector_epochs=1,\n", " pose_epochs=1,\n", - " dest_folder = '/content/'\n", - " )" + " dest_folder = '/content/')" ] }, { @@ -1971,13 +1961,11 @@ } ], "source": [ - "import glob\n", - "videos = glob.glob(os.path.join(in_video_folder, '*'))\n", "video_inference_superanimal(\n", - " videos=videos,\n", + " videos=video_path,\n", " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", " video_adapt=True,\n", - " max_individuals,\n", + " max_individuals=max_individuals,\n", " pseudo_threshold=0.1,\n", " bbox_threshold=0.9,\n", " detector_epochs=1,\n", @@ -2681,7 +2669,7 @@ "id": "ZGhAuyqs0Z4a" }, "source": [ - "#### Prepare trianing shuffle for transfer-learning with SuperAnimal weights" + "#### Prepare training shuffle for transfer-learning with SuperAnimal weights" ] }, { From 624134bf113d36db9e0173cc2faed9b035436d13 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 21 Jun 2024 01:40:47 +0200 Subject: [PATCH 175/293] Update COLAB_Pytorch_SuperAnimal.ipynb --- .../COLAB/COLAB_Pytorch_SuperAnimal.ipynb | 1781 ++++++----------- 1 file changed, 576 insertions(+), 1205 deletions(-) diff --git a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb index e4c4103b52..a5ec8eedd1 100644 --- a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb @@ -31,15 +31,168 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, "collapsed": true, "id": "AjET5cJE5UYM", "jupyter": { "outputs_hidden": true - } + }, + "outputId": "0a9bc286-e21e-4fe2-cd1a-fb570f35e719" }, - "outputs": [], + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Collecting deeplabcut==3.0.0rc1\n", + " Downloading deeplabcut-3.0.0rc1-py3-none-any.whl (2.0 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.0/2.0 MB\u001b[0m \u001b[31m9.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: albumentations<=1.4.3 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.3.1)\n", + "Collecting dlclibrary>=0.0.5 (from deeplabcut==3.0.0rc1)\n", + " Downloading dlclibrary-0.0.6-py3-none-any.whl (15 kB)\n", + "Collecting einops (from deeplabcut==3.0.0rc1)\n", + " Downloading einops-0.8.0-py3-none-any.whl (43 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m43.2/43.2 kB\u001b[0m \u001b[31m6.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting filterpy>=1.4.4 (from deeplabcut==3.0.0rc1)\n", + " Downloading filterpy-1.4.5.zip (177 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m178.0/178.0 kB\u001b[0m \u001b[31m24.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n", + "Collecting ruamel.yaml>=0.15.0 (from deeplabcut==3.0.0rc1)\n", + " Downloading ruamel.yaml-0.18.6-py3-none-any.whl (117 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m117.8/117.8 kB\u001b[0m \u001b[31m15.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: imgaug>=0.4.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.4.0)\n", + "Requirement already satisfied: imageio-ffmpeg in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.5.1)\n", + "Requirement already satisfied: numba>=0.54 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.58.1)\n", + "Collecting matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3 (from deeplabcut==3.0.0rc1)\n", + " Downloading matplotlib-3.8.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (11.6 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m11.6/11.6 MB\u001b[0m \u001b[31m66.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: networkx>=2.6 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (3.3)\n", + "Requirement already satisfied: numpy>=1.18.5 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.25.2)\n", + "Requirement already satisfied: pandas!=1.5.0,>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.0.3)\n", + "Requirement already satisfied: scikit-image>=0.17 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.19.3)\n", + "Requirement already satisfied: scikit-learn>=1.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.2.2)\n", + "Collecting scipy<1.11.0,>=1.4 (from deeplabcut==3.0.0rc1)\n", + " Downloading scipy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (34.4 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m34.4/34.4 MB\u001b[0m \u001b[31m41.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: statsmodels>=0.11 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.14.2)\n", + "Requirement already satisfied: tables==3.8.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (3.8.0)\n", + "Collecting timm (from deeplabcut==3.0.0rc1)\n", + " Downloading timm-1.0.7-py3-none-any.whl (2.3 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.3/2.3 MB\u001b[0m \u001b[31m94.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: torch>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.3.0+cu121)\n", + "Requirement already satisfied: torchvision in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.18.0+cu121)\n", + "Requirement already satisfied: tqdm in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (4.66.4)\n", + "Requirement already satisfied: pycocotools in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.0.8)\n", + "Requirement already satisfied: pyyaml in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (6.0.1)\n", + "Requirement already satisfied: Pillow>=7.1 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (9.4.0)\n", + "Requirement already satisfied: cython>=0.29.21 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (3.0.10)\n", + "Requirement already satisfied: numexpr>=2.6.2 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (2.10.0)\n", + "Requirement already satisfied: blosc2~=2.0.0 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (2.0.0)\n", + "Requirement already satisfied: packaging in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (24.1)\n", + "Requirement already satisfied: py-cpuinfo in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (9.0.0)\n", + "Requirement already satisfied: qudida>=0.0.4 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->deeplabcut==3.0.0rc1) (0.0.4)\n", + "Requirement already satisfied: opencv-python-headless>=4.1.1 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->deeplabcut==3.0.0rc1) (4.10.0.84)\n", + "Requirement already satisfied: huggingface-hub in /usr/local/lib/python3.10/dist-packages (from dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (0.23.4)\n", + "Requirement already satisfied: six in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (1.16.0)\n", + "Requirement already satisfied: opencv-python in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (4.8.0.76)\n", + "Requirement already satisfied: imageio in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (2.31.6)\n", + "Requirement already satisfied: Shapely in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (2.0.4)\n", + "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (1.2.1)\n", + "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (0.12.1)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (4.53.0)\n", + "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (1.4.5)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (3.1.2)\n", + "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (2.8.2)\n", + "Requirement already satisfied: llvmlite<0.42,>=0.41.0dev0 in /usr/local/lib/python3.10/dist-packages (from numba>=0.54->deeplabcut==3.0.0rc1) (0.41.1)\n", + "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut==3.0.0rc1) (2023.4)\n", + "Requirement already satisfied: tzdata>=2022.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut==3.0.0rc1) (2024.1)\n", + "Collecting ruamel.yaml.clib>=0.2.7 (from ruamel.yaml>=0.15.0->deeplabcut==3.0.0rc1)\n", + " Downloading ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl (526 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m526.7/526.7 kB\u001b[0m \u001b[31m50.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: tifffile>=2019.7.26 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->deeplabcut==3.0.0rc1) (2024.5.22)\n", + "Requirement already satisfied: PyWavelets>=1.1.1 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->deeplabcut==3.0.0rc1) (1.6.0)\n", + "Requirement already satisfied: joblib>=1.1.1 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->deeplabcut==3.0.0rc1) (1.4.2)\n", + "Requirement already satisfied: threadpoolctl>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->deeplabcut==3.0.0rc1) (3.5.0)\n", + "Requirement already satisfied: patsy>=0.5.6 in /usr/local/lib/python3.10/dist-packages (from statsmodels>=0.11->deeplabcut==3.0.0rc1) (0.5.6)\n", + "Requirement already satisfied: filelock in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (3.15.1)\n", + "Requirement already satisfied: typing-extensions>=4.8.0 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (4.12.2)\n", + "Requirement already satisfied: sympy in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (1.12.1)\n", + "Requirement already satisfied: jinja2 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (3.1.4)\n", + "Requirement already satisfied: fsspec in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (2023.6.0)\n", + "Collecting nvidia-cuda-nvrtc-cu12==12.1.105 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_cuda_nvrtc_cu12-12.1.105-py3-none-manylinux1_x86_64.whl (23.7 MB)\n", + "Collecting nvidia-cuda-runtime-cu12==12.1.105 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_cuda_runtime_cu12-12.1.105-py3-none-manylinux1_x86_64.whl (823 kB)\n", + "Collecting nvidia-cuda-cupti-cu12==12.1.105 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_cuda_cupti_cu12-12.1.105-py3-none-manylinux1_x86_64.whl (14.1 MB)\n", + "Collecting nvidia-cudnn-cu12==8.9.2.26 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_cudnn_cu12-8.9.2.26-py3-none-manylinux1_x86_64.whl (731.7 MB)\n", + "Collecting nvidia-cublas-cu12==12.1.3.1 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_cublas_cu12-12.1.3.1-py3-none-manylinux1_x86_64.whl (410.6 MB)\n", + "Collecting nvidia-cufft-cu12==11.0.2.54 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_cufft_cu12-11.0.2.54-py3-none-manylinux1_x86_64.whl (121.6 MB)\n", + "Collecting nvidia-curand-cu12==10.3.2.106 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_curand_cu12-10.3.2.106-py3-none-manylinux1_x86_64.whl (56.5 MB)\n", + "Collecting nvidia-cusolver-cu12==11.4.5.107 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_cusolver_cu12-11.4.5.107-py3-none-manylinux1_x86_64.whl (124.2 MB)\n", + "Collecting nvidia-cusparse-cu12==12.1.0.106 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_cusparse_cu12-12.1.0.106-py3-none-manylinux1_x86_64.whl (196.0 MB)\n", + "Collecting nvidia-nccl-cu12==2.20.5 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_nccl_cu12-2.20.5-py3-none-manylinux2014_x86_64.whl (176.2 MB)\n", + "Collecting nvidia-nvtx-cu12==12.1.105 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Using cached nvidia_nvtx_cu12-12.1.105-py3-none-manylinux1_x86_64.whl (99 kB)\n", + "Requirement already satisfied: triton==2.3.0 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (2.3.0)\n", + "Collecting nvidia-nvjitlink-cu12 (from nvidia-cusolver-cu12==11.4.5.107->torch>=2.0.0->deeplabcut==3.0.0rc1)\n", + " Downloading nvidia_nvjitlink_cu12-12.5.40-py3-none-manylinux2014_x86_64.whl (21.3 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m21.3/21.3 MB\u001b[0m \u001b[31m58.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: setuptools in /usr/local/lib/python3.10/dist-packages (from imageio-ffmpeg->deeplabcut==3.0.0rc1) (67.7.2)\n", + "Requirement already satisfied: safetensors in /usr/local/lib/python3.10/dist-packages (from timm->deeplabcut==3.0.0rc1) (0.4.3)\n", + "Requirement already satisfied: msgpack in /usr/local/lib/python3.10/dist-packages (from blosc2~=2.0.0->tables==3.8.0->deeplabcut==3.0.0rc1) (1.0.8)\n", + "Requirement already satisfied: requests in /usr/local/lib/python3.10/dist-packages (from huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2.31.0)\n", + "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.10/dist-packages (from jinja2->torch>=2.0.0->deeplabcut==3.0.0rc1) (2.1.5)\n", + "Requirement already satisfied: mpmath<1.4.0,>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from sympy->torch>=2.0.0->deeplabcut==3.0.0rc1) (1.3.0)\n", + "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (3.3.2)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (3.7)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2.0.7)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2024.6.2)\n", + "Building wheels for collected packages: filterpy\n", + " Building wheel for filterpy (setup.py) ... \u001b[?25l\u001b[?25hdone\n", + " Created wheel for filterpy: filename=filterpy-1.4.5-py3-none-any.whl size=110458 sha256=f0916f6ac2eb765dc181b0fc11dcec97825e3fa37b04dd2b711a685694d9fdcc\n", + " Stored in directory: /root/.cache/pip/wheels/0f/0c/ea/218f266af4ad626897562199fbbcba521b8497303200186102\n", + "Successfully built filterpy\n", + "Installing collected packages: scipy, ruamel.yaml.clib, nvidia-nvtx-cu12, nvidia-nvjitlink-cu12, nvidia-nccl-cu12, nvidia-curand-cu12, nvidia-cufft-cu12, nvidia-cuda-runtime-cu12, nvidia-cuda-nvrtc-cu12, nvidia-cuda-cupti-cu12, nvidia-cublas-cu12, einops, ruamel.yaml, nvidia-cusparse-cu12, nvidia-cudnn-cu12, matplotlib, nvidia-cusolver-cu12, filterpy, dlclibrary, timm, deeplabcut\n", + " Attempting uninstall: scipy\n", + " Found existing installation: scipy 1.11.4\n", + " Uninstalling scipy-1.11.4:\n", + " Successfully uninstalled scipy-1.11.4\n", + " Attempting uninstall: matplotlib\n", + " Found existing installation: matplotlib 3.7.1\n", + " Uninstalling matplotlib-3.7.1:\n", + " Successfully uninstalled matplotlib-3.7.1\n", + "Successfully installed deeplabcut-3.0.0rc1 dlclibrary-0.0.6 einops-0.8.0 filterpy-1.4.5 matplotlib-3.8.4 nvidia-cublas-cu12-12.1.3.1 nvidia-cuda-cupti-cu12-12.1.105 nvidia-cuda-nvrtc-cu12-12.1.105 nvidia-cuda-runtime-cu12-12.1.105 nvidia-cudnn-cu12-8.9.2.26 nvidia-cufft-cu12-11.0.2.54 nvidia-curand-cu12-10.3.2.106 nvidia-cusolver-cu12-11.4.5.107 nvidia-cusparse-cu12-12.1.0.106 nvidia-nccl-cu12-2.20.5 nvidia-nvjitlink-cu12-12.5.40 nvidia-nvtx-cu12-12.1.105 ruamel.yaml-0.18.6 ruamel.yaml.clib-0.2.8 scipy-1.10.1 timm-1.0.7\n" + ] + }, + { + "output_type": "display_data", + "data": { + "application/vnd.colab-display-data+json": { + "pip_warning": { + "packages": [ + "matplotlib", + "mpl_toolkits" + ] + }, + "id": "b652aa18dedc4c6dabc33a40c426773d" + } + }, + "metadata": {} + } + ], "source": [ "!pip install deeplabcut==3.0.0rc1" ] @@ -55,15 +208,28 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 1, "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, "collapsed": true, "id": "LvnlIvQm0Z4X", "jupyter": { "outputs_hidden": true - } + }, + "outputId": "5fff9d1f-621f-4147-9a54-39d4c01db0b3" }, - "outputs": [], + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Loading DLC 3.0.0rc1...\n", + "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n" + ] + } + ], "source": [ "import deeplabcut\n", "import os\n", @@ -110,7 +276,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 2, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -121,7 +287,7 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "b86aaca8-4238-4020-e0b0-bfb4a6da278c" + "outputId": "5cd0b4e7-2d47-4432-ec1e-8f66c0362c7b" }, "outputs": [ { @@ -132,9 +298,9 @@ ], "text/html": [ "\n", - " \n", - " \n", + " \n", " Upload widget is only available when the cell has been executed in the\n", " current browser session. Please rerun this cell to enable.\n", " \n", @@ -354,7 +520,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 3, "metadata": { "collapsed": true, "id": "uH9LXig90Z4Y", @@ -371,334 +537,70 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, + "metadata": { + "collapsed": true, + "id": "OmJtVmHq0Z4Y", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n", + "superanimal_analyze_images(superanimal_name,\n", + " model_name,\n", + " image_name,\n", + " max_individuals,\n", + " out_folder = '/content/')" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6VEjHu-00Z4Y" + }, + "source": [ + "### Zero-shot Video Inference\n", + "- Without Video adaptation (faster, but not self-supervised fine-tuned on your data!)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "qGoAhxZOxPoM" + }, + "source": [ + "#### Upload a video you want to predict" + ] + }, + { + "cell_type": "code", + "execution_count": 5, "metadata": { "colab": { "base_uri": "https://localhost:8080/", - "height": 1000, - "referenced_widgets": [ - "d00216a2601747129cf3fdfa7c083097", - "05ecceef10ef43e5b011d5c3141ee4ac", - "d847ebd070b44f72ae5b8ea4e9346525", - "6b86e9cd93f1452bb774f2253232f91d", - "574f2fc10c794b93866b0728b4b82974", - "92c2b0759d6148baada3ba7aa00364a9", - "28119e084d06416e9c6fcb7167fc1eec", - "6023cb183d9a4d6495a180c6ad25088a", - "0ec583db59074e738fd1545bdeacabdd", - "f79fc9e32f344ab8a0cee579bcfaf303", - "9415c05ae7eb46089d9c77dfdd7a014f", - "6fd9f5ca19464368bb357f14b600b4df", - "6986e059de50418193dfbadb0f371aad", - "58147217c725435e8ae7545437ee9f0c", - "a78de7722c2649ca8e6d2ea26d305ce8", - "692db8a5e6be4cd58762b057edbfe753", - "1744c5f9765f49a78938a670cdf07514", - "439c929438204468b3b4d4c734735789", - "c1cbb03ad13f42a681c615a4abffde86", - "5ce989cb535a4b2eba237bad529e030f", - "f16ff7a640d84bedaf69340d6a4a3762", - "0381efd7cab94a35acba036e1f1ea45e" - ] + "height": 92 }, "collapsed": true, - "id": "OmJtVmHq0Z4Y", + "id": "PK3efA0I0Z4Y", "jupyter": { "outputs_hidden": true }, - "outputId": "d6b01e32-46ad-4243-cb1b-2a126ce99b7d" + "outputId": "e334e7ae-6904-4853-dade-500cee55e7f5" }, "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Loading.... superanimal_quadruped_hrnetw32\n" - ] - }, - { - "output_type": "stream", - "name": "stderr", - "text": [ - "/usr/local/lib/python3.10/dist-packages/huggingface_hub/utils/_token.py:89: UserWarning: \n", - "The secret `HF_TOKEN` does not exist in your Colab secrets.\n", - "To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.\n", - "You will be able to reuse this secret in all of your notebooks.\n", - "Please note that authentication is recommended but still optional to access public models or datasets.\n", - " warnings.warn(\n" - ] - }, - { - "output_type": "display_data", - "data": { - "text/plain": [ - "pose_model.pth: 0%| | 0.00/160M [00:00\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m superanimal_analyze_images(superanimal_name,\n\u001b[0m\u001b[1;32m 3\u001b[0m \u001b[0mmodel_name\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0mimage_name\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 5\u001b[0m \u001b[0mmax_individuals\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py\u001b[0m in \u001b[0;36msuperanimal_analyze_images\u001b[0;34m(superanimal_name, model_name, images, max_individuals, out_folder, progress_bar, device, customized_pose_checkpoint, customized_detector_checkpoint, customized_model_config)\u001b[0m\n\u001b[1;32m 135\u001b[0m \u001b[0mconfig\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"individuals\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mindividuals\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 136\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 137\u001b[0;31m predictions = analyze_image_folder(\n\u001b[0m\u001b[1;32m 138\u001b[0m \u001b[0mmodel_cfg\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mconfig\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 139\u001b[0m \u001b[0mimages\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mimages\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py\u001b[0m in \u001b[0;36manalyze_image_folder\u001b[0;34m(model_cfg, images, snapshot_path, detector_path, device, max_individuals, progress_bar)\u001b[0m\n\u001b[1;32m 330\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mprogress_bar\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 331\u001b[0m \u001b[0mdetector_image_paths\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mtqdm\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdetector_image_paths\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 332\u001b[0;31m \u001b[0mbbox_predictions\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mdetector_runner\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minference\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimages\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mdetector_image_paths\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 333\u001b[0m \u001b[0mpose_inputs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mlist\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mzip\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimage_paths\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mbbox_predictions\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 334\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/torch/utils/_contextlib.py\u001b[0m in \u001b[0;36mdecorate_context\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 113\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mdecorate_context\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 114\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0mctx_factory\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 115\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mfunc\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 116\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 117\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mdecorate_context\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/runners/inference.py\u001b[0m in \u001b[0;36minference\u001b[0;34m(self, images)\u001b[0m\n\u001b[1;32m 102\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpreprocessor\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 103\u001b[0m \u001b[0;31m# TODO: input batch should also be able to be a dict[str, torch.Tensor]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 104\u001b[0;31m \u001b[0minput_image\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpreprocessor\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0minput_image\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 105\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 106\u001b[0m \u001b[0mimage_predictions\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpredict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0minput_image\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/data/preprocessor.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, image, context)\u001b[0m\n\u001b[1;32m 120\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m__call__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mimage\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mImage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mtuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mImage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 121\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mpreprocessor\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcomponents\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 122\u001b[0;31m \u001b[0mimage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mpreprocessor\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 123\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mimage\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 124\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m/usr/local/lib/python3.10/dist-packages/deeplabcut/pose_estimation_pytorch/data/preprocessor.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, image, context)\u001b[0m\n\u001b[1;32m 134\u001b[0m \u001b[0mimage_\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcv2\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mimread\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimage\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 135\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcolor_mode\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;34m\"RGB\"\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 136\u001b[0;31m \u001b[0mimage_\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcv2\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcvtColor\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mimage_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcv2\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mCOLOR_BGR2RGB\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 137\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 138\u001b[0m \u001b[0mimage_\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mimage\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31merror\u001b[0m: OpenCV(4.10.0) /io/opencv/modules/imgproc/src/color.cpp:196: error: (-215:Assertion failed) !_src.empty() in function 'cvtColor'\n" - ] - } - ], - "source": [ - "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n", - "superanimal_analyze_images(superanimal_name,\n", - " model_name,\n", - " image_name,\n", - " max_individuals,\n", - " out_folder = '/content/')" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "6VEjHu-00Z4Y" - }, - "source": [ - "### Zero-shot Video Inference\n", - "- Without Video adaptation (faster, but not self-supervised fine-tuned on your data!)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "qGoAhxZOxPoM" - }, - "source": [ - "#### Upload a video you want to predict" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 92 - }, - "collapsed": true, - "id": "PK3efA0I0Z4Y", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "c5a9050d-3d28-41ce-fc78-6bc9fd111296" - }, - "outputs": [ - { - "output_type": "display_data", - "data": { - "text/plain": [ - "" + "" ], "text/html": [ "\n", - " \n", - " \n", + " \n", " Upload widget is only available when the cell has been executed in the\n", " current browser session. Please rerun this cell to enable.\n", " \n", @@ -918,7 +820,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 6, "metadata": { "collapsed": true, "id": "OiRAP9XD0Z4Z", @@ -930,7 +832,7 @@ "source": [ "superanimal_name = 'superanimal_quadruped' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", "model_name = 'hrnetw32'\n", - "max_individuals = 1 #how many animals do you expect to see?" + "max_individuals = 2 #how many animals do you expect to see?" ] }, { @@ -944,7 +846,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -954,7 +856,7 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "b1809362-9a3f-416c-982a-439312003774" + "outputId": "118cab24-69e6-4ce9-c546-ee9e306dbba2" }, "outputs": [ { @@ -1124,8 +1026,8 @@ "Starting to analyze /content/zebra-dancing.mov\n", "Video metadata: \n", " Overall # of frames: 458\n", - " Duration of video [s]: 7.82\n", - " fps: 58.59\n", + " Duration of video [s]: 7.63\n", + " fps: 60.0\n", " resolution: w=1840, h=1032\n", "\n", "Running Detector\n" @@ -1135,8 +1037,147 @@ "output_type": "stream", "name": "stderr", "text": [ - " 25%|██▌ | 116/458 [02:23<06:18, 1.11s/it]" + " 0%| | 0/458 [00:00 Date: Mon, 24 Jun 2024 18:35:29 +0200 Subject: [PATCH 176/293] Fix video overwrite (#2633) --- deeplabcut/gui/tabs/create_videos.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py index bdf19c55d4..c1807d1644 100644 --- a/deeplabcut/gui/tabs/create_videos.py +++ b/deeplabcut/gui/tabs/create_videos.py @@ -282,6 +282,7 @@ def create_videos(self): draw_skeleton=self.draw_skeleton_checkbox.isChecked(), trailpoints=trailpoints, color_by=color_by, + overwrite=self.overwrite_videos.isChecked(), ) if all(videos_created): self.root.writer.write("Labeled videos created.") From e52b0ff1e41841a5b28a7aa07916b6fcb6092d5d Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Wed, 26 Jun 2024 09:04:27 +0200 Subject: [PATCH 177/293] Visually signal end of super animal video inference (#2637) --- deeplabcut/gui/tabs/modelzoo.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index 80d10681b9..a47ba18788 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -279,8 +279,7 @@ def run_video_adaptation(self): ) self.worker, self.thread = move_to_separate_thread(func) - self.worker.finished.connect(lambda: self.run_button.setEnabled(True)) - self.worker.finished.connect(lambda: self.root._progress_bar.hide()) + self.worker.finished.connect(self.signal_analysis_complete) self.thread.start() self.run_button.setEnabled(False) self.root._progress_bar.show() @@ -293,6 +292,14 @@ def run_video_adaptation(self): dest_folder=self._destfolder, **kwargs, ) + self.signal_analysis_complete() + + def signal_analysis_complete(self): + self.run_button.setEnabled(True) + self.root._progress_bar.hide() + msg = QtWidgets.QMessageBox(text="SuperAnimal video inference complete!") + msg.setIcon(QtWidgets.QMessageBox.Information) + msg.exec_() def _gather_kwargs(self) -> dict: kwargs = {} From d68a137b796722f6850b4029af81efb1011f5e0e Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Wed, 26 Jun 2024 11:01:04 +0200 Subject: [PATCH 178/293] cleaned notebook and added bbox_threshold to analyze_superanimal_images --- .../apis/analyze_images.py | 50 +- .../COLAB/COLAB_Pytorch_SuperAnimal.ipynb | 5948 ++++------------- 2 files changed, 1502 insertions(+), 4496 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py index 6263cf3759..26bcfa3fa0 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py @@ -47,6 +47,7 @@ def superanimal_analyze_images( images: str | Path | list[str] | list[Path], max_individuals: int, out_folder: str, + bbox_threshold: float = 0.6, progress_bar: bool = True, device: str | None = None, customized_pose_checkpoint: str | None = None, @@ -54,19 +55,18 @@ def superanimal_analyze_images( customized_model_config: str | None = None, ): """ - This funciton inferences a superanimal model on a set of images and saves the results as labeled images. + This funciton inferences a superanimal model on a set of images and saves the + results as labeled images. Parameters ---------- superanimal_name: str - The name of the superanimal to analyze. - supported list: - superanimal_topviewmouse - superanimal_quadruped + The name of the superanimal to analyze. Supported list: + - "superanimal_topviewmouse" + - "superanimal_quadruped" model_name: str - The name of the model to use for inference. - supported list: - hrnetw32 + The name of the model to use for inference. Supported list: + - "hrnetw32" images: str | Path | list[str] | list[Path] The images to analyze. Can either be a directory containing images, or a list of paths of images. @@ -74,6 +74,10 @@ def superanimal_analyze_images( The maximum number of individuals to detect in each image. out_folder: str The directory where the labeled images will be saved. + bbox_threshold: float, default=0.1 + The minimum confidence score to keep bounding box detections. Must be in (0, 1). + Only used when `customized_model_config=None` (otherwise, edit your + `customized_model_config` with the desired bbox_threshold). progress_bar: bool Whether to display a progress bar when running inference. device: str | None @@ -95,17 +99,19 @@ def superanimal_analyze_images( -------- >>> import deeplabcut >>> from deeplabcut.pose_estimation_pytorch.apis.analyze_images import superanimal_analyze_images - >>> superanimal_name = 'superanimal_quadruped' - >>> model_name = 'hrnetw32' - >>> device = 'cuda' + >>> superanimal_name = "superanimal_quadruped" + >>> model_name = "hrnetw32" + >>> device = "cuda" >>> max_individuals = 3 - >>> test_images_folder = 'test_rodent_images' - >>> out_images_folder = 'vis_test_rodent_images' - >>> ret = superanimal_analyze_images(superanimal_name, - model_name, - test_images_folder, - max_individuals, - out_images_folder) + >>> test_images_folder = "test_rodent_images" + >>> out_images_folder = "vis_test_rodent_images" + >>> ret = superanimal_analyze_images( + >>> superanimal_name, + >>> model_name, + >>> test_images_folder, + >>> max_individuals, + >>> out_images_folder + >>> ) """ os.makedirs(out_folder, exist_ok=True) @@ -119,6 +125,10 @@ def superanimal_analyze_images( snapshot_path, detector_path, ) = get_config_model_paths(superanimal_name, model_name) + + if "detector" in model_cfg: + model_cfg["detector"]["model"]["box_score_thresh"] = bbox_threshold + config = {**project_config, **model_cfg} config = update_config(config, max_individuals, device) else: @@ -146,9 +156,7 @@ def superanimal_analyze_images( superanimal_colormaps = get_superanimal_colormaps() colormap = superanimal_colormaps[superanimal_name] - create_labeled_images_from_predictions(predictions, out_folder, colormap) - return predictions @@ -164,8 +172,6 @@ def analyze_images( device: str | None = None, max_individuals: int | None = None, progress_bar: bool = True, - superanimal_name=None, - model_name=None, ) -> dict[str, dict]: """Runs analysis on images using a pose model. diff --git a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb index a5ec8eedd1..d5c64ef4ce 100644 --- a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb @@ -1,4474 +1,1474 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "id": "view-in-github", - "colab_type": "text" - }, - "source": [ - "\"Open" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "5SSZpZUu0Z4S" - }, - "source": [ - "# DeepLabCut Model Zoo: SuperAnimal models\n", - "\n", - "![alt text](https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1616492373700-PGOAC72IOB6AUE47VTJX/ke17ZwdGBToddI8pDm48kB8JrdUaZR-OSkKLqWQPp_YUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8N_N4V1vUb5AoIIIbLZhVYwL8IeDg6_3B-BRuF4nNrNcQkVuAT7tdErd0wQFEGFSnBqyW03PFN2MN6T6ry5cmXqqA9xITfsbVGDrg_goIDasRCalqV8R3606BuxERAtDaQ/modelzoo.png?format=1000w)\n", - "\n", - "# 🦄 SuperAnimal in DeepLabCut PyTorch! 🔥\n", - "\n", - "This notebook demos how to use our SuperAnimal models within DLC3. Please read more in [Ye et al. Nature Communications 2024](https://www.nature.com/articles/s41467-024-48792-2) about the available SuperAnimal models, and follow along below!\n", - "\n", - "### **Let's get going: install DeepLabCut into COLAB:**\n", - "\n", - "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 1000 - }, - "collapsed": true, - "id": "AjET5cJE5UYM", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "0a9bc286-e21e-4fe2-cd1a-fb570f35e719" - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Collecting deeplabcut==3.0.0rc1\n", - " Downloading deeplabcut-3.0.0rc1-py3-none-any.whl (2.0 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.0/2.0 MB\u001b[0m \u001b[31m9.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: albumentations<=1.4.3 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.3.1)\n", - "Collecting dlclibrary>=0.0.5 (from deeplabcut==3.0.0rc1)\n", - " Downloading dlclibrary-0.0.6-py3-none-any.whl (15 kB)\n", - "Collecting einops (from deeplabcut==3.0.0rc1)\n", - " Downloading einops-0.8.0-py3-none-any.whl (43 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m43.2/43.2 kB\u001b[0m \u001b[31m6.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hCollecting filterpy>=1.4.4 (from deeplabcut==3.0.0rc1)\n", - " Downloading filterpy-1.4.5.zip (177 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m178.0/178.0 kB\u001b[0m \u001b[31m24.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n", - "Collecting ruamel.yaml>=0.15.0 (from deeplabcut==3.0.0rc1)\n", - " Downloading ruamel.yaml-0.18.6-py3-none-any.whl (117 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m117.8/117.8 kB\u001b[0m \u001b[31m15.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: imgaug>=0.4.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.4.0)\n", - "Requirement already satisfied: imageio-ffmpeg in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.5.1)\n", - "Requirement already satisfied: numba>=0.54 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.58.1)\n", - "Collecting matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3 (from deeplabcut==3.0.0rc1)\n", - " Downloading matplotlib-3.8.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (11.6 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m11.6/11.6 MB\u001b[0m \u001b[31m66.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: networkx>=2.6 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (3.3)\n", - "Requirement already satisfied: numpy>=1.18.5 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.25.2)\n", - "Requirement already satisfied: pandas!=1.5.0,>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.0.3)\n", - "Requirement already satisfied: scikit-image>=0.17 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.19.3)\n", - "Requirement already satisfied: scikit-learn>=1.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (1.2.2)\n", - "Collecting scipy<1.11.0,>=1.4 (from deeplabcut==3.0.0rc1)\n", - " Downloading scipy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (34.4 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m34.4/34.4 MB\u001b[0m \u001b[31m41.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: statsmodels>=0.11 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.14.2)\n", - "Requirement already satisfied: tables==3.8.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (3.8.0)\n", - "Collecting timm (from deeplabcut==3.0.0rc1)\n", - " Downloading timm-1.0.7-py3-none-any.whl (2.3 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.3/2.3 MB\u001b[0m \u001b[31m94.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: torch>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.3.0+cu121)\n", - "Requirement already satisfied: torchvision in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (0.18.0+cu121)\n", - "Requirement already satisfied: tqdm in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (4.66.4)\n", - "Requirement already satisfied: pycocotools in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (2.0.8)\n", - "Requirement already satisfied: pyyaml in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (6.0.1)\n", - "Requirement already satisfied: Pillow>=7.1 in /usr/local/lib/python3.10/dist-packages (from deeplabcut==3.0.0rc1) (9.4.0)\n", - "Requirement already satisfied: cython>=0.29.21 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (3.0.10)\n", - "Requirement already satisfied: numexpr>=2.6.2 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (2.10.0)\n", - "Requirement already satisfied: blosc2~=2.0.0 in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (2.0.0)\n", - "Requirement already satisfied: packaging in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (24.1)\n", - "Requirement already satisfied: py-cpuinfo in /usr/local/lib/python3.10/dist-packages (from tables==3.8.0->deeplabcut==3.0.0rc1) (9.0.0)\n", - "Requirement already satisfied: qudida>=0.0.4 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->deeplabcut==3.0.0rc1) (0.0.4)\n", - "Requirement already satisfied: opencv-python-headless>=4.1.1 in /usr/local/lib/python3.10/dist-packages (from albumentations<=1.4.3->deeplabcut==3.0.0rc1) (4.10.0.84)\n", - "Requirement already satisfied: huggingface-hub in /usr/local/lib/python3.10/dist-packages (from dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (0.23.4)\n", - "Requirement already satisfied: six in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (1.16.0)\n", - "Requirement already satisfied: opencv-python in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (4.8.0.76)\n", - "Requirement already satisfied: imageio in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (2.31.6)\n", - "Requirement already satisfied: Shapely in /usr/local/lib/python3.10/dist-packages (from imgaug>=0.4.0->deeplabcut==3.0.0rc1) (2.0.4)\n", - "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (1.2.1)\n", - "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (0.12.1)\n", - "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (4.53.0)\n", - "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (1.4.5)\n", - "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (3.1.2)\n", - "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.10/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut==3.0.0rc1) (2.8.2)\n", - "Requirement already satisfied: llvmlite<0.42,>=0.41.0dev0 in /usr/local/lib/python3.10/dist-packages (from numba>=0.54->deeplabcut==3.0.0rc1) (0.41.1)\n", - "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut==3.0.0rc1) (2023.4)\n", - "Requirement already satisfied: tzdata>=2022.1 in /usr/local/lib/python3.10/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut==3.0.0rc1) (2024.1)\n", - "Collecting ruamel.yaml.clib>=0.2.7 (from ruamel.yaml>=0.15.0->deeplabcut==3.0.0rc1)\n", - " Downloading ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl (526 kB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m526.7/526.7 kB\u001b[0m \u001b[31m50.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: tifffile>=2019.7.26 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->deeplabcut==3.0.0rc1) (2024.5.22)\n", - "Requirement already satisfied: PyWavelets>=1.1.1 in /usr/local/lib/python3.10/dist-packages (from scikit-image>=0.17->deeplabcut==3.0.0rc1) (1.6.0)\n", - "Requirement already satisfied: joblib>=1.1.1 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->deeplabcut==3.0.0rc1) (1.4.2)\n", - "Requirement already satisfied: threadpoolctl>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from scikit-learn>=1.0->deeplabcut==3.0.0rc1) (3.5.0)\n", - "Requirement already satisfied: patsy>=0.5.6 in /usr/local/lib/python3.10/dist-packages (from statsmodels>=0.11->deeplabcut==3.0.0rc1) (0.5.6)\n", - "Requirement already satisfied: filelock in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (3.15.1)\n", - "Requirement already satisfied: typing-extensions>=4.8.0 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (4.12.2)\n", - "Requirement already satisfied: sympy in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (1.12.1)\n", - "Requirement already satisfied: jinja2 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (3.1.4)\n", - "Requirement already satisfied: fsspec in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (2023.6.0)\n", - "Collecting nvidia-cuda-nvrtc-cu12==12.1.105 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_cuda_nvrtc_cu12-12.1.105-py3-none-manylinux1_x86_64.whl (23.7 MB)\n", - "Collecting nvidia-cuda-runtime-cu12==12.1.105 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_cuda_runtime_cu12-12.1.105-py3-none-manylinux1_x86_64.whl (823 kB)\n", - "Collecting nvidia-cuda-cupti-cu12==12.1.105 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_cuda_cupti_cu12-12.1.105-py3-none-manylinux1_x86_64.whl (14.1 MB)\n", - "Collecting nvidia-cudnn-cu12==8.9.2.26 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_cudnn_cu12-8.9.2.26-py3-none-manylinux1_x86_64.whl (731.7 MB)\n", - "Collecting nvidia-cublas-cu12==12.1.3.1 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_cublas_cu12-12.1.3.1-py3-none-manylinux1_x86_64.whl (410.6 MB)\n", - "Collecting nvidia-cufft-cu12==11.0.2.54 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_cufft_cu12-11.0.2.54-py3-none-manylinux1_x86_64.whl (121.6 MB)\n", - "Collecting nvidia-curand-cu12==10.3.2.106 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_curand_cu12-10.3.2.106-py3-none-manylinux1_x86_64.whl (56.5 MB)\n", - "Collecting nvidia-cusolver-cu12==11.4.5.107 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_cusolver_cu12-11.4.5.107-py3-none-manylinux1_x86_64.whl (124.2 MB)\n", - "Collecting nvidia-cusparse-cu12==12.1.0.106 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_cusparse_cu12-12.1.0.106-py3-none-manylinux1_x86_64.whl (196.0 MB)\n", - "Collecting nvidia-nccl-cu12==2.20.5 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_nccl_cu12-2.20.5-py3-none-manylinux2014_x86_64.whl (176.2 MB)\n", - "Collecting nvidia-nvtx-cu12==12.1.105 (from torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Using cached nvidia_nvtx_cu12-12.1.105-py3-none-manylinux1_x86_64.whl (99 kB)\n", - "Requirement already satisfied: triton==2.3.0 in /usr/local/lib/python3.10/dist-packages (from torch>=2.0.0->deeplabcut==3.0.0rc1) (2.3.0)\n", - "Collecting nvidia-nvjitlink-cu12 (from nvidia-cusolver-cu12==11.4.5.107->torch>=2.0.0->deeplabcut==3.0.0rc1)\n", - " Downloading nvidia_nvjitlink_cu12-12.5.40-py3-none-manylinux2014_x86_64.whl (21.3 MB)\n", - "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m21.3/21.3 MB\u001b[0m \u001b[31m58.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", - "\u001b[?25hRequirement already satisfied: setuptools in /usr/local/lib/python3.10/dist-packages (from imageio-ffmpeg->deeplabcut==3.0.0rc1) (67.7.2)\n", - "Requirement already satisfied: safetensors in /usr/local/lib/python3.10/dist-packages (from timm->deeplabcut==3.0.0rc1) (0.4.3)\n", - "Requirement already satisfied: msgpack in /usr/local/lib/python3.10/dist-packages (from blosc2~=2.0.0->tables==3.8.0->deeplabcut==3.0.0rc1) (1.0.8)\n", - "Requirement already satisfied: requests in /usr/local/lib/python3.10/dist-packages (from huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2.31.0)\n", - "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.10/dist-packages (from jinja2->torch>=2.0.0->deeplabcut==3.0.0rc1) (2.1.5)\n", - "Requirement already satisfied: mpmath<1.4.0,>=1.1.0 in /usr/local/lib/python3.10/dist-packages (from sympy->torch>=2.0.0->deeplabcut==3.0.0rc1) (1.3.0)\n", - "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (3.3.2)\n", - "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (3.7)\n", - "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2.0.7)\n", - "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.10/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.5->deeplabcut==3.0.0rc1) (2024.6.2)\n", - "Building wheels for collected packages: filterpy\n", - " Building wheel for filterpy (setup.py) ... \u001b[?25l\u001b[?25hdone\n", - " Created wheel for filterpy: filename=filterpy-1.4.5-py3-none-any.whl size=110458 sha256=f0916f6ac2eb765dc181b0fc11dcec97825e3fa37b04dd2b711a685694d9fdcc\n", - " Stored in directory: /root/.cache/pip/wheels/0f/0c/ea/218f266af4ad626897562199fbbcba521b8497303200186102\n", - "Successfully built filterpy\n", - "Installing collected packages: scipy, ruamel.yaml.clib, nvidia-nvtx-cu12, nvidia-nvjitlink-cu12, nvidia-nccl-cu12, nvidia-curand-cu12, nvidia-cufft-cu12, nvidia-cuda-runtime-cu12, nvidia-cuda-nvrtc-cu12, nvidia-cuda-cupti-cu12, nvidia-cublas-cu12, einops, ruamel.yaml, nvidia-cusparse-cu12, nvidia-cudnn-cu12, matplotlib, nvidia-cusolver-cu12, filterpy, dlclibrary, timm, deeplabcut\n", - " Attempting uninstall: scipy\n", - " Found existing installation: scipy 1.11.4\n", - " Uninstalling scipy-1.11.4:\n", - " Successfully uninstalled scipy-1.11.4\n", - " Attempting uninstall: matplotlib\n", - " Found existing installation: matplotlib 3.7.1\n", - " Uninstalling matplotlib-3.7.1:\n", - " Successfully uninstalled matplotlib-3.7.1\n", - "Successfully installed deeplabcut-3.0.0rc1 dlclibrary-0.0.6 einops-0.8.0 filterpy-1.4.5 matplotlib-3.8.4 nvidia-cublas-cu12-12.1.3.1 nvidia-cuda-cupti-cu12-12.1.105 nvidia-cuda-nvrtc-cu12-12.1.105 nvidia-cuda-runtime-cu12-12.1.105 nvidia-cudnn-cu12-8.9.2.26 nvidia-cufft-cu12-11.0.2.54 nvidia-curand-cu12-10.3.2.106 nvidia-cusolver-cu12-11.4.5.107 nvidia-cusparse-cu12-12.1.0.106 nvidia-nccl-cu12-2.20.5 nvidia-nvjitlink-cu12-12.5.40 nvidia-nvtx-cu12-12.1.105 ruamel.yaml-0.18.6 ruamel.yaml.clib-0.2.8 scipy-1.10.1 timm-1.0.7\n" - ] - }, - { - "output_type": "display_data", - "data": { - "application/vnd.colab-display-data+json": { - "pip_warning": { - "packages": [ - "matplotlib", - "mpl_toolkits" - ] - }, - "id": "b652aa18dedc4c6dabc33a40c426773d" - } - }, - "metadata": {} - } - ], - "source": [ - "!pip install deeplabcut==3.0.0rc1" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "5h0vq6E50Z4W" - }, - "source": [ - "### PLEASE, click \"restart runtime\" from the output above before proceeding!" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "LvnlIvQm0Z4X", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "5fff9d1f-621f-4147-9a54-39d4c01db0b3" - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Loading DLC 3.0.0rc1...\n", - "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n" - ] - } - ], - "source": [ - "import deeplabcut\n", - "import os\n", - "from deeplabcut.pose_estimation_pytorch.apis.analyze_images import (\n", - " superanimal_analyze_images,\n", - ")\n", - "from deeplabcut.core.weight_init import WeightInitialization\n", - "from deeplabcut.core.engine import Engine\n", - "from deeplabcut.modelzoo.video_inference import video_inference_superanimal\n", - "from deeplabcut.utils.pseudo_label import keypoint_matching\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "UeXjmtu40Z4X" - }, - "source": [ - "## Zero-shot Image Inference & Video Inference\n", - "SuperAnimal models are foundation animal pose models. They can be used for zero-shot predictions without further training on the data.\n", - "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another dest folder." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "FvFzntDMxPoL" - }, - "source": [ - "## Zero-shot image inference\n", - "\n", - "- If you have a single Image you want to test, upload it here!" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "NbDsZQfsxPoL" - }, - "source": [ - "#### Upload the images you want to predict" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 92 - }, - "collapsed": true, - "id": "c4yfTj7r0Z4Y", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "5cd0b4e7-2d47-4432-ec1e-8f66c0362c7b" - }, - "outputs": [ - { - "output_type": "display_data", - "data": { - "text/plain": [ - "" - ], - "text/html": [ - "\n", - " \n", - " \n", - " Upload widget is only available when the cell has been executed in the\n", - " current browser session. Please rerun this cell to enable.\n", - " \n", - " " - ] - }, - "metadata": {} - }, - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Saving zebra.png to zebra.png\n", - "User uploaded file \"zebra.png\" with length 1291107 bytes\n" - ] - } - ], - "source": [ - "from google.colab import files\n", - "\n", - "uploaded = files.upload()\n", - "for filepath, content in uploaded.items():\n", - " print(f'User uploaded file \"{filepath}\" with length {len(content)} bytes')\n", - "image_path = os.path.abspath(filepath)\n", - "image_name = os.path.splitext(image_path)[0]\n", - "\n", - "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n", - "# manually upload your video via the Files menu to the left\n", - "# and define `video_path` yourself with right click > copy path on the video." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Jashzdjb0Z4Y" - }, - "source": [ - "### Select a SuperAnimal name and corresponding model architecture\n", - "\n", - "Check Our Docs on [SuperAnimals](https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/ModelZoo.md) to learn more!" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "collapsed": true, - "id": "uH9LXig90Z4Y", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "superanimal_name = 'superanimal_quadruped' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", - "model_name = 'hrnetw32'\n", - "max_individuals = 1 #how many animals do you expect to see?" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "collapsed": true, - "id": "OmJtVmHq0Z4Y", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n", - "superanimal_analyze_images(superanimal_name,\n", - " model_name,\n", - " image_name,\n", - " max_individuals,\n", - " out_folder = '/content/')" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "6VEjHu-00Z4Y" - }, - "source": [ - "### Zero-shot Video Inference\n", - "- Without Video adaptation (faster, but not self-supervised fine-tuned on your data!)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "qGoAhxZOxPoM" - }, - "source": [ - "#### Upload a video you want to predict" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 92 - }, - "collapsed": true, - "id": "PK3efA0I0Z4Y", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "e334e7ae-6904-4853-dade-500cee55e7f5" - }, - "outputs": [ - { - "output_type": "display_data", - "data": { - "text/plain": [ - "" - ], - "text/html": [ - "\n", - " \n", - " \n", - " Upload widget is only available when the cell has been executed in the\n", - " current browser session. Please rerun this cell to enable.\n", - " \n", - " " - ] - }, - "metadata": {} - }, - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Saving zebra-dancing.mov to zebra-dancing.mov\n", - "User uploaded file \"zebra-dancing.mov\" with length 21553881 bytes\n" - ] - } - ], - "source": [ - "from google.colab import files\n", - "\n", - "uploaded = files.upload()\n", - "for filepath, content in uploaded.items():\n", - " print(f'User uploaded file \"{filepath}\" with length {len(content)} bytes')\n", - "video_path = os.path.abspath(filepath)\n", - "video_name = os.path.splitext(video_path)[0]\n", - "\n", - "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n", - "# manually upload your video via the Files menu to the left\n", - "# and define `video_path` yourself with right click > copy path on the video.\n", - "\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "JoA-RATSICj_" - }, - "source": [ - "#### Choose the superanimal and the model name" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "collapsed": true, - "id": "OiRAP9XD0Z4Z", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "superanimal_name = 'superanimal_quadruped' # 'superanimal_topviewmouse', 'superanimal_quadruped'\n", - "model_name = 'hrnetw32'\n", - "max_individuals = 2 #how many animals do you expect to see?" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Zv3v0QgSJNOg" - }, - "source": [ - "### Zero-shot Video Inference without video adaptation" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "poqynL0UJTBp", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "118cab24-69e6-4ce9-c546-ee9e306dbba2" - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "running video inference on /content/zebra-dancing.mov with superanimal_quadruped_hrnetw32\n", - "Using pytorch for model hrnetw32\n", - "Task: None\n", - "scorer: None\n", - "date: None\n", - "multianimalproject: None\n", - "identity: None\n", - "project_path: /usr/local/lib/python3.10/dist-packages/deeplabcut/modelzoo/project_configs\n", - "engine: pytorch\n", - "video_sets: None\n", - "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - "start: None\n", - "stop: None\n", - "numframes2pick: None\n", - "skeleton: []\n", - "skeleton_color: black\n", - "pcutoff: None\n", - "dotsize: None\n", - "alphavalue: None\n", - "colormap: rainbow\n", - "TrainingFraction: None\n", - "iteration: None\n", - "default_net_type: None\n", - "default_augmenter: None\n", - "snapshotindex: None\n", - "detector_snapshotindex: None\n", - "batch_size: 1\n", - "cropping: None\n", - "x1: None\n", - "x2: None\n", - "y1: None\n", - "y2: None\n", - "corner2move2: None\n", - "move2corner: None\n", - "SuperAnimalConversionTables: None\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 250\n", - "device: auto\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 39\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 39]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 10\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 200\n", - " pretrained_weights: None\n", - " seed: 42\n", - "metadata:\n", - " project_path: /usr/local/lib/python3.10/dist-packages/deeplabcut/modelzoo/project_configs\n", - " pose_config_path: /usr/local/lib/python3.10/dist-packages/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", - " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - " unique_bodyparts: []\n", - " individuals: ['animal']\n", - " with_identity: None\n", - "Processing video /content/zebra-dancing.mov\n", - "Starting to analyze /content/zebra-dancing.mov\n", - "Video metadata: \n", - " Overall # of frames: 458\n", - " Duration of video [s]: 7.63\n", - " fps: 60.0\n", - " resolution: w=1840, h=1032\n", - "\n", - "Running Detector\n" - ] - }, - { - "output_type": "stream", - "name": "stderr", - "text": [ - " 0%| | 0/458 [00:00\n", - " \n", - " Upload widget is only available when the cell has been executed in the\n", - " current browser session. Please rerun this cell to enable.\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Saving daniel3mouse.zip to daniel3mouse.zip\n", - "Contents of the extracted folder:\n", - "- __MACOSX\n", - "- daniel3mouse\n" - ] - } - ], - "source": [ - "uploaded = files.upload()\n", - "for filename in uploaded.keys():\n", - " zip_file_path = os.path.join(\"/content\", filename)\n", - " with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:\n", - " zip_ref.extractall(\"/content/dlc_project_folder\")\n", - "\n", - "print(\"Contents of the extracted folder:\")\n", - "extracted_files = os.listdir(\"/content/dlc_project_folder\")\n", - "for file in extracted_files:\n", - " print(f'- {file}')\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "b5UqfHcnxPoO" - }, - "source": [ - "#### Change the path to your project in dlc_project_folder" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "collapsed": true, - "id": "nY7Sv9pslaMh", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "dlc_proj_root = Path(\"/content/dlc_project_folder/daniel3mouse\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "BPvoL9uZ0Z4a" - }, - "source": [ - "#### Comparison between different training baselines\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "eVmpaLdB0Z4a" - }, - "source": [ - "Definition of data split: the unique combination of training images and testing images.\n", - "We create a data split named split 0. All baselines will share the data split to make fair comparisons.\n", - "- split 0 -> shared by all baselines\n", - "- shuffle 0 (split0) -> imagenet transfer learning\n", - "- shuffle 1 (split0) -> superanimal transfer learning\n", - "- shuffle 2 (split0) -> superanimal naive fine-tuning\n", - "- shuffle 3 (split0) -> superanimal memory-replay fine-tuning" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "WofR2jytxPoR" - }, - "source": [ - "### What is the difference between baselines?\n", - "\n", - "**Transfer learning** For canonical task-agnostic transfer learning,\n", - "the encoder learns universal visual features from ImageNet, and a randomly\n", - "initialized decoder is used to learn the pose fromthe downstream dataset.\n", - "\n", - "**Fine-tuning** For task aware\n", - "fine-tuning, both encoder and decoder learn task-related visual-pose features\n", - "in the pre-training datasets, and the decoder is fine-tuned to update pose\n", - "priors in downstream datasets. Crucially, the network has pose-estimation-specific\n", - "weights\n", - "\n", - "**ImageNet transfer-learning** The encoder was pre-trained from ImageNet. The decoder is trained from scratch in the downstream tasks\n", - "\n", - "**SuperAnimal transfer-learning** The encoder was pre-trained first from ImageNet, then in pose datasets we colleceted. Then decoder is trained from scratch in downstream tasks.\n", - "\n", - "**SuperAnimal naive fine-tuning** Both the encoder and the decoder were pre-trained in pose datasets we collected. In downstream datsets, we only finetune convolutional channels that correspond to the annotated keypoints in the downstream datasets. This introduces catastrophic forgetting in keypoints that are not annotated in the downstream datasets.\n", - "\n", - "**SuperAnimal memory-replay fine-tuning** If we apply fine-tuning with SuperAnimal without further cares, the models will forget about keypoints that are not annotated in the downstream datasets. To mitigate this, we mix the annotations and zero-shot predictions of SuperAnimal models to create a dataset that 'replays' the memory of the SuperAnimal keypoints.\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "collapsed": true, - "id": "AgIsUu6v0Z4a", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "imagenet_transfer_learning_shuffle = 0\n", - "superanimal_transfer_learning_shuffle = 1\n", - "superanimal_naive_finetune_shuffle = 2\n", - "superanimal_memory_replay_shuffle = 3" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "kuKcxM8F0Z4a", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "bb09b5cd-bd25-416b-8b94-aa25a5ffd1a7" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", - "Creating training data for: Shuffle: 0 TrainFraction: 0.95\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 152/152 [00:00<00:00, 3254.90it/s]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" - ] - } - ], - "source": [ - "config_path = dlc_proj_root / 'config.yaml'\n", - "deeplabcut.create_training_dataset(\n", - " config_path,\n", - " Shuffles = [imagenet_transfer_learning_shuffle],\n", - " net_type=\"top_down_hrnet_w32\",\n", - " engine=Engine.PYTORCH,\n", - " userfeedback=False,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "_6RncQbr0Z4a" - }, - "source": [ - "### ImageNet transfer learning\n", - "\n", - "Historically, the transfer learning using ImageNet weights strategies assumed no “animal pose task priors” in the pretrained\n", - "model, a paradigm adopted from previous task-agnostic transfer learning." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "H2z8kM340Z4a", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "bd322c11-20e4-4b75-da4d-fdae1b7e5937" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Training with configuration:\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " rotation: 30\n", - " scaling: [1.0, 1.0]\n", - " translation: 0\n", - " collate: None\n", - " covering: False\n", - " gaussian_noise: 12.75\n", - " hist_eq: False\n", - " motion_blur: False\n", - " normalize_images: True\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " rotation: 30\n", - " scaling: [1.0, 1.0]\n", - " translation: 40\n", - " collate:\n", - " type: ResizeFromDataSizeCollate\n", - " min_scale: 0.4\n", - " max_scale: 1.0\n", - " min_short_side: 128\n", - " max_short_side: 1152\n", - " multiple_of: 32\n", - " to_square: False\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " variant: fasterrcnn_mobilenet_v3_large_fpn\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0001\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [160]\n", - " lr_list: [[1e-05]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 0\n", - "device: auto\n", - "metadata:\n", - " project_path: /content/dlc_project_folder/daniel3mouse\n", - " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset95shuffle0/train/pose_cfg.yaml\n", - " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", - " unique_bodyparts: []\n", - " individuals: ['mus1', 'mus2', 'mus3']\n", - " with_identity: None\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " freeze_bn_stats: False\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 12\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32]\n", - " kernel_size: []\n", - " strides: []\n", - " final_conv:\n", - " out_channels: 12\n", - " kernel_size: 1\n", - "net_type: hrnet_w32\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " gpus: None\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0005\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-05], [1e-06]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 3\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 64\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 3\n", - " seed: 42\n", - "Loading pretrained weights from Hugging Face hub (timm/hrnet_w32.ms_in1k)\n", - "[timm/hrnet_w32.ms_in1k] Safe alternative available for 'pytorch_model.bin' (as 'model.safetensors'). Loading weights using safetensors.\n", - "Unexpected keys (downsamp_modules.0.0.bias, downsamp_modules.0.0.weight, downsamp_modules.0.1.bias, downsamp_modules.0.1.num_batches_tracked, downsamp_modules.0.1.running_mean, downsamp_modules.0.1.running_var, downsamp_modules.0.1.weight, downsamp_modules.1.0.bias, downsamp_modules.1.0.weight, downsamp_modules.1.1.bias, downsamp_modules.1.1.num_batches_tracked, downsamp_modules.1.1.running_mean, downsamp_modules.1.1.running_var, downsamp_modules.1.1.weight, downsamp_modules.2.0.bias, downsamp_modules.2.0.weight, downsamp_modules.2.1.bias, downsamp_modules.2.1.num_batches_tracked, downsamp_modules.2.1.running_mean, downsamp_modules.2.1.running_var, downsamp_modules.2.1.weight, final_layer.0.bias, final_layer.0.weight, final_layer.1.bias, final_layer.1.num_batches_tracked, final_layer.1.running_mean, final_layer.1.running_var, final_layer.1.weight, incre_modules.0.0.bn1.bias, incre_modules.0.0.bn1.num_batches_tracked, incre_modules.0.0.bn1.running_mean, incre_modules.0.0.bn1.running_var, incre_modules.0.0.bn1.weight, incre_modules.0.0.bn2.bias, incre_modules.0.0.bn2.num_batches_tracked, incre_modules.0.0.bn2.running_mean, incre_modules.0.0.bn2.running_var, incre_modules.0.0.bn2.weight, incre_modules.0.0.bn3.bias, incre_modules.0.0.bn3.num_batches_tracked, incre_modules.0.0.bn3.running_mean, incre_modules.0.0.bn3.running_var, incre_modules.0.0.bn3.weight, incre_modules.0.0.conv1.weight, incre_modules.0.0.conv2.weight, incre_modules.0.0.conv3.weight, incre_modules.0.0.downsample.0.weight, incre_modules.0.0.downsample.1.bias, incre_modules.0.0.downsample.1.num_batches_tracked, incre_modules.0.0.downsample.1.running_mean, incre_modules.0.0.downsample.1.running_var, incre_modules.0.0.downsample.1.weight, incre_modules.1.0.bn1.bias, incre_modules.1.0.bn1.num_batches_tracked, incre_modules.1.0.bn1.running_mean, incre_modules.1.0.bn1.running_var, incre_modules.1.0.bn1.weight, incre_modules.1.0.bn2.bias, incre_modules.1.0.bn2.num_batches_tracked, incre_modules.1.0.bn2.running_mean, incre_modules.1.0.bn2.running_var, incre_modules.1.0.bn2.weight, incre_modules.1.0.bn3.bias, incre_modules.1.0.bn3.num_batches_tracked, incre_modules.1.0.bn3.running_mean, incre_modules.1.0.bn3.running_var, incre_modules.1.0.bn3.weight, incre_modules.1.0.conv1.weight, incre_modules.1.0.conv2.weight, incre_modules.1.0.conv3.weight, incre_modules.1.0.downsample.0.weight, incre_modules.1.0.downsample.1.bias, incre_modules.1.0.downsample.1.num_batches_tracked, incre_modules.1.0.downsample.1.running_mean, incre_modules.1.0.downsample.1.running_var, incre_modules.1.0.downsample.1.weight, incre_modules.2.0.bn1.bias, incre_modules.2.0.bn1.num_batches_tracked, incre_modules.2.0.bn1.running_mean, incre_modules.2.0.bn1.running_var, incre_modules.2.0.bn1.weight, incre_modules.2.0.bn2.bias, incre_modules.2.0.bn2.num_batches_tracked, incre_modules.2.0.bn2.running_mean, incre_modules.2.0.bn2.running_var, incre_modules.2.0.bn2.weight, incre_modules.2.0.bn3.bias, incre_modules.2.0.bn3.num_batches_tracked, incre_modules.2.0.bn3.running_mean, incre_modules.2.0.bn3.running_var, incre_modules.2.0.bn3.weight, incre_modules.2.0.conv1.weight, incre_modules.2.0.conv2.weight, incre_modules.2.0.conv3.weight, incre_modules.2.0.downsample.0.weight, incre_modules.2.0.downsample.1.bias, incre_modules.2.0.downsample.1.num_batches_tracked, incre_modules.2.0.downsample.1.running_mean, incre_modules.2.0.downsample.1.running_var, incre_modules.2.0.downsample.1.weight, incre_modules.3.0.bn1.bias, incre_modules.3.0.bn1.num_batches_tracked, incre_modules.3.0.bn1.running_mean, incre_modules.3.0.bn1.running_var, incre_modules.3.0.bn1.weight, incre_modules.3.0.bn2.bias, incre_modules.3.0.bn2.num_batches_tracked, incre_modules.3.0.bn2.running_mean, incre_modules.3.0.bn2.running_var, incre_modules.3.0.bn2.weight, incre_modules.3.0.bn3.bias, incre_modules.3.0.bn3.num_batches_tracked, incre_modules.3.0.bn3.running_mean, incre_modules.3.0.bn3.running_var, incre_modules.3.0.bn3.weight, incre_modules.3.0.conv1.weight, incre_modules.3.0.conv2.weight, incre_modules.3.0.conv3.weight, incre_modules.3.0.downsample.0.weight, incre_modules.3.0.downsample.1.bias, incre_modules.3.0.downsample.1.num_batches_tracked, incre_modules.3.0.downsample.1.running_mean, incre_modules.3.0.downsample.1.running_var, incre_modules.3.0.downsample.1.weight, classifier.bias, classifier.weight) found while loading pretrained weights. This may be expected if model is being adapted.\n", - "Data Transforms:\n", - " Training: Compose([\n", - " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", - " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "Using 456 images and 27 for testing\n", - "\n", - "Starting pose model training...\n", - "--------------------------------------------------\n", - "Training for epoch 1 done, starting evaluation\n", - "Epoch 1 performance:\n", - "metrics/test.rmse: 58.034\n", - "metrics/test.rmse_pcutoff:57.757\n", - "metrics/test.mAP: 1.523\n", - "metrics/test.mAR: 2.222\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 1/3 (lr=0.0005), train loss 0.00532, valid loss 0.08804\n", - "Training for epoch 2 done, starting evaluation\n", - "Epoch 2 performance:\n", - "metrics/test.rmse: 24.018\n", - "metrics/test.rmse_pcutoff:4.871\n", - "metrics/test.mAP: 41.487\n", - "metrics/test.mAR: 48.519\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 2/3 (lr=0.0005), train loss 0.00385, valid loss 0.00458\n", - "Training for epoch 3 done, starting evaluation\n", - "Epoch 3 performance:\n", - "metrics/test.rmse: 14.797\n", - "metrics/test.rmse_pcutoff:4.767\n", - "metrics/test.mAP: 64.759\n", - "metrics/test.mAR: 71.852\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 3/3 (lr=0.0005), train loss 0.00298, valid loss 0.00334\n" - ] - } - ], - "source": [ - "# Note we skip the detector training to save time. The evaluation is by default using ground-truth bounding box.\n", - "# But to train a model that can be used to inference videos and images, you have to set detector_epochs > 0\n", - "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, batch_size = 64, shuffle = imagenet_transfer_learning_shuffle)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "J-udMck7nDbG" - }, - "source": [ - "#### Though the evaluation was also done during training, let's just do it again here to double-check" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "TDHMdKz4m_16", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "091082c1-0c61-4967-9750-092541dad0ae" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", - "100%|██████████| 152/152 [01:20<00:00, 1.88it/s]\n", - "100%|██████████| 9/9 [00:04<00:00, 1.86it/s]\n", - "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle0_snapshot_001-results.csv (pcutoff: 0.01):\n", - "INFO:root:train rmse 54.74\n", - "train rmse_pcutoff 54.74\n", - "train mAP 0.58\n", - "train mAR 3.46\n", - "train mAP_pcutoff 0.58\n", - "train mAR_pcutoff 3.46\n", - "test rmse 55.73\n", - "test rmse_pcutoff 55.73\n", - "test mAP 2.78\n", - "test mAR 7.04\n", - "test mAP_pcutoff 2.78\n", - "test mAR_pcutoff 7.04\n", - "Name: (0.95, 0, 1, -1, 0.01), dtype: float64\n" - ] - } - ], - "source": [ - "deeplabcut.evaluate_network(config_path, Shuffles = [imagenet_transfer_learning_shuffle])" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "0GIFWU-MxPoR" - }, - "source": [ - "### Transfer learning with SuperAnimal weights" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "ZGhAuyqs0Z4a" - }, - "source": [ - "#### Prepare training shuffle for transfer-learning with SuperAnimal weights" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "wOSdZQtOp8qa", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "5c76dbca-4706-4f9d-a70d-0d7763cdcda0" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", - "You passed a split with the following fraction: 94%\n", - "Creating training data for: Shuffle: 1 TrainFraction: 0.94\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 152/152 [00:00<00:00, 7673.55it/s]" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], - "source": [ - "weight_init = WeightInitialization(\n", - " dataset=f\"{superanimal_name}\",\n", - " with_decoder=False,\n", - ")\n", - "\n", - "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", - " from_shuffle = imagenet_transfer_learning_shuffle,\n", - " shuffles = [superanimal_transfer_learning_shuffle],\n", - " engine = Engine.PYTORCH,\n", - " net_type=\"top_down_hrnet_w32\",\n", - " weight_init = weight_init,\n", - " userfeedback = False)\n", - "\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "3qFxlRHixPoR" - }, - "source": [ - "#### Launch the training for transfer-learning with SuperAnimal weights" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "W60UgRQWqghn", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "1ebd95f1-830a-4ba0-9f4b-71ac749ef50e" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Training with configuration:\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " rotation: 30\n", - " scaling: [1.0, 1.0]\n", - " translation: 0\n", - " collate: None\n", - " covering: False\n", - " gaussian_noise: 12.75\n", - " hist_eq: False\n", - " motion_blur: False\n", - " normalize_images: True\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " rotation: 30\n", - " scaling: [1.0, 1.0]\n", - " translation: 40\n", - " collate:\n", - " type: ResizeFromDataSizeCollate\n", - " min_scale: 0.4\n", - " max_scale: 1.0\n", - " min_short_side: 128\n", - " max_short_side: 1152\n", - " multiple_of: 32\n", - " to_square: False\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " freeze_bn_stats: False\n", - " freeze_bn_weights: False\n", - " variant: fasterrcnn_mobilenet_v3_large_fpn\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0001\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [160]\n", - " lr_list: [[1e-05]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 0\n", - "device: auto\n", - "metadata:\n", - " project_path: /content/dlc_project_folder/daniel3mouse\n", - " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle1/train/pose_cfg.yaml\n", - " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", - " unique_bodyparts: []\n", - " individuals: ['mus1', 'mus2', 'mus3']\n", - " with_identity: None\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 12\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32]\n", - " kernel_size: []\n", - " strides: []\n", - " final_conv:\n", - " out_channels: 12\n", - " kernel_size: 1\n", - "net_type: hrnet_w32\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " gpus: None\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0001\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-05], [1e-06]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 3\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 64\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 3\n", - " seed: 42\n", - " weight_init:\n", - " dataset: superanimal_quadruped\n", - " with_decoder: False\n", - " memory_replay: False\n", - "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=False, memory_replay=False, conversion_array=None, bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", - "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", - "Data Transforms:\n", - " Training: Compose([\n", - " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", - " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "\n", - "Note: According to your model configuration, you're training with batch size 1 and/or ``freeze_bn_stats=false``. This is not an optimal setting if you have powerful GPUs.\n", - "This is good for small batch sizes (e.g., when training on a CPU), where you should keep ``freeze_bn_stats=true``.\n", - "If you're using a GPU to train, you can obtain faster performance by setting a larger batch size (the biggest power of 2 where you don't geta CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` for the backbone of your model. \n", - "This also allows you to increase the learning rate (empirically you can scale the learning rate by sqrt(batch_size) times).\n", - "\n", - "Using 456 images and 27 for testing\n", - "\n", - "Starting pose model training...\n", - "--------------------------------------------------\n", - "Training for epoch 1 done, starting evaluation\n", - "Epoch 1 performance:\n", - "metrics/test.rmse: 45.606\n", - "metrics/test.rmse_pcutoff:nan\n", - "metrics/test.mAP: 1.715\n", - "metrics/test.mAR: 5.556\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 1/3 (lr=0.0001), train loss 0.00603, valid loss 0.00577\n", - "Training for epoch 2 done, starting evaluation\n", - "Epoch 2 performance:\n", - "metrics/test.rmse: 47.635\n", - "metrics/test.rmse_pcutoff:nan\n", - "metrics/test.mAP: 0.216\n", - "metrics/test.mAR: 2.222\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 2/3 (lr=0.0001), train loss 0.00542, valid loss 0.00507\n", - "Training for epoch 3 done, starting evaluation\n", - "Epoch 3 performance:\n", - "metrics/test.rmse: 35.083\n", - "metrics/test.rmse_pcutoff:nan\n", - "metrics/test.mAP: 21.118\n", - "metrics/test.mAR: 27.407\n", - "metrics/test.mAP_pcutoff:0.000\n", - "metrics/test.mAR_pcutoff:0.000\n", - "Epoch 3/3 (lr=0.0001), train loss 0.00476, valid loss 0.00445\n" - ] - } - ], - "source": [ - "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, batch_size = 64, shuffle = superanimal_transfer_learning_shuffle)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "XzOWKiOixPoR" - }, - "source": [ - "#### Evaluate the model obtained by transfer-learning with SuperAnimal weights" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "jpO3aIAIsWbz", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "51ab747e-bf7f-4cf0-bdb5-02774439a08b" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", - "100%|██████████| 152/152 [01:22<00:00, 1.84it/s]\n", - "100%|██████████| 9/9 [00:04<00:00, 1.90it/s]\n", - "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle1_snapshot_003-results.csv (pcutoff: 0.01):\n", - "INFO:root:train rmse 34.03\n", - "train rmse_pcutoff 34.03\n", - "train mAP 21.50\n", - "train mAR 27.52\n", - "train mAP_pcutoff 21.50\n", - "train mAR_pcutoff 27.52\n", - "test rmse 34.55\n", - "test rmse_pcutoff 34.55\n", - "test mAP 22.24\n", - "test mAR 28.15\n", - "test mAP_pcutoff 22.24\n", - "test mAR_pcutoff 28.15\n", - "Name: (0.94, 1, 3, -1, 0.01), dtype: float64\n" - ] - } - ], - "source": [ - "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_transfer_learning_shuffle])" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "_Es6RR-_0Z4b" - }, - "source": [ - "### Fine-tuning with SuperAnimal (without keeping full SuperAnimal keypoints)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "6oo9oJ8XyZrn" - }, - "source": [ - "#### Setup the weight init and dataset\n", - "First we do keypoint matching. This steps make it possible to understand the correspondance between the existing annotations and SuperAnimal annotations. This step produces 3 outputs\n", - "- The confusion matrix\n", - "- The conversion table\n", - "- Pseudo predictions over the whole dataset" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "fRm62Ji_xPoS" - }, - "source": [ - "#### What is keypoint matching?\n", - "\n", - "Because SuperAnimal models have their pre-defined keypoints that are potentially different from your annotations, we porposed this algorithm to minimize the gap between the model and the dataset. We use our model to perform zero-shot inference on the whole dataset. This gives pairs of predictions and ground truth for every image. Then, we cast the matching between models’ predictions (2D coordinates)\n", - "and ground truth as bipartitematching using the Euclidean distance as the cost between paired of keypoints. We then solve the matching using the Hungarian algorithm. Thus for every image, we end up getting a matching matrix where 1 counts formatch and 0 counts for non-matching. Because the models’ predictions can be noisy from image to image, we average the aforementioned matching matrix across all the images and perform another bipartite matching, resulting in the final keypoint conversion table between the model and the dataset. Note that the quality of thematching will impact the performance\n", - "of the model, especially for zero-shot. In the case where, e.g., the annotation nose is mistakenly converted to keypoint tail and vice versa, the model will have to unlearn the channel that corresponds to nose and tail (see also case study in Mathis et al.)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 1000 - }, - "collapsed": true, - "id": "vEHeuKSKyjA6", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "f85fa523-910a-444d-914f-4a67730f1bc7" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Before checking trainset temp_dataset\n", - "Before checking testset temp_dataset\n", - "Task: None\n", - "scorer: None\n", - "date: None\n", - "multianimalproject: None\n", - "identity: None\n", - "project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - "engine: pytorch\n", - "video_sets: None\n", - "bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - "start: None\n", - "stop: None\n", - "numframes2pick: None\n", - "skeleton: []\n", - "skeleton_color: black\n", - "pcutoff: None\n", - "dotsize: None\n", - "alphavalue: None\n", - "colormap: rainbow\n", - "TrainingFraction: None\n", - "iteration: None\n", - "default_net_type: None\n", - "default_augmenter: None\n", - "snapshotindex: None\n", - "detector_snapshotindex: None\n", - "batch_size: 1\n", - "cropping: None\n", - "x1: None\n", - "x2: None\n", - "y1: None\n", - "y2: None\n", - "corner2move2: None\n", - "move2corner: None\n", - "SuperAnimalConversionTables: None\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 250\n", - "device: cpu\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: True\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 39\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 39]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 10\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 25\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 200\n", - " pretrained_weights: None\n", - " seed: 42\n", - "metadata:\n", - " project_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/project_configs\n", - " pose_config_path: /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/model_configs/hrnetw32.yaml\n", - " bodyparts: ['nose', 'upper_jaw', 'lower_jaw', 'mouth_end_right', 'mouth_end_left', 'right_eye', 'right_earbase', 'right_earend', 'right_antler_base', 'right_antler_end', 'left_eye', 'left_earbase', 'left_earend', 'left_antler_base', 'left_antler_end', 'neck_base', 'neck_end', 'throat_base', 'throat_end', 'back_base', 'back_end', 'back_middle', 'tail_base', 'tail_end', 'front_left_thai', 'front_left_knee', 'front_left_paw', 'front_right_thai', 'front_right_knee', 'front_right_paw', 'back_left_paw', 'back_left_thai', 'back_right_thai', 'back_left_knee', 'back_right_knee', 'back_right_paw', 'belly_bottom', 'body_middle_right', 'body_middle_left']\n", - " unique_bodyparts: []\n", - " individuals: ['animal']\n", - " with_identity: None\n" - ] - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAnYAAAHWCAYAAAD6oMSKAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAADmLElEQVR4nOzde1zP9///8du70vHdQSSHRSikMbUct/S2sZrDhs35s2RkhmHktANyyGwO05iZQ8WYbQ6xMTaHmnOKYg4hEtYWhuSQVL8//Hp9vXV6l+jFHtfL5X3Zer9Oz/dL5eH5fD3vT01ubm4uQgghhBDiqWdU3g0QQgghhBBlQwo7IYQQQohnhBR2QgghhBDPCCnshBBCCCGeEVLYCSGEEEI8I6SwE0IIIYR4RkhhJ4QQQgjxjJDCTgghhBDiGSGFnRBCCCHEM0IKOyGEEOXK2dmZgICA8m6GEM8EKeyEEOIps2fPHiZNmsS1a9fKuyl6jh07xqRJk0hOTi7vpgjxnyWFnRBCPGX27NlDcHCwKgu74ODgEhd2iYmJLFq06PE0Soj/GCnshBBCPHG5ubncvn0bADMzMypUqFDOLRLi2SCFnRBClIGLFy/Sv39/qlevjpmZGbVr1+b999/n7t27yj5nzpyhW7du2NvbY2lpSYsWLdi4cWO+c3311Ve4u7tjaWlJxYoV8fLyYuXKlQBMmjSJ0aNHA1C7dm00Gg0ajabIXjKdTsfzzz/P4cOH8fHxwdLSEhcXF1avXg1AdHQ0zZs3x8LCgvr167N161a948+dO8fgwYOpX78+FhYWVKpUiW7duuldMzw8nG7dugHQpk0bpV1RUVHA/efoOnbsyJYtW/Dy8sLCwoKFCxcq2/KescvNzaVNmzY4ODiQlpamnP/u3bs0atSIunXrcvPmTQP+RIT4bzIp7wYIIcTT7q+//qJZs2Zcu3aNgQMH0qBBAy5evMjq1au5desWpqam/PPPP7Rq1Ypbt24xbNgwKlWqREREBG+88QarV6+mS5cuACxatIhhw4bx9ttvM3z4cO7cucPhw4fZv38/vXv3pmvXrpw8eZLvv/+eOXPmULlyZQAcHByKbOPVq1fp2LEjPXv2pFu3bixYsICePXuyYsUKRowYwaBBg+jduzdffPEFb7/9NufPn8fa2hqAAwcOsGfPHnr27Mlzzz1HcnIyCxYsQKfTcezYMSwtLWndujXDhg0jNDSUjz76CDc3NwDlv3B/yLVXr1689957BAYGUr9+/Xzt1Gg0LF26lMaNGzNo0CDWrl0LwMSJEzl69ChRUVFYWVk9+h+aEM+qXCGEEI/E398/18jIKPfAgQP5tuXk5OTm5ubmjhgxIhfI3blzp7Ltxo0bubVr1851dnbOzc7Ozs3Nzc198803c93d3Yu83hdffJEL5J49e9ag9vn4+OQCuStXrlTeO3HiRC6Qa2RklLtv3z7l/S1btuQCuWFhYcp7t27dynfOvXv35gK5y5YtU9776aefcoHcHTt25Nu/Vq1auUDu5s2bC9zWt29fvfcWLlyYC+R+9913ufv27cs1NjbOHTFihEGfV4j/MhmKFUKIR5CTk0NkZCSdOnXCy8sr33aNRgPApk2baNasGS+//LKyTavVMnDgQJKTkzl27BgAdnZ2XLhwgQMHDpRpO7VaLT179lS+rl+/PnZ2dri5udG8eXPl/bz/P3PmjPKehYWF8v9ZWVlcuXIFFxcX7OzsOHjwoMFtqF27Nr6+vgbtO3DgQHx9ffnggw945513qFu3LiEhIQZfS4j/KinshBDiEVy6dIn09HSef/75Ivc7d+5cgUOPeUOV586dA2Ds2LFotVqaNWuGq6srQ4YMYffu3Y/czueee04pMvPY2tri5OSU7z24P3Sb5/bt20yYMAEnJyfMzMyoXLkyDg4OXLt2jevXrxvchtq1a5eozUuWLOHWrVucOnWK8PBwvQJTCFEwKeyEEEJF3NzcSExMZNWqVbz88susWbOGl19+mYkTJz7SeY2NjUv0fm5urvL/H3zwAdOmTaN79+78+OOP/Pbbb/z+++9UqlSJnJwcg9tQ0sIsKiqKzMxMAI4cOVKiY4X4r5LJE0II8QgcHBywsbHhzz//LHK/WrVqkZiYmO/9EydOKNvzWFlZ0aNHD3r06MHdu3fp2rUr06ZNY/z48Zibm+freXvcVq9eTd++fZk1a5by3p07d/Ll6JVlu1JTU/nggw947bXXMDU1JSgoCF9fX737JITIT3rshBDiERgZGdG5c2d+/vlnYmNj823P6/lq3749MTEx7N27V9l28+ZNvv32W5ydnWnYsCEAV65c0Tve1NSUhg0bkpubS1ZWFoAyK/RJBRQbGxvr9eDB/UiW7OxsvffKsl2BgYHk5OSwZMkSvv32W0xMTOjfv3++dggh9EmPnRBCPKKQkBB+++03fHx8GDhwIG5ubqSmpvLTTz+xa9cu7OzsGDduHN9//z2vv/46w4YNw97enoiICM6ePcuaNWswMrr/7+zXXnuNqlWr8tJLL+Ho6Mjx48eZN28eHTp0UOJHXnzxRQA+/vhjevbsSYUKFejUqdNjiwHp2LEjy5cvx9bWloYNG7J37162bt1KpUqV9PZr0qQJxsbGzJgxg+vXr2NmZsYrr7xClSpVSnS9sLAwNm7cSHh4OM899xxwv5D83//+x4IFCxg8eHCZfTYhnjnlOidXCCGeEefOncv19/fPdXBwyDUzM8utU6dO7pAhQ3IzMzOVfZKSknLffvvtXDs7u1xzc/PcZs2a5f7yyy9651m4cGFu69atcytVqpRrZmaWW7du3dzRo0fnXr9+XW+/KVOm5NaoUSPXyMio2OgTHx+fAiNUatWqlduhQ4d87wO5Q4YMUb6+evVqbr9+/XIrV66cq9Vqc319fXNPnDhRYEzJokWLcuvUqZNrbGysF31S2LXytuWd5/z587m2tra5nTp1yrdfly5dcq2srHLPnDlT6GcV4r9Ok5sr/dpCCCGEEM8CecZOCCGEEOIZIYWdEEIIIcQzQgo7IYQQQohnhBR2QgghhBDPCCnshBBCCCGeEVLYCSGEEEI8IySgWJSZnJwc/vrrL6ytrZ/4kkdCCCHEsyo3N5cbN25QvXp1Jcy8qJ3LnI+PT+7w4cPL9JxhYWG5tra2ZXrOkjLkc9WqVSt3zpw5Re4D5K5bty43Nzc39+zZs7lA7qFDh55YGx+2bt263Lp16+YaGRk90p/b+fPncwF5yUte8pKXvOT1GF7nz58v9u9i6bErgbVr11KhQoXybkaZe++99+jXrx/Dhg3D2tqagIAArl27RmRkZInOk7fc0emz57G2sXkMLRUiv8ysnPJuQqHuZGUXv1M5ylVxPr2NpXp/12pQ94iEmgdMclT8PXcrU70/rzdupNOkQW3l79miSGFXAvb29uXdhDKXkZFBWloavr6+VK9e/ZHOlTf8am1jg40UduIJUXNhV0EKu1KTwq70pLArHWMVF3Z5DHnM6bFNnrh37x5Dhw7F1taWypUr8+mnnyq/RK5evYq/vz8VK1bE0tKS119/nVOnTukdHx4eTs2aNbG0tKRLly5cuXJF2ZacnIyRkRGxsbF6x3z55ZfUqlWLnJyif9FHRUWh0WjYsmULHh4eWFhY8Morr5CWlsavv/6Km5sbNjY29O7dm1u3binH6XQ6RowYoXydlpZGp06dsLCwoHbt2qxYsSLftU6dOkXr1q0xNzenYcOG/P7778Xeuz///JPXX38drVaLo6Mj77zzDpcvXy72uIJkZmYSFBREjRo1sLKyonnz5kRFRSn3Ia/6f+WVV9BoNOh0OiIiIli/fj0ajQaNRqPsL4QQQgh1e2yFXUREBCYmJsTExDB37lxmz57N4sWLAQgICCA2NpYNGzawd+9ecnNzad++PVlZWQDs37+f/v37M3ToUOLj42nTpg1Tp05Vzu3s7Ezbtm0JCwvTu2ZYWBgBAQHFP1j4/02aNIl58+axZ88ezp8/T/fu3fnyyy9ZuXIlGzdu5LfffuOrr74q9PiAgADOnz/Pjh07WL16NV9//TVpaWnK9pycHLp27YqpqSn79+/nm2++YezYsUW26dq1a7zyyit4eHgQGxvL5s2b+eeff+jevbtBn+lhQ4cOZe/evaxatYrDhw/TrVs3/Pz8OHXqFK1atSIxMRGANWvWkJqayoYNG+jevTt+fn6kpqaSmppKq1atCjx3ZmYm6enpei8hhBBClJ/HNhTr5OTEnDlz0Gg01K9fnyNHjjBnzhx0Oh0bNmxg9+7dSsGwYsUKnJyciIyMpFu3bsydOxc/Pz/GjBkDQL169dizZw+bN29Wzj9gwAAGDRrE7NmzMTMz4+DBgxw5coT169cb3MapU6fy0ksvAdC/f3/Gjx9PUlISderUAeDtt99mx44dBRZjJ0+e5NdffyUmJoamTZsCsGTJEtzc3JR9tm7dyokTJ9iyZYsyzBkSEsLrr79eaJvmzZuHh4cHISEhyntLly7FycmJkydPUq9ePYM/X0pKCmFhYaSkpCjXDwoKYvPmzYSFhRESEkKVKlWA+8PMVatWBcDCwoLMzEzl68JMnz6d4OBgg9sjhBBCiMfrsfXYtWjRQm8suGXLlpw6dYpjx45hYmJC8+bNlW2VKlWifv36HD9+HIDjx4/rbc87/kGdO3fG2NiYdevWAfeHbtu0aYOzs7PBbWzcuLHy/46OjlhaWipFXd57D/bAPej48eOYmJjw4osvKu81aNAAOzs7vX2cnJz0nl17+HM8LCEhgR07dqDVapVXgwYNAEhKSjL4swEcOXKE7Oxs6tWrp3e+6OjoEp+rIOPHj+f69evK6/z58498TiGEEEKU3lM7ecLU1BR/f3/CwsLo2rUrK1euZO7cuSU6x4MzXDUaTb4ZrxqNptjn9cpaRkYGnTp1YsaMGfm2VatWrcTnMjY2Ji4uDmNjY71tWq32kdoJYGZmhpmZ2SOfRwghhBBl47EVdvv379f7et++fbi6utKwYUPu3bvH/v37laHYK1eukJiYSMOGDQFwc3Mr8PiHDRgwgOeff56vv/6ae/fu0bVr18f0afJr0KAB9+7dIy4uThmKTUxM5Nq1a8o+bm5unD9/ntTUVKUoK+hzPMjT05M1a9bg7OyMicmj/fF4eHiQnZ1NWloa3t7eBh9nampKdrb6ZwcJIYQQQl+JhmIfnhValJSUFEaOHEliYiLff/89X331FcOHD8fV1ZU333yTwMBAdu3axeTJk6latSo1atTgzTffBGDYsGFs3ryZmTNncurUKebNm6f3fF0eNzc3WrRowdixY+nVqxcWFhYl+Th6n2vlypVF7uPs7MyFCxeUr+vXr4+fnx/vvfce+/fvJy4ujgYNGmBqagrcn7nr6+tLzZo16du3LwkJCezcuZOPP/64yOsMGTKEf//9l169enHgwAGSkpLYsmUL/fr1K7DY0mg0hebN1atXjz59+uDv78/atWs5e/YsCxYsQKPR8MMPPxT5WQ8fPkxiYiKXL19WJrUIIYQQQt0eW4+dv78/t2/fplmzZhgbGzN8+HAGDhwI3J+9Onz4cDp27Mjt27cB2LRpkzIU2qJFCxYtWsTEiROZMGECbdu25ZNPPmHKlCn5rtO/f3/27NnDu+++W+q2rl27lh9//JGYmJgSHRcWFsaAAQPw8fHB0dERAFtbW719Zs+ezcyZM2nWrBnOzs6Ehobi5+dX6DmrV6/O7t27GTt2LK+99hqZmZnUqlULPz+/Amf7pqamUrFixSLbOHXqVEaNGsXFixeVfLnnnnuu0GOuXr3Kv//+i5eXFxkZGezYsQOdTlfUrRCi3Ny6e6+8m1CoI39dL+8mFMmlcvFhp+XFpnT/Tn8i1JwTB5Cdo96sODWzMjUufqdykl2CtmlyS5BQqdPpaNKkCV9++WVp2lWg8PBwRowYoTeEWRJTpkzhp59+4vDhw2XWpoI4OzszYsSIInssNRoN69ato3PnziQnJ1O7dm0OHTpEkyZNyrw9d+/eVXoHSyIqKoo2bdpw9epVvYkeD5o0aRKRkZHEx8eX6Nzp6enY2tryz5XrElAsnpirN++WdxMKJYVd6Tnaqvf5XSOVV3ZqDgFWMzX/qaanp1PNwY7r14v/+7XEs2LVEjyckZHBn3/+ybx58/jggw+AZzt4WKfTMXToUEaMGEHlypXx9fUF8g/F7tmzhyZNmmBubo6XlxeRkZFoNJp8RVpcXBxeXl5YWlrq5dmFh4cTHBxMQkKCElAcHh5uUBuFEEIIUb5KXNipJXh46NChvPjii+h0unzDsP7+/iQmJiqrJlSrVo1OnTpx7tw52rRp89QGD4eFhfH1119z+/ZtYmJilJmtvXr1QqvVcvToUTp16kSjRo04ePAgU6ZMKbRdH3/8MbNmzSI2NhYTExPlHvbo0YNRo0bh7u6uBBT36NGjwHNIQLEQQgihLiV+xk4twcPh4eGF9iTNnz9fGf785ptvmDVrFtu2baNmzZrY2NgwYcKEpzJ4uF69evz0009677m6ujJ79mzatWvHli1b0Gg0LFq0SOlJvHjxIoGBgfnONW3aNHx8fAAYN24cHTp04M6dO1hYWKDVajExMZGAYiGEEOIpU+Ieu6cheNjHxwcXFxdcXFxwd3fH0tKSV155BRcXF6pUqfLUBg97eXkpnyvvBffz7VxcXDh9+jSNGzfG3NxcOaZZs2YFnuvBcOa8KJbC7klhJKBYCCGEUBfVBRRL8HDhrKysyqw9D98joMT3RAKKhRBCCHUpcY+dIcHDeR4leHjr1q3lHjycp6jg4TyGBA8fPXoUZ2fnfL1uZVWw5Q2NZ2ZmKu8dOHCgxOeRgGIhhBDi6VTiws7Q4OGEhAT+97//PZHg4ZIEJxfk77//ZsGCBcD94sjFxQUfHx8leHjAgAF6bWjbti316tUzKHh4w4YN2NnZlTh4uDR69+5NTk4OAwcO5Pjx42zZsoWZM2cC6A2fF8fZ2ZmzZ88SHx/P5cuX9QpFIYQQQqhXiYdiDQ0evnv3Lq1bty7X4OHSevPNN/n222+V4OGpU6fy6aefKtuNjIxYt24d/fv3f2zBww+Ljo7G2lo/c2rSpEl6X9vY2PDzzz/z/vvv06RJExo1asSECRPo3bu33nN3xXnrrbdYu3Ytbdq04dq1a8qsZEPl5t5/qc2dLHX3QpqalPjfWU+MmgNPTYzVe9/qOag3J07tMu6oN3haa666p5j0ZGU/2UeNSsLMRL0hwGqOJzQyMrxxJQoofpJKEjz8qMHJD4cklzag15Bzl9aD4cd5DGnnihUr6NevH9evXy/1kmuGygso/vuyOgOKpbArPTUXdpn31PuX2E0VFydqZ6HiVQCksCs9KexKJz09HcdKto8noPhxKyh42BBFBSdnZmYSFBREjRo1sLKyonnz5kRFRRl03j/++IMKFSrw999/670/YsQIvL29DW5fZGQkrq6umJub4+vrm28G6YIFC6hbty6mpqbUr1+f5cuXK9vyZgR36dIFjUaDs7NzoUHCy5YtY82aNbRt2xZzc3PeeecdqlWrppcxN2nSJJo0acLSpUupWbMmWq2WwYMHk52dzeeff07VqlWpUqUK06ZNM/jzCSGEEKL8qa6wKyp4eNCgQXpxIXmvnTt3snDhwkKDk4cOHcrevXtZtWoVhw8fplu3bvj5+eVbFaMgrVu3pk6dOnqFVlZWFitWrDB4mPjWrVtMmzaNZcuWsXv3bq5du0bPnj2V7d9++y2DBw/mwoULVKhQgeTkZPz9/ZVMubzol7CwMFJTUzlw4EChQcKpqan06tWL7du3U6lSJXr06KH890FJSUn8+uuvbN68me+//54lS5bQoUMHLly4QHR0NDNmzOCTTz7JN9nlQRJQLIQQQqiL6vqTiwoenjx5MkFBQfne79OnD1evXi0wONnX15ewsDBSUlKU3LmgoCA2b95MWFiYXmBwYfr3709YWBijR48G4Oeff+bOnTsGrxqRlZXFvHnzlAy/iIgI3NzciImJoVmzZoSHh9O9e3e9HrJhw4Zx69YtFi9erPTY2dnZ6YUGFxQk7OnpSU5ODufOncPJyQmAY8eO4e7uzoEDB5TQ5ZycHJYuXYq1tTUNGzakTZs2JCYmsmnTJoyMjKhfvz4zZsxgx44d+bIH80hAsRBCCKEuquuxK0qVKlXyRYW4uLhgYWHByy+/XGBw8pEjR8jOzqZevXp6vXzR0dEGBwMHBARw+vRpJdIkrxAzNKbExMREKajg/wKP84KbT5w4Qfv27fU+k6+vLykpKbi4uGBiYnj9nReenFfUATRs2FDvenB/ePfByRiOjo40bNhQbyJHUUHOIAHFQgghhNqorseurGVkZGBsbExcXBzGxvoPbeattVqcKlWq0KlTJ8LCwqhduza//vqrwc/oqVVBoc0lDXKWgGIhhBBCXZ6Zwq6w4GQPDw+ys7NJS0sr0WSHhw0YMIBevXrx3HPPUbduXV566SWDj7137x6xsbHK8l55gcd568+6ubmxe/du+vbtqxyze/duJdgZ7hdiD+fdFRQknBeefP78eb2h2GvXrumdTwghhBDPnnIdin3UYOEHFRScbGtry5gxY+jTpw/+/v6sXbuWs2fPEhMTw/Tp09m4caPB5/f19cXGxoaJEydSuXLlErfvf//7nxJ4HBAQQIsWLZRCb/To0YSHh7NgwQJOnTrF7NmzWbt2rd7zhM7Ozmzbto2///6bq1evKu89HCTctm1bGjVqRJ8+fTh48CAxMTH4+/vj4+ODl5dXidsthBBCiKfHM9NjV1Bw8oULF7h+/TphYWFMnTqVUaNGcfHiRSpXrkyLFi3o2LGjwec3MjIiICCAkJAQwsLCSty+Ll260Lt3by5evIi3tzdLlixRttnZ2ZGVlcXnn3/O8OHDqV27NmFhYeh0OmWfWbNmMXLkSBYtWkSNGjVITk4uNEh4/fr1fPDBB7Ru3RojIyP8/Pz46quvStzm0rp99x4md9WX3/WElwcuMSONerPiDMjQLjfWKs4U05qpt22g7hBg8woq/qZTuax76v1dkp2j3u85ExX/osvMMvwvsHINKH7UYOHiBAQEcO3aNSIjIx/5XHfv3uX999/n0qVLbNiwoUTHFhQu/KCoqCjatGnD1atXsbOze+S2lpe8gOKzf13BWoUBxWov7NQcUKzi33dUUPHKE+qMf/8/UtiVTklWASgPtzLVG8au5t8lai7s0tPTqVm14tMRUFxUsPDy5cvx8vLC2tqaqlWr0rt373yzNI8ePUrHjh2xsbHB2toab2/vQme7HjhwAAcHB2bMmFFsu/JCfBcvXkytWrUwMzNj5cqVJCcn6w0fp6am0qFDBywsLKhduzYrV67E2dk5X7F6+fJlunTpgqWlJa6urkpxmJycTJs2bQCoWLEiGo3GoOW7dDodQ4cOLfTeGXL/vLy8lLVkATp37kyFChXIyMgA4MKFC2g0Gk6fPl1se4QQQghR/sq9sIuIiCg0WDgrK4spU6aQkJBAZGQkycnJekXPxYsXad26NWZmZmzfvp24uDjeffdd7t3L/6/Q7du3065dO6ZNm8bYsWMNatvp06dZs2YNDg4OmJubM2jQIOzt7fX2adSoEZs3b0aj0fDPP//wzjvvcO7cOcaOHauXkRccHEz37t05fPgw7du3p0+fPvz77784OTmxZs0a4P6kitTUVObOnfvI986Q++fj46PM7s3NzWXnzp3Y2dmxa9cu4P76tDVq1MDFxaXA60tAsRBCCKEu5f4AiJOTU4HBwoGBgXorO9SpU4fQ0FCaNm1KRkYGWq2W+fPnY2try6pVq5Sojnr16uW7xrp16/D392fx4sX5VmAoyt27d1m2bBkODg7Kew8+93bixAmuXLnC2rVradSoEQDnzp2jbdu2BAUFMWjQIGXfgIAAevXqBUBISAihoaHExMTg5+enFItVqlQp0VBsUfcOKPb+6XQ6lixZQnZ2Nn/++Sempqb06NGDqKgo/Pz8iIqKwsfHp9DrS0CxEEIIoS7l3mPXokWLAoOFs7OziYuLo1OnTtSsWRNra2ulyEhJSQEgPj4eb2/vfPlrD9q/fz/dunVj+fLlJSrqAGrVqqVX1D0sMTERExMT3nzzTSVY+NVXX6VixYo4ODjo9e41btxY+X8rKytsbGyKDP81RFH3Dij2/nl7e3Pjxg0OHTpEdHQ0Pj4+6HQ6pRcvOjpar5B9mAQUCyGEEOpS7oVdYe7cuaNEjKxYsYIDBw4oa6bevXsXAAsLi2LPU7duXRo0aMDSpUvJysoqURsMXVnCECUN/31UN2/eLPb+2dnZ8cILLxAVFaUUca1bt+bQoUOcPHmSU6dOFdljZ2Zmho2Njd5LCCGEEOWn3Au7woKF84Y5P/vsM7y9vWnQoEG+Hq7GjRuzc+fOIgu2ypUrs337dk6fPk337t1LXNwVpX79+ty7d49Dhw4p750+fVrJmTOUqakpQL6w4eIUdu+MjY0Nun9w/zm7HTt28Mcff6DT6bC3t8fNzY1p06ZRrVq1Aoe2hRBCCKFOT6ywKyyMuKBg4eHDh1OzZk1MTU356quvOHPmDBs2bGDKlCl6xw4dOpT09HR69uxJx44dadu2LcuXLycxMVFvvypVqrB9+3ZOnDhBr169CpxcUZDTp08XGaDcoEED2rZty8CBA4mJieHQoUO4urpiamqqN0RanFq1aqHRaPjll1+4dOmSMis1T0nvHWDQ/cs795YtWzAxMaFBgwbKeytWrCiyt04IIYQQ6lPuPXYPBgsPGTKE4cOHM3DgQBwcHAgPD+enn36iYcOGfPbZZ3rRHACVKlVi+/btZGRksGXLFqKjo1m0aFGBz9xVrVqV7du3c+TIEfr06WNQ71jt2rULLIYetGzZMhwdHWndujVdunQB7g8Rm5ubF7h/VFQUGo1GL5akRo0aBAcHM27cOBwdHRk6dGixbYPC7x1g0P2D+8/Z5eTk6BVxOp2O7OzsIp+vE0IIIYT6PLGA4qctjDhveLSk8nrqtm7dyquvvppve2nCiAu6d4/7fpZGXkDxX5euqfJ5u+wcdafFqjlo9162ytOdVUrtQbZ376n3z9WoBKMeT9oNFQc7g7p/12nNjcu7CYUyVvHPa3p6OrWq2qsvoPhpCCOuXbu20tv28BBoQWHEjo6ODBgwgLNnz7Jnzx7gfk9iaGhomYURP2zjxo3s2rWLEydOAPeL2s6dOzNz5kyqVatGpUqVGDJkiN7zhJmZmQQFBVGjRg2srKxo3ry5Mvs1z65du/D29sbCwgInJyeGDRvGzZs3S9w+IYQQQpSPJ1rYqSmM2N3dHa1Wi1arJSQkhISEBAYNGsQ///zDtGnTCjzG39+fv/76i6ioKNasWcO3337LtWvX2LhxI+7u7spQbIUKFejZs2epw4hTUlKUtmm1Wnbu3MnXX3+NVqvF3NycHj164ObmpjwTB7Bjxw6SkpLYsWMHERERhIeHEx4ermwfOnQoe/fuZdWqVRw+fJhu3brh5+fHqVOnAEhKSsLPz4+33nqLw4cP88MPP7Br1y6Dh4WFEEIIUf6e6FBsWloaR48eVYYrx40bx4YNGzh27Fi+/WNjY2natCk3btxAq9Xy0UcfsWrVKhITEwt8hi5vKLZv374GhRGfO3dO6dEKDQ1lwYIF7Nq1i0qVKuHo6Ii1tbXecOeJEydwc3PjwIEDeHl5AfcnV7i6ujJnzhylZ0+j0fDJJ58oz+bdvHkTrVbLr7/+qoT+FjcUe+/ePZKTk5Wv+/Tpg5ubG87OzsyaNYvIyEi9Yd6AgACioqJISkrC2Ph+N3f37t0xMjJi1apVpKSkUKdOHVJSUqhevbpyXNu2bWnWrBkhISEMGDAAY2NjFi5cqGzftWsXPj4+3Lx5s8BnBjMzM8nMzFS+Tk9Px8nJSYZiS0mGYp89MhRbejIUW3pq/l0nQ7GlU5Kh2Ce68kRBgbqzZs0iOzub+Ph4Jk2aREJCAlevXlUy3lJSUmjYsKHBYcS//PILq1evpnPnzkW2pVatWsr/29vb4+zsTPPmzQvdPy+M2NPTU3nPxcWFihUr5tv3UcOITUxM9JbxsrCwYOvWraSlpbF7926aNm2a7xh3d3elqAOoVq0aR44cAeDIkSNkZ2fniy7JzMykUqVKACQkJHD48GFWrFihbM/NzSUnJ4ezZ8/i5uaW75qy8oQQQgihLuW+pBj8Xxixr68vK1aswMHBgZSUFHx9fUscRlypUiWWLl1Khw4diiwCH6b2MGIPDw8OHjzI0qVL8fLyyhenUtQ1MzIyMDY2Ji4uTq/4A9Bqtco+7733HsOGDct37Zo1axbYpvHjxzNy5Ejl67weOyGEEEKUjyda2BkSRpxXGMTGxurt27hxYyIiIsjKyiq0YKtcuTJr165Fp9PRvXt3fvzxxxIVd0V5MIz4xRdfBJ5sGHHdunWZNWsWOp0OY2Nj5s2bZ/CxHh4eZGdnk5aWhre3d4H7eHp6cuzYMb2ewuKYmZlhZmZm8P5CCCGEeLye6EM9hoQRN2/enDfeeKPIMOLY2FhOnTpVqjDivBmkJdWgQQPq1KmDt7e3EkY8cOBALCwsigwjPnHiBBkZGQQGBtKkSZNiw4gLsm/fPg4dOkS9evXYsWMHa9asKTI4+WH16tWjT58++Pv7s3btWs6ePUtMTAzTp09n48aNAIwdO5Y9e/YwdOhQ4uPjOXXqFOvXr5fJE0IIIcRT5IkWdoaEER84cIDY2Ngiw4h9fHx48cUXyzSM2BCdO3fGxMRECSMODAzE2tq60DBigIkTJ6LRaJg+fTrbtm3j999/x9TUtMRhxHnq16/P9u3b+f777xk1apTBx4WFheHv78+oUaOoX78+nTt35sCBA8owa+PGjYmOjubkyZN4e3vj4eHBhAkT9CZbCCGEEELdntisWEOpOch40qRJREZGEh8fD8CFCxdwcnIqNIwYwMvLiw4dOiiTDMLDwxkxYgTXrl0z+LrOzs6MGDGiRL105SEvoDj1sjpnxWpQ74wngFt31TvT7hEfEX2srMzUO8suU8WzTgFy1PXrX8+l9Lvl3YRCOdiULsD+SVHzjOJ72er9nqtgot77ptqAYkOpNcj47NmzXL9+XQkj7tmzpxIGbG5uToMGDfj666+V/TUaDXFxcUyePBmNRoNOp6Nfv35cv34djUaDRqNh0qRJJb4/ixcvxs7Ojm3btgH3i+Fhw4YxZswY7O3tqVq1ar7zXrt2jQEDBuDg4ICNjQ2vvPIKCQkJevusX78eT09PzM3NqVOnDsHBwQavqyuEEEKI8qeKWbEPi4iIoH///sTExBAbG8vAgQOpWbMmgYGBSpBx/fr1SUtLY+TIkQQEBLBp0ybg/4KMdTod27dvx8bGhjZt2tC4cWM0Gg2ZmZnk5uai1WrJzs5Go9Hw5ZdfKmusFiU7O5u///4bd3d3rK2tcXJywsjIiJCQEDw8PDh06BCBgYFYWVnRt29fUlNTadu2LX5+fgQFBWFpaUlYWBgTJkxQng28evWqMjO1IA9n/H3++ed8/vnn/PbbbzRr1kzvno0cOZL9+/ezd+9eAgICeOmll2jXrh0A3bp1w8LCgl9//RVbW1sWLlzIq6++ysmTJ7G3t2fnzp34+/sTGhqqFMJ592TixIkl+wMUQgghRLlQZWHn5OTEnDlz0Gg01K9fnyNHjjBnzhwCAwN59913lf3q1KlDaGgoTZs2JSMjA61Wy/z587G1tWXVqlXK83d79uxRwojHjBlDeno6Xbt2ZfTo0cyfP9/gZb1cXFyoX7++MhTr4uLC3Llz6dq1KwC1a9fm2LFjLFy4kL59+1K1alVMTEzQarVUrVoVAFtbWzQajfJ15cqVlfMV5MFn3MaOHcvy5cuJjo7G3d1db7/GjRsrBZirqyvz5s1j27ZttGvXjl27dhETE0NaWpoyi3XmzJlERkayevVqBg4cSHBwMOPGjaNv377KvZ0yZQpjxowptLArKKBYCCGEEOVHlYVdWQcZPxhGbGNjw969e4mKijIoyLgwN2/eJCkpif79+xMYGKi8f+/ePWxtbQ0+z8NhxIWZNWsWN2/eJDY2ljp16uTb/mAoMtwPKM4bok5ISCAjI0MJI85z+/ZtZYg6ISGB3bt36y2nlp2dzZ07d7h16xaWlpb5rikBxUIIIYS6qLKwK4wagozz5MWULFq0KN+KFQ+HAJcFb29vNm7cyI8//si4cePybS8uoLhatWpERUXlOy5vWbOMjAyCg4OV3scHFTbrVwKKhRBCCHVRZWH3NAQZOzo6Ur16dc6cOUOfPn0MPs7U1LRU8SvNmjVj6NCh+Pn5YWJiQlBQkMHHenp68vfff2NiYoKzs3Oh+yQmJkpAsRBCCPEUU+WsWEOCjM+cOcOGDRseW5CxIYKDg5k+fTqhoaGcPHmSI0eOEBYWxuzZsws9xtnZmYyMDLZt28bly5e5deuWwddr1aoVmzZtIjg4uERxMG3btqVly5Z07tyZ3377jeTkZPbs2cPHH3+sFMYTJkxg2bJlBAcHc/ToUY4fP86qVav45JNPDL6OEEIIIcqXKgo7nU6nl9FmSJBxw4YN+eyzzwwKMg4KCiowCPhRgox1Oh1//vknixcvJiwsjEaNGuHj40N4eDi1a9cu8BiNRkNaWhqDBg2iR48eODg48Pnnnxt8TYCXX36ZjRs38sknn/DVV18VuE9UVBTr169Xhqc1Gg2bNm2idevW9OvXj3r16tGzZ0/OnTuHo6MjAL6+vvzyyy/89ttvNG3alBYtWtC3b19OnTpVovYJIYQQovyoIqBYzaHEhfn333+pUKEC1tbWBh+j0WhYt25doRM2oqKiaNOmDVevXlWefStOQfeuNOcpyKVLl7Cysipw4kRBlIDiSyoNKFZxaCeACn4Un0rZKr5vag5jBUhLzyx+p3KSfiurvJtQqIbPqe/324PUHDyt5qD4eypOYi9JQLEqn7FTs7t372Jqaoq9vX15N+Wxc3BwKO8mCCGEEKIEVDEUC+pYbcLd3R2tVqv3MjU1xdjYmAEDBlC7dm1lhujDw8epqal06NABCwsLateuzcqVK3F2ds7XC3n58mW6dOmCpaUlrq6uTJ8+Ha1Wi5WVFW3atAGgYsWKaDSaYid0BAQEEB0dzdy5c5WVLJKTk5XtcXFxeHl5YWlpSatWrfSeM0xKSuLNN9/E0dERrVZL06ZN2bp1q975C2q/EEIIIdRLNYVdREQEJiYmxMTEMHfuXGbPns3ixYsBlNUmEhISiIyMJDk5WS9UOG+1CTMzM7Zv305cXBzvvvtugRMitm/fTrt27Zg2bRpjx47V27Zp0ybi4+P1XoMGDcLMzIxz586xdu3aQsOE/f39+euvv4iKimLNmjV8++23+YpPuD/honv37hw+fJj27dszbdo0oqKiOHjwIPPmzQPgt99+Y8+ePcTExBR5z+bOnUvLli0JDAwkNTWV1NRUvbiRjz/+mFmzZhEbG4uJiYleuHNGRgbt27dn27ZtHDp0CD8/Pzp16kRKSkqR13xQZmYm6enpei8hhBBClB/VDMWW9WoT9erVy3eNdevW4e/vz+LFi+nRo0e+7Q8GGeext7fn3r17rFy5stChyRMnTrB161YOHDiAl5cXcH89V1dX13z7BgQE0KtXLwBCQkIIDQ3l8uXLeHl5kZqaCkDTpk0NejbO1tYWU1NTLC0tlZUsHjRt2jR8fHwAGDduHB06dODOnTuYm5vzwgsv8MILLyj7TpkyhXXr1rFhw4YCJ5oURAKKhRBCCHVRTY9dQatNnDp1iuzsbOLi4ujUqRM1a9bE2tpaKVbyepcKWm3iYfv376dbt24sX768wKKuKLVq1SryebPExERMTEzw9PRU3nNxcaFixYr59n1whQgrKytsbGwK7NkrCw9eq1q1agDKtTIyMggKCsLNzQ07Ozu0Wi3Hjx8vUY/d+PHjuX79uvI6f/582X4AIYQQQpSIagq7wuStNmFjY8OKFSs4cOAA69atAyjxahMNGjRg6dKlyrqxhrKysip5wwtR1AoRZe3Ba+UVzXnXCgoKYt26dYSEhLBz507i4+Np1KiRck8NYWZmho2Njd5LCCGEEOVHNYWdIatNeHt706BBg3w9XI0bN2bnzp1FFmyVK1dm+/btnD59mu7du5e4uCtK/fr1uXfvHocOHVLeO336NFevXi3ReUxNTQFKlKdX2pUsdu/eTUBAAF26dKFRo0ZUrVpVb+KFEEIIIZ4+qinsDFltonnz5rzxxhuPbbWJgICAQjPmitKgQQPq1KmDt7c3MTExHDp0iIEDB2JhYVFkdtqJEyfIyMggMDCQJk2aUKtWLTQaDb/88guXLl1S1qMtirOzM/v37yc5OZnLly+Tk5PD5s2biz3O1dVVmQySkJBA79698/Ucnjt3jiNHjhR/A4QQQgihCqqZPPHgahPGxsbKahMajYbw8HA++ugjzp07R9WqVVm4cCFvvPGGcmzeahOjR4/Gx8cHY2NjmjRpwksvvZTvOnmrTeh0Ovr06cPKlSsxNjZ+5PZ37tyZRYsW0bp1a6pWrcr06dM5evSoEo9SkIkTJ6LRaJg+fTr9+vXj559/xtTUlHHjxtGvXz/8/f0JDw8v8robN26kQoUKNGzYkNu3b3P27FmD2jt79mzeffddWrVqReXKlRk7dmy+Wa3PPfccbm5uBp3vQUZGGoyM1BdCqebQTkCV9yzP3XvqDe68lVnyHusnxcZCNb9iC5SVrd4/12uZ6g0oVu9P6n1qDsZW8a85TIxU09eVT0napoqVJwyl5hUqJk2aRGRkpBKHcuHCBZycnNi6dSuvvvpqgcd4eXnRoUMHZWZpeHg4I0aM4Nq1awZf19nZmREjRuhl6pXmPGUhb+WJf64Un4xdHlRf2Kl4ZQwp7EpH7YXdmUs3y7sJhUpNv1PeTSjUS3UrlXcTipSp4p9XNf+eM1Zx1Zmenk6NKnYGrTyh3vK0EGoIMi7I2bNnuX79OmfPnmXPnj307NmTSpUqMWTIEMzNzWnQoAFff/21sr9GoyEuLo7Jkyej0WjQ6XT069eP69evK2HDkyZNKvKaOp2Oc+fO8eGHHyrHPGjLli24ubmh1Wrx8/NT4lTyPlu7du2oXLkytra2+Pj4cPDgQb3jNRpNmS7DJoQQQojHS93/nCxAREQE/fv3JyYmhtjYWAYOHEjNmjUJDAxUgozr169PWloaI0eOJCAggE2bNgH/F2Ss0+nYvn07NjY2tGnThsaNG6PRaMjMzCQ3NxetVkt2djYajYYvv/ySgQMHFtuu7Oxs/v77b9zd3bG2tsbJyQkjIyNCQkLw8PDg0KFDBAYGYmVlRd++fUlNTaVt27b4+fkRFBSEpaUlYWFhTJgwQXk28OrVq2i12kKvuWfPHjp06MDAgQMJDAzU23br1i1mzpzJ8uXLMTIy4n//+x9BQUGsWLECgBs3btC3b1+++uorcnNzmTVrFu3bt+fUqVMGr3+bmZlJZub/rTUpAcVCCCFE+XrqCruyDjLes2ePMkN2zJgxpKen07VrV0aPHs38+fP1VrgoiouLC/Xr11eGYl1cXJg7dy5du3YFoHbt2hw7doyFCxfSt29fqlatiomJCVqtVgkXtrW1RaPRKF9Xrly50JUu4P4wrLGxsdJD+aCsrCy++eYb6tatC9yfYDJ58mRl+yuvvKK3/7fffoudnR3R0dF07NjRoM8sAcVCCCGEujx1hV1BQcazZs0iOzub+Ph4Jk2aREJCAlevXlVmeaakpNCwYcMCg4wfXG3CxsaGvXv3EhUVxerVq0s1Qxbg5s2bJCUl0b9/f72etHv37mFra2vweUxMTHBxcSlVGywtLZWiDu4HFD84LP3PP//wySefEBUVRVpaGtnZ2dy6davEAcUjR45Uvk5PT9db0kwIIYQQT9ZTV9gVJi/I2NfXlxUrVuDg4EBKSgq+vr4lDjKuVKkSS5cupUOHDkWuZlGYvJiSRYsW0bx5c71tZTED1xAFBSE/OE+mb9++XLlyhblz51KrVi3MzMxo2bJliQOKzczMyqzNQgghhHg0T93kiachyNjR0ZHq1atz5swZXFxc9F61a9cu9LjShA0/SkDxsGHDaN++Pe7u7piZmXH58uUSn0cIIYQQ6vFUFHY6nU6J8zAkyPjMmTNs2LDB4CDjLl266A27Fhdk/KBJkybRpEmTfO8HBwczffp0QkNDOXnyJEeOHCEsLIzZs2dz4sQJWrRoweHDh/nmm2+UY5ydncnIyGDbtm1cvnyZW7duFXtvnJ2d+eOPP7h48WKJCjNXV1eWL1/O8ePH2b9/P3369DGoR1MIIYQQ6vXUDcUaEmQcGhqKp6cnM2fONCjI2MHBIV+v16MGGQ8YMABLS0u++OILRo8ejZWVFY0aNWLEiBFMnDgRKysrGjRoQMeOHfVy5wYNGkSPHj24cuUKEydOLDbyZPLkybz33nvUrVtXmdVriCVLljBw4EA8PT1xcnIiJCSEoKAggz9fUW5l3sM4s/BiuLxUMFb3v2OyH9OawWXBvMKTeYSgNGwt1fvnqvaY0DoOZbcOdll7zl7F/9BUb9yZ6qk4A5h7Kv4dXJK2PRUBxU9TMLEhyiKYWI3yAorP/nUFaxUGFKu/sFPvj6KaCzs1ewp+varWXRWvimFqou7fJZlZ6r13JsbqrYrV/Ds4PT2dWlXtn62AYrUGExdk8eLFuLm5PbZgYrg/BDtlyhR69eqFlZUVNWrUYP78+Xr7zJ49m0aNGmFlZYWTkxODBw9WJnbk5ubi4ODA6tWrlf2bNGlCtWrVlK937dqFmZmZQUPCQgghhCh/T01hFxERgYmJCTExMcydO5fZs2ezePFiACWYOCEhgcjISJKTk/Xy5/KCic3MzNi+fTtxcXG8++67BT47t337dtq1a8e0adMYO3as8r67uztarTbfKyQkhKtXryr7rVixggkTJjBt2jSOHz9OSEgIn376KREREQCkpqbi7u7OqFGjSE1NZcOGDXz55ZfY2NiQmppKamqqMiS6c+fOAq+p1WpJSUnhiy++4IUXXuDQoUOMGzeO4cOH8/vvvyttMTIyIjQ0lKNHjxIREcH27dsZM2YMcL/AbN26NVFRUcD9MOTjx49z+/ZtTpw4AUB0dDRNmzbF0tKywD+TzMxM0tPT9V5CCCGEKD9PzTN2ZR1MXK9evXzXWLduHf7+/ixevJgePXrobdu0aVOBM2RDQ0PZsWOH8vXEiROZNWvWIwUT5/Hy8ip0iNfHx4fGjRszbtw45fPs3r2bOXPm0K5dOwC99WOdnZ2ZOnUqgwYNUnoQdTodCxcuBOCPP/7Aw8ODqlWrEhUVRYMGDYiKisLHx6fA64MEFAshhBBq89QUdmUdTPyw/fv388svvxQaTPxgkPGD7O3tlUkVZRVMnMfCwqLQgOIKFSrQsmVLvfdatmyp9xzi1q1bmT59OidOnCA9PZ179+5x584dbt26haWlJT4+PgwfPpxLly4RHR2NTqdTCrv+/fuzZ88epYevIBJQLIQQQqjLUzMUW5i8YGIbGxtWrFjBgQMHWLduHUCJg4kbNGjA0qVLS5VdB/rBxPHx8crrzz//ZN++faU6Z2klJyfTsWNHGjduzJo1a4iLi1Oewcu7L40aNcLe3p7o6GilsNPpdERHR3PgwAGysrJo1apVodcwMzPDxsZG7yWEEEKI8vPU9NgZEkyc11sUGxurt2/jxo2JiIggKyur0F67ypUrs3btWnQ6Hd27d+fHH38s8aoTDwYT9+nTx+DjShsy/HCxuG/fPtzc3ACIi4sjJyeHWbNmYfT/55f/+OOPevtrNBq8vb1Zv349R48e5eWXX8bS0pLMzEwWLlyIl5cXVlbqjUMQQgghhL5y7bHLzc1l4MCB2Nvbo9FoiowMeRzBxImJiXr7GRJM/GBYckEKCibWaDT069ev0GNKE0wM91eP+Pzzzzl58iTz58/np59+Yvjw4QC4uLiQlZWl3Jfly5frhSE/+Hm+//57mjRpglarxcjIiNatW7NixYoin68TQgghhPqUa4/d5s2bCQ8PJyoqijp16lC5cuVC933UYOIrV65w5swZvWDil156Kd91igsmXrt2bZE9eQUFE8P93rzC5A2Ndu/enX///degYGKAUaNGERsbS3BwMDY2NsyePRtfX18AXnjhBWbPns2MGTMYP348rVu3Zvr06fj7++udw8fHh+zsbHQ6nfKeTqdj/fr1eu+VjAaNChM8b2WWvFf0Sbp2q3SPADwJag6LNTZS3/dangefCxYlo+bsxByV5xOq+d6pOStOzT+tJfk7tVwDiufNm8cXX3zBuXPnCtx+9+5dTE1Ny+RaGo2GdevWFTgxwhCP0pbirh0VFUWbNm24evUqdnZ2Bp3T2dmZESNGFNl7+KT9X0Dxv6p83u7uPfWGdoIUdqWl5sJOlJ6aa2K1F3Zq/Id1HjUXdmpuW3p6OjWrVlR3QHFAQAAffPABKSkpaDQanJ2d0el0DB06lBEjRlC5cmWl9yk6OppmzZphZmZGtWrVGDdunN4wqU6nY9iwYYwZMwZ7e3uqVq2q1+Pl7OwMQJcuXZRrFSdvDdjFixdTu3ZtzM3NlWs9WEylpqbSoUMHLCwsqF27NitXrsTZ2TnfKhmXL1+mS5cuWFpa4urqyoYNG4D7kxzatGkDQMWKFdFoNHoZfIX5+++/WbNmTaGhzVB8cLOXlxczZ85Uvu7cuTMVKlRQJoFcuHABjUbD6dOni22PEEIIIcpfuRV2c+fOZfLkyTz33HOkpqZy4MAB4H4QsampKbt37+abb77h4sWLtG/fnqZNm5KQkMCCBQtYsmQJU6dO1TtfREQEVlZW7N+/n88//5zJkycrYb155w4LC9O7VnFOnz7Nhx9+yD///IO5uTlarZadO3fy9ddfo9VqWbFiBf7+/vz1119ERUWxZs0avv3223yrXsD9Z++6d+/O4cOHad++PX369OHff//FycmJNWvWAJCYmEhqaio9evQoNJhYq9Uq54yJiSk0tBmKD2728fFRAopzc3PZuXMndnZ27Nq1C7hfUNeoUaPQyBUJKBZCCCHUpdyesbO1tcXa2hpjY2O9YF5XV1c+//xz5euPP/4YJycn5s2bh0ajoUGDBvz111+MHTuWCRMmKDM+GzduzMSJE5VzzJs3j23bttGuXTscHBwAsLOzyxcCXJS7d++yb98+vW7PPn364ObmxieffML169fZunUrBw4cwMvLC7i/nJirq2u+cwUEBNCrVy8AQkJCCA0NJSYmBj8/P+zt7YH7kzfs7OzQ6XTFrj3bokUL0tLSCg1tBooNbtbpdCxZsoTs7Gz+/PNPTE1N6dGjB1FRUfj5+UlAsRBCCPGUUV2O3Ysvvqj39fHjx2nZsqXeQ8gvvfQSGRkZXLhwQXmvcePGesdVq1atwJ6zkqhVqxaenp64uLgoLwsLC+zs7HBxceHChQuYmJjg6empHOPi4kLFihXznevB9llZWWFjY1No+/KCiQt75SkotPnUqVNKdEpcXBydOnWiZs2aWFtbK0VaSkoKAN7e3ty4cYNDhw4RHR2Nj48POp1O6cXLy7YrzPjx47l+/bryOn/+fDF3VAghhBCPk+oKu9Lmpj08U1Wj0SgrUDzpthTkcbSvKDdv3iw2uNnOzo4XXniBqKgopYhr3bo1hw4d4uTJk5w6darIHjsJKBZCCCHURXWF3cPc3NzYu3ev3qSA3bt3Y21tzXPPPWfweSpUqFCqEOCi1K9fn3v37nHo0CHlvdOnT3P16tUSnSdvtm1J21dYaLOxsbFecLO3tzcNGjQosIfQx8eHHTt28Mcff6DT6bC3t8fNzY1p06ZRrVq1AtfUFUIIIYQ6PbHCriRhxA8aPHgw58+f54MPPuDEiROsX7+eiRMnMnLkSOX5OkM4Ozuzbds2/v77b4MLr9OnTxcZJ9KgQQPatm3LwIEDiYmJ4dChQ7i6umJqalqi/KpatWqh0Wj45ZdfuHTpkjIrNU9hociFhTYDBgU35517y5YtmJiY0KBBA+U9CSgWQgghnj5PrLDLCyP+5ZdfSE1N5fnnnzfouBo1arBp0yZiYmJ44YUXGDRoEP379+eTTz4p9BiNRkNqaqree7NmzeL333/HyckJDw8Pg65du3btAouhBy1btgxHR0dat25Nly5dgPvPyOXFozwsKioKjUaj1wNZo0YNgoODGTduHI6OjgwdOtSg9j0Y2jxkyBAltBnAwcGB8PBwfvrpJxo2bMhnn32mF22Sx9vbm5ycHL0iTqfT5QstFkIIIYT6PbGA4v9SGDHA1q1befXVV/NtL00YsU6no0mTJnrZeAW9V97UHlBsaabeNHZQ98oYag6LNTNR7xMl6o07vU/NgaymKv5zVfN9A7iu4rBzawv1LlFvUoJRwCctPT2dqpVt1RNQ/CyHETs6OjJgwADOnj3Lnj17gPtLmIWGhpZZGPHDNm7cyK5duzhx4oRyfzt37szMmTOpVq0alSpVYsiQIWRl/d8Pd2ZmJkFBQdSoUQMrKyuaN2+uzH7Ns2vXLry9vbGwsMDJyYlhw4Zx8+bNErdPCCGEEOXjiRR2agwjdnd3VwJ/Q0JCSEhIYNCgQfzzzz9MmzatwGMKCiO+du0aGzduxN3dXRmKrVChAj179jQojHju3Ln5rpOSkqIXSPxgKLK5uTk9evTAzc1NeSYOYMeOHSQlJbFjxw4iIiIIDw8nPDxc2T506FD27t3LqlWrOHz4MN26dcPPz49Tp04BkJSUhJ+fH2+99RaHDx/mhx9+YNeuXQYPCwshhBCi/D2xodgvv/ySL7/8kuTkZOB+b1h6ejoHDx5U9vn4449Zs2YNx48fV4Y0v/76a8aOHcv169cxMjJSnv/auXOnclyzZs145ZVX+Oyzz+5/KAOGYs+dO6f0aIWGhrJgwQJ27dpFpUqVcHR0xNraWm+488SJE7i5uemFEZ8+fRpXV1fmzJmj9OxpNBo++eQT5dm8mzdvotVq+fXXX5XQ3+KGYu/du6fcJ/i/UGRnZ2dmzZpFZGSk3jBvQEAAUVFRJCUlYWx8f7ixe/fuGBkZsWrVKlJSUqhTpw4pKSlUr15dOa5t27Y0a9aMkJAQBgwYgLGxMQsXLlS279q1Cx8fH27evFngM4OZmZlkZmYqX6enp+Pk5CRDsaUkQ7GlI0OxpafmIUUZii09GYotnWdlKLZc73BJw4hr1qwJlE0Yca1atZT/t7e3x9nZmebNmxe6f2Ji4mMJIy6IiYmJXhCxhYUFW7duJS0tjd27d9O0adN8x7i7uytFHdy/J0eOHAHgyJEjZGdn54suyczMpFKlSgAkJCRw+PBhVqxYoWzPzc0lJyeHs2fP4ubmlu+asvKEEEIIoS7lWthJGLHhPDw8OHjwIEuXLsXLyytfnEpR18zIyMDY2Ji4uDi94g9Q1p7NyMjgvffeY9iwYfmunVdQP2z8+PGMHDlS+Tqvx04IIYQQ5UNVfaJubm6sWbOG3NxcpXBRYxhxXk/jkwwjrlu3LrNmzUKn02FsbMy8efMMPtbDw4Ps7GzS0tLw9vYucB9PT0+OHTum11NYHDMzM8zMzAzeXwghhBCPlyoGlPPCi+fPn09iYiK9evV64mHExXk4jPjFF1+kTZs2WFhYGBxGrNFolOcHCwsjLkq9evXYsWMHa9asKTI4OSoqirlz5yqzievVq0efPn3w9/dn7dq1nD17lpiYGKZPn87GjRsBGDt2LHv27GHo0KHEx8dz6tQpqlSpUmghKIQQQgj1UUVhlxdevGnTJtasWUNSUpLBYcQFKSqMWKPREBkZWap2PhhGfOnSJYKDg7G2ti40jLgglSpVKjSMOC+8+Nq1a4UeX79+fbZv387333/PqFGj0Ol0xMTEFHvdsLAw/P39GTVqFPXr16dz584cOHBA77nF6OhoTp48ibe3Nx4eHjg4ONC2bVuDP5sQQgghytcTmxVblKc1vPjChQs4OTkVGkZc0muXVXhxac5TFvICiv++XPysnfKQq/I5ijdVPCvW1FgV/wYsUPJl9WYtPmdvUd5NKFIFFf+5qngiNkZGam6dumexG6m4ceVfDRVOdQHFRXmawourVq2KmZkZZ8+excPDg2bNmuHs7Ezr1q0LDC92dnbOtzLE5cuX6dKlS5mEFwcEBBAdHc3cuXPRaDRoNBq9mJS4uDi8vLywtLSkVatWJCYmKtuSkpJ48803cXR0RKvV0rRpU7Zu3ap3/oLaL4QQQgj1KvfC7kmEFzs7O6PVarl06RJw/6F/S0tLLl26pBfvUZjTp0+zZs0aPv30U+rWrYu7uztHjx7F0tKSqKgoKlSoUGB4cUERJ8HBwXTv3l0JL+7VqxdarZbnn39eGdK1tLTE0tKS1atXk5KSUuS9a9myJYGBgaSmppKamqo3K/Xjjz9m1qxZxMbGYmJiwrvvvqtsy8jIoH379mzbto1Dhw7h5+dHp06diryeEEIIIdSt3GfF2traYm1tjbGxMVWrVlXed3V15fPPP1e+/vjjj3FycmLevHloNBoaNGjAX3/9xdixY5kwYYIyuaJx48ZMnDhROce8efN48cUXGTVqlPLenDlzaNeuHQCOjo7FtvHu3bssW7YMBwcHhgwZAvzfEGitWrU4ceIEW7du1QsvXrx4Ma6urvnOFRAQQK9evQAICQkhNDSUJUuW0Lp1a/bv38///vc/du7cqXS1PhgoXNC9MzU1xdLSUu/e5Zk2bRo+Pj4AjBs3jg4dOnDnzh3Mzc154YUXeOGFF5R9p0yZwrp169iwYYPBq00UFFAshBBCiPJT7oVdYcoyvDgzM1MvxqNatWolivWoVasWDg4OhW5/1PDivEDiCxcuAFCnTp0yeTbuwWtVq1YNgLS0NGrWrElGRgaTJk1i48aNpKamcu/ePW7fvl2iHjsJKBZCCCHUpdyHYgsj4cVle628gjjvWkFBQaxbt46QkBB27txJfHw8jRo14u7duwaff/z48Vy/fl15nT9/vmw/gBBCCCFKRLU9dg+T8OLCjynN59m9ezcBAQF06dIFuP/M3YMTLwwhAcVCCCGEuqi2x+5hgwcP5vz583zwwQeqDy8+dOgQAwcOLFF4Mdwf8i1peLGzszP79+8nOTmZy5cvG9z75+rqytq1a4mPjychIYHevXs/tp5DIYQQQjwZT0Vhl5ubS3BwMEZGRsyfP5/GjRs/lvDi0siLQ3kwvLhLly4EBgYWGl584sQJWrRogbm5OTdu3FDer1GjRqHhxYUJCgrC2NiYhg0b4uDgYPAzcrNnz6ZixYq0atWKTp064evrq/eMoBBCCCGePqoIKC7Or7/+yptvvklUVBR16tShcuXKmJg8nlHkkgYYT5o0icjISOLj4/XeLyq8uEePHly+fJmlS5ei1Wr5+eefGTFiRJErTjwN8gKKL6ZdU2VA8am/DV++rTw4O1iWdxMKZaLiQNYKJur99+m9bHX/elVxVqyqg2xV3DTVy8xS78hQBWP1/sGmp6dTzcHOoIDip+IZu6SkJKpVq0arVq0K3F6WK1OU1vbt28nIyKBRo0akpqYyZswYJbz4YUlJSXTo0IFatWqVQ0uFEEII8axS7z91/7/HvTJFlSpV0Gq1aLVa5Vm9vJUpKleubHA7s7Ky+Oijj3B3d8fPz4+EhAT++usvGjVqxNdff63sp9FoiIuLY/LkyWg0GnQ6Hf369eP69evK6hF5q2WkpKQobXv4ZWRkxKhRo+jVqxdWVlbUqFGD+fPn67Vp9uzZNGrUCCsrK5ycnBg8eLDy3F5ubi4ODg6sXr1a2b9JkyZKLArArl27MDMz49atWwbfByGEEEKUH9UXdo97ZYpLly4xb9484uPj2bt3LwCfffYZe/bsUa5lCF9fX/78808WLVqEVqslIiKCEydOEBISwqeffkpERAQAqampuLu7M2rUKFJTU9mwYQNffvklNjY2yuoRQUFBwP1w4vj4+AJf1apV49tvv+WFF17g0KFDjBs3juHDh/P7778rbTIyMiI0NJSjR48SERHB9u3bGTNmDHC/wGzdujVRUVEAXL16lePHj3P79m1OnDgB3C+UmzZtiqVlwUOEmZmZpKen672EEEIIUX5UPxT7JFamOHHiBAEBAUpocf369WnZsmWp2jtx4kRmzZpF165dAahduzbHjh1j4cKF9O3bl6pVq2JiYoJWq1U+j62tLRqNJt/qEXnBxQWpUKECL7/8MuPGjQOgXr167N69W29VjREjRij7Ozs7M3XqVAYNGqT0IOp0OhYuXAjAH3/8gYeHB1WrViUqKooGDRoQFRWlrFxREAkoFkIIIdRF9T12hSnpyhR5ClqZoqA1XUvj5s2bJCUl0b9/f71h06lTp5KUlFQm13jQw8Vny5YtOX78uPJ13sSNGjVqYG1tzTvvvMOVK1eUoVUfHx+OHTvGpUuXiI6ORqfTodPpiIqKIisriz179qDT6Qq9vgQUCyGEEOqi+h67wqhpZYo8ec+vLVq0iObNm+ttMzY2LpNrGCo5OZmOHTvy/vvvM23aNOzt7dm1axf9+/fn7t27WFpa0qhRI+zt7YmOjiY6Oppp06ZRtWpVZsyYwYEDB8jKyip0wgpIQLEQQgihNk9tYfcwNaxM4ejoSPXq1Tlz5gx9+vQx+LjSrh6xb9++fF+7ubkBEBcXR05ODrNmzVKGoX/88Ue9/TUaDd7e3qxfv56jR4/y8ssvY2lpSWZmJgsXLsTLy6tMl1MTQgghxOOluqHY3NxcBg4ciL29PRqNJl8+XGGexMoUeWHERQkODmb69OmEhoZy8uRJNmzYQN26dTExMSn0WGdnZzIyMti2bRuXL1/ONwvV2dmZL7/8Mt9xu3fv5vPPP+fkyZPMnz+fn376ieHDhwPg4uJCVlYWX331FWfOnGH58uV88803+c6h0+n4/vvvadKkiTLbtnXr1qxYsaLI5+uEEEIIoT6q67HbvHkz4eHhemHEeTM3i1KjRg02bdrE6NGjeeGFF7C3ty92ZQqNRkOzZs2ws7NT3ps1axYjR45k0aJF1KhRo8Trpw4YMABLS0u++OILRo8eDYClpSXz58/n7bffJjw8nCNHjugFILdq1YpBgwbRo0cPrly5wsSJE5XIk6KMGjWK2NhYgoODsbGxYfbs2Ur0ywsvvMDs2bOZMWMG48ePp3Xr1kyfPh1/f3+9c/j4+JCdna33LJ1Op2P9+vVFPl9XFGMjDcYqDLRVcwAwgFkF1f07S5Gdo96g3dt3y3bt5/+SCsbq/Z67p+IlDo1VHGQLcOeueu9d+u2s8m5CoarYqvjRohJ8y6lu5Yl58+bxxRdfcO7cuQK3l2UYcVmtMlEULy8vOnTooMweDQ8PL/EqE87OzowYMSLfLNeH3ytveStP/H25+GTs8nAnS90FgBR2paP21R3UTM2Fncr+atIjhV3pSWFXOunp6VSrbNjKE6r6qX7cYcQP9oI5OzsD/xdGnPd1SS1evBg3NzfMzc1p0KBBqcOIS3rNlJQUTp48adBnBbh27RoDBgzAwcEBGxsbXnnlFRISEvT2Wb9+PZ6enpibm1OnTh2Cg4P17qkQQggh1E1VQ7Fz586lbt26fPvttxw4cABjY2O6detGREQE77//Prt37wZQwogDAgJYtmwZJ06cIDAwEHNzc72CJiIigpEjR7J//3727t1LQEAAL730Eu3atePAgQNUqVKFsLAw/Pz8Cp216u7urvQe3r17l+zsbLRaLQD+/v5ERkYyb948PDw8OHToEIGBgVhZWdG3b19SU1Np27Ytfn5+BAUFYWlpSVhYGBMmTCAxMRFAOdeDdu7cyeuvv658fevWLcaOHcsnn3xCVlYW1tbWVK1alXr16hn0WQG6deuGhYUFv/76K7a2tixcuJBXX32VkydPYm9vz86dO/H39yc0NBRvb2+SkpIYOHAggJL797DMzEwyMzOVryWgWAghhChfqirsnkQY8bZt22jXrh0ODg4A2NnZ5QsGftCmTZvIyrrfdRwaGsrvv//Ozz//DMBrr71WZmHED/Ly8tIb7vXx8SEgIIBLly4RGRnJjh07cHd31zumqM+6a9cuYmJiSEtLU+JJZs6cSWRkJKtXr2bgwIEEBwczbtw4+vbtC0CdOnWYMmUKY8aMKbSwk4BiIYQQQl1UVdgVpqRhxDVr1gTKJoy4Vq1ayv/b29tjZmaGi4sLN2/e5OzZs/Tv35/AwEBln3v37mFra1uiazzMwsJCb8WJChUqsGzZMm7evElsbCx16tTJd0xRnzUhIYGMjAwqVaqkt8/t27eV4OSEhAR2797NtGnTlO3Z2dncuXOHW7duFbis2Pjx4xk5cqTydXp6Ok5OTqX4xEIIIYQoC09FYSdhxODt7c3GjRv58ccflWXEHlTUZ83IyKBatWoFzi7OmxGckZFBcHCw0vv4IHNz8wLbJAHFQgghhLo8FYXdw/6LYcTNmjVj6NCh+Pn5YWJiQlBQkMHHenp68vfff2NiYlLoJBFPT08SExMLXZtWCCGEEOqnqlmxxckLL54/fz6JiYn06tXrsYURG+LBMOIPPviA+vXrExYWxuzZs4u8Zl4Y8d69e2natCnm5ubFBh/D/by7TZs2ERwcXGBgcZ7w8HA2btyofN22bVtatmxJ586d+e2330hOTmbPnj18/PHHxMbGAjBhwgSWLVtGcHAwR48e5fjx42g0Grp162bw/RBCCCFE+XqqeuweDC/++++/mT59usFhxAUpKozYkIy7B8OIjxw5AvxfTl1hHg4jdnZ2JjExEa1Wa1DG3csvv8zGjRtp3749xsbGzJo1q8hVLfI+y6ZNm/j444/p168fly5domrVqrRu3RpHR0cAfH19+eWXX5g8eTIzZsygQoUKeHh40KZNm0LPW5jc3FxVZlBZmD7Z9XqfJbdUnAF47vKt4ncqJ7Ud1L0knwpzxBXq/Y6Dm5lqbh1ozdT7V7ua8zqNNOr9gShJ21QXUFwUCS/Or6Cg4tKcpyzkBRSnXrqmyoBiIzX/LaZyGXfUm2cohV3pmav4L9lsFf/VdCdLvQHAoO7CLkfFf65qXDEpT3p6Oo6VbJ++gOKiSHhxfjqdjnPnzvHhhx8qxzxoy5YtuLm5odVq8fPzIzU1Vdl24MAB2rVrR+XKlbG1tcXHx4eDBw/qHa/RaIiMjCzVZxdCCCHEk/fUFHZz585l8uTJPPfcc6SmpnLgwAHgfjCvqakpu3fv5ptvvlHCi5s2bUpCQgILFixgyZIlTJ06Ve98ERERWFlZsX//fj7//HMmT56Ms7MzWq2WS5cuAfdnfVpaWnLp0iVWrFhRovauWLGCCRMmMG3aNI4fP05ISAiffvopERERAKSmpuLu7k6PHj2wtLQkNjZW6W20tLTE0tKSmTNnFnmNtWvX8txzzzF58mRSU1P1Crdbt24xc+ZMli9fzh9//EFKSorehIsbN27Qt29fdu3axb59+3B1daV9+/bcuHHD4M+YmZlJenq63ksIIYQQ5Ue9/bUPeRLhxS+++CKjRo1S3pszZ46yckPes2iGmjhxokHhxXXr1lWW9lqzZg1Tp07l0KFDBl3D3t4eY2NjZSWKB2VlZfHNN99Qt25dAIYOHcrkyZOV7a+88ore/t9++y12dnZER0fTsWNHg64vAcVCCCGEujw1hV1hyjK8ODMzUy/uo1q1aqWK/7h58yZJSUkGhRdXqFBBuYajoyPGxsZlEjliaWmpFHWQP5z5n3/+4ZNPPiEqKoq0tDSys7O5desWKSkpBl9DAoqFEEIIdXnqCzsJLy5YQZ/vwXkyffv25cqVK8ydO5datWphZmZGy5YtuXv3rsHXkIBiIYQQQl2e+sLuYf+18OLSBh7v3r2br7/+mvbt2wNw/vx5Ll++XOLzCCGEEEI9nprJE4YaPHgw58+f54MPPlBNePHJkyc5cuRIkeHFAQEBfPPNN0p48UsvvcSQIUMMaucff/zBxYsXS1SYubq6snz5co4fP87+/fvp06cPFhYW+fabPn26wecUQgghRPl65nrsatSowaZNmxg9evRjCy8ujk6no0mTJixevJgvvviC0aNHY2VlRaNGjQoNL547dy65ubmMHz9eCS82pCdu8uTJvPfee9StW5fMzEyDg4GXLFnCwIED8fT0xMnJiZCQkBItU1aUrOxcsrLVl1VkpuKMIrWzNFNvuLNbDevybkKh1Bx4qna5Ko6Ks1R52HnmPfUGKKddzyzvJhTqhW6flXcTCpV7z/D79lQFFD8t8gq7opb9ehLneFQBAQFcu3bN4Cy7vIDilL+vqjKgWM2J52qn5lBRNZPCrvSyc+R7rrSystVbFUthVzq59zLJ3Pf5sxVQ/LQICAggOjqauXPnKqHBeTNka9eujYWFBfXr12fu3Ln5jjN0lYuHZWZmEhQURI0aNbCysqJ58+ZERUUp28PDw7GzsysysDg7O5uRI0diZ2dHpUqVGDNmjCqXBRNCCCFE4aSwM5C7uztarbbA14PhxXPnzqVly5YEBgYqocHPPfcczz33HD/99BPHjh1jwoQJfPTRR/z4449FXvPixYuFXlOr1Sr7DR06lL1797Jq1SoOHz5Mt27d8PPz49SpU8o+xQUWz5o1i/DwcJYuXcquXbv4999/WbduXRneQSGEEEI8bs/cM3aPy6ZNm8jKyipw24Phxba2tpiammJpaakXGvxgkG/t2rXZu3cvP/74I927dy/0mo6OjsWuPZuSkkJYWBgpKSlUr14dgKCgIDZv3kxYWBghISFA8YHFX375JePHj1cClb/55hu2bNlS5LUzMzPJzPy/bnVZeUIIIYQoX1LYGahWrVqPdPz8+fNZunQpKSkp3L59m7t379KkSZMijzExMSk2rPjIkSNkZ2dTr149vfczMzOpVKmS8nVRgcXXr18nNTVVL3PPxMQELy+vIodjZeUJIYQQQl2ksHsCVq1aRVBQELNmzaJly5ZYW1vzxRdfsH///kc+d0ZGBsbGxsTFxeULP35wuLa4wOLSkJUnhBBCCHWRwu4xeDg0ePfu3bRq1YrBgwcr7yUlJZXJtTw8PMjOziYtLQ1vb+9SncPW1pZq1aqxf/9+WrduDdxf/iwuLg5PT89Cj5OVJ4QQQgh1kckTj4GzszP79+8nOTmZy5cv4+rqSmxsLO3ataNt27Z8+umnHDhwgNOnTxeaa1fQOQuKPqlXrx59+vTB39+ftWvXcvbsWWJiYpg+fTobN240uM3Dhw/ns88+IzIykhMnTjB48GCuXbtm8PFCCCGEKH9S2JWSTqcrtCgLCgrC2NiYhg0b4uDggK+vL127diUmJobY2FiuXLmi13v3qMLCwvD392fUqFHUr1+fzp07c+DAAWrWrGnwOUaNGsU777xD3759leHiLl26lFkbhRBCCPH4SUBxKT3pEGJnZ2dGjBhhcA9feZCA4kdzJ0u9afHmFdSdtC+ePdduFZxCoAa1fT4s7yYU6c/fvijvJhTKwVq9j++Ymqj374j09HQcK9lKQPHjUh4hxAA3btygV69eWFlZUaNGDebPn6+3ffbs2TRq1AgrKyucnJwYPHgwGRkZyvZz587RqVMnKlasiJWVFe7u7mzatEnZ/ueff/L666+j1WpxdHTknXfeKdH6s0IIIYQoX1LYlUJZhhAXZ+fOnWi1WlJSUpgwYQJr164F4PLlywwdOpTff/9d2dfIyIjQ0FCOHj1KREQE27dvZ8yYMcr2IUOGkJmZyR9//MGRI0eYMWOGMnP22rVrvPLKK3h4eBAbG8vmzZv5559/iszZE0IIIYS6yKzYUijLEOLieHl5ER8fj4+PD3Xr1mXp0qXKtuHDhzNnzhzatWsHoDdM6+zszNSpUxk0aBBff/01cD/M+K233qJRo0YA1KlTR9l/3rx5eHh4KIHGAEuXLsXJyYmTJ0/my8kDCSgWQggh1EYKuzJUmhDi4lhYWODi4kKFChVo27atXmDxa6+9pvd83tatW5k+fTonTpwgPT2de/fucefOHW7duoWlpSXDhg3j/fff57fffqNt27a89dZbNG7cGICEhAR27Nihl32XJykpqcDCTgKKhRBCCHWRodgykhdC3L9/f3777Tfi4+Pp168fd+/efSLXT05OpmPHjjRu3Jg1a9YQFxenPIOX14YBAwZw5swZ3nnnHY4cOYKXlxdfffUVcD/ouFOnTsTHx+u9Tp06pWTbPWz8+PFcv35deZ0/f/6JfFYhhBBCFEx67ErpSYYQ59m3b1++r93c3ACIi4sjJyeHWbNmYWR0v14v6Pk+JycnBg0axKBBgxg/fjyLFi3igw8+wNPTkzVr1uDs7IyJiWHfFhJQLIQQQqiL9NiVUmEhxFu2bOHkyZNKCHFZ2r17N59//jknT55k/vz5/PTTTwwfPhwAFxcXsrKy+Oqrrzhz5gzLly/nm2++0Tt+xIgRbNmyhbNnz3Lw4EF27NihFIZDhgzh33//pVevXhw4cICkpCS2bNlCv3799ApYIYQQQqjXM1fYFRUcXBbyIksKCyHu0aMHzZs3L9MQYo1Gw61btxg1ahSxsbF4eHgwdepUZs+eja+vL8nJyTRp0oRRo0YxY8YMnn/+eVasWMH06dOVc0RFRTF37lzef/993Nzc0Ol0HDx4UJlYUb16dXbv3k12djavvfYajRo1YsSIEdjZ2Sk9gEIIIYRQt2cuoLgsgoOLEhAQwLVr14iMjHws5y/I33//TcWKFQsd9kxOTqZ27docOnSo0MkaUVFRtGnThqtXr2JnZ0d4eDgjRowo02XD8gKK/7lSfICieLqo+bfElYzM4ncqJ9FnLpV3E4pU09qqvJtQKNeq+SdyqYWVmboDu401mvJuQuFU3DQ1S09Pp1plO4MCiuUZu6fAg5EqQgghhBCFeSbH2O7du8fQoUOxtbWlcuXKfPrpp+R1TC5fvhwvLy+sra2pWrUqvXv3Ji0tTe/4o0eP0rFjR2xsbLC2tsbb27vQiRAHDhzAwcGBGTNmFNuuSZMm0aRJE5YuXUrNmjXRarUMHjyYqKgozMzMMDIyQqPRYGZmhlarVV4ajUavhzAmJgYPDw/Mzc3x8vLi0KFD+a61adMm6tWrh4WFBW3atCE5ObnY9q1fvx5PT0/Mzc2pU6cOwcHB3Lt3r9jjhBBCCKEOz2SPXUREBP379ycmJobY2FgGDhxIzZo1CQwMJCsriylTplC/fn3S0tIYOXIkAQEBytJaFy9epHXr1uh0OrZv346NjQ27d+8usMDZvn07Xbt25fPPP2fgwIEGtS0pKYlff/2VzZs3k5SUxNtvv82pU6fo0aMH//vf/zh48CDjx49nxYoVyrCqq6urcnxGRgYdO3akXbt2fPfdd5w9e1aZQJHn/PnzdO3alSFDhjBw4EBiY2MZNWpUke3auXMn/v7+hIaGKoVs3meaOHFigcdIQLEQQgihLs9kYefk5MScOXPQaDTUr1+fI0eOMGfOHAIDA3n33XeV/erUqUNoaChNmzYlIyMDrVbL/PnzsbW1ZdWqVVSoUAGgwHDedevW4e/vz+LFi+nRo4fBbcvJyWHp0qVYW1vTsGFD2rRpQ2JiIlu2bMHIyIjXXnuN8PBwTp8+zdtvv53v+JUrV5KTk8OSJUswNzfH3d2dCxcu8P777yv7LFiwgLp16zJr1iwA5R4U1asYHBzMuHHj6Nu3r3JvpkyZwpgxYwot7CSgWAghhFCXZ3IotkWLFmgeeHi0ZcuWnDp1iuzsbOLi4ujUqRM1a9bE2toaHx8f4P5yWwDx8fF4e3srRV1B9u/fT7du3Vi+fHmJijq4H5NibW2tfO3o6EjDhg31Zp46OjrmGx7Oc/z4cRo3boy5ubne53t4n+bNm+u99/A+D0tISGDy5Ml6Q8B5a+HeunWrwGMkoFgIIYRQl2eyx64wd+7cwdfXF19fX1asWIGDgwMpKSn4+voqqzNYWFgUe566detSqVIlli5dSocOHYosAh/28L4ajabA93Jycgw+Z1nIyMggODiYrl275tv2YBH5IAkoFkIIIdTlmeyx279/v97X+/btw9XVlRMnTnDlyhU+++wzvL29adCgQb6escaNG7Nz506ysrIKPX/lypXZvn07p0+fpnv37kXuW9bc3Nw4fPgwd+7cUd57eEUKNzc3YmJi9N57eJ+HeXp6kpiYiIuLS76X5NgJIYQQT4dn8m/slJQURo4cSWJiIt9//z1fffUVw4cPp2bNmpiamiqrM2zYsIEpU6boHTt06FDS09Pp2bMnsbGxnDp1iuXLl9OsWTO94OMqVaqwfft2Tpw4Qa9evR559mhe8HFxevfujUajITAwkGPHjrFp0yZmzpypt8+gQYM4deoUo0ePJjExkZUrVxIeHl7keSdMmMCyZcsIDg7m6NGjHD9+nAkTJqDRaMo0604IIYQQj88zORTr7+/P7du3adasGcbGxgwfPpyBAwei0WgIDw/no48+IjQ0FE9PT2bOnMkbb7yhHFupUiW2b9/O6NGj8fHxwdjYmCZNmug9s5enatWqbN++HZ1OR58+fVi5ciXGxo83uFKr1fLzzz8zaNAgPDw8aNiwITNmzOCtt95S9qlZsyZr1qzhww8/5KuvvqJZs2aEhIToTRx5mK+vL7/88guTJ09mxowZVKhQgerVqz/WzyKeHmrOO61srd7HAd564bnybkKRsnPUmzydfvvJjYQ8a17+bEd5N6FQ64a8VN5NKFRFK8Mfq3rS7mUb/rP6zK088bg8iytaFOfh1SqKIytPCPF0kcKudLTm6u4T8ZkRVd5NKJQUdqWTnp6Ok2NFg1aeeCaHYh8XtQYfQ/HhwhqNhsWLF9OlSxcsLS1xdXVlw4YNeucoTaixEEIIIdRDCrsSiIiIwMTEhJiYGObOncvs2bNZvHgxAFlZWVy6dImcnBzS09P54YcfqF69uhId8tVXX9G6dWvMzMzYvn07cXFxvPvuu4UGH7dr145p06YxduzYYtuVFy48fPhwjh07xsKFCwkPD2fatGl6+wUHB9O9e3cOHz5M+/bt6dOnD//++y/wf6HGnTp1Ij4+ngEDBjBu3Lgir5uZmUl6erreSwghhBDlR4ZiDaTT6UhLS+Po0aPK83bjxo1jw4YNHDt2DIBz584pM2SPHDlC165diY+Px8rKigULFrBu3ToSExMLjEfJG4rt27dviYOP27Zty6uvvsr48eOV97777jvGjBnDX3/9Bdzvsfvkk0+UySI3b95Eq9Xy66+/4ufnx0cffcT69es5evSoco5x48YxY8aMQodiJ02aVGBAsQzFCvF0kKHY0pGh2NKTodjSKclQrLq/O1WmoODjWbNmkZ2dTXx8PJMmTSIhIYGrV68qOXQVKlTAxcWF48ePGxR8/Msvv7B69WqDZsjmSUhIYPfu3Xo9dNnZ2dy5c4dbt25haWkJ3I9yyWNlZYWNjY0yXFyaUOPx48czcuRI5ev09HScnJwMbrcQQgghypYUdmWgvIOPDQ0XLusgZAkoFkIIIdRFnrErAbUGH5dFuHBpQo2FEEIIoS5S2D1Ap9PphRA/7FGDj//66y9q1aqlF3ycmJiot19hwceTJk2iSZMmBbaroHDhVatW8cknnxj82UsTaiyEEEIIdZGh2BJ41ODj1157jX379ukFH7/0Uv4HSQsKPi5KQeHCDRo0YMCAAQZ/ttKEGhcm614OWfee7Fq3hjAyUnHKLpCj4nlMan7IXsW3DTMTdf/b2VjFPxNmJo837P1RqPnnAWDX+Dbl3YRCaVDv95ya55KalOBnVWbFPkDNIcSTJk0iMjKS+Pj4Mm9XWckLKL7wz1VVzoqVwq701PwXmYpvm+oLOzX/TNzKzC7vJhRK7ctnm6r4+04Ku9JJT0+nmoOdBBSXhppDiAEWLlyIk5MTlpaWdO/enevXr+udr127dlSuXBlbW1t8fHw4ePCgsj03N5dJkyZRs2ZNzMzMqF69OsOGDVO2Z2ZmEhQURI0aNbCysqJ58+ZERUUZ3DYhhBBClC8p7B5SXAjxlClTSEhIIDIykuTkZAICApRjL168+FhCiN3d3QkJCSEhIYEhQ4Zw5coVAFavXs3rr7+u7Hfjxg369u3Lrl27lIkd7du358aNGwCsWbOGOXPmsHDhQk6dOkVkZCSNGjVSjh86dCh79+5l1apVHD58mG7duuHn58epU6cKbJcEFAshhBDqIkOxDzAkhPhBsbGxNG3alBs3bqDVavnoo49YtWpVmYcQnzt3jlmzZjF//nyio6OpWrUqAH/88QcDBgzgr7/+Ut57UE5ODnZ2dqxcuZKOHTsye/ZsFi5cyJ9//pmvfSkpKdSpU4eUlBSqV6+uvN+2bVvlebuHFRZQLEOxpSNDsaWj4tsmQ7GPQIZiS0+GYktHzeWQDMU+goJCiE+dOkV2djZxcXF06tSJmjVrYm1tjY+PD3C/KAKIj483KIS4W7duLF++3OCVJWrVqoW9vT21atXi5ZdfVqJM3nrrLXJzc5WZtf/88w+BgYG4urpia2uLjY0NGRkZSvu6devG7du3qVOnDoGBgaxbt07pTTxy5AjZ2dnUq1dPWQZNq9USHR1d6FDy+PHjuX79uvI6f/68QZ9HCCGEEI+HzIo1UHmHEBuib9++XLlyhblz51KrVi3MzMxo2bKl0j4nJycSExPZunUrv//+O4MHD+aLL74gOjqajIwMjI2NiYuLw9hYfzaaVqst8HoSUCyEEEKoi/TYPUStIcRwv2cwb+3XvLYZGRlRv359AHbv3s2wYcNo37497u7umJmZcfnyZb1zWFhY0KlTJ0JDQ4mKimLv3r0cOXIEDw8PsrOzSUtLyxdyXNAwrxBCCCHURwq7h6SkpNCtWzc0Gg2LFy8ucQhxeno6PXv2LFUIcXHMzc3p27cvCQkJ7Ny5k2HDhtG9e3el8HJ1dWX58uUcP36c/fv306dPH71exPDwcJYsWcKff/7JmTNn+O6777CwsKBWrVrUq1ePPn364O/vz9q1azl79iyRkZFoNBq++uqrR7yrQgghhHgSZCiW/8uvg/shxBcuXABg9OjRJQ4h3r59O6NHjy5VCHHeEKizszMjRozItwqGi4sLXbt2pX379vz777907NiRr7/+Wtm+ZMkSBg4ciKenJ05OToSEhBAUFKRst7Oz47PPPmPkyJFkZ2fTqFEjfv75ZypVqgRAWFgYU6dOZdSoUVy8eJGKFSsqbS2JCiZGVFDhw7u376r3YWxQ9wPPlzLulncTCmVrqd5fY5kqDOp+kJq/58wrqLdt6n3E/j5VT1BQ891T720rUdtkViz5g4mjoqJo06YNV69exc7OzqBz3L17F1NT0zJpT2GF3ZOWnJxM7dq1OXToUKHLmT0oL6D4nyvFz9opD1LYld4/1zPLuwmFUnNhZ6RR898U6v6eU/OdU/tfmmr+vlN1Yadi6enpVKsss2INEhAQQHR0NHPnzkWj0aDRaEhOTgYgLi4OLy8vLC0tadWqld6Qat7arYsXL6Z27dqYm5sD94dy33zzTbRaLTY2NnTv3p1//vlHOS4pKYk333wTR0dHtFotTZs2ZevWrcp2nU7HuXPn+PDDD5X2GGLXrl14e3tjYWGBk5MTw4YN4+bNm8p2Z2dnZYkwa2tratasybfffqt3jpiYGDw8PDA3N8fLy4tDhw6V+H4KIYQQovz85wu7uXPn0rJlSwIDA0lNTSU1NRUnJycAPv74Y2bNmkVsbCwmJib51k09ffo0a9asYe3atcTHx5OTk8Obb77Jv//+S3R0NL///jtnzpzRizXJyMigffv2bNu2jUOHDuHn50enTp2UmJHY2Fg0Gg2mpqZYWlpiaWnJihUrivwMSUlJ+Pn58dZbb3H48GF++OEHdu3axdChQ/X2mzVrllKwDR48mPfff18pVjMyMujYsSMNGzYkLi6OSZMm6Q3jFkQCioUQQgh1Ue8YxhNia2urFFF5z5KdOHECgGnTpilZdePGjaNDhw7cuXNH6Z27e/cuy5Ytw8HBAYDff/+dI0eOcPbsWaU4XLZsGe7u7hw4cICmTZvywgsv8MILLyjXnzJlCuvWraN79+707t0bAB8fHwICAujXrx8Ajo6ORX6G6dOn06dPH2Xo1tXVldDQUHx8fFiwYIHS3vbt2zN48GAAxo4dy5w5c9ixYwf169dn5cqV5OTksGTJEszNzXF3d+fChQu8//77RV63oIBiIYQQQpSP/3yPXVEaN26s/H+1atUA9CJOatWqpRR1AMePH8fJyUkp6gAaNmyInZ0dx48fB+73jAUFBeHm5oadnR1arZbjx4+TkZGhxItUqFABBwcH5Wtra+si25mQkEB4eLhesLCvry85OTmcPXu2wM+j0WioWrWq8nmOHz9O48aNlSIQ7oczF0UCioUQQgh1+c/32BXlwfDgvGfdcnL+b5ablZVVic8ZFBTE77//zsyZM3FxccHCwoK3335bCREujYyMDN577z2GDRuWb1vNmjWV/384DFmj0eh9npKSgGIhhBBCXaSwA0xNTcnOfvQZk25ubpw/f57z588rvXbHjh3j2rVrNGzYELgfIhwQEECXLl2A+0VZ3mSN0rbH09OTY8eO4eLi8khtX758ud5Q8759+0p9PiGEEEI8ef/ZodioqCg0Gg3Xrl3D2dmZ/fv3k5yczOXLl0vdi9W2bVsaNWpEnz59OHjwIDExMfj7++Pj44OXlxdw//m3vMkWCQkJ9O7dO9/1nJ2d+eOPP7h48aKyckR4eHih0Stjx45lz549DB06lPj4eE6dOsX69evzTZ4oSu/evdFoNAQGBnLs2DE2bdrEzJkzS3UfhBBCCFE+/jOFnU6nKzQXLigoCGNjYxo2bKisAVsaGo2G9evXU7FiRVq3bk3btm2pU6cOP/zwg7LP7NmzqVixIp6enuh0Onx9ffH09NQ7z+TJk0lOTqZu3bp6z/AVpnHjxkRHR3Py5Em8vb3x8PBgwoQJVK9e3eC2a7Vafv75Z2V5sY8//pgZM2YY/uGFEEIIUe7+MwHFT3sIcXh4OCNGjODatWtlcv3HIS+gOPXSNXUGFGepO6D4ropXKbiXrd5fE+YVjMu7CYWyMldv2wDU/Ns/R8WNq2Cs7j4RNd87NecTGxmpN9g5PT0dx0q2ElCc51kJIQaIjIzE1dUVc3NzfH199WaiFnddgK+//lo53tHRkbffflvZlpOTw/Tp06lduzYWFha88MILrF692uC2CSGEEKJ8/ScKO7WEEOcN8a5du5bnnnuOyZMnK+0pyuuvv86gQYO4fv06b731FhcvXsTIyIitW7fy8ssvG3zd2NhYhg0bxuTJk0lMTGTz5s20bt1aOX769OksW7aMb775hqNHj/Lhhx/yv//9j+jo6Ef7AxBCCCHEE/GfH4rdunUrr776KgCbNm2iQ4cO3L59G3NzcyZNmkRISAgXL17UCyF+/fXX9UKIjx07hru7OzExMTRt2rTA6z///PMMGjRImdBQkqHYixcv8t133zFu3Dh++uknZd3WvBUn9u/fT7NmzYq97tq1a+nXrx8XLlzIl42XmZmJvb09W7du1cuvGzBgALdu3WLlypX5zp2ZmUlm5v+tIZqeno6Tk5MMxZaSDMWWjgzFlp6af/ureThRhmIfgYqbJkOxz4gnGUJc2kkZNWrUwNHRERMTE7p27aoEF/v6+pbouu3ataNWrVrUqVOHd955hxUrVnDr1i3gfs/krVu3aNeunV7Q8bJly0hKSiqwXdOnT8fW1lZ5PXhPhBBCCPHk/edz7J6WEOKyuK61tTUHDx4kKiqK3377jQkTJjBp0iQOHDhARkYGABs3bqRGjRp65y0shHj8+PGMHDlS+Tqvx04IIYQQ5eM/U9g97SHEAPfu3SM2NlYZdk1MTOTatWu4ubkZfF0TExPatm1L27ZtmThxInZ2dmzfvp127dphZmZGSkqKsj5ucWTlCSGEEEJd/jNDsWoPIXZ2dlae/ytMhQoV+OCDD9i/fz9xcXEEBATQokULpdAr7rq//PILoaGhxMfHc+7cOZYtW0ZOTg7169fH2tqaoKAgPvzwQyIiIkhKSuK9996jRo0aRERElOpeCSGEEOLJ+s8UduURQtyqVSs6depUJiHEAJaWlowdO5bevXvz0ksvodVqS3RdOzs71q5dyyuvvIKbmxvffPMN33//Pe7u7gBMmTKFTz/9lOnTp+Pm5sZ3333HjRs3qF27dqnulRBCCCGerP/MrFi1K2lg8ZMwadIkIiMjiY+PN2j/vIDif64UP2unPGRlq3fWKah75um/GY/3+dBHYVZBvf8+rWyt7kcVVP0zod4fhxJlj5YHE2N1t0+t1FwNpaenU7WyzIotUzqdjmHDhjFmzBjs7e2pWrUqkyZNUrZfu3aNAQMG4ODggI2NDa+88goJCQl65/j5559p2rQp5ubmVK5cWXkWriCLFy/Gzs6Obdu2Fdu24oKF89bF3bZtW6FhzACfffYZjo6OWFtb079/f+7cuWPg3RFCCCGEGkhhVwIRERFYWVmxf/9+Pv/8cyZPnszvv/8OQLdu3UhLS+PXX38lLi4OT09PXn31Vf7991/g/mzTLl260L59ew4dOsS2bdv0suf++ecfxo4di1arxczMjMDAQO7evcubb75JSEhIke0yNFi4qDDmH3/8Ucnti42NpVq1anz99ddldeuEEEII8QTIUKyBdDod2dnZ7Ny5U3mvWbNmvPLKK3Ts2JEOHTqQlpamN0vUxcWFMWPGMHDgQFq1akWdOnX47rvvCjz/c889R9++fbl06RKRkZFERETg6uoKgL29Pfb29gUeZ0iwsCFhzK1atcLDw4P58+cr52jRogV37twpdCi2sIBiGYotHRmKLR0Zii09Vf9MqPfHQYZin1FqroZKMhT7n4k7KQsPhhnD/UDjtLQ0EhISyMjIoFKlSnrbb9++rYT7xsfHExgYWOi5TUxMWLZsGTdv3iQ2NpY6deoY1KYHg4UfdPfuXTw8PApt/4NhzDVr1uT48eMMGjRIb/+WLVuyY8eOQq89ffp0goODDWqnEEIIIR4/KexK4MEwY7j/r7acnBwyMjKoVq0aUVFR+Y6xs7MDwMLCotjze3t7s3HjRn788UfGjRtnUJtKEixcXBhzSUlAsRBCCKEuUtiVAU9PT/7++29MTExwdnYucJ/GjRuzbds2+vXrV+h5mjVrxtChQ/Hz88PExISgoKBir92wYcMSBwsXxM3Njf379+Pv76+8t2/fviKPkYBiIYQQQl2ksCsDbdu2pWXLlnTu3JnPP/+cevXq8ddffykTJry8vJg4cSKvvvoqdevWpWfPnty7d49NmzYxduxYvXO1atWKTZs28frrr2NiYlJs/MmDwcI5OTm8/PLLXL9+nd27d2NjY0Pfvn0N+gzDhw8nICAALy8vXnrpJVasWMHRo0cNHhIWQgghRPmTwq4MaDQaNm3axMcff0y/fv24dOkSVatWpXXr1jg6OgL3J19UqlSJJUuW8Nlnn2FjY0Pr1q0LPN/LL7/Mxo0bad++PcbGxnzwwQfodDqaNGlS4OoUU6ZMwcHBgenTp3PmzBns7Ozw9PTko48+Mvgz9OjRg6SkJMaMGcOdO3d46623eP/999myZUup7okQQgghnjyZFfsEPUoIcVGFnVqoPaA4J0fd3+qZ99Q7Q3HB3uTybkKhuj1fvbybUKgqNup+VCFHxb/+zUzUO9vZyEhmnT6LVPzjIAHFQgghhBD/RVLYPUCNq0ukpKSg1WrZuXMn8+fPp0KFCmg0GjQaDaamppw7d07Zd/ny5Xh5eWFtbU3VqlXp3bs3aWlpyvarV6/Sp08fHBwcsLCwwNXVlbCwMGX7+fPn6d69O3Z2dtjb2/Pmm2+SnJxcgjsohBBCiPIkhd1DHufqEg/6/PPPGTduHL/99psSGlyQ6tWrEx8fj5eXF6ampvTp04ctW7Ywc+ZMjI2N+fXXX5V9s7KymDJlCgkJCURGRpKcnExAQICy/dNPP+XYsWP8+uuvHD9+nAULFlC5cmXlWF9fX6ytrdm5cye7d+9Gq9Xi5+fH3bsFh9NmZmaSnp6u9xJCCCFE+ZFn7B7wuFeXyHvGLjU1leXLl/P777/j7u5ucNvS0tI4evSokkE3btw4NmzYwLFjxwo8JjY2lqZNm3Ljxg20Wi1vvPEGlStXZunSpfn2/e6775g6dSrHjx9Xzn/37l3s7OyIjIzktddey3fMpEmTCgwolmfsSkeesSsdecau9OQZu9KRZ+yeTSr+cZBn7B6FIatLaLVa5XX27Fm91SWK6n0DmDVrFosWLWLXrl0GF3V5WrRoobeUTcuWLTl16hTZ2dkAxMXF0alTJ2rWrIm1tbWSa5eSkgLA+++/z6pVq2jSpAljxoxhz549yrkSEhI4ffo01tbWymezt7fnzp07yud72Pjx47l+/bryOn/+fIk+jxBCCCHKlsSdPESNq0sY4ubNm/j6+uLr68uKFStwcHAgJSUFX19fZSj19ddf59y5c2zatInff/+dV199lSFDhjBz5kwyMjJ48cUXWbFiRb5zOzg4FHhNCSgWQggh1EUKOwOV5+oSefbv36/39b59+3B1dcXY2JgTJ05w5coVPvvsM2VZr9jY2HzncHBwoG/fvvTt2xdvb29Gjx7NzJkz8fT05IcffqBKlSqqHEYVQgghRPFUNxSr0+lKlPMWGRmJi4sLxsbGpcqHM9SDq0v89ttvJCcns2fPHj7++GOlgJo4cSLff/89EydO5Pjx4yxduhSNRsO1a9f0zpW3ukRwcHCJculSUlIYOXIkiYmJfP/993z11VcMHz4cgJo1a2JqaspXX33FmTNn2LBhA1OmTNE7fsKECaxfv57Tp09z9OhRfvnlF9zc3ADo06cPlStX5s0332Tnzp2cPXtWCVm+cOFC6W+cEEIIIZ6Yp77H7r333qNfv34MGzYMa2trAgICuHbtGpGRkWV6neJWl8gLEP7pp5+YMmUKn332WZFDswWtLlEcf39/bt++TbNmzTA2Nmb48OEMHDgQuN8TFx4ezkcffURoaCienp7MnDmTN954Qzne1NSU8ePHk5ycjIWFBd7e3qxatQoAS0tL/vjjD8aOHUvXrl25ceMG2dnZZGVlSQ/eE2JhalzeTSjUh63rlncTCiXzv0rPyEh1/7YXZSBbxRPFOn1T9Brk5emn/k3LuwmFun33nsH7qm5WbElWWMjIyMDa2prt27fTpk0bgMdW2BWnoHZHRUXRpk0brl69qjyHV1J3797F1NS0bBpZQiVdKUNWnng0ap5pp67fEvpU9ivsqaLm7zlRelLYlY6aC7sb6enUrl7p6Z8Vm5mZSVBQEDVq1MDKyormzZsrkxeioqKwtrYG4JVXXkGj0aDT6YiIiGD9+vVKiG9Bkx0eNnbsWOrVq4elpSV16tTh008/JSsrS9k+adIkmjRpwvLly3F2dsbW1paePXty48YN4H4xGR0dzdy5c5XrFhbsu2vXLry9vbGwsMDJyYlhw4Zx8+ZNZbuzszNTpkzB398fGxsbpUeuKMUFCwcEBNC5c2dmzpxJtWrVqFSpEkOGDNH7jGlpaXTq1AkLCwtq165d4CQKIYQQQqibqgu7oUOHsnfvXlatWsXhw4fp1q0bfn5+nDp1ilatWpGYmAjAmjVrSE1NZcOGDXTv3h0/Pz9SU1NJTU2lVatWxV7H2tqa8PBwjh07xty5c1m0aBFz5szR2ycpKYnIyEh++eUXfvnlF6Kjo/nss88AmDt3Li1btiQwMFC5bt4EhofP4efnx1tvvcXhw4f54Ycf2LVrFwEBAUrESEpKChMmTOCHH34gOzubn376SYkrKYihwcI7duwgKSmJHTt2EBERQXh4OOHh4cr2gIAAzp8/z44dO1i9ejVff/213qoVBZGAYiGEEEJdVPuMXUpKCmFhYaSkpFC9+v0A0qCgIDZv3kxYWBghISFUqVIFQFn+C+5HjmRmZipfG+KTTz5R/t/Z2ZmgoCBWrVrFmDFjlPdzcnIIDw9Xegnfeecdtm3bxrRp07C1tcXU1BRLS8sirzt9+nT69OmjDG26uroSGhpK69atOXLkCGZmZvj4+NCwYUMWLFigHJf3+Qvyww8/kJOTw+LFi5WMu7CwMOzs7IiKilKChStWrMi8efMwNjamQYMGdOjQgW3bthEYGMjJkyf59ddfiYmJoWnT+13RS5YsUSZWFPV5CgooFkIIIUT5UG1hd+TIEbKzs6lXr57e+5mZmVSqVKlMr/XDDz8QGhpKUlISGRkZ3Lt3L98YtrOzs1LUwf8FF5dEQkIChw8f1hvmzM3NJTc3FyMjI1xcXKhQoQI6nQ4XFxeDz5kXLPygh4OF3d3dMTb+v4fzq1WrxpEjRwA4fvw4JiYmvPjii8r2Bg0aFPtc4Pjx4xk5cqTydXp6eoE9lUIIIYR4MlRb2GVkZGBsbExcXJxeQQKg1WrL7Dp79+6lT58+BAcH4+vri62tLatWrWLWrFl6+xUWXFwSGRkZvPfeewwbNizftpo1ayr/b2VlVaJzGhIsXBbtf5gEFAshhBDqotrCzsPDg+zsbNLS0vD29jb4OFNTU2WJLUPs2bOHWrVq8fHHHyvvnTt3rkRtNfS6np6eHDt2zODeOEOURbBwgwYNuHfvHnFxccpQbGJiYr78PSGEEEKoW7lMnjAkhLhevXr06dMHf39/xo4dS61atTAyMuKll15i48aNhR7n7OzM4cOHSUxM5PLly3ozPwvi6upKSkoKq1atIikpidDQUNatW2fwZ4mKikKj0VC9enX2799PcnIyly9fLrA3bOzYsezZs4ehQ4cSHx/PqVOnWL9+PUOHDi3yGsnJyWg0GuLj4/NtKyhYOCoqimHDhhkcLFy/fn38/Px477332L9/P3FxcQwYMMCgJdKEEEIIoR6q7bGD+5MApk6dytSpUwGoUqUKlSpV4ttvv2XRokV6szrzBAYGEhUVhZeXFxkZGezYsQOdTlfoNd544w0+/PBDhg4dSmZmJh06dODTTz9l0qRJ+fYtKmNv6NChfPDBBzRs2JDbt29z9uzZfPs0btyY6OhoPv74Y7y9vcnNzaVu3br06NHD0FuST0HBwjVq1ODVV18tUQ9eWFgYAwYMwMfHB0dHR6ZOncqnn35aqjZlZeeQlf1ow7yPg5qznQByDc+ffOJMTdQ7gV7NSWx5E5rUKkfFGYAaFf/JqvyPFWMV5xNuGtyyvJtQKDX/PGSVIMC+XAKKJYRYX3EhxMnJydSuXZtDhw7RpEmTUl3jScgLKL6QdlWVAcWqL+xU3Dwp7EpH7YVdLur9ppPCTjxpai7s0tPTqVbZ7ukIKJYQYsNCiAFOnDhBq1atMDc35/nnnyc6OlrZlp2dTf/+/alduzYWFhbUr1+fuXPn6h0fFRVFs2bNsLKyws7OjpdeeknvecL169fj6emJubk5derUITg4mHv3VNyNJIQQQgg95V7YPe4Q4pCQELRaLV9++SUXLlxAo9GQmprKtGnTeP755/X2fZwhxA8/Rzdz5kxeeOEFDh06VOSQZ0hICO7u7gD07t2bQ4cOYWRkRGJiIq+++ipXrlwB7ufsPffcc/z0008cO3aMCRMm8NFHH/Hjjz8CcO/ePTp37oyPjw+HDx9m7969DBw4UOlR2LlzJ/7+/gwfPpxjx46xcOFCwsPDmTZtWqFtk4BiIYQQQl3KdSh25MiR1KlTRy+EGKBt27Y0a9aMkJAQrl27RsWKFfWelSvJUOy///7Lv//+m+/9xYsXs2nTJg4fPgzc77H74osv+Pvvv5VewjFjxvDHH3+wb98+vXYXNRQ7YMAAjI2NWbhwobLPrl278PHx4ebNm5ibm+Ps7IyHh4dBkzT+/fdfDh8+TJs2bQgKCuK9994D7hdqr7zyCiNGjNALUn7Q0KFD+fvvv1m9ejX//vsvlSpVIioqCh8fn3z7tm3blldffZXx48cr73333XeMGTOGv/76q8DzT5o0qcCAYhmKLR0VjwLIUGwpyVBs6clQrHjSnpWh2HKdPPEkQojt7e2xt7cv9xDinJwczp49q6zm4OXlZXD7nZ2dAejUqZNeVErz5s05fvy48vX8+fNZunQpKSkp3L59m7t37yrP5Nnb2xMQEICvry/t2rWjbdu2dO/enWrVqint3r17t14PXXZ2Nnfu3OHWrVtYWlrma5sEFAshhBDqUq6FnYQQl51Vq1YRFBTErFmzaNmyJdbW1nzxxRfs379f2ScsLIxhw4axefNmfvjhBz755BN+//13WrRoQUZGBsHBwXTt2jXfuc3NzQu8pgQUCyGEEOpSroWdhBCXzL59+2jdujWAEiic9+ze7t27adWqFYMHD1b2f3BJsTweHh54eHgwfvx4WrZsycqVK2nRogWenp4kJiY+lnYLIYQQ4sko1wdnHgwhXrt2LWfPniUmJobp06c/9hDiiIgIbt++bXBbIyMj2bdvH/PmzePdd98t8xBiQ8yfP59169Zx4sQJhgwZwtWrV3n33XeVzxgbG8uWLVs4efIkn376KQcOHFCOPXv2LOPHj2fv3r2cO3eO3377jVOnTilDwxMmTGDZsmUEBwdz9OhRjh8/zoQJE9BoNLIChRBCCPGUKPeA4rwQ4lGjRnHx4kUqV65MixYt6NixY6HHlEUIca1atTh//rzB7Xzvvffo2bMncXFxrFq1irCwMNq1a8dHH32kt9/jCCHO89lnn/HZZ58RHx+Pi4sLGzZsoHLlykr7Dh06RI8ePdBoNPTq1YvBgwfz66+/AveDjE+cOEFERARXrlyhWrVqDBkyRJmM4evryy+//MLkyZOZMWMGFSpU0JvQUhLGGg3GKny6+OzlW+XdhCJVti48y7C8mRir788zjxq/1/KouGn35aq9gaI0clQ8UUzFTVN1sHNJJhOVy6xYNXhaQ5IflpWVle/ZwLJS0vDlvIDi1EvXVDkr9vQ/N4vfqRypubCztij3fwMWSs2FnZGK/6IAdc/EVjMVf8sBUtiVlpoLu/T0dKpWtn06AorV4EmFJJ8/f57u3btjZ2eHvb09b775pl7I8YEDB2jXrh2VK1fG1tYWHx8fDh48qHcOjUbDggULeOONN7CyslJmsRYXLqzRaFi8eDFdunTB0tISV1dXNmzYoHfuTZs2Ua9ePSwsLGjTpk2hAcxCCCGEUKdnorDLCyEu6PX6668Xe/zjDkmG+z1rvr6+WFtbs3PnTnbv3o1Wq8XPz48pU6ag1Wpp3bo1O3fu5Pbt22RlZbFnzx5atmyprH6RZ9KkSXTp0oUjR47w7rvvGhwuHBwcTPfu3Tl8+DDt27enT58+Ssbf+fPn6dq1K506dSI+Pp4BAwYwbty4Ij+TBBQLIYQQ6qLe8ZUSGDRoEN27dy9wm4WFRZHHpqSkEBYWpheSHBQUxObNmwkLCyMkJIQqVaoA97Pgqlatqpw3MzNT+bo4P/zwAzk5OSxevFgJLQ0LC8POzg43Nzfi4+PzHZOTk4OnpyfR0dF6zxz27t2bfv36KV+/++67jBs3jr59+wJQp04dpkyZwpgxY5g4caKyX0BAAL169QLuF8OhoaHExMTg5+fHggULqFu3rhIBU79+fY4cOcKMGTMK/UzTp08vMKBYCCGEEOXjmSjs8kKIS+NJhCTD/QDg06dP6wUgA9y5c4dLly7h4uLCP//8wyeffEJUVBRpaWlkZ2dz69YtUlJS9I55ONzY0HDhxo0bK9utrKywsbFRApiPHz9O8+bN9c7bsmXLIj+TBBQLIYQQ6vJMFHaP4kmFJGdkZPDiiy/qrUiRx8HBAYC+ffty5coV5s6dS61atTAzM6Nly5bcvXtXb/+Hw40NDRcuiwDmB0lAsRBCCKEu//nC7kmFJHt6evLDDz9QpUqVQme07N69m6+//pr27dsD9597u3z5skHnftRwYTc3t3yTKfLWyBVCCCHE0+GZmDxREJ1Ox4gRI4rdLy8k+e2336Zq1f/X3r3HxZT/fwB/zXS/TCXlskSlokgX1oalXNaldUnkLrXYdb+kxEa0trRWltyvhc1ay4p1v6yiWqVkhCijhC2XSCZ0Pb8/+s35NioqmXO07+fjMY+H5pzOvGfK9J5zPp/XpxmEQiFGjx5d7yHJ48aNg4GBAYYOHYqLFy8iIyMDUVFRmD17Nh48eACgPGR4z549SE1NRXx8PMaNGwcNDQ3cuXPnnUHBVYUL79u3D4sXL37v85eZOnUq0tPT4ePjg9u3b2Pv3r0IDw+v8fcTQgghhHsNtrGrjbCwMEilUrx58wbKysqIjo7G5s2bK60lW9GUKVPQtm1bdO7cGYaGhoiNjX3nY2hqauLChQto1aoVXF1dYWlpiUmTJuHNmzfsGbwdO3bg+fPnsLe3x4QJEzB79mx24sa7yMKFT58+jc8//xwODg745Zdf0Lp16xq/Bq1atcLBgwcRGRkJGxsbbN68GUFBQTX+fkIIIYRwr8EGFDekAOLY2NhaBQVzhe8Bxan/vnz/Thxqqf/uGdxcUldRev9OHOFxpiiUeLxiB+/x+C8T34On+axhdhwfHwUUv6UhBBAD5WPwOnbsCHV1dTg4OOD69evsttzcXIwZMwYtWrSApqYmrK2t8dtvv8kd+8CBA7C2toaGhgYaN26Mvn37oqDgf6sxbN++HZaWllBXV0e7du2wcePGmr7EhBBCCOGB/0Rjp4gA4uXLl6N169Y4dOgQioqK8ObNGxw/fhympqbo378/AODly5eYOHEiYmJicOnSJZibm8PZ2fm9AcQyPj4+CAkJweXLl2FoaIjBgwezY/vevHmDTp064dixY7h+/Tq+/fZbTJgwAQkJCQCA7OxsjBkzBt988w1SU1MRFRUFV1dXyE7YRkREwN/fH4GBgUhNTUVQUBCWLFmCXbt21c8PgRBCCCEfXYOfFauoAGJDQ0MYGxvj1KlTbABxUVER7O3t2eDg3r17y33P1q1boaen994A4rt37wIAli5diq+++goAsGvXLrRs2RKHDh3CyJEj0aJFC3h7e7PfM2vWLJw6dQr79+9Hly5dkJ2djZKSEri6urJj76ytrdn9ly5dipCQEDYyxcTEhF3FQlb/2woLC1FYWMh+TStPEEIIIdxq8I2dogKIJRIJsrKyYGdnJ3d/UVERXrx4AQB1DiCWqRgYrK+vj7Zt2yI1NRVAeSBxUFAQ9u/fj4cPH6KoqAiFhYVsOLGNjQ369OkDa2tr9O/fH/369cOIESPQqFEjFBQUQCKRYNKkSZgyZQr7GCUlJdDV1a32OdPKE4QQQgi/NPjGriEEENfEzz//jLVr12LNmjWwtraGlpYW5s6dyx5bSUkJZ86cQVxcHE6fPo1169bBz88P8fHxbPO3bdu2SqtPvP2aVUQrTxBCCCH80uAbu4YQQCxz6dIltGrVCgDw/PlzpKWlwdLSkj320KFDMX78eADl68ympaXBysqK/X6BQIDu3buje/fu8Pf3Z8cEenl54bPPPsPdu3cxbty4GtdDK08QQggh/NLgJ0/IAojd3d3x559/IiMjAwkJCR81gNjW1haenp41DiBOSUmBmZnZO8+OAcAPP/yAc+fO4fr16/Dw8ICBgQFcXFzYY8vOyKWmpuK7777Do0eP2O+Nj49HUFAQEhMTkZWVhT///BNPnjxhG8OAgACsWLECoaGhSEtLQ0pKCsLCwrB69eqavMyEEEII4YEG39gB5QHE7u7umD9/Ptq2bQsXFxdcvnyZPftVlQ8JIL558yb27NlT4wDi/fv3Y8SIEbh//z4AIDQ0lG3YKgoODsacOXPQqVMn5OTk4K+//oKqqioAYPHixbC3t0f//v3h5OSEZs2ayR1DR0cHFy5cgLOzMywsLLB48WKEhIRg4MCBAIDJkydj+/btCAsLg7W1NRwdHREeHg4TE5PavNSEEEII4VCDDSjmUkMJR64tWUBx6r0nEPEwoFhVid+fY7TU+RsCXFrK37cJ/lbG/zBWFR4HKMvSBfiIx6WRBio/Px9NG1NAMS/wIRz5woULUFFRQU5Ojtz3zJ07V27cYUxMDHr06AENDQ0YGRlh9uzZcgHGhBBCCOE3auxqKCgoCNra2lXeZJczq6KIcOTi4mL0798fIpEIFy9eRGxsLLS1tTFgwAAUFRWhZ8+eMDU1xZ49e+S+JyIigg1AlkgkGDBgAIYPH45r167h999/R0xMDGbOnFkPrx4hhBBCFIEuxdbQs2fP8OzZsyq3aWhooEWLFuzXskuxXl5eMDU1lQtHBoC+ffuiS5cuCAoKQl5eHho1aoTz58/DyckJQO0vxf7666/48ccfkZqaKheOrKenh8jISPTr1w8rV65EeHg4bt68CQD4888/MXHiROTk5EBLSwuTJ0+GkpIStmzZwh43JiYGjo6OKCgogLq6eqXHrSqg2MjIiC7F1hFdiq0b/lZGl2I/BF2KJeR/anMptsHHndQXfX196Ovr1+p7FBWOLBaLcefOHfayrsybN28gkUgAlDeLixcvxqVLl+Dg4IDw8HCMHDmSzcwTi8W4du2aXA4fwzAoKytDRkYGO3u2IgooJoQQQviFGruPiE/hyE2aNMHgwYMRFhYGExMTnDhxQm7snlQqxXfffYfZs2dXOkZ1s4cpoJgQQgjhF2rsPiI+hSMD5ZEmY8aMQcuWLdGmTRt0795d7hg3b96EmZlZjR+XAooJIYQQfuH3oKNPHBfhyBcvXkRGRkalcGQA6N+/P3R0dPDjjz/C09NT7hi+vr6Ii4vDzJkzcfXqVaSnp+Pw4cM0eYIQQgj5hHDa2AkEglpltUVFRUEgECAvL++j1VSfMjMzERERgX79+iksHNnV1RWWlpaVwpEBQCgUwsPDA6WlpXB3d5c7RseOHREdHY20tDT06NEDdnZ2GDduHK5cufJhLwIhhBBCFIbTWbE5OTlo1KhRjS/nRUVFoVevXnj+/Dn09PSq3GfZsmWIjIzE1atX66/QGqhqJmtmZiZMTEyQnJwMW1tbhdZTnUmTJuHJkyc4cuTIe/etTdAy8L+A4uwnee+dtcMFoZDfU9nKyvg7hZLvrx2pmzIeT9sV0tTTOuPxjxUlZWVcl/BJys/PR8smjfg9K7aoqAjNmjXj6uE/GUVFReyyYR/ixYsXSElJwd69e2vU1BFCCCHk06OwS7FOTk6YOXMm5s6dCwMDA/Tv37/Spdi4uDjY2tpCXV0dnTt3RmRkJAQCQaWzb0lJSejcuTM0NTXlQn7Dw8MREBAAsVjMrtoQHh7+3tpWr14Na2traGlpwcjICNOnT4dUKmW3h4eHQ09PD6dOnYKlpSUb/pudnQ2g/CxhTVeLuH79OgYOHAhtbW00bdoUEyZMwNOnT9/5Or0vHDkvLw+TJ0+GoaEhdHR00Lt3b4jFYvaYy5YtQ4sWLdCnTx+oqKhgxIgRGD16NF6+fMnuU1BQAHd3d2hra6N58+YICQl57+tGCCGEEH5R6Bi7Xbt2QVVVFbGxsdi8ebPctvz8fAwePBjW1ta4cuUKli9fDl9f3yqP4+fnh5CQECQmJkJZWZldPWHUqFGYP38+2rdvz67aMGrUqPfWJRQKERoaihs3bmDXrl34+++/sWDBArl9Xr16hVWrVmHPnj24cOECsrKy4O3tDQDw9vau0WoReXl56N27N+zs7JCYmIiTJ0/i0aNHGDly5Dtfp6lTp+Lq1atV3rZv3w43Nzc8fvwYJ06cQFJSEuzt7dGnTx+5QGWBQIBBgwYhLi4OR48eRXR0NIKDg9ntPj4+iI6OxuHDh3H69GlERUW9d3xdYWEh8vPz5W6EEEII4Y5CL8Wam5tj5cqVVW7bu3cvBAIBtm3bBnV1dVhZWeHhw4eYMmVKpX0DAwPh6OgIAFi4cCG+/vprvHnzBhoaGtDW1oaysnKtLvPOnTuX/bexsTF+/PFHTJ06FRs3bmTvLy4uxubNm9GmTRsA5UuF/fDDDwDKM+k0NDRQWFj4zsddv3497OzsEBQUxN63c+dOGBkZIS0tjQ0yrup1qi4cOSYmBgkJCXj8+DE7VnHVqlWIjIzEgQMH8O233wIAysrKEB4ezoYYT5gwAefOnUNgYCCkUil27NiBX3/9FX369AFQ3ly2bNnyna8bBRQTQggh/KLQM3adOnWqdtvt27fRsWNHuaWrunTpUuW+HTt2ZP/dvHlzAMDjx4/rXNfZs2fRp08ftGjRAiKRCBMmTEBubi5evXrF7qOpqck2dbLHre1jisVinD9/Xu5Sart27QCAXSECePfrVNUxpVIpGjduLHfcjIwMuWMaGxvLrUxRsX6JRIKioiJ88cUX7HZ9fX20bdv2nY+9aNEivHjxgr3dv3+/xnUTQgghpP4p9IydbPmqD6WiosL+W7aeYFkdZ9pkZmZi0KBBmDZtGgIDA6Gvr4+YmBhMmjQJRUVF0NTUrPSYsset7YRiqVSKwYMH46effqq0TdagArV7naRSKZo3b17lmL6KM4erqr+ur5kMBRQTQggh/MKblSfatm2LX3/9FYWFhWyzcPny5Vofp7arNiQlJaGsrAwhISEQCstPYO7fv/+jPK69vT0OHjwIY2NjKCvXz0tvb2+PnJwcKCsrw9jYuE7HaNOmDVRUVBAfH8/m6z1//hxpaWnsJW9CCCGE8N9HvxRb0xDisWPHoqysDEOGDIFAIMDBgwexatUq9hg1ZWxsjIyMDFy9ehVPnz5FYWHhO/c3MzNDcXEx1q1bh7t372LPnj2VJna8S2ZmJgQCAdTU1N67WsSMGTPw7NkzjBkzBpcvX4ZEIsGpU6fg6en53qawutexb9++6Nq1K1xcXHD69GlkZmYiLi4Ofn5+SExMrNFz0NbWxqRJk+Dj44O///4b169fh4eHB9voEkIIIeTT8NHP2GVnZ6NRo0bvDbnV0dHBX3/9hQkTJgAAfvzxR/j7+2Ps2LFy4+4AIDg4GCdPnqwyhHj48OH4888/0atXL+Tl5SEsLAweHh7VPq6NjQ1Wr16Nn376CYsWLULPnj2xYsWKSiszAFWHEMsMGzYMt2/fRufOnSGVSnH+/PlKZ9A+++wzxMbGwtfXF/369UNhYSFat26NAQMG1LmJEggEOH78OPz8/ODp6YknT56gWbNm6NmzJ5o2bVrj4/z888/spWKRSIT58+fjxYsXda6pNs24ohQUlnBdwjvxOZBVVZm/TT6fXzcelwYAEIDnBZI64fPvnRKPi+NzELuKUs3fgz/qyhN1CdetuLrEsWPH4OnpiRcvXkBDQ4Pdp6GtLlGT10kgEODQoUNwcXGp02MogmzliZyn70/G5sKrImrs6ooau7rhcWkA+L1CAd9fO1I3tMJO3eTn56NpY90arTxRr+/WHxpCvHv3bqSkpAAAQkND4enpibKyMvTp06fBhxDXRHZ2NgYOHAgNDQ2YmpriwIEDctt9fX1hYWEBTU1NmJqaYsmSJXKXhMViMXr16gWRSAQdHR106tRJ7nJtTEwMevToAQ0NDRgZGWH27NkoKCioUW2EEEII4V69fwz/kBDinJwcBAYGAgCCgoIwdOhQxMfHf1AIcUREBLS1tbFo0SLcuXMHAPD06VNs3ryZnSggw2UI8fvqB4Dp06fj/PnzEAqFePDgAdzc3JCamsruKxKJEB4ejps3b2Lt2rXYtm0bfvnlF3b7uHHj0LJlS1y+fBlJSUlYuHAhO1tWIpFgwIABGD58OK5du4bff/8dMTExmDlzZrW1UUAxIYQQwi/1PsbuQ0KIFyxYgC5duqBXr144duwYG5b7ISHEQ4YMkctnkzlx4gT8/f3l7uM6hPhd9Zubm2PMmDFsPQDg5uaGdevWsUHKixcvZrcZGxvD29sb+/btY1fRyMrKgo+PD5udZ25uzu6/YsUKjBs3jg1rNjc3R2hoKBwdHbFp06ZK4xxl30MBxYQQQgh/1Htj97FDiN8+y/Y+IpEIIpEIZ8+exYoVK3Dr1i3k5+ejpKQEb968watXr9isuvoOIX6bRCJhG7uahhDL6geAAQMGwMzMjN3m5OQkN87w999/R2hoKCQSCaRSKUpKSuSuxXt5eWHy5MnYs2cP+vbtCzc3N/b5isViXLt2DREREez+DMOgrKwMGRkZsLS0rFTbokWL4OXlxX6dn58PIyOjGj0vQgghhNS/er8Uy+cQ4o4dO+LgwYNISkrChg0bAJRPXKjqMWWPW9cQ4rfXdE1PT0fPnj3Z/errdZL5559/MG7cODg7O+Po0aNITk6Gn5+f3PNbtmwZbty4ga+//hp///03rKyscOjQIbbu7777Tq5msViM9PR0uWa3IjU1Nejo6MjdCCGEEMIdhQYUUwjxh7l06ZJcDMulS5dgZ2cHoHxSSuvWreHn58duv3fvXqVjWFhYwMLCAvPmzcOYMWMQFhaGYcOGwd7eHjdv3pQ7I0gIIYSQT4tCMwxkIcTffvstUlNTcerUKYWEEI8YMaJWIcRRUVEQCASVZoQaGxt/1BDi9/njjz+wc+dOpKWlYenSpUhISGAnN5ibmyMrKwv79u2DRCJBaGgoezYOAF6/fo2ZM2ciKioK9+7dQ2xsLC5fvsxeYvX19UVcXBxmzpzJnmHcunUrO2OZEEIIIfyn0DN2shDiadOmwdbWFtbW1tWGEL9LbUOIs7OzsWfPnhqFEL/LlClTEBUVxYYQt2nTBmfPnpXb52OEEMsEBARg3759mD59Opo3b47ffvsNVlZWAMonWcybNw8zZ85EYWEhvv76ayxZsgTLli0DACgpKSE3Nxfu7u549OgRDAwM4Orqyk5+6NixI6Kjo+Hn54cePXqAYZg6j5crLi1DcemHrUP7MXzg0rgfXW7Buz+gcKmFvsb7d+IIn/POyvgcFAcKKG6o+Pxrx+esOD5n7NWmto8aUFwTERERVYYQ15cPDUnW09Orch+uQpLfVpfnV1O1DV+WBRTff/Scl+PtCov53dk9Lyh6/04c4XNjp8TnPxR8/gsLfjd2fG7Y+Y7Pv3Z8/rnyubHLz89Hc0M9xQcU18Tu3bsRExODjIwMREZGwtfXFyNHjqy3pu5DQ5IrSkpKQufOnaGpqYlu3bp9cEhyXl4eJk+eDENDQ+jo6KB3794Qi8XsdolEgqFDh6Jp06bQ1tbG559/XumMoLGxMZYvXw53d3fo6Ojg22+/BfD+cGFjY2MEBQXhm2++gUgkQqtWrbB161a5YyckJMDOzo59XZKTk2vykhNCCCGEJxTe2OXk5GD8+PGwtLTEvHnz4ObmVqnBqC1ZiK+2tjYuXryIDRs2YOPGjXj9+jUePHggt+/7QpIr8vPzQ0hICBITEz8oJFnGzc0Njx8/xokTJ5CUlAR7e3v06dMHW7Zsgba2NqytrXHq1Cm8fPkSDMNALBajX79+yMrKkjvOqlWrYGNjg+TkZCxZsqTG4cIhISFswzZ9+nRMmzaNbValUikGDRoEKysrJCUlYdmyZWw4c3UooJgQQgjhF4WOsQPKQ4hlgbn1pWII8bhx4yCVSnH48GEA5REmxsbG7L7vC0muKDAwEI6OjgA+LCQZKD+jlpCQgMePH7MzgletWoXIyEgUFhZWe0nX2dkZR44ckWvSevfujfnz57NfT548uUbhws7Ozpg+fTqA8skSv/zyC86fP4+2bdti7969KCsrw44dO6Curo727dvjwYMHmDZtWrXPiQKKCSGEEH5ReGP3MVQM8dXQ0EDHjh2rje3gIiQZKA8AlkqlaNy4sdz9r1+/xsOHD2FmZgapVIply5bh2LFjyM7ORklJCV6/fl3pjF3nzp0rHbsm4cIVn49AIECzZs3YAObU1NRKr0vXrl3f+ZwooJgQQgjhlwbR2L2NjyHJUqkUzZs3R1RUVKVtsgka3t7eOHPmDFatWgUzMzNoaGhgxIgRciHDQOXnJwsXnj17dqVjV2xCqwpgruvzAcoDimVnHwkhhBDCvQbZ2L0LVyHJ9vb2yMnJgbKystyl4YpiY2Ph4eGBYcOGAShv2DIzM2t07A8NF7a0tMSePXvw5s0b9qzdpUuX6nw8QgghhCiewidPfAxvz3p9l7Fjx6KoqAjq6uqIj49XWEhy37590bVrV7i4uOD06dPIzMxEXFwc/Pz8kJiYCKB8bNyff/7JLuclC3TOz89/Z1BwVeHChw8frjR54l3Gjh0LgUCAKVOm4ObNmzh+/Dj7uhBCCCHk09AgzthlZ2ejUaNGNdpXR0cHgYGBmDVrFnr27PnOkOQePXogJSWlyuPUNiRZIBDg+PHj8PPzg6enJ548eYJmzZqhZ8+eaNq0KQBg9erV+Oabb9CtWzcYGBjA19e3RjNNqwoXbtOmTY1m6spoa2vjr7/+wtSpU2FnZwcrKyv89NNPGD58eI2PIaMsFECZh9lipUr8q6miF68qr2TCF7qaKu/fiSMidf6+jfE5jBXgd84ew+PYSb7/XPmcFcfjXznwONaxVrVxHlD8oeojgLiqkGQ+BRD/+++/tQoK5oosoDj7SR4/A4pLePyXAsCdHCnXJVSrlYEm1yVUixq7uuNzYwcel8b3nyuf8ftXjr/F5efno7kBTwOKP1R9BBCfOnUKAHD06FGYmZlh/Pjx0NXVZWef8jGAGABu3bqFbt26QV1dHR06dEB0dDS7rbS0FJMmTYKJiQk0NDTQtm1brF27Vu7YUVFR6NKlC7S0tKCnp4fu3bvj3r177PbDhw/D3t4e6urqMDU1RUBAAEpKSt77vAkhhBDCD59cYwcAu3btgqqqKmJjY7F582a5bTUJIH727BkAwN3dHQUFBZgwYQIsLCw+KIA4IiICBgYGCA8PR0FBAUpLSxETEwNbW1u0a9cOQPlkCGdnZ5w7dw7JyckYMGAABg8e/N4AYhkfHx/Mnz8fycnJ6Nq1KwYPHozc3FwA5bN1W7ZsiT/++AM3b96Ev78/vv/+e+zfvx8AUFJSAhcXFzg6OuLatWv4559/8O2337LjCi9evAh3d3fMmTMHN2/exJYtWxAeHo7AwMBa/WwIIYQQwh3+XsN4B3Nzc6xcubLKbTUJIB4zZgy2bt2KM2fOoE+fPgCA48ePf1AAsaGhITQ0NBAfHy93abhPnz6YOHEiAMDGxgY2NjbstuXLl+PQoUPvDSCWzYydOXMmO+Zt06ZNOHnyJHbs2IEFCxZARUVFLizYxMQE//zzD/bv34+RI0ciPz8fL168wKBBg9CmTRsAYPPtACAgIAALFy5kazU1NcXy5cuxYMECLF26tMrnXFhYKDdphFaeIIQQQrj1STZ2nTp1qnYbVwHE6enpePXqVaXHev36NfLy8gCgzgHEMhUDg5WVldG5c2ekpqay923YsAE7d+5EVlYWXr9+jaKiInZMnr6+Pjw8PNC/f3989dVX6Nu3L0aOHMk+b7FYjNjYWLkzdKWlpXjz5g1evXoFTc3KY6xo5QlCCCGEXz7Jxu6/FkBcE/v27YO3tzdCQkLQtWtXiEQi/Pzzz4iPj2f3CQsLw+zZs3Hy5En8/vvvWLx4Mc6cOQMHBwdIpVIEBATA1dW10rHfni0sQytPEEIIIfzySTZ279IQA4hlLl26hJ49ewIoHzOXlJTEXsKNjY1Ft27d2LVggfLJGm+zs7ODnZ0dFi1ahK5du2Lv3r1wcHCAvb09bt++XauQY1p5ghBCCOGXT3LyxLvIQn2//fZbpKamfvQAYtmM3JoGEO/evRsCgQAXL15ka62pDRs24NChQ7h16xZmzJiB58+fsxM+zM3NkZiYiFOnTiEtLQ1LliyRa2gzMjKwaNEi/PPPP7h37x5Onz6N9PR0dpydv78/du/ejYCAANy4cQOpqanYt28fFi9eXOP6CCGEEMKtBtfY6ejo4K+//sLVq1dha2sLPz8/+Pv7A6j+kmJVhg8fjgEDBqBXr14wNDTEb7/9VuV+2dnZGDhwIBtA3LNnT3h6esLCwgKjR4/GvXv35AKIRSIRgPIJHP3794e9vX2lY548ebLKvLrg4GAEBwfDxsYGMTExOHLkCAwMDAAA3333HVxdXTFq1Ch88cUXyM3NlTt7p6mpiVu3bmH48OGwsLDAt99+ixkzZuC7774DAPTv3x9Hjx7F6dOn8fnnn8PBwQG//PILWrduXePXjBBCCCHc+uQDimuiqgDi+lAf4chV4Us4cm3JAoqv3MmBSMS/gOJmujVv7Lnwprjml/4VTVNNiesSqvWmiL/B09JCfudAGmjX7v1LkfgcAszrYGcAQj4vPcFjfP65NuiA4prYvXs3YmJikJGRgcjISPj6+mLkyJEf3NTVRziyTFJSEjp37gxNTU1069YNt2/fBvBxwpEzMzMhFArZS8Iya9asQevWrdnLwdevX8fAgQOhra2Npk2bYsKECXj69GndXzBCCCGEKFSDbOxycnIwfvx4WFpaYt68eXBzc8PWrVs/6JgRERG4ePEiNmzYgI0bN+L169dISEgAAMyePRtAzcKRZfz8/BASEoLExEQoKyt/UDgyALi5ueHx48c4ceIEkpKSYG9vjz59+uDZs2cwNjZG3759ERYWJvc9svVthUIh8vLy0Lt3b9jZ2SExMREnT57Eo0ePMHLkyA952QghhBCiQA1uViwALFiwAAsWLKjXYw4ZMgSdO3eGVCrF4cOH2fvNzc3ZCQY1CUeWCQwMhKOjIwBg4cKFHxSOHBMTg4SEBDx+/Jidpbpq1SpERkbiwIED+PbbbzF58mRMnToVq1evhpqaGq5cuYKUlBT2uaxfvx52dnYICgpij7tz504YGRkhLS0NFhYWlR6XAooJIYQQfmmQZ+w+BpFIBA0NDXTr1g1mZmbsDQCaNGkCoH7CketCLBZDKpWicePG0NbWZm8ZGRls5ImLiwuUlJRw6NAhAOWXfHv16sVGs4jFYpw/f17u+2VLoVUVmwKUBxTr6uqyN8qwI4QQQrjVIM/YfUyfajiyqqoq3N3dERYWBldXV+zduxdr166VO8bgwYPx008/VTqGrPF8GwUUE0IIIfxCjV094nM4MgBMnjwZHTp0wMaNG1FSUiK3yoS9vT0OHjwIY2NjKCvX7NeCAooJIYQQfqFLsfVI0eHIMjUJRwYAS0tLODg4wNfXF2PGjJGbJTxjxgw8e/YMY8aMweXLlyGRSHDq1Cl4enrWqskkhBBCCHeosatg2bJlVQYD19THCkceNWrUO+uqGI7s4uICU1PTSuHIMpMmTUJRURE7CxcoX46sf//+ePbsGWJjY9GvXz9YW1tj7ty50NPTg1BIvyaEEELIp+A/eylWIBDg0KFDcHFxqfH3VDWG7e18527durH5cUB5TIqKigpatWoFoDwL7+3vsbW1lbtPTU0NBw4cYL9etmzZe9eUFYlECA0NRX5+PvLy8uSy9Sp6+PAhrK2t8fnnn7P3eXl5wdbWFidOnIC2tjbWrFnzQQHJehoqEGmqvH9HBeNz+CQAaKvz979jaRl/Xzs+hyerq/D7QxGff66FJfwNnlZT5vfPtQz8/bnyOjyZvy9brWrj71+ST9Tu3bthamqKFi1aQCwW11s48oeSSqXIzMzE+vXr8eOPP8ptk0gkmDp1Klq2bMlRdYQQQgipD5x/7HBycsKsWbMwd+5cNGrUCE2bNsW2bdtQUFAAT09PiEQimJmZ4cSJE+z3REdHo0uXLlBTU0Pz5s2xcOFClJT8b+keY2NjrFmzRu5xbG1tsWzZMnY7AAwbNgwCgaDShIM9e/bA2NgYurq6GD16NF6+fFmj51JWVobffvsNTk5OMDU1xciRI2Fra8uGI0dFRUEgEODcuXNVrjohExwcjKZNm0IkEmHSpElISkpCSkqKXBSJ7Na+ffv31rRixQo0a9YM1tbWKCwsZJcjyczMhEAgQG5uLr755ht2lYu6rHxBCCGEEO5x3tgBwK5du2BgYICEhATMmjUL06ZNg5ubG7p164YrV66gX79+mDBhAl69eoWHDx/C2dkZn3/+OcRiMTZt2oQdO3ZUOgv1LrKZqmFhYcjOzpabuSqRSBAZGYmjR4/i6NGjiI6ORnBwcI2Ou2LFCmRmZuLYsWOQSCTYunUrzp49W2lmbHWrTgDA/v37sWzZMgQFBSExMZGNMbGwsMDVq1cr3Y4fP/7emnbv3o2DBw9CIpFgzZo1mDhxIqKjo2FkZITs7Gzo6OhgzZo17CoXNV35orCwEPn5+XI3QgghhHCHF5dibWxs2NUbFi1ahODgYBgYGLArNvj7+2PTpk24du0a/vrrLxgZGWH9+vUQCARo164d/v33X/j6+sLf379GA/0NDQ0BlGe8vb26Q1lZGcLDwyESiQAAEyZMwLlz5xAYGPjOYxYWFiIoKAhnz55F165dAQCmpqaIiYnBli1b2FUmgOpXnVBXV8eaNWswadIkTJo0CQDw448/4uzZs3jz5g0biFxTNampWbNmEAgE0NXVZV+Lmq58sWLFCgQEBNSqJkIIIYR8PLw4Y1dxFQYlJSU0btwY1tbW7H2ymZ2PHz9GamoqunbtKhcf0r17d0ilUjx48OCDazE2NmabOqA8nLcmK0LcuXMHr169wldffSV3qXT37t2VVm5416oTqamp+OKLL+T2lzVltVWbmupi0aJFePHiBXu7f//+Bx+TEEIIIXXHizN2FVdhAMpnrH7IygxCobDSzNPi4uI611KTx5VKpQCAY8eOoUWLFnLb3g7xrc9VJ+qrprqggGJCCCGEX3jR2NWGpaUlDh48CIZh2KYoNjYWIpGIndVpaGiI7Oxs9nvy8/ORkZEhdxwVFZV6Dd61srKCmpoasrKy5C671palpSXi4+Ph7u7O3nfp0iWF1lTblS8IIYQQwg+8uBRbG9OnT8f9+/cxa9Ys3Lp1C4cPH8bSpUvh5eXFjq/r3bs39uzZg4sXLyIlJQVffPFFpdUbjI2Nce7cOeTk5OD58+cfXJdIJIK3tzfmzZuHXbt2QSKR4MqVK1i3bh127dpV5fcsW7as0sSEOXPmYOfOnQgLC0NaWhqWLl2KGzduVPu4Vc0AfldNR48ehUAgwA8//PDOY9Zm5QtCCCGE8MMnd8auRYsWOH78OHx8fGBjYwN9fX1MmjSJnXwhEAgQEREBR0dHDBo0CLq6urC3t68U8BsSEgIvLy9s27YNLVq0eG8AcE0sX74choaGWLFiBe7evQs9PT3Y29vj+++/r/ExRo0aBYlEggULFuDNmzcYPnw4pk2bhlOnTlW5/+XLl6GlpVXjmmRRJ29fmq2ooKAAhYWF6NWrF/Ly8hAWFgYPD48aP4fCkjKo8jBclM9BtgBQUsrfdEw+Lz7C59dNScjjMFYAfM6KVeLx75yQ5z9Xvoex8xaff6y1qE3AvD0Y7RNX1YoSy5Yt+6CVFD6WD6mrqKgIqqqqtf6+zMxMmJiYIDk5udplysLDwzF37lzk5eXV6tj5+fnQ1dVF6r0nEP1/A8knjbT4txpGRR9hmGW94XNjx+fXje+NHZ9XnuBzc6LK95UnePza8XnlCT6/bvn5+WhuoIcXL16wJ2iq89F+Oxta8PCKFStgYmICDQ0N2NjYyC35Vdfg4Tdv3tTo8QHAw8MDLi4uCAwMxGeffYa2bdtW+ZrcunULX375JdTV1WFlZYWzZ89CIBBUWmLs7t276NWrFzQ1NWFjY4N//vmHfS6enp548eIFG1Ase30JIYQQwm8f9WNHQwoe3rFjB3JyciAUCnH79m24ublBQ0MD2traGDhwIIDaBw9v3Lixxs8NAM6dO4dly5YhLy8Pjx49gra2NrKysuDr6wttbW1ERUXBxcUFmpqaiI+Px9atW+Hn51flsfz8/ODt7Y2rV6/CwsICY8aMQUlJCbp164Y1a9ZAR0eHDSj29vau8hgUUEwIIYTwy0cdY9eQgodPnjzJZs4BwPfff4/Xr1/jl19+QXx8PMaPH1/n4OGa0tLSQnx8vNwlWEdHR3h4eMDT0xM3btyARCJBVFQU+/wDAwPx1VdfVTqWt7c3vv76awBAQEAA2rdvjzt37qBdu3bQ1dWFQCCggGJCCCHkE/NRz9g1pODhgQMHwtbWlr1FRkbi8ePHMDMzYycifOzgYWtra1hZWcHMzIy9qaiowNDQEGZmZsjMzISRkZFcQ9alS5cqj/WuWmuKAooJIYQQfvmoZ+woeLh+g4ffNfu1tuqjVgooJoQQQviFN1N7LC0t8c8//8g1bnwLHq54pszMzAxGRkY1Po4seLiiugYPV6dt27a4f/8+Hj16xN5XcZxhTVFAMSGEEPJp4k2O3fTp07FmzRrMmjULM2fOxO3bt6sMHg4PD8fgwYOhp6cHf39/KCnJZ5PJgoe7d+8ONTU1NGrUqM41OTk5wdbWlg35LSsrw5dffokXL14gNjYWOjo6mDhxYo2ONWfOHHh4eKBz587o3r07IiIicOPGDZiamta5vrft3bsX6urqmDhxIlauXImXL1/K5fvVlLGxMaRSKc6dOwcbGxtoampCU1Oz3uokhBBCyMfBm8bufcHDQPmYroyMDDZ4ePny5ZXO2DWU4OG6WLduHWbOnAkvLy98/vnnMDU1xc8//4zBgwdDXV29xsfp1q0bpk6dilGjRiE3NxdLly6tVeSJipIAKkr8yyoqKOT3WUh1Fd6cQK+krIx/P08ZPmfs8TiyCwC/c7v4/NoV8TCAvSIev3QAj3Pi+ZyxV5vaGlxAcX2SnbGrbskuRaprIHFsbCy+/PJL3LlzB23atPkIlf2PLKD4zoOnvAwors1ZSy7wubET8PhPBZ8bOz7/oQD43aDw+aXj+19NHr90UOLhh34ZPv9/zc/PR9PGutwGFDc0z58/h7u7Oxo1agRNTU0MHDgQ6enpAACGYWBoaCgXWmxraysXjxITEwM1NTW8evUKAJCXl4fJkyfD0NAQOjo66N27N8RiMbv/smXLYGtri+3bt8PExKRGZ9w8PDzQpUsXnDlzBpmZmVixYgW++uorKCkpoUuXLhg0aBAkEgm7/4gRIzBz5kz267lz50IgEODWrVsAyptJLS0tnD17to6vGiGEEEIU6T/f2GVlZUFbW7vK28WLF9nQXQ8PDyQmJuLIkSPsJA9nZ2cUFxdDIBCgZ8+eiIqKAlDeBKampuL169dskxQdHY3PP/+cHavm5uaGx48f48SJE0hKSkJMTAxsbW3Zxw4KCoJYLMbUqVOxePHiGi87VlJSghkzZqBdu3ZYtWoVvvjiC/zzzz84d+4chEIhhg0bxs5+dXR0ZGuW1WhgYMDed/nyZRQXF6Nbt25VPhYFFBNCCCH8wpsxdlz57LPPqm2axo0bB21tbaSnp+PIkSOIjY1lm5yIiAgYGRkhMjISbm5ucHJywpYtWwAAFy5cgJ2dHZo1a4aoqCi0a9cOUVFRbHhxTEwMEhIS8PjxYzYu5ObNm+jTpw+mTJmC0aNHIzQ0FJs2bUJMTAw6duwIDQ2NGj2fVq1aVVo+TGbnzp0wNDTEzZs30aFDBzg5OWHOnDl48uQJlJWVcfPmTSxZsgRRUVGYOnUqoqKi5JrRt1FAMSGEEMIv//kzdsrKypViTGQ3DQ0NCIVCpKamQllZWS5guHHjxmjbti1SU1MBlJ/9unnzJp48eYLo6Gg4OTnByckJUVFRKC4uRlxcHJycnAAAYrEYUqkUjRs3Zs/Q2dra4sGDB3j58iXMzMygr68PY2NjfPHFFzVu6t6Wnp6OMWPGwNTUFDo6OuzauVlZWQCADh06QF9fH9HR0bh48SLs7OwwaNAgREdHAwD7PKpDAcWEEEIIv/znz9jVF2tra7ZJio6ORmBgIJo1a4affvqp0iVNqVSK5s2by10GldHT02P//aGBxIMHD0br1q2xbds2fPbZZygrK0OHDh1QVFQEAHKXkNXU1ODk5ISOHTuisLAQ169fR1xcXLXrxAIUUEwIIYTwDTV2NWBpaYmSkhLEx8ezzVlubi5u374NKysrAOVNUo8ePXD48GHcuHEDX375JTQ1NVFYWIgtW7agc+fObKNmb2+PnJwcKCsrs2fR6pusvm3btqFHjx4Ayi8Bv83R0RHbtm2DmpoaAgMDIRQK0bNnT/z8888oLCxE9+7dP0p9hBBCCKl///lLsTVhbm6OoUOHYsqUKYiJiYFYLMb48ePRokULDB06lN3PyckJv/32GzsJQtYkRUREsOPrAKBv377o2rUrXFxccPr0aWRmZiIuLg5+fn5ITEysc50HDhxgZ702atQIjRs3xtatW3Hnzh38/fff8PLyqvQ9Tk5OuHnzJtuMyu6LiIiQa0YJIYQQwn90xq6GwsLCMGfOHAwaNAhFRUXo2bMnjh8/LrfmqqOjI0pLS+XGpTk5OeHw4cNy9wkEAhw/fhx+fn7w9PTEkydP0KxZM/Ts2RNNmzatc42DBg1CQUEBgPJ1dfft24fZs2ejQ4cOaNu2LUJDQyuNmbO2toaenh4sLCygra3N1vz286iN0jIGpWX8C3oSqdOve10pCfmb78TnkN3XxfwOxVZV5u9nez5nivEdvXR1w+f3ktrURgHFn4iaBBSPGTMGSkpK+PXXXxVUlTxZQPHtrCe8DCgWqau8fydSJWrs6obPAcAANXYNFb10dcPn95L8/Hw0N9CjgOK6MDY2rrTShK2tLbuklkAgwKZNmzBw4EBoaGjA1NRULpg4MzMTAoEA+/btQ7du3aCuro4OHTqwM01lrl+/joEDB0JbWxtNmzbFhAkT8PTpU3a7k5MTZs6ciblz58LAwAD9+/evtuaSkhLcvHkTf/zxB/Ly8tj7V69eDWtra2hpacHIyAjTp0+HVCoFULdQZUIIIYTwGzV2dbBkyRIMHz4cYrEY48aNw+jRo9nYExkfHx/Mnz8fycnJ6Nq1KwYPHozc3FwA5atO9O7dG3Z2dkhMTMTJkyfx6NEjjBw5Uu4Yu3btgqqqKmJjY+Hv719tkLKenh46dOgAVVVVuTBhoVCI0NBQ3LhxA7t27cLff/+NBQsWAECdQpXfRgHFhBBCCL/QoKM6cHNzw+TJkwEAy5cvx5kzZ7Bu3Tps3LiR3WfmzJkYPnw4AGDTpk04efIkduzYgQULFmD9+vWws7NDUFAQu//OnTthZGSEtLQ0WFhYACiftLFy5UoAQJs2bd65+oSxsTHMzMzkmrC5c+fKbf/xxx8xdepUts7ahCpXhQKKCSGEEH6hxq4OunbtWunrt5uuivsoKyujc+fO7Fk9sViM8+fPs5MVKpJIJGxj16lTJ7ljmJmZ1arOs2fPYsWKFbh16xby8/NRUlKCN2/e4NWrV9DU1ISjoyO78oQsjFjW2E2aNAlxcXHsGb6qLFq0SG6mbX5+PoyMjGpVIyGEEELqD12KfYtQKMTb80mKi4vr9TGkUikGDx6Mq1evyt3S09PRs2dPdr8PiRrJzMzEoEGD0LFjRxw8eBBJSUnYsGEDALABxW+HKstWy4iOjn7vOrFAeUCxjo6O3I0QQggh3KHG7i2GhobIzs5mv87Pz0dGRobcPpcuXar0taWlZbX7lJSUICkpid3H3t4eN27cYC+fVrzVV25cUlISysrKEBISAgcHB1hYWODff/+V26eqUGXZyhNvhyoTQgghhP+osXtL7969sWfPHly8eBEpKSmYOHEilJSU5Pb5448/sHPnTqSlpWHp0qVISEjAzJkz5fbZsGEDDh06hFu3bmHGjBl4/vw5vvnmGwDAjBkz8OzZM4wZMwaXL1+GRCLBqVOn4OnpidLS+sm9MjMzQ3FxMdatW4e7d+9iz5492Lx5c6X9ahqqTAghhBD+ozF2b1m0aBEyMjIwaNAg6OrqYvny5ZXO2AUEBGDfvn2YPn06mjdvjt9++41dWkwmODgYwcHBuHr1KszMzHDkyBEYGBgAAD777DPExsbC19cX/fr1Q2FhIVq3bo0BAwZAKKyfXtvGxgarV6/GTz/9hEWLFqFnz55YsWIF3N3d5faraahybYTG3YOaZuXxg1wL6GfBdQmfLD7nYinxuDg+58QBAI9ju/CmhL/hzuoqSu/fiXxy+JydWJvaKKC4lgQCAQ4dOgQXF5cqt2dmZsLExATJycmwtbVVaG3NmzfH8uXL2Rm7iiYLKJ6xL5EauwZGWYm/b3h8xscVWCri87t/cSl/w5353tjxuD8hdZSfn4+mjXUpoLihcXJywuzZs7FgwQLo6+ujWbNmWLZsGV69eoUzZ87g0aNH2LVrF7S1taGjo4ORI0fi0aNH7PeLxWL06tULIpEIOjo66NSpk9zatDExMejRowc0NDRgZGSE2bNns0uUEUIIIYT/qLH7RERERODixYtYt24d1qxZg8LCQuTl5SEgIAD6+voYPXo0e6k3OjoaZ86cwd27dzFq1Cj2GOPGjUPLli1x+fJlJCUlYeHChexatxKJBAMGDMDw4cNx7do1/P7774iJiak0dpAQQggh/EWXYj8RL1++RN++fVFaWop9+/ax97u6uqJ79+5wdXXFwIEDkZGRwWbJ3bx5E+3bt0dCQgI+//xz6OjoYN26dZg4cWKl40+ePBlKSkpsYDFQfgbP0dERBQUFUFdXr/Q9hYWFKCwsZL+W5djRpdiGhy7F1g1diq07uhRbd3QptuGhS7ENkEgkgoaGBr744gu5eBQTExMUFhYiNTUVRkZGcgHBVlZW0NPTY4ORvby8MHnyZPTt2xfBwcGQSCTsvmKxGOHh4XJLlfXv3x9lZWWVJo/IrFixArq6uuyNwokJIYQQblFj94mRXTqVEQgEKCur2SfbZcuW4caNG/j666/x999/w8rKCocOHQJQHpr83XffyQUmi8VipKeno02bNlUeb9GiRXjx4gV7u3///oc9OUIIIYR8EIo7aSAsLS1x//593L9/X+5SbF5enlwUi4WFBSwsLDBv3jyMGTMGYWFhGDZsGOzt7XHz5s1aLVumpqYGNTW1en8uhBBCCKkbauwaiL59+8La2hrjxo3DmjVrUFJSgunTp8PR0RGdO3fG69ev4ePjgxEjRsDExAQPHjzA5cuXMXz4cACAr68vHBwcMHPmTEyePBlaWlq4efMmzpw5g/Xr19eoBtlwzaJX0o/2PD9Efn4+1yV8smiMXd3QGLu64/MYuyIaY0cU7OX///2qybQIauwaCIFAgMOHD2PWrFno2bMnhEIhBgwYgHXr1gEAlJSUkJubC3d3dzx69AgGBgZwdXVFQEAAAKBjx46Ijo6Gn58fevToAYZh0KZNG7lZte/z8uVLAMC2b5zq/fnVhw1cF0AIIYR8gJcvX0JXV/ed+9CsWFJvysrK8O+//0IkEkFQDx8ZZbNs79+//95ZQIrG59oAftdHtdUdn+uj2uqGz7UB/K7vv1QbwzB4+fIlPvvss/euUEVn7Ei9EQqFaNmyZb0fV0dHh3f/aWX4XBvA7/qotrrjc31UW93wuTaA3/X9V2p735k6GZoVSwghhBDSQFBjRwghhBDSQFBjR3hLTU0NS5cu5WWkCp9rA/hdH9VWd3yuj2qrGz7XBvC7PqqtajR5ghBCCCGkgaAzdoQQQgghDQQ1doQQQgghDQQ1doQQQgghDQQ1doQQQgghDQQ1doRXLly4gJKSkkr3l5SU4MKFCxxURD4UwzDIysrCmzdvuC6FEEIaPGrsCK/06tULz549q3T/ixcv0KtXLw4q+p+SkhL88MMPePDgAad1fGoYhoGZmRnu37/PdSnkPywjI6PKD41cunPnDk6dOoXXr18DqNkC7/9lDx48gFQqrXR/cXExffCvgBo7wisMw1S5zmxubi60tLQ4qOh/lJWV8fPPP/PujwNQ/sb2zTffICMjg+tSKhEKhTA3N0dubi7XpdSKRCJB7969OXv87Oxs/Prrrzh+/DiKiorkthUUFOCHH37gqLJyZ86cwdKlS/H3338DKD/bPnDgQPTu3RthYWGc1laVtm3bIj09nesyAJS/n/Xt2xcWFhZwdnZGdnY2AGDSpEmYP38+x9VV7dGjR5z9zmVnZ6NLly5o3bo19PT04O7uLtfgPXv2jLMP/q6urjW+KQrl2BFekP3SHz58GAMGDJALdSwtLcW1a9fQtm1bnDx5kqsSAQBDhw6Fq6srJk6cyGkdVdHV1cXVq1dhYmLCdSmV/PXXX1i5ciU2bdqEDh06cF1OjYjFYtjb26O0tFThj3358mX069cPZWVlKC4uRosWLRAZGYn27dsDKP8j+9lnn3FSGwD8+uuv8PT0RMeOHZGWloZ169Zh3rx5GDFiBMrKyvDrr78iIiICI0aMUHht1f0BPXz4MHr37g2RSAQA+PPPPxVZlhx3d3c8fvwY27dvh6WlJcRiMUxNTXHq1Cl4eXnhxo0bnNVWHS7/P0ycOBG3b9/G+vXrkZeXh4ULF0IgEOD06dNo1KgRHj16hObNm6OsrEzhtXl6erL/ZhgGhw4dgq6uLjp37gwASEpKQl5eHlxdXRX2gUdZIY9CyHvIFjdmGAYikQgaGhrsNlVVVTg4OGDKlClclccaOHAgFi5ciJSUFHTq1KnSWcQhQ4ZwVBng4uKCyMhIzJs3j7MaquPu7o5Xr17BxsYGqqqqcj9fAFVefv/YQkND37n94cOHCqqksu+//x7Dhg3D9u3bUVBQAF9fXzg6OuLMmTOws7PjrC6ZkJAQhISEYPbs2Th37hwGDx6MwMBA9nfPysoKa9as4aSxi4yMRM+ePav8gKOtrV3jhdQ/ptOnT+PUqVNo2bKl3P3m5ua4d+8eJzVdu3btndtv376toEoqO3v2LA4dOsQ2S7GxsXBzc0Pv3r1x7tw5AKjySo8iVGzWfH19MXLkSGzevBlKSkoAyk9MTJ8+HTo6Ogqric7YEV4JCAiAt7c355ddqyMUVj96QSAQcHYGBQB+/PFHhISEoE+fPlU2nbNnz+aoMmDXrl3v3M7FGVChUIjmzZtDVVW1yu1FRUXIycnh5Geqr6+PS5cuwcLCgr0vODgYK1euxKlTp9CqVStOz9hpa2sjJSWFbZ5UVVWRmJiIjh07AgBu3bqFL7/8Ek+fPlV4bfv27YOPjw9++OEHubMpKioqEIvFsLKyUnhNbxOJRLhy5QrMzc0hEonYM3aJiYno378/J8MWhEIhBAJBleP8ZPdz9R6nra2N5ORkmJubs/eVlJTAzc0Nd+/exa+//gpbW1tO338BwNDQEDExMWjbtq3c/bdv30a3bt0U9nOlM3aEV5YuXcp1Ce/Exan+mtqxYwf09PSQlJSEpKQkuW0CgYDTxo6Pl65bt26Nn376CSNHjqxy+9WrV9GpUycFV/U/b88iXrhwIZSVldGvXz/s3LmTo6rKqaioyI37U1NTg7a2ttzXsgkBijZ69Gg4ODhg/PjxOHr0KLZv345GjRpxUkt1evTogd27d2P58uUAyv9/lpWVYeXKlZyNFdPX18fKlSvRp0+fKrffuHEDgwcPVnBV5UxNTXHt2jW5xk5ZWRl//PEH3NzcMGjQIE7qeltJSQlu3bpVqbG7deuWQv92UGNHeMXExOSdp9Tv3r2rwGo+LXycOFGRRCJBWFgYJBIJ1q5diyZNmuDEiRNo1aoVO3ZMkTp16oSkpKRqG7vqzl4oQocOHRAXF8eeAZPx9vZGWVkZxowZw0ldMmZmZnJ/wB4+fMiOXQPKf9ZvX2ZUJGNjY1y4cAEBAQGwsbHBtm3bOLtUVxVZA5WYmIiioiIsWLAAN27cwLNnzxAbG8tJTZ06dcK///6L1q1bV7k9Ly+Ps/8PAwcOxNatWzF8+HC5+2XN3fDhw3mRVuDp6YlJkyZBIpGgS5cuAID4+HgEBwfLnT3+2KixI7wyd+5cua+Li4uRnJyMkydPwsfHh5ui3lJQUIDo6GhkZWVVmq3I5VkxPouOjsbAgQPRvXt3XLhwAYGBgWjSpAnEYjF27NiBAwcOKLymH374Aa9evap2u5WVFWfNsru7O6KjozF16tRK2xYsWACGYbB582YOKiv3/fffy50Fe3v8UGJiYrUNs6IIhUIEBATgq6++gru7O+eX6Srq0KED0tLSsH79eohEIkilUri6umLGjBlo3rw5JzVNnToVBQUF1W5v1aoVZ7OdAwMDq/2/qqysjIMHD3I6JlZm1apVaNasGUJCQtiZzs2bN4ePj49CZzvTGDvySdiwYQMSExM5j1FITk6Gs7MzXr16hYKCAujr6+Pp06fQ1NREkyZNOD+j+ODBAxw5cqTKpnP16tUcVQV07doVbm5u8PLykhtTlJCQAFdXV1582iYNl1QqhUQigaWlZbVjKgmpT/n5+QAqf+hRBGrsyCfh7t27sLW1Zf+zcMXJyQkWFhbYvHkzdHV1IRaLoaKigvHjx2POnDkKzSp627lz5zBkyBCYmpri1q1b6NChAzIzM8EwDOzt7dm8MS5UHGxfsbHLzMxEu3btOF+VoqSkBFFRUZBIJBg7dixEIhH+/fdf6OjoyI0do9o+rfr4WtvJkyehra2NL7/8EkD5B9dt27bBysoKGzZs4N2YQC55eXnVeF8uP7zyCV2KJZ+EAwcOQF9fn+sycPXqVWzZsgVCoRBKSkooLCyEqakpVq5ciYkTJ3La2C1atAje3t4ICAiASCTCwYMH0aRJE4wbNw4DBgzgrC4A0NPTQ3Z2dqUIiuTkZLRo0YKjqsrdu3cPAwYMQFZWFgoLC/HVV19BJBLhp59+QmFhIaeXPPlcG9/r43NtPj4++OmnnwAAKSkp8PLywvz583H+/Hl4eXkp/MoEn5un5OTkGu3HhzGUjx49gre3N86dO4fHjx9XGpOoqOEA1NgRXrGzs5P7D8owDHJycvDkyRNs3LiRw8rKqaiosJEnTZo0QVZWFiwtLaGrq8v5klmpqan47bffAJSPO3n9+jW0tbXxww8/YOjQoZg2bRpntY0ePRq+vr74448/2BmAsbGx8Pb2hru7O2d1AcCcOXPQuXNniMViNG7cmL1/2LBhnGcn8rk2gN/18bm2jIwMNnbl4MGDGDx4MIKCgnDlyhU4OzsrvB4+N0/nz59X+GPWlYeHB7KysrBkyRI0b96cs2aTGjvCKy4uLnJfC4VCGBoawsnJCe3ateOmqArs7Oxw+fJlmJubw9HREf7+/nj69Cn27NnD+YoKWlpa7Li65s2bQyKRsLNNucgTqygoKAgzZsyAkZERSktLYWVlhdLSUowdOxaLFy/mtLaLFy8iLi6u0tgrY2Njzgdk87k2gN/18bk2VVVVdjLA2bNn2Q83+vr6nAw3+ZSaJz6LiYnBxYsXYWtry2kd1NgRXuF7jl1QUBBevnwJoHymlru7O6ZNmwZzc3POs8UcHBwQExMDS0tLODs7Y/78+UhJScGff/4JBwcHTmtTVVXFtm3bsGTJEly/fh1SqRR2dnZyuVRcKSsrq/ISyYMHD+QiPLjA59oAftfH59q+/PJLeHl5oXv37khISMDvv/8OAEhLS+M0JoaPXF1dER4eDh0dnfcOdeFymTgAMDIy4iwSpiJq7AjvlJaWIjIyEqmpqQCA9u3bY8iQIewSLVySLWkDlF+K5Xrt2opWr17NLowdEBAAqVSK33//Hebm5rwZVNyqVSu0atWK6zLk9OvXD2vWrMHWrVsBlF9ukkqlWLp0KSeXxT6V2gB+18fn2tavX4/p06fjwIED2LRpEzvO9MSJE5yMh+Vz86Srq8te0uTDcnDvsmbNGixcuBBbtmyBsbExZ3XQrFjCK3fu3IGzszMePnzIhp/evn0bRkZGOHbsGNq0acNxhfydacc3fB6QXdGDBw/Qv39/MAyD9PR0dO7cGenp6TAwMMCFCxfQpEkTqu0TrI/PtfGNp6cnQkNDIRKJ3huky3XkFJ81atQIr169QklJCTQ1NaGioiK3XVFrYlNjR3jF2dkZDMMgIiKCnQWbm5uL8ePHQygU4tixY5zW9/ZMu7S0NJiammLOnDmcz7QDytPhDxw4AIlEAh8fH+jr6+PKlSto2rSpwmefvr000pUrV1BSUsI27GlpaVBSUkKnTp04jWIBypv1ffv24dq1a5BKpbC3t8e4ceOgoaHBaV18rw3gd318rk3mzZs3lTInucg+Ix+OL2tiU2NHeEVLSwuXLl2CtbW13P1isRjdu3dnLzVyxcXFBSKRCDt27EDjxo3ZPLaoqChMmTIF6enpnNV27do19O3bF7q6usjMzMTt27dhamqKxYsXIysrC7t37+asttWrVyMqKgq7du1iM7qeP38OT09P9OjRQ6Gp7IRwraCgAL6+vti/f3+VC8PzaZUMvjlw4AD2799fZQj7lStXOKqKX2iMHeEVNTU1dnJCRVKplBeJ8Xyeaefl5QUPDw+sXLlSbnC4s7Mzxo4dy2FlQEhICE6fPi0XvNqoUSP8+OOP6NevH+eNXXp6Os6fP4/Hjx9XWqzb39+fo6rK8bk2gN/18bW2BQsW4Pz589i0aRMmTJiADRs24OHDh9iyZQuCg4M5q0uGr81TaGgo/Pz84OHhgcOHD8PT0xMSiQSXL1/GjBkzOKurIl6sic0QwiMTJkxg2rdvz1y6dIkpKytjysrKmH/++Yfp0KEDM3HiRK7LY/T09JgbN24wDMMw2trajEQiYRiGYS5evMg0adKEy9IYHR0d5s6dOwzDyNeWmZnJqKmpcVkao62tzZw/f77S/X///Tejra2t+IIq2Lp1K6OkpMQ0bdqUsbGxYWxtbdmbnZ0d1faJ1sfn2oyMjNj/DyKRiElPT2cYhmF2797NDBw4kMPKGGbt2rWMtrY2M3PmTEZVVZX57rvvmL59+zK6urrM999/z2ltbdu2Zfbu3cswjPx73JIlS5gZM2ZwWRrDMAwTFRXFaGhoMH379mVUVVXZ+lasWMEMHz5cYXVQY0d45fnz58yQIUMYgUDAqKqqMqqqqoxQKGRcXFyYvLw8rstjRo4cyUyZMoVhmPI3lrt37zIvX75kevfuzXh4eHBam6GhIXPlyhW2NtmbyunTp5mWLVtyWRozYcIExtjYmDl48CBz//595v79+8yBAwcYExMTxt3dndPaWrVqxQQHB3NaQ3X4XBvD8Ls+PtempaXF3Lt3j2EYhmnRogUTHx/PMAzD3L17l9HS0uKyNF43TxoaGkxmZibDMOXvd1evXmUYhmHS0tIYfX19LktjGIZhHBwcmJCQEIZh5F+7+Ph4pkWLFgqrgxo7wktpaWnMkSNHmCNHjrCfZvng/v37jJWVFWNpackoKyszDg4OTOPGjZm2bdsyjx494rS2SZMmMS4uLkxRURHbdN67d4+xs7Nj5syZw2ltBQUFzLRp0xg1NTVGKBQyQqGQUVVVZaZNm8ZIpVJOaxOJROwbMN/wuTaG4Xd9fK7N2tqaiYqKYhiGYfr06cPMnz+fYZjys2WKbACqwufmycTEhP3w2qlTJ2bz5s0MwzDMqVOnmEaNGnFZGsMw5Q373bt3GYaRb+wyMjIUetVEqJgLvoTUjrm5OQYPHozBgwfDzMyM63JYLVu2hFgshp+fH+bNmwc7OzsEBwcjOTmZ8/iEkJAQSKVSNGnSBK9fv4ajoyPMzMwgEokQGBjIaW2amprYuHEjcnNzkZycjOTkZDx79gwbN26ElpYWp7W5ubnh9OnTnNZQHT7XBvC7Pj7X5unpCbFYDABYuHAhNmzYAHV1dcybNw8+Pj6c1tasWTM2lqNVq1a4dOkSgPJl0BiO51r27t0bR44cAVD+Gs6bNw9fffUVRo0ahWHDhnFaG/C/NbHfpug1sWnyBOGV0tJShIeHs4sovz3gmetYjAsXLqBbt24YN24cxo0bx95fUlKCCxcuoGfPnpzVpqurizNnziA2NhZisZiNd+jbty9nNb1NS0sLHTt25LoMOWZmZliyZAk7G/vt7KnZs2dzVBm/awP4XR+fa5s3bx777759+yI1NRVXrlyBmZkZ5/8/ZM2TnZ0d2zwdOHAAiYmJ7w0v/tj8/PzYBmnGjBlo3Lgx4uLiMGTIEE6Cnd/GlzWxKe6E8MrMmTMRHh6Or7/+uspFlH/55ReOKiunpKSE7OzsSmfncnNz0aRJE05jCnbv3o1Ro0ZBTU1N7v6ioiLs27dPoW8sAGr1R4DLpYBMTEyq3SYQCHD37l0FViOPz7UB/K6Pz7XxWUZGBlq0aMHO/N+3bx/i4uJgbm6OAQMGcLoMIJ/ff4Hy99oZM2YgPDwcpaWlUFZWZtfEDg8PV9jqSdTYEV4xMDDA7t27OV/ypzpCoRCPHj2CoaGh3P1paWno3LkzJwt4y/DtTe99CfYVUZo9+a85d+4cfvnlF3bpREtLS8ydO5fzM+x8ex+pSCgUIicnp1Jt9+7dg5WVFQoKCjiqTF5WVhana2LTpVjCK6qqqrwaUycjO/skEAjg4eEhd1astLQU165dQ7du3bgqDwDAMEylM5xA+dJKXKyxSM0aIVXbuHEj5syZgxEjRmDOnDkAgEuXLsHZ2Rm//PILp5ls1Z3rkUqlUFdXV3A15WTLEwoEAvj7+0NTU5PdVlpaivj4eNja2nJSW1W4XhObGjvCK/Pnz8fatWuxfv36KpsUrsgaI4ZhIBKJ5JYkUlVVhYODA6ZMmcJJbXZ2dhAIBBAIBOjTpw+Ulf/337q0tBQZGRm8GH/CJ15eXli+fDm0tLTeu6atotex5XNtAL/r43NtFQUFBeGXX37BzJkz2ftmz56N7t27IygoiJPGjs/NU3JyMoDy99+UlBS5gHhVVVXY2NjA29ubk9r4uCY2NXaEV2JiYnD+/HmcOHEC7du3rzTgmYuxWF5eXli/fj20tLSQmZmJ7du3Q1tbW+F1VMfFxQUAcPXqVfTv31+uNlVVVRgbG2P48OEcVVfOxMTknY26osc7JScno7i4mP13dbj4cMHn2gB+18fn2irKy8ur8sNWv3794Ovry0FF/G6ezp8/D6B8eMfatWt5tZbuu37PKlLk7xyNsSO88r5xWVxc3lNRUcGDBw/QtGnTasef8MGuXbswatQozi6XvMvatWvlvi4uLkZycjJOnjwJHx8fLFy4kKPKCFG8sWPHws7OrlK0yapVq5CYmIh9+/ZxVBk/mydSO9TYEV55/fo1ysrK2GyzzMxMREZGwtLSEv379+ekJnNzc4wcORL9+vVDr169cOjQIbk1TyviMu5EpqioqMqoGC7HfFRnw4YNSExM5M14vPv37wMAjIyMOK6kMj7XBvC7Pj7UFhoayv47Pz8fq1atQvfu3dG1a1cA5WPsYmNjMX/+fCxevJirMkkDQI0d4ZV+/frB1dUVU6dORV5eHtq1awcVFRU8ffoUq1evxrRp0xReU2RkJKZOnYrHjx9DIBBUO7hYIBBwOmMsPT0d33zzDeLi4uTul02q4DoKoCp3796Fra0tp7OJS0pKEBAQgNDQUEilUgCAtrY2Zs2ahaVLl1YaDkC1fRr18a22d8WvVERRLJ8WV1dXhIeHQ0dH570RT4oaSkRj7AivXLlyhc2qO3DgAJo2bYrk5GQcPHgQ/v7+nDR2Li4ucHFxgVQqhY6ODm7fvs3LS7EeHh5QVlbG0aNHq8wA5KMDBw5AX1+f0xpmzZqFP//8EytXrmTPnvzzzz9YtmwZcnNzsWnTJqrtE6yPb7VlZGQo9PGIYujq6rLvtVykD1RJYYuXEVIDGhoa7OLYbm5uzLJlyxiGYZisrCxGQ0ODy9IYhmGYqKgopri4mOsyqqSpqcmkpqZyXUaVbG1tGTs7O/Zma2vLNGvWjFFSUmK2bNnCaW06OjrM8ePHK91/7NgxRkdHh4OK/ofPtTEMv+vjc22EfEx0xo7wipmZGSIjIzFs2DCcOnWKXXrn8ePHvBjM6+joCIlEgrCwMEgkEqxduxZNmjTBiRMn0KpVK7Rv356z2qysrPD06VPOHv9dZDN3ZYRCIQwNDeHk5IR27dpxU9T/U1NTg7GxcaX7TUxM5GYGcoHPtQH8ro9vtfExFoM0TDTGjvDKgQMHMHbsWJSWlqJPnz7sIt4rVqzAhQsXcOLECU7ri46OxsCBA9G9e3dcuHABqampMDU1RXBwMBITE3HgwAGF1lNxbFpiYiIWL16MoKCgKtfG5ENjzEc//PADbt26hbCwMDZ4urCwEJMmTYK5uTmWLl1KtX2C9fGttl69etVoP4FAwPma2KTuDhw4gP379yMrKwtFRUVy265cuaKQGqixI7yTk5OD7Oxs2NjYQCgUAgASEhKgo6PD+dmdrl27ws3NDV5eXhCJRBCLxTA1NUVCQgJcXV3x4MEDhdYjFArlxtIxVaw+wfBk8kRpaSkiIyPZJZTat2+PIUOGKGz9xOoMGzYM586dg5qaGmxsbAAAYrEYRUVF6NOnj9y+is5R5HNtfK+Pz7WRhik0NBR+fn7w8PDA1q1b4enpCYlEgsuXL2PGjBkIDAxUSB10KZbwTrNmzdCsWTO5+7p06cJRNfJSUlKwd+/eSvc3adKEk8ugsuBOvrtz5w6cnZ3x8OFDtG3bFkD5WVgjIyMcO3YMbdq04aw2PT29SgHOfIns4HNtAL/r43NtpGHauHEjtm7dijFjxiA8PBwLFiyAqakp/P398ezZM4XVQWfsCKmFli1bYv/+/ejWrZvcGbtDhw7B29sbEomE6xJ5ydnZGQzDICIigp0Fm5ubi/Hjx0MoFOLYsWOc1cbH7MRPoTaA3/XxrTY+xmKQ+qWpqYnU1FS0bt0aTZo0wZkzZ2BjY4P09HQ4ODggNzdXIXXQGTtCamH06NHw9fXFH3/8AYFAgLKyMsTGxsLb2xvu7u6c1nbt2rUq7xcIBFBXV0erVq3YsUaKFh0djUuXLslFmzRu3BjBwcHo3r07JzXJDB06VC470cHBgfPsxE+hNr7Xx7faeBmLQepVs2bN8OzZM7Ru3RqtWrXCpUuXYGNjg4yMjGrzTz8KrqbjEvIpKiwsZCZPnswoKyszAoGAUVFRYQQCATN+/HimpKSE09oEAgEjFAqrvampqTHu7u7M69evFV5bo0aNmNjY2Er3x8TEMI0aNVJ4PRU1btyYuX79OsMwDLNt2zamY8eOTGlpKbN//36mXbt2VNs78Lk+PtdGGqZJkyaxEV3r169nNDQ0mL59+zJ6enrMN998o7A66IwdIbWgqqqKbdu2wd/fHykpKZBKpbCzs4O5uTnXpeHQoUPw9fWFj48POyYxISEBISEhWLp0KUpKSrBw4UIsXrwYq1atUmhtgwYNwrfffosdO3awtcXHx2Pq1KkYMmSIQmt526tXryASiQAAp0+fhqurK4RCIRwcHHDv3j2q7R34XB+fayMNk5+fH1q0aAEAmDFjBho3boy4uDgMGTIEAwYMUFgd1NgR8h7vy5+6dOkS+28u86cCAwOxdu1aufFD1tbWaNmyJZYsWYKEhARoaWlh/vz5Cm/sQkNDMXHiRHTt2pWNYSkpKcGQIUOwdu1ahdbyNj5nJ/K5NoDf9fG5NoAfsRikfpmZmSE7O5tdmWj06NEYPXo0cnNz0aRJE4UlE1BjR8h7JCcn12g/rpfwSklJQevWrSvd37p1a6SkpAAAbG1tkZ2drejSoKenh8OHDyM9PR23bt0CAFhaWsLMzEzhtbzN398fY8eOxbx589CnTx92+anTp0/Dzs6OansHPtfH59oqxmIcPny4UiwG+TQx1Yyjk0qlUFdXV1gdNCuWkAbCzs4ONjY22Lp1K5usX1xcjClTpkAsFiM5ORmxsbEYP348rVv5Fj5nJ/K5NoDf9fG1tnbt2mHp0qUYM2aM3Ox6WSzG+vXrOauN1J7sqs7atWsxZcoUaGpqsttKS0sRHx8PJSUlxMbGKqQeauwIaSBkYzmEQiE6duwIoPwsXmlpKY4ePQoHBwfs2bMHOTk58PHxUWhtpaWlCA8Px7lz5/D48WOUlZXJbaekffJfwpdYDFI/ZKuKREdHo2vXrnJL1qmqqsLY2Bje3t4KG4tNl2IJaSC6deuGjIwMREREIC0tDQDg5uaGsWPHsoPIJ0yYwEltc+bMQXh4OL7++mt06NCB88vWhHCJN7EYpF7IguI9PT2xdu1azsdw0hk7QshHZ2BggN27d8PZ2ZnrUgjh3OTJk2FkZISlS5diw4YN8PHxQffu3ZGYmAhXV1fs2LGD6xLJJ4waO0I+YUeOHMHAgQOhoqKCI0eOvHNfLmNFPvvsM0RFRcHCwoKzGgjhi4yMDLRo0YK9ZLdv3z7ExcXB3NwcAwYM4EV8Evl0UWNHyCdMKBQiJycHTZo0YQeHV0UgEChsqn1VQkJCcPfuXaxfv54uw5L/PCUlJblYDBlFx2KQhonG2BHyCZNNQiguLoaTkxM2b97Mm7Nib6+H+ffff+PEiRNo3749m2UnQ2tjkv8SvsRikIaJGjtCGgAVFRWkpKS886ydor29HuawYcM4qoQQfpDFYggEAvj7+1cZi2Fra8tRdaShoEuxhDQQ8+bNg5qaGoKDg7kupZLXr1+jrKwMWlpaAIDMzExERkbC0tJSbqUMQhoyvsVikIaJGjtCGohZs2Zh9+7dMDc3R6dOndgmSobL5c769esHV1dXTJ06FXl5eWjXrh1UVFTw9OlTrF69GtOmTeOsNkIUjS+xGKRhosaOkAZCdjagKgKBgNMQYAMDA0RHR6N9+/bYvn071q1bh+TkZBw8eBD+/v5ITU3lrDZCCGlIaIwdIQ2ELCSTj169esWGJJ8+fRqurq4QCoVwcHDAvXv3OK6OEEIaDv6MtCaENFhmZmaIjIzE/fv3cerUKfTr1w8A8PjxY7ocRQgh9YgaO0LIR+fv7w9vb28YGxvjiy++QNeuXQGUn72zs7PjuDpCCGk4aIwdIUQhcnJykJ2dDRsbGzaWJSEhATo6OmjXrh3H1RFCSMNAjR0hhBBCSANBl2IJIYQQQhoIauwIIYQQQhoIauwIIYQQQhoIauwIIYQQQhoIauwIIYQQQhoIauwIIYQQQhoIauwIIYQQQhoIauwIIYQQQhqI/wNuBtfPcINC7QAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "keypoint_matching(\n", - " config_path,\n", - " superanimal_name,\n", - " model_name,\n", - ")\n", - "\n", - "conversion_table_path = dlc_proj_root / \"memory_replay\" / \"conversion_table.csv\"\n", - "confusion_matrix_path = dlc_proj_root / \"memory_replay\" / \"confusion_matrix.png\"\n", - "# You can visualize the pseudo predictions, or do pose embedding clustering etc.\n", - "pseudo_prediction_path = dlc_proj_root / \"memory_replay\" / \"pseudo_predictions.json\"" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "sA8yyLgs0zoO" - }, - "source": [ - "#### Display the confusion matrix\n", - "\n", - "The x axis lists the keypoints in the existing annotations. The y axis lists the keypoints in SuperAnimal keypoint space. Darker color encodes stronger correspondance between the human annotation and SuperAnimal annotations." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 406 - }, - "collapsed": true, - "id": "luDxpD9H0zYZ", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "d6420e08-3e9c-40dc-8a13-92bc0d8c220b" - }, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "\n", - "confusion_matrix_image = mpimg.imread(confusion_matrix_path)\n", - "plt.imshow(confusion_matrix_image)\n", - "plt.axis('off') # Hide the axes for better view\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "i0QWikYmy_Mj" - }, - "source": [ - "#### Display the conversion table\n", - "The gt columns represents the keypoint names in the existing dataset. The MasterName represents the correspoinding keypoints in SuperAnimal keypoint space." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "CeA-NzDMynYV", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "ae27fb36-223c-4aa2-f63f-42adadb95f02" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " gt MasterName\n", - "0 snout nose\n", - "6 rightear right_earbase\n", - "11 leftear left_earbase\n", - "14 tail2 left_antler_end\n", - "15 shoulder neck_base\n", - "20 tail1 back_end\n", - "21 spine3 back_middle\n", - "22 tailbase tail_base\n", - "23 tailend tail_end\n", - "24 spine1 front_left_thai\n", - "31 spine4 back_left_thai\n", - "37 spine2 body_middle_right\n" - ] - } - ], - "source": [ - "import pandas as pd\n", - "df = pd.read_csv(conversion_table_path)\n", - "df = df.dropna()\n", - "print (df)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GkfIo8zTxPoS" - }, - "source": [ - "#### Prepare the training shuffle and weight initialization for (naive) fine-tuning with SuperAnimal weights" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "xEeM_hrOu6k8", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "4a5f4d5f-d1c5-42f8-f4ed-e8c208a1dc10" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", - "You passed a split with the following fraction: 94%\n", - "Creating training data for: Shuffle: 2 TrainFraction: 0.94\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 152/152 [00:00<00:00, 12111.90it/s]" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], - "source": [ - "from deeplabcut.modelzoo.utils import (\n", - " create_conversion_table,\n", - " read_conversion_table_from_csv,\n", - ")\n", - "table = create_conversion_table(\n", - " config=config_path,\n", - " super_animal=superanimal_name,\n", - " project_to_super_animal=read_conversion_table_from_csv(conversion_table_path),\n", - ")\n", - "\n", - "weight_init = WeightInitialization(\n", - " dataset=superanimal_name,\n", - " with_decoder=True,\n", - " conversion_array=table.to_array()\n", - ")\n", - "\n", - "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", - " from_shuffle = imagenet_transfer_learning_shuffle,\n", - " shuffles = [superanimal_naive_finetune_shuffle],\n", - " engine = Engine.PYTORCH,\n", - " net_type=\"top_down_hrnet_w32\",\n", - " weight_init = weight_init,\n", - " userfeedback = False)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "gZx6nr-ExPoS" - }, - "source": [ - "#### Launch the training for (naive) fine-tuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "c3XAr6uRyXOD", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "03740373-f9cd-4708-d140-0127033bfdc8" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Training with configuration:\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 0\n", - "device: auto\n", - "metadata:\n", - " project_path: /content/dlc_project_folder/daniel3mouse\n", - " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle2/train/pose_cfg.yaml\n", - " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", - " unique_bodyparts: []\n", - " individuals: ['mus1', 'mus2', 'mus3']\n", - " with_identity: False\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: False\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 12\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 12]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "net_type: hrnet_w32\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0005\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 3\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 64\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 3\n", - " pretrained_weights: None\n", - " seed: 42\n", - " weight_init:\n", - " dataset: superanimal_quadruped\n", - " with_decoder: True\n", - " memory_replay: False\n", - " conversion_array: [0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]\n", - "eval_interval: 1\n", - "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=True, memory_replay=False, conversion_array=array([ 0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]), bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", - "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", - "Data Transforms:\n", - " Training: Compose([\n", - " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", - " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "Using 456 images and 27 for testing\n", - "\n", - "Starting pose model training...\n", - "--------------------------------------------------\n", - "Training for epoch 1 done, starting evaluation\n", - "Epoch 1 performance:\n", - "metrics/test.rmse: 11.625\n", - "metrics/test.rmse_pcutoff:5.703\n", - "metrics/test.mAP: 71.971\n", - "metrics/test.mAR: 75.185\n", - "metrics/test.mAP_pcutoff:36.845\n", - "metrics/test.mAR_pcutoff:40.370\n", - "Epoch 1/3 (lr=0.0005), train loss 0.00387, valid loss 0.00279\n", - "Training for epoch 2 done, starting evaluation\n", - "Epoch 2 performance:\n", - "metrics/test.rmse: 9.842\n", - "metrics/test.rmse_pcutoff:4.464\n", - "metrics/test.mAP: 77.846\n", - "metrics/test.mAR: 80.000\n", - "metrics/test.mAP_pcutoff:33.924\n", - "metrics/test.mAR_pcutoff:35.926\n", - "Epoch 2/3 (lr=0.0005), train loss 0.00210, valid loss 0.00188\n", - "Training for epoch 3 done, starting evaluation\n", - "Epoch 3 performance:\n", - "metrics/test.rmse: 8.163\n", - "metrics/test.rmse_pcutoff:3.807\n", - "metrics/test.mAP: 82.317\n", - "metrics/test.mAR: 84.815\n", - "metrics/test.mAP_pcutoff:49.699\n", - "metrics/test.mAR_pcutoff:53.704\n", - "Epoch 3/3 (lr=0.0005), train loss 0.00167, valid loss 0.00154\n" - ] - } - ], - "source": [ - "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, eval_interval = 1, batch_size = 64, shuffle = superanimal_naive_finetune_shuffle)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "oXuRshzhxPoS" - }, - "source": [ - "#### Evaluate the model obtained by (naive) fine-tuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "VXfdKS-H2yqw", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "53b1b8fa-6aa3-4dad-a5be-153e96eb0323" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:root:Using GT bounding boxes to compute evaluation metrics\n", - "100%|██████████| 152/152 [01:25<00:00, 1.79it/s]\n", - "100%|██████████| 9/9 [00:04<00:00, 1.84it/s]\n", - "INFO:root:Evaluation results for DLC_HrnetW32_MultiMouseDec16shuffle2_snapshot_003-results.csv (pcutoff: 0.01):\n", - "INFO:root:train rmse 48.46\n", - "train rmse_pcutoff 47.76\n", - "train mAP 10.08\n", - "train mAR 21.36\n", - "train mAP_pcutoff 10.07\n", - "train mAR_pcutoff 21.29\n", - "test rmse 47.00\n", - "test rmse_pcutoff 46.74\n", - "test mAP 12.16\n", - "test mAR 22.22\n", - "test mAP_pcutoff 12.16\n", - "test mAR_pcutoff 22.22\n", - "Name: (0.94, 2, 3, -1, 0.01), dtype: float64\n" - ] - } - ], - "source": [ - "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_naive_finetune_shuffle])" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "_nUAMlbZ0Z4b" - }, - "source": [ - "## Memory-replay fine-tuning with SuperAnimal (keeping full SuperAnimal keypoints)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "n6HPu6RaxPoS" - }, - "source": [ - "**Catastrophic forgetting** describes a\n", - "classic problemin continual learning. Indeed, amodel gradually loses\n", - "its ability to solve previous tasks after it learns to solve new ones.\n", - "Fine-tuning a SuperAnimal models falls into the category of continual\n", - "learning: the downstream dataset defines potentially different\n", - "keypoints than those learned by the models. Thus, the models might\n", - "forget the keypoints they learned and only pick up those defined in the\n", - "target dataset. Here, retraining with the original dataset and the new\n", - "one, is not a feasible option as datasets cannot be easily shared and\n", - "more computational resources would be required.\n", - "To counter that, we treat zero-shot inference of the model as a\n", - "memory buffer that stores knowledge from the original model. When\n", - "we fine-tune a SuperAnimal model, we replace the model predicted\n", - "keypoints with the ground-truth annotations, resulting in hybrid\n", - "learning of old and new knowledge. The quality of the zero-shot predictions\n", - "can vary and we use the confidence of prediction (0.7) as a\n", - "threshold to filter out low-confidence predictions. With the threshold\n", - "set to 1, memory replay fine-tuning becomes naive-fine-tuning." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "CSLmjlCIxPoS" - }, - "source": [ - "#### Prepare training shuffle and weight initialization for memory-replay finetuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "BKEF76AI0Z4c", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "bf107c7b-6e3c-4ece-f680-067e4d7641f0", - "vscode": { - "languageId": "plaintext" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n", - "You passed a split with the following fraction: 94%\n", - "Creating training data for: Shuffle: 3 TrainFraction: 0.94\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 152/152 [00:00<00:00, 11984.40it/s]" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], - "source": [ - "weight_init = WeightInitialization(\n", - " dataset=superanimal_name,\n", - " conversion_array=table.to_array(),\n", - " with_decoder=True,\n", - " memory_replay=True,\n", - ")\n", - "\n", - "deeplabcut.create_training_dataset_from_existing_split(config_path,\n", - " from_shuffle = imagenet_transfer_learning_shuffle,\n", - " shuffles = [superanimal_memory_replay_shuffle],\n", - " engine = Engine.PYTORCH,\n", - " net_type=\"top_down_hrnet_w32\",\n", - " weight_init = weight_init,\n", - " userfeedback = False)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "MKwJiIyKxPoT" - }, - "source": [ - "#### Launch the training for memory-replay fine-tuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "collapsed": true, - "id": "Ru8tIFmD2Mkv", - "jupyter": { - "outputs_hidden": true - }, - "outputId": "81a0ec13-6ba7-4089-bed5-f19c6bae0bcb" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Training with configuration:\n", - "data:\n", - " colormode: RGB\n", - " inference:\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - " normalize_images: True\n", - " train:\n", - " affine:\n", - " p: 0.5\n", - " scaling: [1.0, 1.0]\n", - " rotation: 30\n", - " translation: 0\n", - " gaussian_noise: 12.75\n", - " normalize_images: True\n", - " auto_padding:\n", - " pad_width_divisor: 32\n", - " pad_height_divisor: 32\n", - "detector:\n", - " data:\n", - " colormode: RGB\n", - " inference:\n", - " normalize_images: True\n", - " train:\n", - " hflip: True\n", - " normalize_images: True\n", - " device: auto\n", - " model:\n", - " type: FasterRCNN\n", - " variant: fasterrcnn_resnet50_fpn_v2\n", - " box_score_thresh: 0.6\n", - " pretrained: False\n", - " runner:\n", - " type: DetectorTrainingRunner\n", - " eval_interval: 50\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 1e-05\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " milestones: [90]\n", - " lr_list: [[1e-06]]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 50\n", - " save_optimizer_state: False\n", - " train_settings:\n", - " batch_size: 1\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 0\n", - "device: auto\n", - "metadata:\n", - " project_path: /content/dlc_project_folder/daniel3mouse\n", - " pose_config_path: /content/dlc_project_folder/daniel3mouse/dlc-models-pytorch/iteration-0/MultiMouseDec16-trainset94shuffle2/train/pose_cfg.yaml\n", - " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n", - " unique_bodyparts: []\n", - " individuals: ['mus1', 'mus2', 'mus3']\n", - " with_identity: False\n", - "method: td\n", - "model:\n", - " backbone:\n", - " type: HRNet\n", - " model_name: hrnet_w32\n", - " pretrained: False\n", - " freeze_bn_stats: False\n", - " freeze_bn_weights: False\n", - " interpolate_branches: False\n", - " increased_channel_count: False\n", - " backbone_output_channels: 32\n", - " heads:\n", - " bodypart:\n", - " type: HeatmapHead\n", - " weight_init: normal\n", - " predictor:\n", - " type: HeatmapPredictor\n", - " apply_sigmoid: False\n", - " clip_scores: True\n", - " location_refinement: False\n", - " locref_std: 7.2801\n", - " target_generator:\n", - " type: HeatmapGaussianGenerator\n", - " num_heatmaps: 12\n", - " pos_dist_thresh: 17\n", - " heatmap_mode: KEYPOINT\n", - " generate_locref: False\n", - " locref_std: 7.2801\n", - " criterion:\n", - " heatmap:\n", - " type: WeightedMSECriterion\n", - " weight: 1.0\n", - " heatmap_config:\n", - " channels: [32, 12]\n", - " kernel_size: [1]\n", - " strides: [1]\n", - "net_type: hrnet_w32\n", - "runner:\n", - " type: PoseTrainingRunner\n", - " key_metric: test.mAP\n", - " key_metric_asc: True\n", - " eval_interval: 1\n", - " optimizer:\n", - " type: AdamW\n", - " params:\n", - " lr: 0.0005\n", - " scheduler:\n", - " type: LRListScheduler\n", - " params:\n", - " lr_list: [[1e-06], [1e-07]]\n", - " milestones: [160, 190]\n", - " snapshots:\n", - " max_snapshots: 5\n", - " save_epochs: 3\n", - " save_optimizer_state: False\n", - "train_settings:\n", - " batch_size: 64\n", - " dataloader_workers: 0\n", - " dataloader_pin_memory: True\n", - " display_iters: 500\n", - " epochs: 3\n", - " pretrained_weights: None\n", - " seed: 42\n", - " weight_init:\n", - " dataset: superanimal_quadruped\n", - " with_decoder: True\n", - " memory_replay: False\n", - " conversion_array: [0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]\n", - "eval_interval: 1\n", - "Loading pretrained model weights: WeightInitialization(dataset='superanimal_quadruped', with_decoder=True, memory_replay=False, conversion_array=array([ 0, 11, 6, 15, 24, 37, 21, 31, 22, 20, 14, 23]), bodyparts=None, customized_pose_checkpoint=None, customized_detector_checkpoint=None)\n", - "The pose model is loading from /content/drive/My Drive/DLCdev/deeplabcut/modelzoo/checkpoints/superanimal_quadruped_hrnetw32_parsed.pth\n", - "Data Transforms:\n", - " Training: Compose([\n", - " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n", - " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - " Validation: Compose([\n", - " PadIfNeeded(always_apply=False, p=1.0, min_height=None, min_width=None, pad_height_divisor=32, pad_width_divisor=32, position=PositionType.RANDOM, border_mode=4, value=None, mask_value=None),\n", - " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n", - "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n", - "Using 456 images and 27 for testing\n", - "\n", - "Starting pose model training...\n", - "--------------------------------------------------\n", - "Training for epoch 1 done, starting evaluation\n", - "Epoch 1 performance:\n", - "metrics/test.rmse: 11.625\n", - "metrics/test.rmse_pcutoff:5.703\n", - "metrics/test.mAP: 71.971\n", - "metrics/test.mAR: 75.185\n", - "metrics/test.mAP_pcutoff:36.845\n", - "metrics/test.mAR_pcutoff:40.370\n", - "Epoch 1/3 (lr=0.0005), train loss 0.00387, valid loss 0.00279\n", - "Training for epoch 2 done, starting evaluation\n", - "Epoch 2 performance:\n", - "metrics/test.rmse: 9.842\n", - "metrics/test.rmse_pcutoff:4.464\n", - "metrics/test.mAP: 77.846\n", - "metrics/test.mAR: 80.000\n", - "metrics/test.mAP_pcutoff:33.924\n", - "metrics/test.mAR_pcutoff:35.926\n", - "Epoch 2/3 (lr=0.0005), train loss 0.00210, valid loss 0.00188\n", - "Training for epoch 3 done, starting evaluation\n", - "Epoch 3 performance:\n", - "metrics/test.rmse: 8.163\n", - "metrics/test.rmse_pcutoff:3.807\n", - "metrics/test.mAP: 82.317\n", - "metrics/test.mAR: 84.815\n", - "metrics/test.mAP_pcutoff:49.699\n", - "metrics/test.mAR_pcutoff:53.704\n", - "Epoch 3/3 (lr=0.0005), train loss 0.00167, valid loss 0.00154\n" - ] - } - ], - "source": [ - "deeplabcut.train_network(config_path, detector_epochs = 0, epochs = 3, save_epochs = 3, eval_interval = 1, batch_size = 64, shuffle = superanimal_naive_finetune_shuffle)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "i-2MBRDjxPoT" - }, - "source": [ - "#### Evaluate the model obtained by memory-replay finetuning with SuperAnimal" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "collapsed": true, - "id": "sfMcK3gq8WxZ", - "jupyter": { - "outputs_hidden": true - } - }, - "outputs": [], - "source": [ - "deeplabcut.evaluate_network(config_path, Shuffles = [superanimal_memory_replay_shuffle])" - ] - } - ], - "metadata": { - "accelerator": "GPU", - "colab": { - "gpuType": "A100", - "provenance": [], - "machine_shape": "hm", - "include_colab_link": true - }, - "kernelspec": { - "display_name": "Python 3", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.19" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "fec6601a01a44935a06d432c1f94349b": { - "model_module": "@jupyter-widgets/controls", - "model_name": "HBoxModel", - "model_module_version": "1.5.0", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_867c5b27246c40b3823c60a014329427", - "IPY_MODEL_4aba38bf1a9b4be092fffb7581f7cd0b", - "IPY_MODEL_69a82035258c40b5beed0d9673f8b3b6" - ], - "layout": "IPY_MODEL_c16a46a621ab497183ec0b136749eaeb" - } - }, - "867c5b27246c40b3823c60a014329427": { - "model_module": "@jupyter-widgets/controls", - "model_name": "HTMLModel", - "model_module_version": "1.5.0", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_3b1eaec29f014a97a8ee14e62b34db83", - "placeholder": "​", - "style": "IPY_MODEL_013e4fb17d234b18934f11593557eec4", - "value": "model.safetensors: 100%" - } - }, - "4aba38bf1a9b4be092fffb7581f7cd0b": { - "model_module": "@jupyter-widgets/controls", - "model_name": "FloatProgressModel", - "model_module_version": "1.5.0", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_787f617ecbf74db6b020a066acb12840", - "max": 165432914, - "min": 0, - "orientation": "horizontal", - "style": "IPY_MODEL_7cd0520e8c26480297b86824e92d77ec", - "value": 165432914 - } - }, - "69a82035258c40b5beed0d9673f8b3b6": { - "model_module": "@jupyter-widgets/controls", - "model_name": "HTMLModel", - "model_module_version": "1.5.0", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_c5d2d04a28d743799dfa706e00303f08", - "placeholder": "​", - "style": "IPY_MODEL_579cc60d128445b8ae10ee036c036bb5", - "value": " 165M/165M [00:03<00:00, 36.1MB/s]" - } - }, - "c16a46a621ab497183ec0b136749eaeb": { - "model_module": "@jupyter-widgets/base", - "model_name": "LayoutModel", - "model_module_version": "1.2.0", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "3b1eaec29f014a97a8ee14e62b34db83": { - "model_module": "@jupyter-widgets/base", - "model_name": "LayoutModel", - "model_module_version": "1.2.0", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "013e4fb17d234b18934f11593557eec4": { - "model_module": "@jupyter-widgets/controls", - "model_name": "DescriptionStyleModel", - "model_module_version": "1.5.0", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "787f617ecbf74db6b020a066acb12840": { - "model_module": "@jupyter-widgets/base", - "model_name": "LayoutModel", - "model_module_version": "1.2.0", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "7cd0520e8c26480297b86824e92d77ec": { - "model_module": "@jupyter-widgets/controls", - "model_name": "ProgressStyleModel", - "model_module_version": "1.5.0", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "c5d2d04a28d743799dfa706e00303f08": { - "model_module": "@jupyter-widgets/base", - "model_name": "LayoutModel", - "model_module_version": "1.2.0", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "1.2.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "overflow_x": null, - "overflow_y": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "579cc60d128445b8ae10ee036c036bb5": { - "model_module": "@jupyter-widgets/controls", - "model_name": "DescriptionStyleModel", - "model_module_version": "1.5.0", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - } - } - } - }, - "nbformat": 4, - "nbformat_minor": 0 -} +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "colab_type": "text", + "id": "view-in-github" + }, + "source": [ + "\"Open" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "5SSZpZUu0Z4S" + }, + "source": [ + "# DeepLabCut Model Zoo: SuperAnimal models\n", + "\n", + "![alt text](https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1616492373700-PGOAC72IOB6AUE47VTJX/ke17ZwdGBToddI8pDm48kB8JrdUaZR-OSkKLqWQPp_YUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8N_N4V1vUb5AoIIIbLZhVYwL8IeDg6_3B-BRuF4nNrNcQkVuAT7tdErd0wQFEGFSnBqyW03PFN2MN6T6ry5cmXqqA9xITfsbVGDrg_goIDasRCalqV8R3606BuxERAtDaQ/modelzoo.png?format=1000w)\n", + "\n", + "# 🦄 SuperAnimal in DeepLabCut PyTorch! 🔥\n", + "\n", + "This notebook demos how to use our SuperAnimal models within DLC3. Please read more in [Ye et al. Nature Communications 2024](https://www.nature.com/articles/s41467-024-48792-2) about the available SuperAnimal models, and follow along below!\n", + "\n", + "### **Let's get going: install DeepLabCut into COLAB:**\n", + "\n", + "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, + "id": "AjET5cJE5UYM", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "0a9bc286-e21e-4fe2-cd1a-fb570f35e719" + }, + "outputs": [], + "source": [ + "!pip install deeplabcut==3.0.0rc1" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "5h0vq6E50Z4W" + }, + "source": [ + "### PLEASE, click \"restart runtime\" from the output above before proceeding!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "LvnlIvQm0Z4X", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "5fff9d1f-621f-4147-9a54-39d4c01db0b3" + }, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "from PIL import Image\n", + "\n", + "import deeplabcut\n", + "from deeplabcut.pose_estimation_pytorch.apis.analyze_images import (\n", + " superanimal_analyze_images,\n", + ")\n", + "from deeplabcut.core.weight_init import WeightInitialization\n", + "from deeplabcut.core.engine import Engine\n", + "from deeplabcut.modelzoo.utils import (\n", + " create_conversion_table,\n", + " read_conversion_table_from_csv,\n", + ")\n", + "from deeplabcut.modelzoo.video_inference import video_inference_superanimal\n", + "from deeplabcut.utils.pseudo_label import keypoint_matching" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "UeXjmtu40Z4X" + }, + "source": [ + "## Zero-shot Image Inference & Video Inference\n", + "SuperAnimal models are foundation animal pose models. They can be used for zero-shot predictions without further training on the data.\n", + "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another dest folder." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "FvFzntDMxPoL" + }, + "source": [ + "## Zero-shot image inference\n", + "\n", + "- If you have a single Image you want to test, upload it here!" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "NbDsZQfsxPoL" + }, + "source": [ + "#### Upload the images you want to predict" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 92 + }, + "id": "c4yfTj7r0Z4Y", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "5cd0b4e7-2d47-4432-ec1e-8f66c0362c7b" + }, + "outputs": [], + "source": [ + "from google.colab import files\n", + "\n", + "uploaded = files.upload()\n", + "for filepath, content in uploaded.items():\n", + " print(f\"User uploaded file '{filepath}' with length {len(content)} bytes\")\n", + "image_path = os.path.abspath(filepath)\n", + "image_name = os.path.splitext(image_path)[0]\n", + "\n", + "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n", + "# manually upload your video via the Files menu to the left\n", + "# and define `video_path` yourself with right click > copy path on the video." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Jashzdjb0Z4Y" + }, + "source": [ + "### Select a SuperAnimal name and corresponding model architecture\n", + "\n", + "Check Our Docs on [SuperAnimals](https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/ModelZoo.md) to learn more!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "uH9LXig90Z4Y", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "# @markdown ---\n", + "# @markdown SuperAnimal Configurations\n", + "superanimal_name = \"superanimal_topviewmouse\" #@param [\"superanimal_topviewmouse\", \"superanimal_quadruped\"]\n", + "model_name = \"hrnetw32\" #@param [\"hrnetw32\"]\n", + "\n", + "# @markdown ---\n", + "# @markdown What is the maximum number of animals you expect to have in an image\n", + "max_individuals = 3 # @param {type:\"slider\", min:1, max:30, step:1}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "OmJtVmHq0Z4Y", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n", + "superanimal_analyze_images(\n", + " superanimal_name,\n", + " model_name,\n", + " image_path,\n", + " max_individuals,\n", + " out_folder=\"/content/\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6VEjHu-00Z4Y" + }, + "source": [ + "### Zero-shot Video Inference\n", + "- Without Video adaptation (faster, but not self-supervised fine-tuned on your data!)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "qGoAhxZOxPoM" + }, + "source": [ + "#### Upload a video you want to predict" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 92 + }, + "id": "PK3efA0I0Z4Y", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "e334e7ae-6904-4853-dade-500cee55e7f5" + }, + "outputs": [], + "source": [ + "from google.colab import files\n", + "\n", + "uploaded = files.upload()\n", + "for filepath, content in uploaded.items():\n", + " print(f\"User uploaded file '{filepath}' with length {len(content)} bytes\")\n", + "video_path = os.path.abspath(filepath)\n", + "video_name = os.path.splitext(video_path)[0]\n", + "\n", + "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n", + "# manually upload your video via the Files menu to the left\n", + "# and define `video_path` yourself with right click > copy path on the video." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "JoA-RATSICj_" + }, + "source": [ + "#### Choose the superanimal and the model name" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "OiRAP9XD0Z4Z", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "# @markdown ---\n", + "# @markdown SuperAnimal Configurations\n", + "superanimal_name = \"superanimal_topviewmouse\" #@param [\"superanimal_topviewmouse\", \"superanimal_quadruped\"]\n", + "model_name = \"hrnetw32\" #@param [\"hrnetw32\"]\n", + "\n", + "# @markdown ---\n", + "# @markdown What is the maximum number of animals you expect to have in an image\n", + "max_individuals = 3 # @param {type:\"slider\", min:1, max:30, step:1}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Zv3v0QgSJNOg" + }, + "source": [ + "### Zero-shot Video Inference without video adaptation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "poqynL0UJTBp", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "118cab24-69e6-4ce9-c546-ee9e306dbba2" + }, + "outputs": [], + "source": [ + "video_inference_superanimal(\n", + " videos=video_path,\n", + " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", + " video_adapt=False,\n", + " max_individuals=max_individuals,\n", + " dest_folder=\"/content/\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Z8Z5GSti0Z4Z" + }, + "source": [ + "### Zero-shot Video Inference with video adaptation (unsupervised)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000, + "referenced_widgets": [ + "fec6601a01a44935a06d432c1f94349b", + "867c5b27246c40b3823c60a014329427", + "4aba38bf1a9b4be092fffb7581f7cd0b", + "69a82035258c40b5beed0d9673f8b3b6", + "c16a46a621ab497183ec0b136749eaeb", + "3b1eaec29f014a97a8ee14e62b34db83", + "013e4fb17d234b18934f11593557eec4", + "787f617ecbf74db6b020a066acb12840", + "7cd0520e8c26480297b86824e92d77ec", + "c5d2d04a28d743799dfa706e00303f08", + "579cc60d128445b8ae10ee036c036bb5" + ] + }, + "id": "5mhOmtzw0Z4Z", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "a87f668a-c735-4372-c5ac-15fea95f5a99" + }, + "outputs": [], + "source": [ + "video_inference_superanimal(\n", + " videos=[video_path],\n", + " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", + " video_adapt=True,\n", + " max_individuals=max_individuals,\n", + " pseudo_threshold=0.1,\n", + " bbox_threshold=0.9,\n", + " detector_epochs=1,\n", + " pose_epochs=1,\n", + " dest_folder=\"/content/\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "br3pwGf40Z4a" + }, + "source": [ + "## Training with SuperAnimal\n", + "In this section, we compare different ways to train the model, using and without using SuperAnimal.\n", + "You can compare the evaluation results and get a sense of each baseline.\n", + "We have following baselines:\n", + "\n", + "- ImageNet transfer learning (training without superanimal)\n", + "- SuperAnimal transfer learning (baseline 1)\n", + "- SuperAnimal naive fine-tuning (baseline 2)\n", + "- SuperAnimal memory-replay fine-tuning (baseline3)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "L2wxevEn0Z4a" + }, + "source": [ + "#### Uploading your DLC project into Drive. Note you have to zip your DLC project and select the zipped file" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 129 + }, + "id": "visacW8i0Z4a", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "db60ca6e-b38d-44c7-bc69-a327b40c8d4a" + }, + "outputs": [], + "source": [ + "uploaded = files.upload()\n", + "for filename in uploaded.keys():\n", + " zip_file_path = os.path.join(\"/content\", filename)\n", + " with zipfile.ZipFile(zip_file_path, \"r\") as zip_ref:\n", + " zip_ref.extractall(\"/content/dlc_project_folder\")\n", + "\n", + "print(\"Contents of the extracted folder:\")\n", + "extracted_files = os.listdir(\"/content/dlc_project_folder\")\n", + "for file in extracted_files:\n", + " print(f\"- {file}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "b5UqfHcnxPoO" + }, + "source": [ + "#### Change the path to your project in dlc_project_folder" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "nY7Sv9pslaMh", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "dlc_proj_root = Path(\"/content/dlc_project_folder/daniel3mouse\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "BPvoL9uZ0Z4a" + }, + "source": [ + "#### Comparison between different training baselines\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "eVmpaLdB0Z4a" + }, + "source": [ + "Definition of data split: the unique combination of training images and testing images.\n", + "We create a data split named split 0. All baselines will share the data split to make fair comparisons.\n", + "- split 0 -> shared by all baselines\n", + "- shuffle 0 (split0) -> imagenet transfer learning\n", + "- shuffle 1 (split0) -> superanimal transfer learning\n", + "- shuffle 2 (split0) -> superanimal naive fine-tuning\n", + "- shuffle 3 (split0) -> superanimal memory-replay fine-tuning" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "WofR2jytxPoR" + }, + "source": [ + "### What is the difference between baselines?\n", + "\n", + "**Transfer learning** For canonical task-agnostic transfer learning,\n", + "the encoder learns universal visual features from ImageNet, and a randomly\n", + "initialized decoder is used to learn the pose fromthe downstream dataset.\n", + "\n", + "**Fine-tuning** For task aware\n", + "fine-tuning, both encoder and decoder learn task-related visual-pose features\n", + "in the pre-training datasets, and the decoder is fine-tuned to update pose\n", + "priors in downstream datasets. Crucially, the network has pose-estimation-specific\n", + "weights\n", + "\n", + "**ImageNet transfer-learning** The encoder was pre-trained from ImageNet. The decoder is trained from scratch in the downstream tasks\n", + "\n", + "**SuperAnimal transfer-learning** The encoder was pre-trained first from ImageNet, then in pose datasets we colleceted. Then decoder is trained from scratch in downstream tasks.\n", + "\n", + "**SuperAnimal naive fine-tuning** Both the encoder and the decoder were pre-trained in pose datasets we collected. In downstream datsets, we only finetune convolutional channels that correspond to the annotated keypoints in the downstream datasets. This introduces catastrophic forgetting in keypoints that are not annotated in the downstream datasets.\n", + "\n", + "**SuperAnimal memory-replay fine-tuning** If we apply fine-tuning with SuperAnimal without further cares, the models will forget about keypoints that are not annotated in the downstream datasets. To mitigate this, we mix the annotations and zero-shot predictions of SuperAnimal models to create a dataset that 'replays' the memory of the SuperAnimal keypoints.\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "AgIsUu6v0Z4a", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "imagenet_transfer_learning_shuffle = 0\n", + "superanimal_transfer_learning_shuffle = 1\n", + "superanimal_naive_finetune_shuffle = 2\n", + "superanimal_memory_replay_shuffle = 3" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "kuKcxM8F0Z4a", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bb09b5cd-bd25-416b-8b94-aa25a5ffd1a7" + }, + "outputs": [], + "source": [ + "config_path = dlc_proj_root / \"config.yaml\"\n", + "deeplabcut.create_training_dataset(\n", + " config_path,\n", + " Shuffles = [imagenet_transfer_learning_shuffle],\n", + " net_type=\"top_down_hrnet_w32\",\n", + " engine=Engine.PYTORCH,\n", + " userfeedback=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_6RncQbr0Z4a" + }, + "source": [ + "### ImageNet transfer learning\n", + "\n", + "Historically, the transfer learning using ImageNet weights strategies assumed no “animal pose task priors” in the pretrained\n", + "model, a paradigm adopted from previous task-agnostic transfer learning." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "H2z8kM340Z4a", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bd322c11-20e4-4b75-da4d-fdae1b7e5937" + }, + "outputs": [], + "source": [ + "# Note we skip the detector training to save time. \n", + "# The evaluation is by default using ground-truth bounding box.\n", + "\n", + "# But to train a model that can be used to inference videos and\n", + "# images, you have to set detector_epochs > 0.\n", + "deeplabcut.train_network(\n", + " config_path,\n", + " detector_epochs=0,\n", + " epochs=3,\n", + " save_epochs=3,\n", + " batch_size=64,\n", + " shuffle=imagenet_transfer_learning_shuffle,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "J-udMck7nDbG" + }, + "source": [ + "#### Though the evaluation was also done during training, let's just do it again here to double-check" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "TDHMdKz4m_16", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "091082c1-0c61-4967-9750-092541dad0ae" + }, + "outputs": [], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles=[imagenet_transfer_learning_shuffle])" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "0GIFWU-MxPoR" + }, + "source": [ + "### Transfer learning with SuperAnimal weights" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "ZGhAuyqs0Z4a" + }, + "source": [ + "#### Prepare training shuffle for transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "wOSdZQtOp8qa", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "5c76dbca-4706-4f9d-a70d-0d7763cdcda0" + }, + "outputs": [], + "source": [ + "weight_init = WeightInitialization(\n", + " dataset=f\"{superanimal_name}\",\n", + " with_decoder=False,\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(\n", + " config_path,\n", + " from_shuffle=imagenet_transfer_learning_shuffle,\n", + " shuffles=[superanimal_transfer_learning_shuffle],\n", + " engine=Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init=weight_init,\n", + " userfeedback=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "3qFxlRHixPoR" + }, + "source": [ + "#### Launch the training for transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "W60UgRQWqghn", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "1ebd95f1-830a-4ba0-9f4b-71ac749ef50e" + }, + "outputs": [], + "source": [ + "deeplabcut.train_network(\n", + " config_path,\n", + " detector_epochs=0,\n", + " epochs=3,\n", + " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " shuffle=superanimal_transfer_learning_shuffle,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "XzOWKiOixPoR" + }, + "source": [ + "#### Evaluate the model obtained by transfer-learning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "jpO3aIAIsWbz", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "51ab747e-bf7f-4cf0-bdb5-02774439a08b" + }, + "outputs": [], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles=[superanimal_transfer_learning_shuffle])" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_Es6RR-_0Z4b" + }, + "source": [ + "### Fine-tuning with SuperAnimal (without keeping full SuperAnimal keypoints)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "6oo9oJ8XyZrn" + }, + "source": [ + "#### Setup the weight init and dataset\n", + "First we do keypoint matching. This steps make it possible to understand the correspondance between the existing annotations and SuperAnimal annotations. This step produces 3 outputs\n", + "- The confusion matrix\n", + "- The conversion table\n", + "- Pseudo predictions over the whole dataset" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "fRm62Ji_xPoS" + }, + "source": [ + "#### What is keypoint matching?\n", + "\n", + "Because SuperAnimal models have their pre-defined keypoints that are potentially different from your annotations, we porposed this algorithm to minimize the gap between the model and the dataset. We use our model to perform zero-shot inference on the whole dataset. This gives pairs of predictions and ground truth for every image. Then, we cast the matching between models’ predictions (2D coordinates)\n", + "and ground truth as bipartitematching using the Euclidean distance as the cost between paired of keypoints. We then solve the matching using the Hungarian algorithm. Thus for every image, we end up getting a matching matrix where 1 counts formatch and 0 counts for non-matching. Because the models’ predictions can be noisy from image to image, we average the aforementioned matching matrix across all the images and perform another bipartite matching, resulting in the final keypoint conversion table between the model and the dataset. Note that the quality of thematching will impact the performance\n", + "of the model, especially for zero-shot. In the case where, e.g., the annotation nose is mistakenly converted to keypoint tail and vice versa, the model will have to unlearn the channel that corresponds to nose and tail (see also case study in Mathis et al.)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, + "id": "vEHeuKSKyjA6", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "f85fa523-910a-444d-914f-4a67730f1bc7" + }, + "outputs": [], + "source": [ + "keypoint_matching(\n", + " config_path,\n", + " superanimal_name,\n", + " model_name,\n", + ")\n", + "\n", + "conversion_table_path = dlc_proj_root / \"memory_replay\" / \"conversion_table.csv\"\n", + "confusion_matrix_path = dlc_proj_root / \"memory_replay\" / \"confusion_matrix.png\"\n", + "# You can visualize the pseudo predictions, or do pose embedding clustering etc.\n", + "pseudo_prediction_path = dlc_proj_root / \"memory_replay\" / \"pseudo_predictions.json\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sA8yyLgs0zoO" + }, + "source": [ + "#### Display the confusion matrix\n", + "\n", + "The x axis lists the keypoints in the existing annotations. The y axis lists the keypoints in SuperAnimal keypoint space. Darker color encodes stronger correspondance between the human annotation and SuperAnimal annotations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 406 + }, + "id": "luDxpD9H0zYZ", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "d6420e08-3e9c-40dc-8a13-92bc0d8c220b" + }, + "outputs": [], + "source": [ + "confusion_matrix_image = Image.open(confusion_matrix_path)\n", + "\n", + "plt.imshow(confusion_matrix_image)\n", + "plt.axis('off') # Hide the axes for better view\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "i0QWikYmy_Mj" + }, + "source": [ + "#### Display the conversion table\n", + "The gt columns represents the keypoint names in the existing dataset. The MasterName represents the correspoinding keypoints in SuperAnimal keypoint space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "CeA-NzDMynYV", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "ae27fb36-223c-4aa2-f63f-42adadb95f02" + }, + "outputs": [], + "source": [ + "df = pd.read_csv(conversion_table_path)\n", + "df = df.dropna()\n", + "\n", + "df" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GkfIo8zTxPoS" + }, + "source": [ + "#### Prepare the training shuffle and weight initialization for (naive) fine-tuning with SuperAnimal weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "xEeM_hrOu6k8", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "4a5f4d5f-d1c5-42f8-f4ed-e8c208a1dc10" + }, + "outputs": [], + "source": [ + "table = create_conversion_table(\n", + " config=config_path,\n", + " super_animal=superanimal_name,\n", + " project_to_super_animal=read_conversion_table_from_csv(conversion_table_path),\n", + ")\n", + "\n", + "weight_init = WeightInitialization(\n", + " dataset=superanimal_name,\n", + " with_decoder=True,\n", + " conversion_array=table.to_array()\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(\n", + " config_path,\n", + " from_shuffle=imagenet_transfer_learning_shuffle,\n", + " shuffles=[superanimal_naive_finetune_shuffle],\n", + " engine=Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init=weight_init,\n", + " userfeedback=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "gZx6nr-ExPoS" + }, + "source": [ + "#### Launch the training for (naive) fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "c3XAr6uRyXOD", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "03740373-f9cd-4708-d140-0127033bfdc8" + }, + "outputs": [], + "source": [ + "deeplabcut.train_network(\n", + " config_path,\n", + " detector_epochs=0,\n", + " epochs=3,\n", + " save_epochs=3,\n", + " eval_interval=1,\n", + " batch_size=64,\n", + " shuffle=superanimal_naive_finetune_shuffle,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "oXuRshzhxPoS" + }, + "source": [ + "#### Evaluate the model obtained by (naive) fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "VXfdKS-H2yqw", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "53b1b8fa-6aa3-4dad-a5be-153e96eb0323" + }, + "outputs": [], + "source": [ + "deeplabcut.evaluate_network(\n", + " config_path,\n", + " Shuffles=[superanimal_naive_finetune_shuffle],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_nUAMlbZ0Z4b" + }, + "source": [ + "## Memory-replay fine-tuning with SuperAnimal (keeping full SuperAnimal keypoints)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "n6HPu6RaxPoS" + }, + "source": [ + "**Catastrophic forgetting** describes a\n", + "classic problemin continual learning. Indeed, amodel gradually loses\n", + "its ability to solve previous tasks after it learns to solve new ones.\n", + "Fine-tuning a SuperAnimal models falls into the category of continual\n", + "learning: the downstream dataset defines potentially different\n", + "keypoints than those learned by the models. Thus, the models might\n", + "forget the keypoints they learned and only pick up those defined in the\n", + "target dataset. Here, retraining with the original dataset and the new\n", + "one, is not a feasible option as datasets cannot be easily shared and\n", + "more computational resources would be required.\n", + "To counter that, we treat zero-shot inference of the model as a\n", + "memory buffer that stores knowledge from the original model. When\n", + "we fine-tune a SuperAnimal model, we replace the model predicted\n", + "keypoints with the ground-truth annotations, resulting in hybrid\n", + "learning of old and new knowledge. The quality of the zero-shot predictions\n", + "can vary and we use the confidence of prediction (0.7) as a\n", + "threshold to filter out low-confidence predictions. With the threshold\n", + "set to 1, memory replay fine-tuning becomes naive-fine-tuning." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "CSLmjlCIxPoS" + }, + "source": [ + "#### Prepare training shuffle and weight initialization for memory-replay finetuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "BKEF76AI0Z4c", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "bf107c7b-6e3c-4ece-f680-067e4d7641f0", + "vscode": { + "languageId": "plaintext" + } + }, + "outputs": [], + "source": [ + "weight_init = WeightInitialization(\n", + " dataset=superanimal_name,\n", + " conversion_array=table.to_array(),\n", + " with_decoder=True,\n", + " memory_replay=True,\n", + ")\n", + "\n", + "deeplabcut.create_training_dataset_from_existing_split(\n", + " config_path,\n", + " from_shuffle=imagenet_transfer_learning_shuffle,\n", + " shuffles=[superanimal_memory_replay_shuffle],\n", + " engine=Engine.PYTORCH,\n", + " net_type=\"top_down_hrnet_w32\",\n", + " weight_init=weight_init,\n", + " userfeedback=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "MKwJiIyKxPoT" + }, + "source": [ + "#### Launch the training for memory-replay fine-tuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "Ru8tIFmD2Mkv", + "jupyter": { + "outputs_hidden": true + }, + "outputId": "81a0ec13-6ba7-4089-bed5-f19c6bae0bcb" + }, + "outputs": [], + "source": [ + "deeplabcut.train_network(\n", + " config_path,\n", + " detector_epochs=0,\n", + " epochs=3,\n", + " save_epochs=3,\n", + " eval_interval=1,\n", + " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " shuffle=superanimal_naive_finetune_shuffle,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "i-2MBRDjxPoT" + }, + "source": [ + "#### Evaluate the model obtained by memory-replay finetuning with SuperAnimal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "sfMcK3gq8WxZ", + "jupyter": { + "outputs_hidden": true + } + }, + "outputs": [], + "source": [ + "deeplabcut.evaluate_network(config_path, Shuffles=[superanimal_memory_replay_shuffle])" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "A100", + "include_colab_link": true, + "machine_shape": "hm", + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "013e4fb17d234b18934f11593557eec4": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "3b1eaec29f014a97a8ee14e62b34db83": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "4aba38bf1a9b4be092fffb7581f7cd0b": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "FloatProgressModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_787f617ecbf74db6b020a066acb12840", + "max": 165432914, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_7cd0520e8c26480297b86824e92d77ec", + "value": 165432914 + } + }, + "579cc60d128445b8ae10ee036c036bb5": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "69a82035258c40b5beed0d9673f8b3b6": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_c5d2d04a28d743799dfa706e00303f08", + "placeholder": "​", + "style": "IPY_MODEL_579cc60d128445b8ae10ee036c036bb5", + "value": " 165M/165M [00:03<00:00, 36.1MB/s]" + } + }, + "787f617ecbf74db6b020a066acb12840": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "7cd0520e8c26480297b86824e92d77ec": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "ProgressStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "867c5b27246c40b3823c60a014329427": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_3b1eaec29f014a97a8ee14e62b34db83", + "placeholder": "​", + "style": "IPY_MODEL_013e4fb17d234b18934f11593557eec4", + "value": "model.safetensors: 100%" + } + }, + "c16a46a621ab497183ec0b136749eaeb": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "c5d2d04a28d743799dfa706e00303f08": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "fec6601a01a44935a06d432c1f94349b": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HBoxModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_867c5b27246c40b3823c60a014329427", + "IPY_MODEL_4aba38bf1a9b4be092fffb7581f7cd0b", + "IPY_MODEL_69a82035258c40b5beed0d9673f8b3b6" + ], + "layout": "IPY_MODEL_c16a46a621ab497183ec0b136749eaeb" + } + } + } + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} From 830f4ac9d16f01af2b75890a7e2c433691e368a7 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Tue, 2 Jul 2024 18:59:43 +0200 Subject: [PATCH 179/293] worked on superanimal notebook --- .../COLAB/COLAB_Pytorch_SuperAnimal.ipynb | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb index d5c64ef4ce..3e719252ef 100644 --- a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb @@ -22,7 +22,7 @@ "\n", "# 🦄 SuperAnimal in DeepLabCut PyTorch! 🔥\n", "\n", - "This notebook demos how to use our SuperAnimal models within DLC3. Please read more in [Ye et al. Nature Communications 2024](https://www.nature.com/articles/s41467-024-48792-2) about the available SuperAnimal models, and follow along below!\n", + "This notebook demos how to use our SuperAnimal models within DeepLabCut 3.0! Please read more in [Ye et al. Nature Communications 2024](https://www.nature.com/articles/s41467-024-48792-2) about the available SuperAnimal models, and follow along below!\n", "\n", "### **Let's get going: install DeepLabCut into COLAB:**\n", "\n", @@ -54,7 +54,7 @@ "id": "5h0vq6E50Z4W" }, "source": [ - "### PLEASE, click \"restart runtime\" from the output above before proceeding!" + "**PLEASE, click \"restart runtime\" from the output above before proceeding!**" ] }, { @@ -73,6 +73,7 @@ "outputs": [], "source": [ "import os\n", + "import zipfile\n", "from pathlib import Path\n", "\n", "import matplotlib.pyplot as plt\n", @@ -101,7 +102,7 @@ "source": [ "## Zero-shot Image Inference & Video Inference\n", "SuperAnimal models are foundation animal pose models. They can be used for zero-shot predictions without further training on the data.\n", - "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another dest folder." + "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another destination folder." ] }, { @@ -110,9 +111,9 @@ "id": "FvFzntDMxPoL" }, "source": [ - "## Zero-shot image inference\n", + "### Zero-shot image inference\n", "\n", - "- If you have a single Image you want to test, upload it here!" + "If you have a single Image you want to test, upload it here!" ] }, { @@ -159,7 +160,7 @@ "id": "Jashzdjb0Z4Y" }, "source": [ - "### Select a SuperAnimal name and corresponding model architecture\n", + "#### Select a SuperAnimal name and corresponding model architecture\n", "\n", "Check Our Docs on [SuperAnimals](https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/ModelZoo.md) to learn more!" ] @@ -197,7 +198,7 @@ "outputs": [], "source": [ "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n", - "superanimal_analyze_images(\n", + "_ = superanimal_analyze_images(\n", " superanimal_name,\n", " model_name,\n", " image_path,\n", @@ -213,7 +214,8 @@ }, "source": [ "### Zero-shot Video Inference\n", - "- Without Video adaptation (faster, but not self-supervised fine-tuned on your data!)" + "\n", + "This can be done with or without video adaptation (faster, but not self-supervised fine-tuned on your data!)." ] }, { @@ -290,7 +292,9 @@ "id": "Zv3v0QgSJNOg" }, "source": [ - "### Zero-shot Video Inference without video adaptation" + "#### Zero-shot Video Inference without video adaptation\n", + "\n", + "The labeled video (and pose predictions for the video) are saved in `\"/content/\"`, with the labeled video name being `{your_video_name}_superanimal_{superanimal_name}_hrnetw32_labeled.mp4`." ] }, { @@ -308,12 +312,12 @@ }, "outputs": [], "source": [ - "video_inference_superanimal(\n", + "_ = video_inference_superanimal(\n", " videos=video_path,\n", " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", " video_adapt=False,\n", " max_individuals=max_individuals,\n", - " dest_folder=\"/content/\"\n", + " dest_folder=\"/content/\",\n", ")" ] }, @@ -323,7 +327,9 @@ "id": "Z8Z5GSti0Z4Z" }, "source": [ - "### Zero-shot Video Inference with video adaptation (unsupervised)" + "#### Zero-shot Video Inference with video adaptation (unsupervised)\n", + "\n", + "The labeled video (and pose predictions for the video) are saved in `\"/content/\"`, with the labeled video name being `{your_video_name}_superanimal_{superanimal_name}_hrnetw32_labeled_after_adapt.mp4`." ] }, { @@ -355,7 +361,7 @@ }, "outputs": [], "source": [ - "video_inference_superanimal(\n", + "_ = video_inference_superanimal(\n", " videos=[video_path],\n", " superanimal_name=f\"{superanimal_name}_{model_name}\",\n", " video_adapt=True,\n", @@ -375,14 +381,16 @@ }, "source": [ "## Training with SuperAnimal\n", - "In this section, we compare different ways to train the model, using and without using SuperAnimal.\n", - "You can compare the evaluation results and get a sense of each baseline.\n", - "We have following baselines:\n", + "\n", + "In this section, we compare different ways to train models in DeepLabCut 3.0, with or without using SuperAnimal-pretrained models.\n", + "You can compare the evaluation results and get a sense of each baseline. We have following baselines:\n", "\n", "- ImageNet transfer learning (training without superanimal)\n", "- SuperAnimal transfer learning (baseline 1)\n", "- SuperAnimal naive fine-tuning (baseline 2)\n", - "- SuperAnimal memory-replay fine-tuning (baseline3)" + "- SuperAnimal memory-replay fine-tuning (baseline3)\n", + "\n", + "This should be done on one of your DeepLabCut projects! If you don't have a DeepLabCut project that you can use SuperAnimal models with, you can always using the example openfield dataset [available in the DeepLabCut repository](https://github.com/DeepLabCut/DeepLabCut/tree/main/examples/openfield-Pranav-2018-10-30)! " ] }, { @@ -391,7 +399,9 @@ "id": "L2wxevEn0Z4a" }, "source": [ - "#### Uploading your DLC project into Drive. Note you have to zip your DLC project and select the zipped file" + "#### Uploading your DeepLabCut project into Google Colab\n", + "\n", + "Note you have to zip your DLC project and select the zipped file." ] }, { @@ -416,6 +426,7 @@ " with zipfile.ZipFile(zip_file_path, \"r\") as zip_ref:\n", " zip_ref.extractall(\"/content/dlc_project_folder\")\n", "\n", + "\n", "print(\"Contents of the extracted folder:\")\n", "extracted_files = os.listdir(\"/content/dlc_project_folder\")\n", "for file in extracted_files:\n", From 8c0c0f4e867335283a2bc5dd5f93547f9468e4b8 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 2 Jul 2024 23:24:50 +0200 Subject: [PATCH 180/293] Fix GUI bugs (#2649) --- deeplabcut/gui/components.py | 5 +++++ deeplabcut/gui/tabs/analyze_videos.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/deeplabcut/gui/components.py b/deeplabcut/gui/components.py index b586d1c098..cd84d5eed9 100644 --- a/deeplabcut/gui/components.py +++ b/deeplabcut/gui/components.py @@ -89,6 +89,11 @@ def __init__( self.itemSelectionChanged.connect(self.update_selected_bodyparts) + def refresh(self): + self.clear() + self.addItems(self.root.all_bodyparts) + self.update_selected_bodyparts() + def update_selected_bodyparts(self): self.selected_bodyparts = [item.text() for item in self.selectedItems()] self.root.logger.info(f"Selected bodyparts:\n\t{self.selected_bodyparts}") diff --git a/deeplabcut/gui/tabs/analyze_videos.py b/deeplabcut/gui/tabs/analyze_videos.py index 649430015c..5e1fa88194 100644 --- a/deeplabcut/gui/tabs/analyze_videos.py +++ b/deeplabcut/gui/tabs/analyze_videos.py @@ -27,6 +27,7 @@ import deeplabcut from deeplabcut.utils.auxiliaryfunctions import edit_config +from deeplabcut.utils import auxfun_multianimal class AnalyzeVideos(DefaultTab): @@ -246,6 +247,7 @@ def update_crop_choice(self, state): def update_plot_trajectory_choice(self, state): if Qt.CheckState(state) == Qt.Checked: + self.bodyparts_list_widget.refresh() self.bodyparts_list_widget.show() self.bodyparts_list_widget.setEnabled(True) self.show_trajectory_plots.setEnabled(True) @@ -346,6 +348,7 @@ def run_enabled(self): shuffle=shuffle, ) + track_method = auxfun_multianimal.get_track_method(self.root.cfg) if filter_data: deeplabcut.filterpredictions( config, @@ -355,6 +358,7 @@ def run_enabled(self): filtertype="median", windowlength=5, save_as_csv=save_as_csv, + track_method=track_method, ) if self.plot_trajectories.checkState() == Qt.Checked: @@ -371,6 +375,7 @@ def run_enabled(self): shuffle=shuffle, filtered=filter_data, showfigures=showfig, + track_method=track_method, ) if self.root.is_multianimal and save_as_csv: From 89aebaf7b16e0e84805be6013573bd3a9274fc84 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Wed, 3 Jul 2024 23:42:25 +0200 Subject: [PATCH 181/293] Filter image names when generating test data (#2650) --- examples/testscript.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/testscript.py b/examples/testscript.py index 09ed733e9c..bd4656e436 100644 --- a/examples/testscript.py +++ b/examples/testscript.py @@ -91,6 +91,7 @@ print("CREATING-SOME LABELS FOR THE FRAMES") frames = os.listdir(os.path.join(cfg["project_path"], "labeled-data", videoname)) + frames = [fn for fn in frames if fn.endswith(".png")] # As this next step is manual, we update the labels by putting them on the diagonal (fixed for all frames) for index, bodypart in enumerate(cfg["bodyparts"]): columnindex = pd.MultiIndex.from_product( From 66acb3d3d4baa2814c4a329adde817e0155184f0 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 13:13:05 +0200 Subject: [PATCH 182/293] fixed symlink creation for keypoint matching --- .../datasets/materialize.py | 41 ++++---- deeplabcut/utils/pseudo_label.py | 93 +++++++++---------- 2 files changed, 62 insertions(+), 72 deletions(-) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py index dd5224a7a1..73da82cfae 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -12,6 +12,7 @@ import os import pickle import shutil +from pathlib import Path import numpy as np import pandas as pd @@ -693,52 +694,42 @@ def _generic2coco( broken_links = [] # copying images via symbolic link for image in train_images + test_images: - src = image["file_name"] + # important to resolve the filepath! Otherwise, errors can occur when running + # this code from Jupyter Notebooks + src = Path(image["file_name"]).resolve() image_id = image["id"] - if not os.path.exists(src): + if not src.exists(): print("problem comes from", image["source_dataset"]) print(src) broken_links.append(image_id) continue - else: - pass - # print ('success comes from', image['source_dataset']) - # print (src) # in dlc, some images have same name but under different folder - # we used to use a parent folder to distinguish them, but it's only applicable to DLC - # so here it's easier to just append a id into the filename - - image_name = src.split(os.sep)[-1] - - if image_name.count(".") > 1: - sep = image_name.rfind(".") - pre, suffix = image_name[:sep], image_name[sep + 1 :] - else: - # this does not work for image file that looks like image9.5.jpg.. - pre, suffix = image_name.split(".") + # we used to use a parent folder to distinguish them, but it's only applicable + # to DLC so here it's easier to just append a id into the filename # not to repeatedly add image id in memory replay training + dest_image_name = src.name if append_image_id: - dest_image_name = f"{pre}_{image_id}.{suffix}" - else: - dest_image_name = image_name - dest = os.path.join(proj_root, "images", dest_image_name) + dest_image_name = f"{src.stem}_{image_id}{src.suffix}" - # now, we will also need to update the path in the config files + dest = Path(proj_root) / "images" / dest_image_name + dest = dest.resolve() + # now, we will also need to update the path in the config files if full_image_path: - image["file_name"] = dest + image["file_name"] = str(dest) else: - image["file_name"] = os.path.join("images", dest_image_name) + image["file_name"] = str(Path(*dest.parts[-2:])) if deepcopy: shutil.copy(src, dest) else: try: os.symlink(src, dest) - except: + except Exception as err: + print(f"Could not create a symlink from {src} to {dest}: {err}") pass lookuptable[dest] = src diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py index 5e279e7ed9..7291e61873 100644 --- a/deeplabcut/utils/pseudo_label.py +++ b/deeplabcut/utils/pseudo_label.py @@ -145,29 +145,36 @@ def plot_cost_matrix( def keypoint_matching( - config_path, - superanimal_name, - model_name, - device=None, - train_file="train.json", - pose_threshold=0.1, + config_path: str | Path, + superanimal_name: str, + model_name: str, + device: str | None = None, + train_file: str = "train.json", ): + """Runs the keypoint matching algorithm for a DeepLabCut project - cfg = af.read_config(config_path) - - trainIndex = 0 + Matches project keypoints to SuperAnimal keypoints automatically, by running + SuperAnimal inference on all images in the dataset - dlc_proj_root = str(Path(config_path).parent) + Args: + config_path: the path of the DeepLabCut project configuration file + superanimal_name: SuperAnimal dataset with which to run keypoint matching + model_name: SuperAnimal model with which to run keypoint matching + device: the device on which to run keypoint matching + train_file: the name of the file containing the labels to output + """ + config_path = Path(config_path) + cfg = af.read_config(str(config_path)) + dlc_proj_root = config_path.parent if "individuals" in cfg: - temp_dataset = MaDLCDataFrame(dlc_proj_root, "temp_dataset") + temp_dataset = MaDLCDataFrame(str(dlc_proj_root), "temp_dataset") max_individuals = len(cfg["individuals"]) else: - temp_dataset = SingleDLCDataFrame(dlc_proj_root, "temp_dataset") + temp_dataset = SingleDLCDataFrame(str(dlc_proj_root), "temp_dataset") max_individuals = 1 - memory_replay_folder = Path(dlc_proj_root) / "memory_replay" - + memory_replay_folder = dlc_proj_root / "memory_replay" temp_dataset.materialize(str(memory_replay_folder), framework="coco") # inferencing the train set @@ -186,7 +193,6 @@ def keypoint_matching( individuals = [f"animal{i}" for i in range(max_individuals)] config["individuals"] = individuals - num_bodyparts = len(config["bodyparts"]) train_file_path = os.path.join(memory_replay_folder, "annotations", train_file) pose_runner, detector_runner = get_inference_runners( @@ -204,30 +210,29 @@ def keypoint_matching( images = train_obj["images"] annotations = train_obj["annotations"] categories = train_obj["categories"] - imagename2id = {} - imageid2name = {} - imagename2gt = defaultdict(list) + image_name_to_id = {} + image_id_to_name = {} + + image_name_to_gt = defaultdict(list) + image_name_to_bbox = defaultdict(list) + image_id_to_annotations = defaultdict(list) for image in images: # this only works with relative path as the testing image can be at a different folder - imagename = image["file_name"].split(os.sep)[-1] - imagename2id[imagename] = image["id"] - imageid2name[image["id"]] = imagename - - imagename2bbox = defaultdict(list) + name = image["file_name"].split(os.sep)[-1] + image_name_to_id[name] = image["id"] + image_id_to_name[image["id"]] = name for anno in annotations: - imagename = imageid2name[anno["image_id"]] - imagename2gt[imagename].append(anno) - imagename2bbox[imagename].append(anno["bbox"]) - - imageid2annotations = defaultdict(list) + name = image_id_to_name[anno["image_id"]] + image_name_to_gt[name].append(anno) + image_name_to_bbox[name].append(anno["bbox"]) - imageids = list(imagename2id.values()) - for annotation in annotations: - image_id = annotation["image_id"] - if annotation["image_id"] in imageids: - imageid2annotations[image_id].append(annotation) + image_ids = set(image_name_to_id.values()) + for anno in annotations: + image_id = anno["image_id"] + if anno["image_id"] in image_ids: + image_id_to_annotations[image_id].append(anno) # need to support more image types image_extensions = ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.tiff"] @@ -238,19 +243,15 @@ def keypoint_matching( ) corresponded_images = [] - for image in images_in_folder: image_path = image - imagename = image.split(os.sep)[-1] - if imagename in imagename2id: + name = image.split(os.sep)[-1] + if name in image_name_to_id: corresponded_images.append(image_path) images = corresponded_images - - bbox_predictions = detector_runner.inference(images=images) - bbox_gts = [ - {"bboxes": np.array(imagename2bbox[image.split(os.sep)[-1]])} + {"bboxes": np.array(image_name_to_bbox[image.split(os.sep)[-1]])} for image in images ] @@ -260,16 +261,14 @@ def keypoint_matching( predictions = pose_runner.inference(pose_inputs) with open(str(memory_replay_folder / "pseudo_predictions.json"), "w") as f: - json.dump(pose_inputs, f, cls=NumpyEncoder) assert len(images) == len(predictions) - imagename2prediction = {} - + image_name_to_pred = {} for image_path, prediction in zip(images, predictions): - imagename = image_path.split(os.sep)[-1] - imagename2prediction[imagename] = prediction + name = image_path.split(os.sep)[-1] + image_name_to_pred[name] = prediction pred_keypoint_names = config["bodyparts"] num_pred_keypoints = len(pred_keypoint_names) @@ -279,10 +278,10 @@ def keypoint_matching( match_matrix = np.zeros((num_pred_keypoints, num_gt_keypoints)) match_dict = defaultdict(lambda: defaultdict(int)) - for imagename, gts in imagename2gt.items(): + for name, gts in image_name_to_gt.items(): bbox_gts = [np.array(gt["bbox"]) for gt in gts] bbox_gts = [xywh2xyxy(e) for e in bbox_gts] - prediction = imagename2prediction[imagename] + prediction = image_name_to_pred[name] bbox_preds = [xywh2xyxy(pred) for pred in prediction["bboxes"]] optimal_pred_indices = optimal_match(bbox_gts, bbox_preds) From ba39fd5264f341cee2882f3a27635d2299dfb2ef Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 13:16:32 +0200 Subject: [PATCH 183/293] renamed COLAB to YOURDATA --- ...Pytorch_SuperAnimal.ipynb => COLAB_YOURDATA_SuperAnimal.ipynb} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/COLAB/{COLAB_Pytorch_SuperAnimal.ipynb => COLAB_YOURDATA_SuperAnimal.ipynb} (100%) diff --git a/examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb similarity index 100% rename from examples/COLAB/COLAB_Pytorch_SuperAnimal.ipynb rename to examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb From 24cc19570039564d8edad32f654f897b84376395 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 13:38:37 +0200 Subject: [PATCH 184/293] fixed some bugs and cleaned the notebook. added more print statements for memory replay --- .../pose_estimation_pytorch/apis/train.py | 5 +- .../COLAB/COLAB_YOURDATA_SuperAnimal.ipynb | 1147 +++++++++++++---- 2 files changed, 918 insertions(+), 234 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index 4a006d3f09..efec0b6db0 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -257,8 +257,10 @@ def train_network( dataset_params = loader.get_dataset_parameters() backbone_name = loader.model_cfg["model"]["backbone"]["model_name"] model_name = modelzoo_utils.get_pose_model_type(backbone_name) - # at some point train_network should support a different train_file passing so memory replay can also take the same train file + # at some point train_network should support a different train_file passing + # so memory replay can also take the same train file + print("Preparing data for memory replay (this can take some time)") prepare_memory_replay( loader.project_path, shuffle, @@ -271,6 +273,7 @@ def train_network( customized_pose_checkpoint=weight_init.customized_pose_checkpoint, ) + print("Loading memory replay data") loader = COCOLoader( project_root=Path(loader.model_folder).parent / "memory_replay", model_config_path=loader.model_config_path, diff --git a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb index 3e719252ef..95faa07c5b 100644 --- a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb @@ -3,7 +3,6 @@ { "cell_type": "markdown", "metadata": { - "colab_type": "text", "id": "view-in-github" }, "source": [ @@ -41,7 +40,7 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "0a9bc286-e21e-4fe2-cd1a-fb570f35e719" + "outputId": "290a589f-a063-4933-d315-e13052ec1024" }, "outputs": [], "source": [ @@ -68,13 +67,15 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "5fff9d1f-621f-4147-9a54-39d4c01db0b3" + "outputId": "ef4fd2ed-4569-41d4-b78a-8bf5ae9a0e6b" }, "outputs": [], "source": [ "import os\n", - "import zipfile\n", + "import requests\n", + "from io import BytesIO\n", "from pathlib import Path\n", + "from zipfile import ZipFile\n", "\n", "import matplotlib.pyplot as plt\n", "import pandas as pd\n", @@ -100,7 +101,7 @@ "id": "UeXjmtu40Z4X" }, "source": [ - "## Zero-shot Image Inference & Video Inference\n", + "## Zero-shot Image & Video Inference\n", "SuperAnimal models are foundation animal pose models. They can be used for zero-shot predictions without further training on the data.\n", "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another destination folder." ] @@ -129,15 +130,10 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 92 - }, "id": "c4yfTj7r0Z4Y", "jupyter": { "outputs_hidden": true - }, - "outputId": "5cd0b4e7-2d47-4432-ec1e-8f66c0362c7b" + } }, "outputs": [], "source": [ @@ -231,15 +227,10 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 92 - }, "id": "PK3efA0I0Z4Y", "jupyter": { "outputs_hidden": true - }, - "outputId": "e334e7ae-6904-4853-dade-500cee55e7f5" + } }, "outputs": [], "source": [ @@ -301,14 +292,10 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, "id": "poqynL0UJTBp", "jupyter": { "outputs_hidden": true - }, - "outputId": "118cab24-69e6-4ce9-c546-ee9e306dbba2" + } }, "outputs": [], "source": [ @@ -336,28 +323,10 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 1000, - "referenced_widgets": [ - "fec6601a01a44935a06d432c1f94349b", - "867c5b27246c40b3823c60a014329427", - "4aba38bf1a9b4be092fffb7581f7cd0b", - "69a82035258c40b5beed0d9673f8b3b6", - "c16a46a621ab497183ec0b136749eaeb", - "3b1eaec29f014a97a8ee14e62b34db83", - "013e4fb17d234b18934f11593557eec4", - "787f617ecbf74db6b020a066acb12840", - "7cd0520e8c26480297b86824e92d77ec", - "c5d2d04a28d743799dfa706e00303f08", - "579cc60d128445b8ae10ee036c036bb5" - ] - }, "id": "5mhOmtzw0Z4Z", "jupyter": { "outputs_hidden": true - }, - "outputId": "a87f668a-c735-4372-c5ac-15fea95f5a99" + } }, "outputs": [], "source": [ @@ -390,18 +359,18 @@ "- SuperAnimal naive fine-tuning (baseline 2)\n", "- SuperAnimal memory-replay fine-tuning (baseline3)\n", "\n", - "This should be done on one of your DeepLabCut projects! If you don't have a DeepLabCut project that you can use SuperAnimal models with, you can always using the example openfield dataset [available in the DeepLabCut repository](https://github.com/DeepLabCut/DeepLabCut/tree/main/examples/openfield-Pranav-2018-10-30)! " + "This is done on one of your DeepLabCut projects! If you don't have a DeepLabCut project that you can use SuperAnimal models with, you can always using the example openfield dataset [available in the DeepLabCut repository](https://github.com/DeepLabCut/DeepLabCut/tree/main/examples/openfield-Pranav-2018-10-30) or the Tri-Mouse dataset available on [Zenodo](https://zenodo.org/records/5851157)." ] }, { "cell_type": "markdown", "metadata": { - "id": "L2wxevEn0Z4a" + "id": "yPy5VgDDhD6o" }, "source": [ - "#### Uploading your DeepLabCut project into Google Colab\n", + "### Preparing the DeepLabCut Project\n", "\n", - "Note you have to zip your DLC project and select the zipped file." + "First, place your DeepLabCut project folder into you google drive! \"i.e. move the folder named \"Project-YourName-TheDate\" into Google Drive." ] }, { @@ -409,51 +378,66 @@ "execution_count": null, "metadata": { "colab": { - "base_uri": "https://localhost:8080/", - "height": 129 - }, - "id": "visacW8i0Z4a", - "jupyter": { - "outputs_hidden": true + "base_uri": "https://localhost:8080/" }, - "outputId": "db60ca6e-b38d-44c7-bc69-a327b40c8d4a" + "id": "SXzBBV8ehDR9", + "outputId": "90d61c19-400b-4e5d-8ac9-63680d72cdb5" }, "outputs": [], "source": [ - "uploaded = files.upload()\n", - "for filename in uploaded.keys():\n", - " zip_file_path = os.path.join(\"/content\", filename)\n", - " with zipfile.ZipFile(zip_file_path, \"r\") as zip_ref:\n", - " zip_ref.extractall(\"/content/dlc_project_folder\")\n", + "# Now, let's link to your GoogleDrive. Run this cell and follow the\n", + "# authorization instructions:\n", + "\n", + "from google.colab import drive\n", + "drive.mount('/content/drive')" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "-QmTftBMo4h6" + }, + "source": [ + "You will need to edit the project path in the config.yaml file to be set to your Google Drive link!\n", "\n", + "Typically, this will be in the format: `/content/drive/MyDrive/yourProjectFolderName`. You can obtain this path by going to the file navigator in the left pane, finding your DeepLabCut project folder, clicking on the vertical `...` next to the folder name and selecting \"Copy path\".\n", "\n", - "print(\"Contents of the extracted folder:\")\n", - "extracted_files = os.listdir(\"/content/dlc_project_folder\")\n", - "for file in extracted_files:\n", - " print(f\"- {file}\")" + "If the `drive` folder is not immediately visible after mounting the drive, refresh the available files!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "_iFFEYAB7Uum" + }, + "outputs": [], + "source": [ + "project_path = Path(\"/content/drive/MyDrive/my-project-2024-07-17\")\n", + "config_path = str(project_path / \"config.yaml\")" ] }, { "cell_type": "markdown", "metadata": { - "id": "b5UqfHcnxPoO" + "id": "HZTG3Eo475w0" }, "source": [ - "#### Change the path to your project in dlc_project_folder" + "Then, use the panel below to select the appropriate SuperAnimal model for your project (don't forget to run the cell)!" ] }, { "cell_type": "code", "execution_count": null, "metadata": { - "id": "nY7Sv9pslaMh", - "jupyter": { - "outputs_hidden": true - } + "id": "t8NtCy1Jo0bu" }, "outputs": [], "source": [ - "dlc_proj_root = Path(\"/content/dlc_project_folder/daniel3mouse\")" + "# @markdown ---\n", + "# @markdown SuperAnimal Configurations\n", + "superanimal_name = \"superanimal_topviewmouse\" #@param [\"superanimal_topviewmouse\", \"superanimal_quadruped\"]\n", + "model_name = \"hrnetw32\" #@param [\"hrnetw32\"]" ] }, { @@ -462,7 +446,7 @@ "id": "BPvoL9uZ0Z4a" }, "source": [ - "#### Comparison between different training baselines\n" + "### Comparison between different training baselines\n" ] }, { @@ -537,14 +521,13 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "bb09b5cd-bd25-416b-8b94-aa25a5ffd1a7" + "outputId": "c7df2943-1e2c-4b85-c20d-8b94a8aabd75" }, "outputs": [], "source": [ - "config_path = dlc_proj_root / \"config.yaml\"\n", "deeplabcut.create_training_dataset(\n", " config_path,\n", - " Shuffles = [imagenet_transfer_learning_shuffle],\n", + " Shuffles=[imagenet_transfer_learning_shuffle],\n", " net_type=\"top_down_hrnet_w32\",\n", " engine=Engine.PYTORCH,\n", " userfeedback=False,\n", @@ -560,7 +543,9 @@ "### ImageNet transfer learning\n", "\n", "Historically, the transfer learning using ImageNet weights strategies assumed no “animal pose task priors” in the pretrained\n", - "model, a paradigm adopted from previous task-agnostic transfer learning." + "model, a paradigm adopted from previous task-agnostic transfer learning.\n", + "\n", + "You can change the number of epochs you want to train for. Training on the Tri-Mouse dataset for 10 epochs should take about 3 minutes on a T4 GPU. To fully train the model, you should aim for 150 epochs (about 45 minutes)." ] }, { @@ -568,27 +553,42 @@ "execution_count": null, "metadata": { "colab": { - "base_uri": "https://localhost:8080/" + "base_uri": "https://localhost:8080/", + "height": 1000, + "referenced_widgets": [ + "7ed11ae2a4be462da84ff716e0725af0", + "0f0ed94a863f49b9b85d0a18fa8ce2a5", + "343f2670d37c4bf18859238c3d81d419", + "d104ae21091e4f10a7de18e191b9f04d", + "5dcbd8f3fb6148cca6cfc72b20ce49bd", + "e1675e53ca9a4da8acf6c16fba7a2578", + "3d2996e10f96404baf24d2c4215b75a1", + "b988f87e676840ee98daa3d996c9ddbc", + "1779b84e748b4989a8ed53434c30016f", + "d37cf6fe7c444bc2a2568c3407389ea8", + "2cef5e028d2e40a6bba7400be922d0c2" + ] }, "id": "H2z8kM340Z4a", "jupyter": { "outputs_hidden": true }, - "outputId": "bd322c11-20e4-4b75-da4d-fdae1b7e5937" + "outputId": "75cc2c95-2ac7-4354-9134-4847937e15ce" }, "outputs": [], "source": [ - "# Note we skip the detector training to save time. \n", - "# The evaluation is by default using ground-truth bounding box.\n", + "# Note we skip the detector training to save time.\n", + "# For Top-Down models, the evaluation is by default using ground-truth bounding\n", + "# boxes. But to train a model that can be used to inference videos and images,\n", + "# you have to set detector_epochs > 0.\n", "\n", - "# But to train a model that can be used to inference videos and\n", - "# images, you have to set detector_epochs > 0.\n", "deeplabcut.train_network(\n", " config_path,\n", " detector_epochs=0,\n", - " epochs=3,\n", - " save_epochs=3,\n", - " batch_size=64,\n", + " epochs=1,\n", + " display_iters=1,\n", + " save_epochs=1,\n", + " batch_size=4,\n", " shuffle=imagenet_transfer_learning_shuffle,\n", ")" ] @@ -599,7 +599,7 @@ "id": "J-udMck7nDbG" }, "source": [ - "#### Though the evaluation was also done during training, let's just do it again here to double-check" + "Now let's evaluate the performance of our trained models." ] }, { @@ -613,7 +613,7 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "091082c1-0c61-4967-9750-092541dad0ae" + "outputId": "1d38fb84-7f4c-45d1-dbcd-fd7117ca4dad" }, "outputs": [], "source": [ @@ -626,16 +626,12 @@ "id": "0GIFWU-MxPoR" }, "source": [ - "### Transfer learning with SuperAnimal weights" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "ZGhAuyqs0Z4a" - }, - "source": [ - "#### Prepare training shuffle for transfer-learning with SuperAnimal weights" + "### Transfer learning with SuperAnimal weights\n", + "\n", + "First, we prepare training shuffle for transfer-learning with SuperAnimal weights. As we've already create a shuffle with a train/test split that we want to reuse, we use `deeplabcut.create_training_dataset_from_existing_split` to keep the same train/test indices as in the ImageNet transfer learning shuffle.\n", + "\n", + "We specify that we want to initialize the model weights with the selected SuperAnimal model, but without keeping the decoding layers (this is called transfer learning)!\n", + "\n" ] }, { @@ -649,12 +645,12 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "5c76dbca-4706-4f9d-a70d-0d7763cdcda0" + "outputId": "ea721606-ea9f-444b-cdae-f62cf0ad30be" }, "outputs": [], "source": [ "weight_init = WeightInitialization(\n", - " dataset=f\"{superanimal_name}\",\n", + " dataset=superanimal_name,\n", " with_decoder=False,\n", ")\n", "\n", @@ -675,7 +671,7 @@ "id": "3qFxlRHixPoR" }, "source": [ - "#### Launch the training for transfer-learning with SuperAnimal weights" + "Then, we launch the training for transfer-learning with SuperAnimal weights." ] }, { @@ -683,21 +679,47 @@ "execution_count": null, "metadata": { "colab": { - "base_uri": "https://localhost:8080/" + "base_uri": "https://localhost:8080/", + "height": 1000, + "referenced_widgets": [ + "9a996c8dc3b34bc5b8805b3687e22b27", + "d012b421c189412dabeac84cba4164a7", + "1abff22a7c9a416d9166e6b150612171", + "7271412c1f0141649a7300dbce2b003c", + "3c011813d7cb48588a8d236785d9c24f", + "3ea385fe815f4e50a0b81ec299040314", + "fe59f6c5ed7b4e2cb87bb60224acdaba", + "04370d8302c04c5ca6a351383126193f", + "d67c4871543e405fbb576a55f8c9048a", + "a6cb25fa67ef4733a720960b3fc8213c", + "b73b1b64620d492dbc4eaf4bd83ca23a", + "dccbe277cc084ed6aa0b329067b5c69c", + "c8b57833d3f946abae69b84075345a54", + "bee292213d8645618536fcdf6a491d83", + "fbbc8c5b20c7423fb21b74296e0eeb28", + "ff0c737c49624b1ea27588611951fc84", + "42874cdab4be4dc38b0c33775b27d98c", + "e3a185abf8a04edabf32d58bdee10dd1", + "7cdcbbf9cb694dbf949e8b7eea8e7836", + "2ec06260b237411cabd3de7c37e03b1b", + "9f8009429aa34b40a65c998230f20c99", + "2a3abfe7867641db9fbfe3ee76854bf4" + ] }, "id": "W60UgRQWqghn", "jupyter": { "outputs_hidden": true }, - "outputId": "1ebd95f1-830a-4ba0-9f4b-71ac749ef50e" + "outputId": "18b931b8-98f4-4539-bf82-1910ff5b7f70" }, "outputs": [], "source": [ "deeplabcut.train_network(\n", " config_path,\n", " detector_epochs=0,\n", - " epochs=3,\n", - " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " epochs=1,\n", + " batch_size=4, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " display_iters=1,\n", " shuffle=superanimal_transfer_learning_shuffle,\n", ")" ] @@ -708,7 +730,7 @@ "id": "XzOWKiOixPoR" }, "source": [ - "#### Evaluate the model obtained by transfer-learning with SuperAnimal weights" + "Finally, we evaluate the model obtained by transfer-learning with SuperAnimal weights." ] }, { @@ -722,7 +744,7 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "51ab747e-bf7f-4cf0-bdb5-02774439a08b" + "outputId": "30415e5b-8011-4651-af77-a781ea2b5af7" }, "outputs": [], "source": [ @@ -745,6 +767,7 @@ }, "source": [ "#### Setup the weight init and dataset\n", + "\n", "First we do keypoint matching. This steps make it possible to understand the correspondance between the existing annotations and SuperAnimal annotations. This step produces 3 outputs\n", "- The confusion matrix\n", "- The conversion table\n", @@ -776,7 +799,7 @@ "jupyter": { "outputs_hidden": true }, - "outputId": "f85fa523-910a-444d-914f-4a67730f1bc7" + "outputId": "5863a81e-e0b9-48c7-f2f9-de14d38e805e" }, "outputs": [], "source": [ @@ -786,10 +809,11 @@ " model_name,\n", ")\n", "\n", - "conversion_table_path = dlc_proj_root / \"memory_replay\" / \"conversion_table.csv\"\n", - "confusion_matrix_path = dlc_proj_root / \"memory_replay\" / \"confusion_matrix.png\"\n", + "conversion_table_path = project_path / \"memory_replay\" / \"conversion_table.csv\"\n", + "confusion_matrix_path = project_path / \"memory_replay\" / \"confusion_matrix.png\"\n", + "\n", "# You can visualize the pseudo predictions, or do pose embedding clustering etc.\n", - "pseudo_prediction_path = dlc_proj_root / \"memory_replay\" / \"pseudo_predictions.json\"" + "pseudo_prediction_path = project_path / \"memory_replay\" / \"pseudo_predictions.json\"" ] }, { @@ -807,15 +831,10 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 406 - }, "id": "luDxpD9H0zYZ", "jupyter": { "outputs_hidden": true - }, - "outputId": "d6420e08-3e9c-40dc-8a13-92bc0d8c220b" + } }, "outputs": [], "source": [ @@ -840,14 +859,10 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, "id": "CeA-NzDMynYV", "jupyter": { "outputs_hidden": true - }, - "outputId": "ae27fb36-223c-4aa2-f63f-42adadb95f02" + } }, "outputs": [], "source": [ @@ -870,14 +885,10 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, "id": "xEeM_hrOu6k8", "jupyter": { "outputs_hidden": true - }, - "outputId": "4a5f4d5f-d1c5-42f8-f4ed-e8c208a1dc10" + } }, "outputs": [], "source": [ @@ -917,24 +928,21 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, "id": "c3XAr6uRyXOD", "jupyter": { "outputs_hidden": true - }, - "outputId": "03740373-f9cd-4708-d140-0127033bfdc8" + } }, "outputs": [], "source": [ "deeplabcut.train_network(\n", " config_path,\n", " detector_epochs=0,\n", - " epochs=3,\n", - " save_epochs=3,\n", + " epochs=1,\n", + " save_epochs=1,\n", " eval_interval=1,\n", - " batch_size=64,\n", + " batch_size=4,\n", + " display_iters=1,\n", " shuffle=superanimal_naive_finetune_shuffle,\n", ")" ] @@ -952,14 +960,10 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, "id": "VXfdKS-H2yqw", "jupyter": { "outputs_hidden": true - }, - "outputId": "53b1b8fa-6aa3-4dad-a5be-153e96eb0323" + } }, "outputs": [], "source": [ @@ -975,7 +979,7 @@ "id": "_nUAMlbZ0Z4b" }, "source": [ - "## Memory-replay fine-tuning with SuperAnimal (keeping full SuperAnimal keypoints)" + "### Memory-replay fine-tuning with SuperAnimal (keeping full SuperAnimal keypoints)" ] }, { @@ -1017,16 +1021,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, "id": "BKEF76AI0Z4c", "jupyter": { "outputs_hidden": true - }, - "outputId": "bf107c7b-6e3c-4ece-f680-067e4d7641f0", - "vscode": { - "languageId": "plaintext" } }, "outputs": [], @@ -1062,25 +1059,22 @@ "cell_type": "code", "execution_count": null, "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, "id": "Ru8tIFmD2Mkv", "jupyter": { "outputs_hidden": true - }, - "outputId": "81a0ec13-6ba7-4089-bed5-f19c6bae0bcb" + } }, "outputs": [], "source": [ "deeplabcut.train_network(\n", " config_path,\n", " detector_epochs=0,\n", - " epochs=3,\n", - " save_epochs=3,\n", + " epochs=1,\n", + " save_epochs=1,\n", " eval_interval=1,\n", - " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", - " shuffle=superanimal_naive_finetune_shuffle,\n", + " batch_size=4, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " display_iters=1,\n", + " shuffle=superanimal_memory_replay_shuffle,\n", ")" ] }, @@ -1111,9 +1105,12 @@ "metadata": { "accelerator": "GPU", "colab": { - "gpuType": "A100", - "include_colab_link": true, - "machine_shape": "hm", + "collapsed_sections": [ + "UeXjmtu40Z4X", + "FvFzntDMxPoL", + "6VEjHu-00Z4Y" + ], + "gpuType": "T4", "provenance": [] }, "kernelspec": { @@ -1131,26 +1128,11 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.10.13" }, "widgets": { "application/vnd.jupyter.widget-state+json": { - "013e4fb17d234b18934f11593557eec4": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "DescriptionStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "1.2.0", - "_view_name": "StyleView", - "description_width": "" - } - }, - "3b1eaec29f014a97a8ee14e62b34db83": { + "04370d8302c04c5ca6a351383126193f": { "model_module": "@jupyter-widgets/base", "model_module_version": "1.2.0", "model_name": "LayoutModel", @@ -1202,7 +1184,44 @@ "width": null } }, - "4aba38bf1a9b4be092fffb7581f7cd0b": { + "0f0ed94a863f49b9b85d0a18fa8ce2a5": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_e1675e53ca9a4da8acf6c16fba7a2578", + "placeholder": "​", + "style": "IPY_MODEL_3d2996e10f96404baf24d2c4215b75a1", + "value": "model.safetensors: 100%" + } + }, + "1779b84e748b4989a8ed53434c30016f": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "ProgressStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "1abff22a7c9a416d9166e6b150612171": { "model_module": "@jupyter-widgets/controls", "model_module_version": "1.5.0", "model_name": "FloatProgressModel", @@ -1218,15 +1237,15 @@ "bar_style": "success", "description": "", "description_tooltip": null, - "layout": "IPY_MODEL_787f617ecbf74db6b020a066acb12840", - "max": 165432914, + "layout": "IPY_MODEL_04370d8302c04c5ca6a351383126193f", + "max": 159594859, "min": 0, "orientation": "horizontal", - "style": "IPY_MODEL_7cd0520e8c26480297b86824e92d77ec", - "value": 165432914 + "style": "IPY_MODEL_d67c4871543e405fbb576a55f8c9048a", + "value": 159594859 } }, - "579cc60d128445b8ae10ee036c036bb5": { + "2a3abfe7867641db9fbfe3ee76854bf4": { "model_module": "@jupyter-widgets/controls", "model_module_version": "1.5.0", "model_name": "DescriptionStyleModel", @@ -1241,28 +1260,62 @@ "description_width": "" } }, - "69a82035258c40b5beed0d9673f8b3b6": { + "2cef5e028d2e40a6bba7400be922d0c2": { "model_module": "@jupyter-widgets/controls", "model_module_version": "1.5.0", - "model_name": "HTMLModel", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "2ec06260b237411cabd3de7c37e03b1b": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "ProgressStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "343f2670d37c4bf18859238c3d81d419": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "FloatProgressModel", "state": { "_dom_classes": [], "_model_module": "@jupyter-widgets/controls", "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", + "_model_name": "FloatProgressModel", "_view_count": null, "_view_module": "@jupyter-widgets/controls", "_view_module_version": "1.5.0", - "_view_name": "HTMLView", + "_view_name": "ProgressView", + "bar_style": "success", "description": "", "description_tooltip": null, - "layout": "IPY_MODEL_c5d2d04a28d743799dfa706e00303f08", - "placeholder": "​", - "style": "IPY_MODEL_579cc60d128445b8ae10ee036c036bb5", - "value": " 165M/165M [00:03<00:00, 36.1MB/s]" + "layout": "IPY_MODEL_b988f87e676840ee98daa3d996c9ddbc", + "max": 165432914, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_1779b84e748b4989a8ed53434c30016f", + "value": 165432914 } }, - "787f617ecbf74db6b020a066acb12840": { + "3c011813d7cb48588a8d236785d9c24f": { "model_module": "@jupyter-widgets/base", "model_module_version": "1.2.0", "model_name": "LayoutModel", @@ -1314,44 +1367,22 @@ "width": null } }, - "7cd0520e8c26480297b86824e92d77ec": { + "3d2996e10f96404baf24d2c4215b75a1": { "model_module": "@jupyter-widgets/controls", "model_module_version": "1.5.0", - "model_name": "ProgressStyleModel", + "model_name": "DescriptionStyleModel", "state": { "_model_module": "@jupyter-widgets/controls", "_model_module_version": "1.5.0", - "_model_name": "ProgressStyleModel", + "_model_name": "DescriptionStyleModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "1.2.0", "_view_name": "StyleView", - "bar_color": null, "description_width": "" } }, - "867c5b27246c40b3823c60a014329427": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HTMLView", - "description": "", - "description_tooltip": null, - "layout": "IPY_MODEL_3b1eaec29f014a97a8ee14e62b34db83", - "placeholder": "​", - "style": "IPY_MODEL_013e4fb17d234b18934f11593557eec4", - "value": "model.safetensors: 100%" - } - }, - "c16a46a621ab497183ec0b136749eaeb": { + "3ea385fe815f4e50a0b81ec299040314": { "model_module": "@jupyter-widgets/base", "model_module_version": "1.2.0", "model_name": "LayoutModel", @@ -1403,7 +1434,7 @@ "width": null } }, - "c5d2d04a28d743799dfa706e00303f08": { + "42874cdab4be4dc38b0c33775b27d98c": { "model_module": "@jupyter-widgets/base", "model_module_version": "1.2.0", "model_name": "LayoutModel", @@ -1455,26 +1486,676 @@ "width": null } }, - "fec6601a01a44935a06d432c1f94349b": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", + "5dcbd8f3fb6148cca6cfc72b20ce49bd": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "1.5.0", - "_model_name": "HBoxModel", + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "1.5.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_867c5b27246c40b3823c60a014329427", - "IPY_MODEL_4aba38bf1a9b4be092fffb7581f7cd0b", - "IPY_MODEL_69a82035258c40b5beed0d9673f8b3b6" - ], - "layout": "IPY_MODEL_c16a46a621ab497183ec0b136749eaeb" + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "7271412c1f0141649a7300dbce2b003c": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_a6cb25fa67ef4733a720960b3fc8213c", + "placeholder": "​", + "style": "IPY_MODEL_b73b1b64620d492dbc4eaf4bd83ca23a", + "value": " 160M/160M [00:00<00:00, 201MB/s]" + } + }, + "7cdcbbf9cb694dbf949e8b7eea8e7836": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "7ed11ae2a4be462da84ff716e0725af0": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HBoxModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_0f0ed94a863f49b9b85d0a18fa8ce2a5", + "IPY_MODEL_343f2670d37c4bf18859238c3d81d419", + "IPY_MODEL_d104ae21091e4f10a7de18e191b9f04d" + ], + "layout": "IPY_MODEL_5dcbd8f3fb6148cca6cfc72b20ce49bd" + } + }, + "9a996c8dc3b34bc5b8805b3687e22b27": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HBoxModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_d012b421c189412dabeac84cba4164a7", + "IPY_MODEL_1abff22a7c9a416d9166e6b150612171", + "IPY_MODEL_7271412c1f0141649a7300dbce2b003c" + ], + "layout": "IPY_MODEL_3c011813d7cb48588a8d236785d9c24f" + } + }, + "9f8009429aa34b40a65c998230f20c99": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "a6cb25fa67ef4733a720960b3fc8213c": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "b73b1b64620d492dbc4eaf4bd83ca23a": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "b988f87e676840ee98daa3d996c9ddbc": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "bee292213d8645618536fcdf6a491d83": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "FloatProgressModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "FloatProgressModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "ProgressView", + "bar_style": "success", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_7cdcbbf9cb694dbf949e8b7eea8e7836", + "max": 517816013, + "min": 0, + "orientation": "horizontal", + "style": "IPY_MODEL_2ec06260b237411cabd3de7c37e03b1b", + "value": 517816013 + } + }, + "c8b57833d3f946abae69b84075345a54": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_42874cdab4be4dc38b0c33775b27d98c", + "placeholder": "​", + "style": "IPY_MODEL_e3a185abf8a04edabf32d58bdee10dd1", + "value": "detector.pt: 100%" + } + }, + "d012b421c189412dabeac84cba4164a7": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_3ea385fe815f4e50a0b81ec299040314", + "placeholder": "​", + "style": "IPY_MODEL_fe59f6c5ed7b4e2cb87bb60224acdaba", + "value": "pose_model.pth: 100%" + } + }, + "d104ae21091e4f10a7de18e191b9f04d": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_d37cf6fe7c444bc2a2568c3407389ea8", + "placeholder": "​", + "style": "IPY_MODEL_2cef5e028d2e40a6bba7400be922d0c2", + "value": " 165M/165M [00:04<00:00, 41.1MB/s]" + } + }, + "d37cf6fe7c444bc2a2568c3407389ea8": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "d67c4871543e405fbb576a55f8c9048a": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "ProgressStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "ProgressStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "bar_color": null, + "description_width": "" + } + }, + "dccbe277cc084ed6aa0b329067b5c69c": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HBoxModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_c8b57833d3f946abae69b84075345a54", + "IPY_MODEL_bee292213d8645618536fcdf6a491d83", + "IPY_MODEL_fbbc8c5b20c7423fb21b74296e0eeb28" + ], + "layout": "IPY_MODEL_ff0c737c49624b1ea27588611951fc84" + } + }, + "e1675e53ca9a4da8acf6c16fba7a2578": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "e3a185abf8a04edabf32d58bdee10dd1": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "fbbc8c5b20c7423fb21b74296e0eeb28": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "1.5.0", + "_view_name": "HTMLView", + "description": "", + "description_tooltip": null, + "layout": "IPY_MODEL_9f8009429aa34b40a65c998230f20c99", + "placeholder": "​", + "style": "IPY_MODEL_2a3abfe7867641db9fbfe3ee76854bf4", + "value": " 518M/518M [00:05<00:00, 101MB/s]" + } + }, + "fe59f6c5ed7b4e2cb87bb60224acdaba": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.5.0", + "model_name": "DescriptionStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "1.5.0", + "_model_name": "DescriptionStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "StyleView", + "description_width": "" + } + }, + "ff0c737c49624b1ea27588611951fc84": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.2.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "1.2.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "1.2.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "overflow_x": null, + "overflow_y": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null } } } From c99a199a2876d8629c7a54e76e8b242d6b7d91d3 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 13:44:10 +0200 Subject: [PATCH 185/293] installed latest version of DeepLabCut instead of from pypi --- examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb index 95faa07c5b..1cf409439c 100644 --- a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb @@ -23,7 +23,7 @@ "\n", "This notebook demos how to use our SuperAnimal models within DeepLabCut 3.0! Please read more in [Ye et al. Nature Communications 2024](https://www.nature.com/articles/s41467-024-48792-2) about the available SuperAnimal models, and follow along below!\n", "\n", - "### **Let's get going: install DeepLabCut into COLAB:**\n", + "### **Let's get going: install the latest version of DeepLabCut into COLAB:**\n", "\n", "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n" ] @@ -44,7 +44,7 @@ }, "outputs": [], "source": [ - "!pip install deeplabcut==3.0.0rc1" + "!pip install \"git+https://github.com/DeepLabCut/DeepLabCut.git@pytorch_dlc#egg=deeplabcut[modelzoo]\"" ] }, { From f59dc69eaf2c2ca2b3cbe5c8286f8192836d0f38 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 13:48:17 +0200 Subject: [PATCH 186/293] changed default training parameters --- .../COLAB/COLAB_YOURDATA_SuperAnimal.ipynb | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb index 1cf409439c..e927a9cbf8 100644 --- a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb @@ -545,7 +545,7 @@ "Historically, the transfer learning using ImageNet weights strategies assumed no “animal pose task priors” in the pretrained\n", "model, a paradigm adopted from previous task-agnostic transfer learning.\n", "\n", - "You can change the number of epochs you want to train for. Training on the Tri-Mouse dataset for 10 epochs should take about 3 minutes on a T4 GPU. To fully train the model, you should aim for 150 epochs (about 45 minutes)." + "You can change the number of epochs you want to train for. How long training will take depends on many parameters, including the number of images in your dataset, the resolution of the images, and the number of epochs you train for." ] }, { @@ -585,10 +585,11 @@ "deeplabcut.train_network(\n", " config_path,\n", " detector_epochs=0,\n", - " epochs=1,\n", - " display_iters=1,\n", - " save_epochs=1,\n", - " batch_size=4,\n", + " epochs=50,\n", + " save_epochs=10,\n", + " eval_interval=10,\n", + " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " display_iters=10,\n", " shuffle=imagenet_transfer_learning_shuffle,\n", ")" ] @@ -717,9 +718,11 @@ "deeplabcut.train_network(\n", " config_path,\n", " detector_epochs=0,\n", - " epochs=1,\n", - " batch_size=4, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", - " display_iters=1,\n", + " epochs=50,\n", + " save_epochs=10,\n", + " eval_interval=10,\n", + " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " display_iters=10,\n", " shuffle=superanimal_transfer_learning_shuffle,\n", ")" ] @@ -938,11 +941,11 @@ "deeplabcut.train_network(\n", " config_path,\n", " detector_epochs=0,\n", - " epochs=1,\n", - " save_epochs=1,\n", - " eval_interval=1,\n", - " batch_size=4,\n", - " display_iters=1,\n", + " epochs=50,\n", + " save_epochs=10,\n", + " eval_interval=10,\n", + " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " display_iters=10,\n", " shuffle=superanimal_naive_finetune_shuffle,\n", ")" ] @@ -1069,11 +1072,11 @@ "deeplabcut.train_network(\n", " config_path,\n", " detector_epochs=0,\n", - " epochs=1,\n", - " save_epochs=1,\n", - " eval_interval=1,\n", - " batch_size=4, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", - " display_iters=1,\n", + " epochs=50,\n", + " save_epochs=10,\n", + " eval_interval=10,\n", + " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", + " display_iters=10,\n", " shuffle=superanimal_memory_replay_shuffle,\n", ")" ] From 3877be34eb8b321a4fa0282fd9218a41ce894bc4 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 14:31:22 +0200 Subject: [PATCH 187/293] added option to not have use symlinks for keypoint matching --- deeplabcut/utils/pseudo_label.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py index 7291e61873..fedbea00a0 100644 --- a/deeplabcut/utils/pseudo_label.py +++ b/deeplabcut/utils/pseudo_label.py @@ -148,6 +148,7 @@ def keypoint_matching( config_path: str | Path, superanimal_name: str, model_name: str, + copy_images: bool = False, device: str | None = None, train_file: str = "train.json", ): @@ -157,11 +158,14 @@ def keypoint_matching( SuperAnimal inference on all images in the dataset Args: - config_path: the path of the DeepLabCut project configuration file - superanimal_name: SuperAnimal dataset with which to run keypoint matching + config_path: The path of the DeepLabCut project configuration file. + superanimal_name: SuperAnimal dataset with which to run keypoint matching. model_name: SuperAnimal model with which to run keypoint matching - device: the device on which to run keypoint matching - train_file: the name of the file containing the labels to output + copy_images: When False, symlinks are created for the dataset used for keypoint + matching. Otherwise, images are copied from the `labeled-data` folder to the + folder used for keypoint matching. + device: The device on which to run keypoint matching. + train_file: The name of the file containing the labels to output. """ config_path = Path(config_path) cfg = af.read_config(str(config_path)) @@ -175,7 +179,9 @@ def keypoint_matching( max_individuals = 1 memory_replay_folder = dlc_proj_root / "memory_replay" - temp_dataset.materialize(str(memory_replay_folder), framework="coco") + temp_dataset.materialize( + str(memory_replay_folder), framework="coco", deepcopy=copy_images + ) # inferencing the train set ( From 97bb2a1afdef7d301d0ec9e3ddef678311a3a1f8 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 15:26:44 +0200 Subject: [PATCH 188/293] no symlinks for keypoint matching in COLAB --- examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb index e927a9cbf8..64cbbc3ee5 100644 --- a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb @@ -810,6 +810,7 @@ " config_path,\n", " superanimal_name,\n", " model_name,\n", + " copy_images=True,\n", ")\n", "\n", "conversion_table_path = project_path / \"memory_replay\" / \"conversion_table.csv\"\n", From 67903f09cf6ee5387ad29d68c9efa647e68bc79a Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 15:45:55 +0200 Subject: [PATCH 189/293] set default eval interval to 10, add superanimal_analyze_images to init --- deeplabcut/pose_estimation_pytorch/apis/__init__.py | 5 ++++- .../pose_estimation_pytorch/config/base/base.yaml | 2 +- examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb | 10 ++-------- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py index 1347cfaa14..8bdaa0632a 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py @@ -9,7 +9,10 @@ # Licensed under GNU Lesser General Public License v3.0 # -from deeplabcut.pose_estimation_pytorch.apis.analyze_images import analyze_images +from deeplabcut.pose_estimation_pytorch.apis.analyze_images import ( + analyze_images, + superanimal_analyze_images, +) from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import analyze_videos from deeplabcut.pose_estimation_pytorch.apis.convert_detections_to_tracklets import ( convert_detections2tracklets, diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml index a507625aaf..5121ee9d97 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -5,7 +5,7 @@ runner: gpus: null key_metric: "test.mAP" key_metric_asc: true - eval_interval: 1 + eval_interval: 10 optimizer: type: AdamW params: diff --git a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb index 64cbbc3ee5..81e95e4f3c 100644 --- a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb @@ -6,7 +6,7 @@ "id": "view-in-github" }, "source": [ - "\"Open" + "\"Open" ] }, { @@ -72,10 +72,7 @@ "outputs": [], "source": [ "import os\n", - "import requests\n", - "from io import BytesIO\n", "from pathlib import Path\n", - "from zipfile import ZipFile\n", "\n", "import matplotlib.pyplot as plt\n", "import pandas as pd\n", @@ -413,6 +410,7 @@ }, "outputs": [], "source": [ + "# TODO: Update the `project_path` to be the path of your DeepLabCut project!\n", "project_path = Path(\"/content/drive/MyDrive/my-project-2024-07-17\")\n", "config_path = str(project_path / \"config.yaml\")" ] @@ -587,7 +585,6 @@ " detector_epochs=0,\n", " epochs=50,\n", " save_epochs=10,\n", - " eval_interval=10,\n", " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", " display_iters=10,\n", " shuffle=imagenet_transfer_learning_shuffle,\n", @@ -720,7 +717,6 @@ " detector_epochs=0,\n", " epochs=50,\n", " save_epochs=10,\n", - " eval_interval=10,\n", " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", " display_iters=10,\n", " shuffle=superanimal_transfer_learning_shuffle,\n", @@ -944,7 +940,6 @@ " detector_epochs=0,\n", " epochs=50,\n", " save_epochs=10,\n", - " eval_interval=10,\n", " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", " display_iters=10,\n", " shuffle=superanimal_naive_finetune_shuffle,\n", @@ -1075,7 +1070,6 @@ " detector_epochs=0,\n", " epochs=50,\n", " save_epochs=10,\n", - " eval_interval=10,\n", " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n", " display_iters=10,\n", " shuffle=superanimal_memory_replay_shuffle,\n", From faba3ac2e709a9515439371c6c7f71a8af0db97f Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Thu, 4 Jul 2024 17:51:55 +0200 Subject: [PATCH 190/293] do not copy images or create symlinks for memory replay --- .../datasets/base.py | 14 +++- .../datasets/materialize.py | 64 +++++++++++-------- .../modelzoo/memory_replay.py | 47 +++++++------- deeplabcut/utils/pseudo_label.py | 5 -- .../COLAB/COLAB_YOURDATA_SuperAnimal.ipynb | 6 +- 5 files changed, 75 insertions(+), 61 deletions(-) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py index 77f5b635a4..8ea478ce00 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py @@ -153,11 +153,20 @@ def populate_generic(self): raise NotImplementedError("Must implement this function") def materialize( - self, proj_root, framework="coco", deepcopy=False, append_image_id=True + self, + proj_root, + framework="coco", + deepcopy=False, + append_image_id=True, + no_image_copy=False, ): mat_func = mat_func_factory(framework) self.meta["mat_datasets"] = {self.meta["dataset_name"]: self} self.meta["imageid2datasetname"] = self.imageid2datasetname + kwargs = dict(deepcopy=deepcopy, append_image_id=append_image_id) + if framework == "coco": + kwargs["no_image_copy"] = no_image_copy + mat_func( proj_root, self.generic_train_images, @@ -165,8 +174,7 @@ def materialize( self.generic_train_annotations, self.generic_test_annotations, self.meta, - deepcopy=deepcopy, - append_image_id=append_image_id, + **kwargs, ) def whether_anno_image_match(self, images, annotations): diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py index 73da82cfae..d6cde5da7e 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -661,9 +661,10 @@ def _generic2coco( train_annotations, test_annotations, meta, - deepcopy=False, - full_image_path=True, - append_image_id=True, + deepcopy: bool = False, + full_image_path: bool = True, + append_image_id: bool = True, + no_image_copy: bool = True, ): """ Take generic data and create coco structure @@ -673,6 +674,17 @@ def _generic2coco( annotations - train.json - test.json + + Args: + deepcopy: Only when no_image_copy=False. If False, images are not copied from + their original location and symlinks are created instead. + full_image_path: Only when no_image_copy=False. If True, the ``file_name`` for + the images in the annotation files contain the resolved path to the images. + Otherwise, a relative path is used. + append_image_id: Only when no_image_copy=False. Appends the image IDs in the + dataset to the image names. + no_image_copy: Instead of copying images to the COCO dataset, the full paths to + the images in the original dataset are used in the annotations. """ os.makedirs(os.path.join(proj_root, "images"), exist_ok=True) @@ -705,33 +717,35 @@ def _generic2coco( broken_links.append(image_id) continue - # in dlc, some images have same name but under different folder - # we used to use a parent folder to distinguish them, but it's only applicable - # to DLC so here it's easier to just append a id into the filename + file_name = str(src) + dest = src + if not no_image_copy: + # in dlc, some images have same name but under different folder + # we used to use a parent folder to distinguish them, but it's only + # applicable to DLC so here it's easier to append an id into the filename - # not to repeatedly add image id in memory replay training - dest_image_name = src.name - if append_image_id: - dest_image_name = f"{src.stem}_{image_id}{src.suffix}" + # not to repeatedly add image id in memory replay training + dest_image_name = src.name + if append_image_id: + dest_image_name = f"{src.stem}_{image_id}{src.suffix}" - dest = Path(proj_root) / "images" / dest_image_name - dest = dest.resolve() + dest = Path(proj_root) / "images" / dest_image_name + dest = dest.resolve() - # now, we will also need to update the path in the config files - if full_image_path: - image["file_name"] = str(dest) - else: - image["file_name"] = str(Path(*dest.parts[-2:])) + file_name = str(Path(*dest.parts[-2:])) + if full_image_path: + file_name = str(dest) - if deepcopy: - shutil.copy(src, dest) - else: - try: - os.symlink(src, dest) - except Exception as err: - print(f"Could not create a symlink from {src} to {dest}: {err}") - pass + if deepcopy: + shutil.copy(src, dest) + else: + try: + os.symlink(src, dest) + except Exception as err: + print(f"Could not create a symlink from {src} to {dest}: {err}") + pass + image["file_name"] = file_name lookuptable[dest] = src train_annotations = [ diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py index f7e547f705..cee7fd5f70 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py @@ -40,17 +40,14 @@ # this is reading from a coco project def prepare_memory_replay_dataset( - source_dataset_folder, - superanimal_name, - model_name, - max_individuals=1, - train_file="train.json", - test_file="test.json", - pose_threshold=0.0, - device=None, - pose_model_path="", - detector_path="", - customized_pose_checkpoint=None, + source_dataset_folder: str, + superanimal_name: str, + model_name: str, + max_individuals: int = 1, + train_file: str = "train.json", + pose_threshold: float = 0.0, + device: str | None = None, + customized_pose_checkpoint: str | None = None, ): """ Need to first run inference on the source project train file @@ -96,7 +93,7 @@ def prepare_memory_replay_dataset( imagename2gt = defaultdict(list) for image in images: - # this only works with relative path as the testing image can be at a different folder + # this only works with rel. path as the test images can be in a different folder imagename = image["file_name"].split(os.sep)[-1] imagename2id[imagename] = image["id"] imageid2name[image["id"]] = imagename @@ -203,10 +200,7 @@ def optimal_match(gts_list, preds_list): # after the mixing, we don't care about confidence anymore for kpt_idx in range(len(matched_gt)): - if ( - matched_gt[kpt_idx][2] < pose_threshold - and matched_gt[kpt_idx][2] > 0 - ): + if 0 < matched_gt[kpt_idx][2] < pose_threshold: matched_gt[kpt_idx][2] = -1 elif matched_gt[kpt_idx][2] > 0: matched_gt[kpt_idx][2] = 2 @@ -228,11 +222,10 @@ def prepare_memory_replay( superanimal_name: str, model_name: str, device: str, - max_individuals=3, - trainingsetindex: int = 0, - train_file="train.json", - pose_threshold=0.1, - customized_pose_checkpoint=None, + max_individuals: int = 3, + train_file: str = "train.json", + pose_threshold: float = 0.1, + customized_pose_checkpoint: str | None = None, ): """TODO: Documentation""" @@ -264,7 +257,10 @@ def prepare_memory_replay( memory_replay_folder = model_folder / "memory_replay" temp_dataset.materialize( - memory_replay_folder, framework="coco", append_image_id=False + memory_replay_folder, + framework="coco", + append_image_id=False, + no_image_copy=True, # use the images in the labeled-data folder ) original_model_config = af.read_config( @@ -290,14 +286,15 @@ def prepare_memory_replay( ) dataset = COCOPoseDataset(memory_replay_folder, "memory_replay_dataset") - conversion_table_path = dlc_proj_root / "memory_replay" / "conversion_table.csv" - # here we project the original DLC projects to superanimal space and save them into a coco project format + # here we project the original DLC projects to superanimal space and save them into + # a coco project format dataset.project_with_conversion_table(str(conversion_table_path)) dataset.materialize(memory_replay_folder, deepcopy=False, framework="coco") - # then in this function, we do pseudo label to match prediction and gts to create memory-replay dataset that will be named memory_replay_train.json + # then in this function, we do pseudo label to match prediction and gts to create + # memory-replay dataset that will be named memory_replay_train.json memory_replay_train_file = os.path.join( memory_replay_folder, "annotations", "memory_replay_train.json" ) diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py index fedbea00a0..a44f922b40 100644 --- a/deeplabcut/utils/pseudo_label.py +++ b/deeplabcut/utils/pseudo_label.py @@ -298,16 +298,11 @@ def keypoint_matching( optimal_index = optimal_pred_indices[idx] matched_gt = np.array(gts[idx]["keypoints"]) matched_pred = prediction["bodyparts"][optimal_index] - bbox_gt = bbox_gts[idx] - bbox_pred = bbox_preds[idx] matched_gt = matched_gt.reshape(num_gt_keypoints, -1) matched_pred = matched_pred.reshape(num_pred_keypoints, -1) - gt_kpt_ids = np.arange(matched_gt.shape[0]) - pred_kpt_ids = np.arange(matched_pred.shape[0]) pair_distance = cdist(matched_pred, matched_gt) row_ind, column_ind = linear_sum_assignment(pair_distance) - original_gt_matched_indices = matched_gt[column_ind] for row, column in zip(row_ind, column_ind): pred_kpt_name = pred_keypoint_names[row] anno_kpt_name = gt_keypoint_names[column] diff --git a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb index 81e95e4f3c..7d50b06351 100644 --- a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb +++ b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb @@ -79,7 +79,7 @@ "from PIL import Image\n", "\n", "import deeplabcut\n", - "from deeplabcut.pose_estimation_pytorch.apis.analyze_images import (\n", + "from deeplabcut.pose_estimation_pytorch.apis import (\n", " superanimal_analyze_images,\n", ")\n", "from deeplabcut.core.weight_init import WeightInitialization\n", @@ -471,8 +471,8 @@ "### What is the difference between baselines?\n", "\n", "**Transfer learning** For canonical task-agnostic transfer learning,\n", - "the encoder learns universal visual features from ImageNet, and a randomly\n", - "initialized decoder is used to learn the pose fromthe downstream dataset.\n", + "the encoder learns universal visual features from a large pre-training dataset, and a randomly\n", + "initialized decoder is used to learn the pose from the downstream dataset.\n", "\n", "**Fine-tuning** For task aware\n", "fine-tuning, both encoder and decoder learn task-related visual-pose features\n", From 2972982902d67dbdf6d95566a535752c4caf8e7a Mon Sep 17 00:00:00 2001 From: AlexEMG Date: Sat, 6 Jul 2024 11:05:21 +0200 Subject: [PATCH 191/293] RC2 flag --- deeplabcut/version.py | 2 +- reinstall.sh | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deeplabcut/version.py b/deeplabcut/version.py index d75fb048bc..4dba388b42 100644 --- a/deeplabcut/version.py +++ b/deeplabcut/version.py @@ -9,5 +9,5 @@ # Licensed under GNU Lesser General Public License v3.0 # -__version__ = "3.0.0rc1" +__version__ = "3.0.0rc2" VERSION = __version__ diff --git a/reinstall.sh b/reinstall.sh index 81f17e5fb2..17ddef1897 100755 --- a/reinstall.sh +++ b/reinstall.sh @@ -1,3 +1,3 @@ pip uninstall deeplabcut python3 setup.py sdist bdist_wheel -pip install dist/deeplabcut-3.0.0rc1-py3-none-any.whl +pip install dist/deeplabcut-3.0.0rc2-py3-none-any.whl diff --git a/setup.py b/setup.py index 34652654be..4f15e36a8a 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def pytorch_config_paths() -> list[str]: setuptools.setup( name="deeplabcut", - version="3.0.0rc1", + version="3.0.0rc2", author="A. & M.W. Mathis Labs", author_email="alexander@deeplabcut.org", description="Markerless pose-estimation of user-defined features with deep learning", From 27a58af2b87a05b67b372c57e747518d8ae561b9 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Tue, 9 Jul 2024 14:37:49 +0200 Subject: [PATCH 192/293] fixed memory replay to only run once --- .../datasets/materialize.py | 2 +- .../modelzoo/memory_replay.py | 274 ++++++++++++------ 2 files changed, 181 insertions(+), 95 deletions(-) diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py index d6cde5da7e..63211b8f35 100644 --- a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py +++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py @@ -664,7 +664,7 @@ def _generic2coco( deepcopy: bool = False, full_image_path: bool = True, append_image_id: bool = True, - no_image_copy: bool = True, + no_image_copy: bool = False, ): """ Take generic data and create coco structure diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py index cee7fd5f70..1432c2d1ef 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py @@ -19,7 +19,6 @@ import numpy as np from scipy.optimize import linear_sum_assignment from scipy.spatial import distance -from scipy.spatial.distance import cdist import deeplabcut.utils.auxiliaryfunctions as af from deeplabcut.core.engine import Engine @@ -27,128 +26,210 @@ from deeplabcut.modelzoo.generalized_data_converter.datasets import ( COCOPoseDataset, MaDLCPoseDataset, - MultiSourceDataset, SingleDLCPoseDataset, ) from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners -from deeplabcut.pose_estimation_pytorch.modelzoo.utils import ( - get_config_model_paths, - update_config, -) -from deeplabcut.utils.pseudo_label import calculate_iou, optimal_match, xywh2xyxy +import deeplabcut.pose_estimation_pytorch.config.utils as config_utils +from deeplabcut.pose_estimation_pytorch.modelzoo.utils import get_config_model_paths +from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner +from deeplabcut.utils.pseudo_label import calculate_iou -# this is reading from a coco project -def prepare_memory_replay_dataset( - source_dataset_folder: str, +def get_superanimal_inference_runners( superanimal_name: str, model_name: str, - max_individuals: int = 1, - train_file: str = "train.json", - pose_threshold: float = 0.0, + max_individuals: int, device: str | None = None, - customized_pose_checkpoint: str | None = None, -): +) -> tuple[InferenceRunner, InferenceRunner | None]: + """Creates the inference runners for SuperAnimal models + + Args: + superanimal_name: the name of the SuperAnimal dataset for which to load runners + model_name: the name of the model + max_individuals: the maximum number of individuals to detect per image + device: the device on which to run + + Returns: + the pose runner for the SuperAnimal model + the detector runner for the SuperAnimal model, if it's a top-down model """ - Need to first run inference on the source project train file - """ - ( model_config, project_config, pose_model_path, detector_path, ) = get_config_model_paths(superanimal_name, model_name) - - if customized_pose_checkpoint is not None: - print( - "memory replay fine-tuning pose checkpoint is replaced by", - customized_pose_checkpoint, - ) - - config = {**project_config, **model_config} - config = update_config(config, max_individuals, device) - individuals = [f"animal{i}" for i in range(max_individuals)] - config["individuals"] = individuals - num_bodyparts = len(config["bodyparts"]) - train_file_path = os.path.join(source_dataset_folder, "annotations", train_file) - - pose_runner, detector_runner = get_inference_runners( - config, + model_config["metadata"]["individuals"] = [ + f"animal{i}" for i in range(max_individuals) + ] + model_config = config_utils.replace_default_values( + model_config, + num_bodyparts=len(model_config["metadata"]["bodyparts"]), + num_individuals=max_individuals, + backbone_output_channels=model_config["model"]["backbone_output_channels"], + ) + return get_inference_runners( + model_config, snapshot_path=pose_model_path, max_individuals=max_individuals, num_bodyparts=len(model_config["metadata"]["bodyparts"]), num_unique_bodyparts=0, + device=device, detector_path=detector_path, ) - with open(train_file_path, "r") as f: - train_obj = json.load(f) - - images = train_obj["images"] - annotations = train_obj["annotations"] - categories = train_obj["categories"] - imagename2id = {} - imageid2name = {} - imagename2gt = defaultdict(list) - for image in images: - # this only works with rel. path as the test images can be in a different folder - imagename = image["file_name"].split(os.sep)[-1] - imagename2id[imagename] = image["id"] - imageid2name[image["id"]] = imagename +def get_pose_predictions( + project_root: Path, + images: list[str], + bboxes: dict[str, list], + superanimal_name: str, + model_name: str, + max_individuals: int, + device: str | None = None, +) -> dict[str, dict]: + """Gets predictions made by a SuperAnimal model on a DeepLabCut project + + Args: + project_root: The path to the root of the project. + images: The images on which to run inference with the SuperAnimal model. + bboxes: The ground truth bounding boxes for each image in the project. + superanimal_name: The name of the SuperAnimal dataset to use. + model_name: The name of the model to use. + max_individuals: The maximum number of individuals to detect per image. + device: The CUDA device to use. + + Returns: + The predictions made by the SuperAnimal model on each image in the images list. + """ + predictions_folder = project_root / "memory_replay" / superanimal_name / model_name + predictions_folder.mkdir(exist_ok=True, parents=True) + predictions_file = predictions_folder / "pseudo-labels.json" + + # COCO-format annotations file containing predictions made by the SuperAnimal model + sa_predictions = {} + if predictions_file.exists(): + with open(predictions_file, "r") as f: + raw_sa_predictions = json.load(f) + + # parse predictions to convert lists to numpy arrays + for image, predictions in raw_sa_predictions.items(): + sa_predictions[image] = { + "bodyparts": np.array(predictions["bodyparts"]), + "bboxes": np.array(predictions["bboxes"]), + # "bbox_scores": np.array(predictions["bbox_scores"]), + } + + # get images that need to be processed + processed_images = set(sa_predictions.keys()) + images_to_process = [image for image in (set(images) - processed_images)] + + # if all images have been processed by the SuperAnimal model, return the predictions + if len(images_to_process) == 0: + return sa_predictions + + pose_runner, detector_runner = get_superanimal_inference_runners( + superanimal_name, + model_name, + max_individuals, + device=device, + ) - imagename2bbox = defaultdict(list) - for anno in annotations: - imagename = imageid2name[anno["image_id"]] - imagename2gt[imagename].append(anno) - imagename2bbox[imagename].append(anno["bbox"]) + # FIXME(niels, yeshaokai) - Use the detector to combine GT-keypoint created bounding + # boxes and predicted bounding boxes - keep the larger of the two + # bbox_predictions = detector_runner.inference(images=images_to_process) + pose_inputs = [ + ( + project_root / Path(image), + {"bboxes": np.array(bboxes[image])} + ) + for image in images_to_process + ] + predictions = pose_runner.inference(pose_inputs) - imageid2annotations = defaultdict(list) + for image, prediction in zip(images_to_process, predictions): + sa_predictions[image] = prediction - imageids = list(imagename2id.values()) - for annotation in annotations: - image_id = annotation["image_id"] - if annotation["image_id"] in imageids: - imageid2annotations[image_id].append(annotation) + # save the updated SuperAnimal predictions + json_sa_predictions = { + image: { + "bodyparts": predictions["bodyparts"].tolist(), + "bboxes": predictions["bboxes"].tolist(), + # "bbox_scores": predictions["bbox_scores"].tolist(), + } + for image, predictions in sa_predictions.items() + } + with open(predictions_file, "w") as f: + json.dump(json_sa_predictions, f, indent=2) - # need to support more image types - image_extensions = ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.tiff"] + return sa_predictions - images_in_folder = [] - for ext in image_extensions: - images_in_folder.extend( - glob.glob(os.path.join(source_dataset_folder, "images", ext)) - ) - corresponded_images = [] - for image in images_in_folder: - image_path = image - imagename = image.split(os.sep)[-1] - if imagename in imagename2id: - corresponded_images.append(image_path) +# this is reading from a coco project +def prepare_memory_replay_dataset( + project_root: str | Path, + source_dataset_folder: str | Path, + superanimal_name: str, + model_name: str, + max_individuals: int = 1, + train_file: str = "train.json", + pose_threshold: float = 0.0, + device: str | None = None, + customized_pose_checkpoint: str | None = None, +): + """ + Need to first run inference on the source project train file + """ + project_root = Path(project_root).resolve() + source_dataset_folder = Path(source_dataset_folder).resolve() - images = corresponded_images + if customized_pose_checkpoint is not None: + print( + "memory replay fine-tuning pose checkpoint is replaced by", + customized_pose_checkpoint, + ) - bbox_predictions = detector_runner.inference(images=images) + # Contains the ground truth annotations for the DeepLabCut project + # .../dlc-models-pytorch/.../...shuffle0/train/memory_replay/annotations/train.json + with open(source_dataset_folder / "annotations" / train_file, "r") as f: + project_gt = json.load(f) - bbox_gts = [ - {"bboxes": np.array(imagename2bbox[image.split(os.sep)[-1]])} - for image in images - ] + # parse the GT so that image paths are in the format (no matter the OS): + # "labeled-data/{video_name}/{image_name}" + for image in project_gt["images"]: + image["file_name"] = "/".join(Path(image["file_name"]).parts[-3:]) - pose_inputs = list(zip(images, bbox_gts)) + image_id_to_name = {} + image_id_to_annotations = defaultdict(list) - # pose inference should return meta data for pseudo labeling - predictions = pose_runner.inference(pose_inputs) + image_name_to_id = {} + image_name_to_gt = defaultdict(list) + image_name_to_bbox = defaultdict(list) - assert len(images) == len(predictions) + for image in project_gt["images"]: + image_name_to_id[image["file_name"]] = image["id"] + image_id_to_name[image["id"]] = image["file_name"] - imagename2prediction = {} + for anno in project_gt["annotations"]: + name = image_id_to_name[anno["image_id"]] + image_name_to_gt[name].append(anno) + image_name_to_bbox[name].append(anno["bbox"]) - for image_path, prediction in zip(images, predictions): - imagename = image_path.split(os.sep)[-1] - imagename2prediction[imagename] = prediction + image_ids = list(image_name_to_id.values()) + for annotation in project_gt["annotations"]: + image_id = annotation["image_id"] + if annotation["image_id"] in image_ids: + image_id_to_annotations[image_id].append(annotation) + + image_name_to_prediction = get_pose_predictions( + project_root=project_root, + images=[image["file_name"] for image in project_gt["images"]], + bboxes=image_name_to_bbox, + superanimal_name=superanimal_name, + model_name=model_name, + max_individuals=max_individuals, + device=device, + ) def xywh2xyxy(bbox): temp_bbox = np.copy(bbox) @@ -170,10 +251,11 @@ def optimal_match(gts_list, preds_list): return col_ind - for imagename, gts in imagename2gt.items(): + num_bodyparts = len(project_gt["categories"][0]["keypoints"]) + for image_name, gts in image_name_to_gt.items(): bbox_gts = [np.array(gt["bbox"]) for gt in gts] bbox_gts = [xywh2xyxy(e) for e in bbox_gts] - prediction = imagename2prediction[imagename] + prediction = image_name_to_prediction[image_name] bbox_preds = [xywh2xyxy(pred) for pred in prediction["bboxes"]] optimal_pred_indices = optimal_match(bbox_gts, bbox_preds) @@ -212,8 +294,13 @@ def optimal_match(gts_list, preds_list): source_dataset_folder, "annotations", "memory_replay_train.json" ) + # parse the GT to put the image paths back into OS-specific format + for image in project_gt["images"]: + image_rel_path = image["file_name"].split("/") + image["file_name"] = str(project_root.resolve() / Path(*image_rel_path)) + with open(memory_replay_train_file_path, "w") as f: - json.dump(train_obj, f, indent=4) + json.dump(project_gt, f, indent=4) def prepare_memory_replay( @@ -291,15 +378,14 @@ def prepare_memory_replay( # here we project the original DLC projects to superanimal space and save them into # a coco project format dataset.project_with_conversion_table(str(conversion_table_path)) - dataset.materialize(memory_replay_folder, deepcopy=False, framework="coco") + dataset.materialize( + memory_replay_folder, framework="coco", deepcopy=False, no_image_copy=True, + ) # then in this function, we do pseudo label to match prediction and gts to create # memory-replay dataset that will be named memory_replay_train.json - memory_replay_train_file = os.path.join( - memory_replay_folder, "annotations", "memory_replay_train.json" - ) - prepare_memory_replay_dataset( + dlc_proj_root, memory_replay_folder, superanimal_name, model_name, From 7688cc08a33f009304fa83c52d62f09f783ed868 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:22:46 +0200 Subject: [PATCH 193/293] Fix unreachable line (#2673) --- deeplabcut/utils/auxiliaryfunctions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index b1f33df57e..806fe97f41 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -437,7 +437,7 @@ def get_list_of_videos( if isinstance(videotype, str): videotype = [videotype] - if videotype is None: + if not videotype: videotype = auxfun_videos.SUPPORTED_VIDEOS # filter list of videos videos = [ From 20dac7df4d23ae3a4a8520ca6854ffc17f0a84fb Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:23:59 +0200 Subject: [PATCH 194/293] Fix and decouple video rotation utility (#2653) Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/tabs/video_editor.py | 20 +++++++++++ deeplabcut/utils/auxfun_videos.py | 56 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/deeplabcut/gui/tabs/video_editor.py b/deeplabcut/gui/tabs/video_editor.py index 38be396f05..ecc0374c4d 100644 --- a/deeplabcut/gui/tabs/video_editor.py +++ b/deeplabcut/gui/tabs/video_editor.py @@ -48,6 +48,11 @@ def _set_page(self): self.down_button.clicked.connect(self.downsample_videos) self.main_layout.addWidget(self.down_button, alignment=Qt.AlignRight) + self.rotate_button = QtWidgets.QPushButton("Rotate") + self.rotate_button.setMinimumWidth(150) + self.rotate_button.clicked.connect(self.rotate_videos) + self.main_layout.addWidget(self.rotate_button, alignment=Qt.AlignRight) + self.trim_button = QtWidgets.QPushButton("Trim") self.trim_button.setMinimumWidth(150) self.trim_button.clicked.connect(self.trim_videos) @@ -130,6 +135,21 @@ def log_video_stop(self, value): def log_rotation_angle(self, value): self.root.logger.info(f"Rotation angle set to {value}") + def rotate_videos(self): + if self.files: + for video in self.files: + if self.video_rotation.currentText() == "specific angle": + auxfun_videos.rotate_video( + video, self.rotation_angle.value(), "Arbitrary" + ) + elif self.video_rotation.currentText() == "clockwise": + auxfun_videos.rotate_video( + video, 0, "Yes" + ) + else: + self.root.logger.error("No videos selected...") + + def trim_videos(self): start = time.strftime("%H:%M:%S", time.gmtime(self.video_start.value())) stop = time.strftime("%H:%M:%S", time.gmtime(self.video_stop.value())) diff --git a/deeplabcut/utils/auxfun_videos.py b/deeplabcut/utils/auxfun_videos.py index c6fd1bd47b..e4d8c033ea 100644 --- a/deeplabcut/utils/auxfun_videos.py +++ b/deeplabcut/utils/auxfun_videos.py @@ -322,6 +322,21 @@ def crop(self, suffix="crop", dest_folder=None): subprocess.call(command, shell=True) return output_path + def rotate(self, angle, rotatecw="Arbitrary", suffix="rotated", dest_folder=None): + output_path = self.make_output_path(suffix, dest_folder) + command = f'ffmpeg -n -i "{self.video_path}" -vf ' + if rotatecw == "Arbitrary": + angle = np.deg2rad(angle) + command += f'rotate={angle} ' + elif rotatecw == "Yes": + command += 'transpose=1 ' + else: + raise ValueError("Unknown rotation direction.") + + command += f'-c:a copy "{output_path}"' + subprocess.call(command, shell=True) + return output_path + def rescale( self, width, @@ -560,6 +575,47 @@ def DownSampleVideo( return writer.rescale(width, height, rotatecw, angle, outsuffix, outpath) +def rotate_video(vname, angle, rotatecw="Arbitrary", outsuffix="rotated", outpath=None): + """ + Auxiliary function to rotate a video and output it to the same folder with "outsuffix" appended in its name. + Angle is in degrees. + + Returns the full path to the rotated video! + + Parameter + ---------- + vname : string + A string containing the full path of the video. + + angle: float + Angle to rotate by in degrees. Negative values rotate counter-clockwise. + + rotatecw: str + Default "Arbitrary", rotates clockwise if "Yes", "Arbitrary" for arbitrary rotation by specified angle. + + outsuffix: str + Suffix for output videoname (see example). + + outpath: str + Output path for saving video to (by default will be the same folder as the video) + + Examples + ---------- + + Linux/MacOs + >>> deeplabcut.rotate_video('/data/videos/mouse1.avi',angle=90) + + Rotates the video by 90 degrees and saves it in /data/videos as mouse1rotated.avi + + Windows: + >>> shortenedvideoname=deeplabcut.rotate_video('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi', angle=180,rotatecw='Yes') + + Rotates the video by 180 degrees and saves it in C:\\yourusername\\rig-95\\Videos as reachingvideo1rotated.avi + """ + writer = VideoWriter(vname) + return writer.rotate(angle, rotatecw, outsuffix, outpath) + + def draw_bbox(video): import matplotlib.pyplot as plt from matplotlib.widgets import RectangleSelector, Button From 61cfdbab3e5a28a227118643c07e961e358868c3 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 16 Jul 2024 15:07:32 +0200 Subject: [PATCH 195/293] merge named snapshot evaluation into pytorch_dlc (#2657) * Add feature to evaluate network for specfic, named snapshots. (#2508) * Add snapshot selection option to evaluate_network while preserving existing functionality. - Replace snapindex with more explicit snapshot_name. snapshotindex from config is used to identify the snapshot name instead. - Refactor logic for snapshotindex allowed values. Add limit to negative index values. * Update evaluate.py - Replace snapshots_to_evaluate numpy indexing with list loop - Raise error if no snapshots are found * Add snapshots_to_evaluate docstring * Remove unused distance function * Update evaluate.py * Improve snapshots_to_evaluate docstring * Get training_iterations from snapshot_name * Rename trainingsiterations -> training_iterations * Improve snapshot_to_evaluate typehint and docstring. * Refactor evaluate.py * Create get_available_requested_snapshots for obtaining specified snapshot names (new feature). * Create get_snapshots_by_index to handle the original implementation. * Minor improvements and fixes * Add tests for new snapshot functions in evaluate.py * Update evaluate_multianimal.py * Add snapshots_to_evaluate parameter to multi-animal evaluation * Minor renaming * Update python-package.yml * add evaluate_by_snapshot branch for testing purposes * Add snapshots_to_evaluate usage in testscript.py * Update get_snapshots_by_index function * Better typehint for int or str * Raise IndexError instead of ValueError * Add get_snapshots_by_index tests to test_evaluate.py * Update return_evaluate_network_data function with get_snapshots_by_index usage * Fix potential reference before assignment by raising ValueError * Refactor getting existing snapshot file names as sorted list. * Add list_sorted_existing_snapshots to auxiliaryfunctions.py. * Update several files to use the newly refactored function. * Preserve same exception handling, albeit with modified message to be more general and concise. * Refactor evaluate_multianimal.py * Add new snapshot auxiliary function usage. * Preserve continuation of loop if no snapshots are found. * Remove indentation level. * Rename list_sorted_exisiting_snapshots -> get_snapshots_in_folder * Update name for all usages * Minor change to function docstring * Remove redundant sorting in evaluate_multianimal.py * Port numpy array to list (not performance critical) * Add get_snapshots_from_folder tests * Test snapshots present and returned in order * Test snapshots not found * Fix deprecated function names * Improve snapshots_to_evaluate test in testscript.py * Correct spelling * Add per_keypoint_evaluation to evaluate_multianimal_full function call (woops!) * minor linting --------- Co-authored-by: Mackenzie Mathis Co-authored-by: Niels Poulsen * fixed file dialog not showing uppercase file extension video files (#2645) * fixed file dialog not showing uppercase file extension video files * bug fix: no shuffle_change slot in main window * bug fix --------- Co-authored-by: Niels Poulsen Co-authored-by: Mackenzie Mathis --------- Co-authored-by: jkopp <32641315+Tetra-quark@users.noreply.github.com> Co-authored-by: Mackenzie Mathis Co-authored-by: Terry Park <58895710+park-jsdev@users.noreply.github.com> --- deeplabcut/compat.py | 5 + deeplabcut/gui/components.py | 10 +- .../core/evaluate.py | 177 ++-- .../core/evaluate_multianimal.py | 799 +++++++++--------- .../pose_estimation_tensorflow/export.py | 27 +- .../predict_videos.py | 80 +- .../visualizemaps.py | 22 +- deeplabcut/utils/auxiliaryfunctions.py | 42 +- examples/testscript.py | 12 +- tests/test_auxiliaryfunctions.py | 46 + tests/test_evaluate.py | 75 ++ 11 files changed, 687 insertions(+), 608 deletions(-) diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index 97cb23d3d0..0519693a27 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -327,6 +327,7 @@ def evaluate_network( rescale: bool = False, modelprefix: str = "", per_keypoint_evaluation: bool = False, + snapshots_to_evaluate: list[str] | None = None, engine: Engine | None = None, **torch_kwargs, ): @@ -386,6 +387,9 @@ def evaluate_network( Compute the train and test RMSE for each keypoint, and save the results to a {model_name}-keypoint-results.csv in the evalution-results folder + snapshots_to_evaluate: List[str], optional, default=None + List of snapshot names to evaluate (e.g. ["snapshot-5000", "snapshot-7500"]). + engine: Engine, optional, default = None. The default behavior loads the engine for the shuffle from the metadata. You can overwrite this by passing the engine as an argument, but this should generally @@ -464,6 +468,7 @@ def evaluate_network( rescale=rescale, modelprefix=modelprefix, per_keypoint_evaluation=per_keypoint_evaluation, + snapshots_to_evaluate=snapshots_to_evaluate, ) elif engine == Engine.PYTORCH: from deeplabcut.pose_estimation_pytorch.apis import evaluate_network diff --git a/deeplabcut/gui/components.py b/deeplabcut/gui/components.py index cd84d5eed9..fd009b512f 100644 --- a/deeplabcut/gui/components.py +++ b/deeplabcut/gui/components.py @@ -160,11 +160,19 @@ def _update_video_selection(self, videopaths): def select_videos(self): cwd = self.root.project_folder + + # Create a filter string with both lowercase and uppercase extensions + + video_types = [f"*.{ext.lower()}" for ext in DLCParams.VIDEOTYPES[1:]] + [ + f"*.{ext.upper()}" for ext in DLCParams.VIDEOTYPES[1:] + ] + video_files = f"Videos ({' '.join(video_types)})" + filenames = QtWidgets.QFileDialog.getOpenFileNames( self, "Select video(s) to analyze", cwd, - f"Videos ({' *.'.join(DLCParams.VIDEOTYPES)[1:]})", + video_files, ) if filenames[0]: diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate.py index 8d24ab0f8d..17ae998974 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/evaluate.py +++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate.py @@ -13,7 +13,8 @@ import argparse import os from pathlib import Path -from typing import List +from typing import List, Union + import numpy as np import pandas as pd from tqdm import tqdm @@ -42,10 +43,6 @@ def pairwisedistances(DataCombined, scorer1, scorer2, pcutoff=-1, bodyparts=None return RMSE, RMSE[mask] -def distance(v, w): - return np.sqrt(np.sum((v - w) ** 2)) - - def calculatepafdistancebounds( config, shuffle=0, trainingsetindex=0, modelprefix="", numdigits=0, onlytrain=False ): @@ -349,44 +346,25 @@ def return_evaluate_network_data( ) ), ) - # Check which snapshots are available and sort them by # iterations - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(str(modelfolder), "train")) - if "index" in fn - ] + + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(modelfolder) / "train", ) - if len(Snapshots) == 0: - print( - "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so." - % (shuffle, trainFraction) - ) - snapindices = [] - else: - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] - if Snapindex is None: - Snapindex = cfg["snapshotindex"] - - if Snapindex == -1: - snapindices = [-1] - elif Snapindex == "all": - snapindices = range(len(Snapshots)) - elif Snapindex < len(Snapshots): - snapindices = [Snapindex] - else: - print( - "Invalid choice, only -1 (last), any integer up to last, or all (as string)!" - ) + if Snapindex is None: + Snapindex = cfg["snapshotindex"] + + snapshot_names = get_snapshots_by_index( + idx=Snapindex, + available_snapshots=Snapshots, + ) DATA = [] results = [] resultsfns = [] - for snapindex in snapindices: + for snapshot_name in snapshot_names: test_pose_cfg["init_weights"] = os.path.join( - str(modelfolder), "train", Snapshots[snapindex] + str(modelfolder), "train", snapshot_name ) # setting weights to corresponding snapshot. trainingsiterations = (test_pose_cfg["init_weights"].split(os.sep)[-1]).split( "-" @@ -411,7 +389,7 @@ def return_evaluate_network_data( resultsfilename, DLCscorer, ) = auxiliaryfunctions.check_if_not_evaluated( - str(evaluationfolder), DLCscorer, DLCscorerlegacy, Snapshots[snapindex] + str(evaluationfolder), DLCscorer, DLCscorerlegacy, snapshot_name ) # resultsfilename=os.path.join(str(evaluationfolder),DLCscorer + '-' + str(Snapshots[snapindex])+ '.h5') # + '-' + str(snapshot)+ ' #'-' + Snapshots[snapindex]+ '.h5') print(resultsfilename) @@ -458,7 +436,7 @@ def return_evaluate_network_data( np.round(testerrorpcutoff, 2), "pixels", ) - print("Snapshot", Snapshots[snapindex]) + print("Snapshot", snapshot_name) r = [ trainingsiterations, @@ -469,7 +447,7 @@ def return_evaluate_network_data( cfg["pcutoff"], np.round(trainerrorpcutoff, 2), np.round(testerrorpcutoff, 2), - Snapshots[snapindex], + snapshot_name, scale, test_pose_cfg["net_type"], ] @@ -489,7 +467,7 @@ def return_evaluate_network_data( comparisonbodyparts, cfg, evaluationfolder, - Snapshots[snapindex], + snapshot_name, ] ) @@ -563,6 +541,7 @@ def evaluate_network( rescale=False, modelprefix="", per_keypoint_evaluation: bool = False, + snapshots_to_evaluate: List[str] = None, ): """Evaluates the network. @@ -620,6 +599,9 @@ def evaluate_network( Compute the train and test RMSE for each keypoint, and save the results to a {model_name}-keypoint-results.csv in the evalution-results folder + snapshots_to_evaluate: List[str], optional, default=None + List of snapshot names to evaluate (e.g. ["snapshot-50000", "snapshot-75000", ...]) + Returns ------- None @@ -673,6 +655,7 @@ def evaluate_network( gputouse=gputouse, modelprefix=modelprefix, per_keypoint_evaluation=per_keypoint_evaluation, + snapshots_to_evaluate=snapshots_to_evaluate, ) else: from deeplabcut.utils.auxfun_videos import imread, imresize @@ -787,36 +770,19 @@ def evaluate_network( evaluationfolder, recursive=True ) - # Check which snapshots are available and sort them by # iterations - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(str(modelfolder), "train")) - if "index" in fn - ] + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(modelfolder) / "train", ) - try: # check if any where found? - Snapshots[0] - except IndexError: - raise FileNotFoundError( - "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so." - % (shuffle, trainFraction) - ) - increasing_indices = np.argsort( - [int(m.split("-")[1]) for m in Snapshots] - ) - Snapshots = Snapshots[increasing_indices] - - if cfg["snapshotindex"] == -1: - snapindices = [-1] - elif cfg["snapshotindex"] == "all": - snapindices = range(len(Snapshots)) - elif cfg["snapshotindex"] < len(Snapshots): - snapindices = [cfg["snapshotindex"]] + if snapshots_to_evaluate is not None: + snapshot_names = get_available_requested_snapshots( + requested_snapshots=snapshots_to_evaluate, + available_snapshots=Snapshots, + ) else: - raise ValueError( - "Invalid choice, only -1 (last), any integer up to last, or all (as string)!" + snapshot_names = get_snapshots_by_index( + idx=cfg["snapshotindex"], + available_snapshots=Snapshots, ) final_result = [] @@ -841,29 +807,25 @@ def evaluate_network( ################################################## # Compute predictions over images ################################################## - for snapindex in snapindices: + for snapshot_name in snapshot_names: test_pose_cfg["init_weights"] = os.path.join( - str(modelfolder), "train", Snapshots[snapindex] + str(modelfolder), "train", snapshot_name ) # setting weights to corresponding snapshot. - trainingsiterations = ( - test_pose_cfg["init_weights"].split(os.sep)[-1] - ).split("-")[ - -1 - ] # read how many training siterations that corresponds to. + training_iterations = int(snapshot_name.split("-")[-1]) # Name for deeplabcut net (based on its parameters) DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name( cfg, shuffle, trainFraction, - trainingsiterations=trainingsiterations, + trainingsiterations=training_iterations, modelprefix=modelprefix, ) print( "Running ", DLCscorer, " with # of training iterations:", - trainingsiterations, + training_iterations, ) ( notanalyzed, @@ -873,7 +835,7 @@ def evaluate_network( str(evaluationfolder), DLCscorer, DLCscorerlegacy, - Snapshots[snapindex], + snapshot_name, ) if notanalyzed: # Specifying state of model (snapshot / training state) @@ -931,7 +893,7 @@ def evaluate_network( print( "Analysis is done and the results are stored (see evaluation-results) for snapshot: ", - Snapshots[snapindex], + snapshot_name, ) DataCombined = pd.concat( [Data.T, DataMachine.T], axis=0, sort=False @@ -955,7 +917,7 @@ def evaluate_network( RMSEpcutoff.iloc[trainIndices].values.flatten() ) results = [ - trainingsiterations, + training_iterations, int(100 * trainFraction), shuffle, np.round(trainerror, 2), @@ -978,7 +940,7 @@ def evaluate_network( if show_errors: print( "Results for", - trainingsiterations, + training_iterations, " training iterations:", int(100 * trainFraction), shuffle, @@ -1013,7 +975,7 @@ def evaluate_network( "LabeledImages_" + DLCscorer + "_" - + Snapshots[snapindex], + + snapshot_name, ) auxiliaryfunctions.attempt_to_make_folder(foldername) Plotting( @@ -1039,7 +1001,7 @@ def evaluate_network( "LabeledImages_" + DLCscorer + "_" - + Snapshots[snapindex], + + snapshot_name, ) if not os.path.exists(foldername): print( @@ -1112,6 +1074,57 @@ def make_results_file(final_result, evaluationfolder, DLCscorer): df.to_csv(output_path) +def get_available_requested_snapshots( + requested_snapshots: List[str], + available_snapshots: List[str], +) -> List[str]: + """ + Intersects the requested snapshot names with the available snapshots. + + Returns: snapshot names + """ + snapshot_names = [] + missing_snapshots = [] + for snap in requested_snapshots: + if snap in available_snapshots: + snapshot_names.append(snap) + else: + missing_snapshots.append(snap) + + if len(snapshot_names) == 0: + raise ValueError( + f"None of the requested snapshots were found: \n{missing_snapshots}" + ) + elif len(missing_snapshots) > 0: + print( + f"The following requested snapshots were not found and will be skipped:\n" + f"{missing_snapshots}" + ) + + return snapshot_names + + +def get_snapshots_by_index( + idx: Union[int, str], available_snapshots: List[str], +) -> List[str]: + """ + Assume available_snapshots is ordered in ascending order. Returns snapshot names. + """ + if ( + isinstance(idx, int) + and -len(available_snapshots) <= idx < len(available_snapshots) + ): + return [available_snapshots[idx]] + elif idx == "all": + return available_snapshots + + raise IndexError( + f"Invalid index: {idx}. The index should be an int less than the number of " + f"available snapshots, negative indexing is supported. The keyword 'all' " + f"is also a valid option." + ) + + if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("config") diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py index c714da02a9..4f52924a6a 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py +++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py @@ -16,12 +16,15 @@ import numpy as np import pandas as pd from tqdm import tqdm +from typing import List from deeplabcut.core import crossvalutils from deeplabcut.core.crossvalutils import find_closest_neighbors from deeplabcut.pose_estimation_tensorflow.core.evaluate import ( make_results_file, keypoint_error, + get_available_requested_snapshots, + get_snapshots_by_index, ) from deeplabcut.pose_estimation_tensorflow.training import return_train_network_path from deeplabcut.pose_estimation_tensorflow.config import load_config @@ -93,6 +96,7 @@ def evaluate_multianimal_full( gputouse=None, modelprefix="", per_keypoint_evaluation: bool = False, + snapshots_to_evaluate: List[str] = None, ): from deeplabcut.pose_estimation_tensorflow.core import ( predict, @@ -216,415 +220,198 @@ def evaluate_multianimal_full( ) auxiliaryfunctions.attempt_to_make_folder(evaluationfolder, recursive=True) - # Check which snapshots are available and sort them by # iterations - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(str(modelfolder), "train")) - if "index" in fn - ] - ) - if len(Snapshots) == 0: - print( - "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so." - % (shuffle, trainFraction) + try: + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(modelfolder) / "train", ) - else: - increasing_indices = np.argsort( - [int(m.split("-")[1]) for m in Snapshots] + except FileNotFoundError as e: + print(e) + continue + + if snapshots_to_evaluate is not None: + snapshot_names = get_available_requested_snapshots( + requested_snapshots=snapshots_to_evaluate, + available_snapshots=Snapshots, ) - Snapshots = Snapshots[increasing_indices] - - if cfg["snapshotindex"] == -1: - snapindices = [-1] - elif cfg["snapshotindex"] == "all": - snapindices = range(len(Snapshots)) - elif cfg["snapshotindex"] < len(Snapshots): - snapindices = [cfg["snapshotindex"]] - else: - print( - "Invalid choice, only -1 (last), any integer up to last, or all (as string)!" - ) - - final_result = [] - ################################################## - # Compute predictions over images - ################################################## - for snapindex in snapindices: - test_pose_cfg["init_weights"] = os.path.join( - str(modelfolder), "train", Snapshots[snapindex] - ) # setting weights to corresponding snapshot. - trainingsiterations = ( - test_pose_cfg["init_weights"].split(os.sep)[-1] - ).split("-")[ - -1 - ] # read how many training siterations that corresponds to. - - # name for deeplabcut net (based on its parameters) - DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name( - cfg, - shuffle, - trainFraction, - trainingsiterations, - modelprefix=modelprefix, + else: + try: + snapshot_names = get_snapshots_by_index( + idx=cfg["snapshotindex"], + available_snapshots=Snapshots, ) + except IndexError as err: print( - "Running ", - DLCscorer, - " with # of trainingiterations:", - trainingsiterations, - ) - ( - notanalyzed, - resultsfilename, - DLCscorer, - ) = auxiliaryfunctions.check_if_not_evaluated( - str(evaluationfolder), - DLCscorer, - DLCscorerlegacy, - Snapshots[snapindex], + "Failed to get snapshot_names for trainFraction=" + f"{trainFraction} and shuffle={shuffle}. Error:" ) + print(err) + snapshot_names = [] - data_path = resultsfilename.split(".h5")[0] + "_full.pickle" + final_result = [] + ################################################## + # Compute predictions over images + ################################################## + for snapshot_name in snapshot_names: + test_pose_cfg["init_weights"] = os.path.join( + str(modelfolder), "train", snapshot_name + ) # setting weights to corresponding snapshot. + training_iterations = int(snapshot_name.split("-")[-1]) + + # name for deeplabcut net (based on its parameters) + DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name( + cfg, + shuffle, + trainFraction, + trainingsiterations=training_iterations, + modelprefix=modelprefix, + ) + print( + "Running ", + DLCscorer, + " with # of trainingiterations:", + training_iterations, + ) + ( + notanalyzed, + resultsfilename, + DLCscorer, + ) = auxiliaryfunctions.check_if_not_evaluated( + str(evaluationfolder), + DLCscorer, + DLCscorerlegacy, + snapshot_name, + ) - if plotting: - foldername = os.path.join( - str(evaluationfolder), - "LabeledImages_" + DLCscorer + "_" + Snapshots[snapindex], - ) - auxiliaryfunctions.attempt_to_make_folder(foldername) - if plotting == "bodypart": - fig, ax = visualization.create_minimal_figure() + data_path = resultsfilename.split(".h5")[0] + "_full.pickle" - if os.path.isfile(data_path): - print("Model already evaluated.", resultsfilename) - else: - ( - sess, - inputs, - outputs, - ) = predict.setup_pose_prediction(test_pose_cfg) - - PredicteData = {} - dist = np.full((len(Data), len(all_bpts)), np.nan) - conf = np.full_like(dist, np.nan) - print("Network Evaluation underway...") - for imageindex, imagename in tqdm(enumerate(Data.index)): - image_path = os.path.join(cfg["project_path"], *imagename) - frame = auxfun_videos.imread(image_path, mode="skimage") - - GT = Data.iloc[imageindex] - if not GT.any(): - continue - - # Pass the image and the keypoints through the resizer; - # this has no effect if no augmenters were added to it. - keypoints = [GT.to_numpy().reshape((-1, 2)).astype(float)] - frame_, keypoints = pipeline( - images=[frame], keypoints=keypoints - ) - frame = frame_[0] - GT[:] = keypoints[0].flatten() + if plotting: + foldername = os.path.join( + str(evaluationfolder), + "LabeledImages_" + DLCscorer + "_" + snapshot_name, + ) + auxiliaryfunctions.attempt_to_make_folder(foldername) + if plotting == "bodypart": + fig, ax = visualization.create_minimal_figure() - df = GT.unstack("coords").reindex(joints, level="bodyparts") + if os.path.isfile(data_path): + print("Model already evaluated.", resultsfilename) + else: + ( + sess, + inputs, + outputs, + ) = predict.setup_pose_prediction(test_pose_cfg) + + PredicteData = {} + dist = np.full((len(Data), len(all_bpts)), np.nan) + conf = np.full_like(dist, np.nan) + print("Network Evaluation underway...") + for imageindex, imagename in tqdm(enumerate(Data.index)): + image_path = os.path.join(cfg["project_path"], *imagename) + frame = auxfun_videos.imread(image_path, mode="skimage") + + GT = Data.iloc[imageindex] + if not GT.any(): + continue + + # Pass the image and the keypoints through the resizer; + # this has no effect if no augmenters were added to it. + keypoints = [GT.to_numpy().reshape((-1, 2)).astype(float)] + frame_, keypoints = pipeline( + images=[frame], keypoints=keypoints + ) + frame = frame_[0] + GT[:] = keypoints[0].flatten() - # FIXME Is having an empty array vs nan really that necessary?! - groundtruthidentity = list( - df.index.get_level_values("individuals") - .to_numpy() - .reshape((-1, 1)) - ) - groundtruthcoordinates = list(df.values[:, np.newaxis]) - for i, coords in enumerate(groundtruthcoordinates): - if np.isnan(coords).any(): - groundtruthcoordinates[i] = np.empty( - (0, 2), dtype=float - ) - groundtruthidentity[i] = np.array([], dtype=str) - - # Form 2D array of shape (n_rows, 4) where the last dimension - # is (sample_index, peak_y, peak_x, bpt_index) to slice the PAFs. - temp = df.reset_index(level="bodyparts").dropna() - temp["bodyparts"].replace( - dict(zip(joints, range(len(joints)))), - inplace=True, - ) - temp["sample"] = 0 - peaks_gt = temp.loc[ - :, ["sample", "y", "x", "bodyparts"] - ].to_numpy() - peaks_gt[:, 1:3] = (peaks_gt[:, 1:3] - stride // 2) / stride - - pred = predictma.predict_batched_peaks_and_costs( - test_pose_cfg, - np.expand_dims(frame, axis=0), - sess, - inputs, - outputs, - peaks_gt.astype(int), - ) + df = GT.unstack("coords").reindex(joints, level="bodyparts") - if not pred: - continue - else: - pred = pred[0] - - PredicteData[imagename] = {} - PredicteData[imagename]["index"] = imageindex - PredicteData[imagename]["prediction"] = pred - PredicteData[imagename]["groundtruth"] = [ - groundtruthidentity, - groundtruthcoordinates, - GT, - ] - - coords_pred = pred["coordinates"][0] - probs_pred = pred["confidence"] - for bpt, xy_gt in df.groupby(level="bodyparts"): - inds_gt = np.flatnonzero( - np.all(~np.isnan(xy_gt), axis=1) - ) - n_joint = joints.index(bpt) - xy = coords_pred[n_joint] - if inds_gt.size and xy.size: - # Pick the predictions closest to ground truth, - # rather than the ones the model has most confident in - xy_gt_values = xy_gt.iloc[inds_gt].values - neighbors = find_closest_neighbors( - xy_gt_values, xy, k=3 - ) - found = neighbors != -1 - min_dists = np.linalg.norm( - xy_gt_values[found] - xy[neighbors[found]], - axis=1, - ) - inds = np.flatnonzero(all_bpts == bpt) - sl = imageindex, inds[inds_gt[found]] - dist[sl] = min_dists - conf[sl] = probs_pred[n_joint][ - neighbors[found] - ].squeeze() - - if plotting == "bodypart": - temp_xy = GT.unstack("bodyparts")[joints].values - gt = temp_xy.reshape( - (-1, 2, temp_xy.shape[1]) - ).T.swapaxes(1, 2) - h, w, _ = np.shape(frame) - fig.set_size_inches(w / 100, h / 100) - ax.set_xlim(0, w) - ax.set_ylim(0, h) - ax.invert_yaxis() - ax = visualization.make_multianimal_labeled_image( - frame, - gt, - coords_pred, - probs_pred, - colors, - cfg["dotsize"], - cfg["alphavalue"], - cfg["pcutoff"], - ax=ax, - ) - visualization.save_labeled_frame( - fig, - image_path, - foldername, - imageindex in trainIndices, - ) - visualization.erase_artists(ax) - - sess.close() # closes the current tf session - - # Compute all distance statistics - df_dist = pd.DataFrame(dist, columns=df.index) - df_conf = pd.DataFrame(conf, columns=df.index) - df_joint = pd.concat( - [df_dist, df_conf], - keys=["rmse", "conf"], - names=["metrics"], - axis=1, - ) - df_joint = df_joint.reorder_levels( - list(np.roll(df_joint.columns.names, -1)), axis=1 + # FIXME Is having an empty array vs nan really that necessary?! + groundtruthidentity = list( + df.index.get_level_values("individuals") + .to_numpy() + .reshape((-1, 1)) ) - df_joint.sort_index( - axis=1, - level=["individuals", "bodyparts"], - ascending=[True, True], + groundtruthcoordinates = list(df.values[:, np.newaxis]) + for i, coords in enumerate(groundtruthcoordinates): + if np.isnan(coords).any(): + groundtruthcoordinates[i] = np.empty( + (0, 2), dtype=float + ) + groundtruthidentity[i] = np.array([], dtype=str) + + # Form 2D array of shape (n_rows, 4) where the last dim is + # (sample_index, peak_y, peak_x, bpt_index) to slice the PAFs. + temp = df.reset_index(level="bodyparts").dropna() + temp["bodyparts"].replace( + dict(zip(joints, range(len(joints)))), inplace=True, ) - write_path = os.path.join( - evaluationfolder, f"dist_{trainingsiterations}.csv" + temp["sample"] = 0 + peaks_gt = temp.loc[ + :, ["sample", "y", "x", "bodyparts"] + ].to_numpy() + peaks_gt[:, 1:3] = (peaks_gt[:, 1:3] - stride // 2) / stride + + pred = predictma.predict_batched_peaks_and_costs( + test_pose_cfg, + np.expand_dims(frame, axis=0), + sess, + inputs, + outputs, + peaks_gt.astype(int), ) - df_joint.to_csv(write_path) - # Calculate overall prediction error - error = df_joint.xs("rmse", level="metrics", axis=1) - mask = ( - df_joint.xs("conf", level="metrics", axis=1) - >= cfg["pcutoff"] - ) - error_masked = error[mask] - error_train = np.nanmean(error.iloc[trainIndices]) - error_train_cut = np.nanmean(error_masked.iloc[trainIndices]) - error_test = np.nanmean(error.iloc[testIndices]) - error_test_cut = np.nanmean(error_masked.iloc[testIndices]) - results = [ - trainingsiterations, - int(100 * trainFraction), - shuffle, - np.round(error_train, 2), - np.round(error_test, 2), - cfg["pcutoff"], - np.round(error_train_cut, 2), - np.round(error_test_cut, 2), + if not pred: + continue + else: + pred = pred[0] + + PredicteData[imagename] = {} + PredicteData[imagename]["index"] = imageindex + PredicteData[imagename]["prediction"] = pred + PredicteData[imagename]["groundtruth"] = [ + groundtruthidentity, + groundtruthcoordinates, + GT, ] - final_result.append(results) - - if per_keypoint_evaluation: - df_keypoint_error = keypoint_error( - error, - error[mask], - trainIndices, - testIndices, - ) - kpt_filename = DLCscorer + "-keypoint-results.csv" - df_keypoint_error.to_csv( - Path(evaluationfolder) / kpt_filename - ) - if show_errors: - string = ( - "Results for {} training iterations, training fraction of {}, and shuffle {}:\n" - "Train error: {} pixels. Test error: {} pixels.\n" - "With pcutoff of {}:\n" - "Train error: {} pixels. Test error: {} pixels." + coords_pred = pred["coordinates"][0] + probs_pred = pred["confidence"] + for bpt, xy_gt in df.groupby(level="bodyparts"): + inds_gt = np.flatnonzero( + np.all(~np.isnan(xy_gt), axis=1) ) - print(string.format(*results)) - - print("##########################################") - print( - "Average Euclidean distance to GT per individual (in pixels; test-only)" - ) - print( - error_masked.iloc[testIndices] - .groupby("individuals", axis=1) - .mean() - .mean() - .to_string() - ) - print( - "Average Euclidean distance to GT per bodypart (in pixels; test-only)" - ) - print( - error_masked.iloc[testIndices] - .groupby("bodyparts", axis=1) - .mean() - .mean() - .to_string() - ) - - PredicteData["metadata"] = { - "nms radius": test_pose_cfg["nmsradius"], - "minimal confidence": test_pose_cfg["minconfidence"], - "sigma": test_pose_cfg.get("sigma", 1), - "PAFgraph": test_pose_cfg["partaffinityfield_graph"], - "PAFinds": np.arange( - len(test_pose_cfg["partaffinityfield_graph"]) - ), - "all_joints": [ - [i] for i in range(len(test_pose_cfg["all_joints"])) - ], - "all_joints_names": [ - test_pose_cfg["all_joints_names"][i] - for i in range(len(test_pose_cfg["all_joints"])) - ], - "stride": test_pose_cfg.get("stride", 8), - } - print( - "Done and results stored for snapshot: ", - Snapshots[snapindex], - ) - - dictionary = { - "Scorer": DLCscorer, - "DLC-model-config file": test_pose_cfg, - "trainIndices": trainIndices, - "testIndices": testIndices, - "trainFraction": trainFraction, - } - metadata = {"data": dictionary} - _ = auxfun_multianimal.SaveFullMultiAnimalData( - PredicteData, metadata, resultsfilename - ) - - tf.compat.v1.reset_default_graph() - - n_multibpts = len(cfg["multianimalbodyparts"]) - if n_multibpts == 1: - continue - - # Skip data-driven skeleton selection unless - # the model was trained on the full graph. - max_n_edges = n_multibpts * (n_multibpts - 1) // 2 - n_edges = len(test_pose_cfg["partaffinityfield_graph"]) - if n_edges == max_n_edges: - print("Selecting best skeleton...") - n_graphs = 10 - paf_inds = None - else: - n_graphs = 1 - paf_inds = [list(range(n_edges))] - ( - results, - paf_scores, - best_assemblies, - ) = crossvalutils.cross_validate_paf_graphs( - config, - str(path_test_config).replace("pose_", "inference_"), - data_path, - data_path.replace("_full.", "_meta."), - n_graphs=n_graphs, - paf_inds=paf_inds, - oks_sigma=test_pose_cfg.get("oks_sigma", 0.1), - margin=test_pose_cfg.get("bbox_margin", 0), - symmetric_kpts=test_pose_cfg.get("symmetric_kpts"), - ) - if plotting == "individual": - assemblies, assemblies_unique, image_paths = best_assemblies - fig, ax = visualization.create_minimal_figure() - n_animals = len(cfg["individuals"]) - if cfg["uniquebodyparts"]: - n_animals += 1 - colors = visualization.get_cmap(n_animals, name=cfg["colormap"]) - for k, v in tqdm(assemblies.items()): - imname = image_paths[k] - image_path = os.path.join(cfg["project_path"], *imname) - frame = auxfun_videos.imread(image_path, mode="skimage") + n_joint = joints.index(bpt) + xy = coords_pred[n_joint] + if inds_gt.size and xy.size: + # Pick the predictions closest to ground truth, + # rather than the ones the model has most confident in + xy_gt_values = xy_gt.iloc[inds_gt].values + neighbors = find_closest_neighbors( + xy_gt_values, xy, k=3 + ) + found = neighbors != -1 + min_dists = np.linalg.norm( + xy_gt_values[found] - xy[neighbors[found]], + axis=1, + ) + inds = np.flatnonzero(all_bpts == bpt) + sl = imageindex, inds[inds_gt[found]] + dist[sl] = min_dists + conf[sl] = probs_pred[n_joint][ + neighbors[found] + ].squeeze() + if plotting == "bodypart": + temp_xy = GT.unstack("bodyparts")[joints].values + gt = temp_xy.reshape( + (-1, 2, temp_xy.shape[1]) + ).T.swapaxes(1, 2) h, w, _ = np.shape(frame) fig.set_size_inches(w / 100, h / 100) ax.set_xlim(0, w) ax.set_ylim(0, h) ax.invert_yaxis() - - gt = [ - s.to_numpy().reshape((-1, 2)) - for _, s in Data.loc[imname].groupby("individuals") - ] - coords_pred = [] - coords_pred += [ass.xy for ass in v] - probs_pred = [] - probs_pred += [ass.data[:, 2:3] for ass in v] - if assemblies_unique is not None: - unique = assemblies_unique.get(k, None) - if unique is not None: - coords_pred.append(unique[:, :2]) - probs_pred.append(unique[:, 2:3]) - while len(coords_pred) < len(gt): - coords_pred.append(np.full((1, 2), np.nan)) - probs_pred.append(np.full((1, 2), np.nan)) ax = visualization.make_multianimal_labeled_image( frame, gt, @@ -640,28 +427,238 @@ def evaluate_multianimal_full( fig, image_path, foldername, - k in trainIndices, + imageindex in trainIndices, ) visualization.erase_artists(ax) - df = results[1].copy() - df.loc(axis=0)[("mAP_train", "mean")] = [ - d[0]["mAP"] for d in results[2] - ] - df.loc(axis=0)[("mAR_train", "mean")] = [ - d[0]["mAR"] for d in results[2] - ] - df.loc(axis=0)[("mAP_test", "mean")] = [ - d[1]["mAP"] for d in results[2] - ] - df.loc(axis=0)[("mAR_test", "mean")] = [ - d[1]["mAR"] for d in results[2] + sess.close() # closes the current tf session + + # Compute all distance statistics + df_dist = pd.DataFrame(dist, columns=df.index) + df_conf = pd.DataFrame(conf, columns=df.index) + df_joint = pd.concat( + [df_dist, df_conf], + keys=["rmse", "conf"], + names=["metrics"], + axis=1, + ) + df_joint = df_joint.reorder_levels( + list(np.roll(df_joint.columns.names, -1)), axis=1 + ) + df_joint.sort_index( + axis=1, + level=["individuals", "bodyparts"], + ascending=[True, True], + inplace=True, + ) + write_path = os.path.join( + evaluationfolder, f"dist_{training_iterations}.csv" + ) + df_joint.to_csv(write_path) + + # Calculate overall prediction error + error = df_joint.xs("rmse", level="metrics", axis=1) + mask = ( + df_joint.xs("conf", level="metrics", axis=1) + >= cfg["pcutoff"] + ) + error_masked = error[mask] + error_train = np.nanmean(error.iloc[trainIndices]) + error_train_cut = np.nanmean(error_masked.iloc[trainIndices]) + error_test = np.nanmean(error.iloc[testIndices]) + error_test_cut = np.nanmean(error_masked.iloc[testIndices]) + results = [ + training_iterations, + int(100 * trainFraction), + shuffle, + np.round(error_train, 2), + np.round(error_test, 2), + cfg["pcutoff"], + np.round(error_train_cut, 2), + np.round(error_test_cut, 2), ] - with open(data_path.replace("_full.", "_map."), "wb") as file: - pickle.dump((df, paf_scores), file) + final_result.append(results) + + if per_keypoint_evaluation: + df_keypoint_error = keypoint_error( + error, + error[mask], + trainIndices, + testIndices, + ) + kpt_filename = DLCscorer + "-keypoint-results.csv" + df_keypoint_error.to_csv( + Path(evaluationfolder) / kpt_filename + ) + + if show_errors: + string = ( + "Results for {} training iterations, training fraction of {}, and shuffle {}:\n" + "Train error: {} pixels. Test error: {} pixels.\n" + "With pcutoff of {}:\n" + "Train error: {} pixels. Test error: {} pixels." + ) + print(string.format(*results)) + + print("##########################################") + print( + "Average Euclidean distance to GT per individual (in pixels; test-only)" + ) + print( + error_masked.iloc[testIndices] + .groupby("individuals", axis=1) + .mean() + .mean() + .to_string() + ) + print( + "Average Euclidean distance to GT per bodypart (in pixels; test-only)" + ) + print( + error_masked.iloc[testIndices] + .groupby("bodyparts", axis=1) + .mean() + .mean() + .to_string() + ) + + PredicteData["metadata"] = { + "nms radius": test_pose_cfg["nmsradius"], + "minimal confidence": test_pose_cfg["minconfidence"], + "sigma": test_pose_cfg.get("sigma", 1), + "PAFgraph": test_pose_cfg["partaffinityfield_graph"], + "PAFinds": np.arange( + len(test_pose_cfg["partaffinityfield_graph"]) + ), + "all_joints": [ + [i] for i in range(len(test_pose_cfg["all_joints"])) + ], + "all_joints_names": [ + test_pose_cfg["all_joints_names"][i] + for i in range(len(test_pose_cfg["all_joints"])) + ], + "stride": test_pose_cfg.get("stride", 8), + } + print( + "Done and results stored for snapshot: ", + snapshot_name, + ) + + dictionary = { + "Scorer": DLCscorer, + "DLC-model-config file": test_pose_cfg, + "trainIndices": trainIndices, + "testIndices": testIndices, + "trainFraction": trainFraction, + } + metadata = {"data": dictionary} + _ = auxfun_multianimal.SaveFullMultiAnimalData( + PredicteData, metadata, resultsfilename + ) + + tf.compat.v1.reset_default_graph() + + n_multibpts = len(cfg["multianimalbodyparts"]) + if n_multibpts == 1: + continue + + # Skip data-driven skeleton selection unless + # the model was trained on the full graph. + max_n_edges = n_multibpts * (n_multibpts - 1) // 2 + n_edges = len(test_pose_cfg["partaffinityfield_graph"]) + if n_edges == max_n_edges: + print("Selecting best skeleton...") + n_graphs = 10 + paf_inds = None + else: + n_graphs = 1 + paf_inds = [list(range(n_edges))] + ( + results, + paf_scores, + best_assemblies, + ) = crossvalutils.cross_validate_paf_graphs( + config, + str(path_test_config).replace("pose_", "inference_"), + data_path, + data_path.replace("_full.", "_meta."), + n_graphs=n_graphs, + paf_inds=paf_inds, + oks_sigma=test_pose_cfg.get("oks_sigma", 0.1), + margin=test_pose_cfg.get("bbox_margin", 0), + symmetric_kpts=test_pose_cfg.get("symmetric_kpts"), + ) + if plotting == "individual": + assemblies, assemblies_unique, image_paths = best_assemblies + fig, ax = visualization.create_minimal_figure() + n_animals = len(cfg["individuals"]) + if cfg["uniquebodyparts"]: + n_animals += 1 + colors = visualization.get_cmap(n_animals, name=cfg["colormap"]) + for k, v in tqdm(assemblies.items()): + imname = image_paths[k] + image_path = os.path.join(cfg["project_path"], *imname) + frame = auxfun_videos.imread(image_path, mode="skimage") + + h, w, _ = np.shape(frame) + fig.set_size_inches(w / 100, h / 100) + ax.set_xlim(0, w) + ax.set_ylim(0, h) + ax.invert_yaxis() + + gt = [ + s.to_numpy().reshape((-1, 2)) + for _, s in Data.loc[imname].groupby("individuals") + ] + coords_pred = [] + coords_pred += [ass.xy for ass in v] + probs_pred = [] + probs_pred += [ass.data[:, 2:3] for ass in v] + if assemblies_unique is not None: + unique = assemblies_unique.get(k, None) + if unique is not None: + coords_pred.append(unique[:, :2]) + probs_pred.append(unique[:, 2:3]) + while len(coords_pred) < len(gt): + coords_pred.append(np.full((1, 2), np.nan)) + probs_pred.append(np.full((1, 2), np.nan)) + ax = visualization.make_multianimal_labeled_image( + frame, + gt, + coords_pred, + probs_pred, + colors, + cfg["dotsize"], + cfg["alphavalue"], + cfg["pcutoff"], + ax=ax, + ) + visualization.save_labeled_frame( + fig, + image_path, + foldername, + k in trainIndices, + ) + visualization.erase_artists(ax) + + df = results[1].copy() + df.loc(axis=0)[("mAP_train", "mean")] = [ + d[0]["mAP"] for d in results[2] + ] + df.loc(axis=0)[("mAR_train", "mean")] = [ + d[0]["mAR"] for d in results[2] + ] + df.loc(axis=0)[("mAP_test", "mean")] = [ + d[1]["mAP"] for d in results[2] + ] + df.loc(axis=0)[("mAR_test", "mean")] = [ + d[1]["mAR"] for d in results[2] + ] + with open(data_path.replace("_full.", "_map."), "wb") as file: + pickle.dump((df, paf_scores), file) - if len(final_result) > 0: # Only append if results were calculated - make_results_file(final_result, evaluationfolder, DLCscorer) + if len(final_result) > 0: # Only append if results were calculated + make_results_file(final_result, evaluationfolder, DLCscorer) os.chdir(str(start_path)) diff --git a/deeplabcut/pose_estimation_tensorflow/export.py b/deeplabcut/pose_estimation_tensorflow/export.py index ab9d2d96b2..50f9b6acbf 100644 --- a/deeplabcut/pose_estimation_tensorflow/export.py +++ b/deeplabcut/pose_estimation_tensorflow/export.py @@ -13,6 +13,7 @@ import os import shutil import tarfile +from pathlib import Path import numpy as np import ruamel.yaml @@ -131,26 +132,9 @@ def load_model(cfg, shuffle=1, trainingsetindex=0, TFGPUinference=True, modelpre % (shuffle, train_fraction) ) - # Check which snapshots are available and sort them by # iterations - try: - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(model_folder, "train")) - if "index" in fn - ] - ) - except FileNotFoundError: - raise FileNotFoundError( - "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Please train it before trying to export.\n Use the function 'train_network' to train the network for shuffle %s." - % (shuffle, shuffle) - ) - - if len(Snapshots) == 0: - raise FileNotFoundError( - "The train folder for iteration %s and shuffle %s exists, but no snapshots were found.\n Please train this model before trying to export.\n Use the function 'train_network' to train the network for iteration %s shuffle %s." - % (cfg["iteration"], shuffle, cfg["iteration"], shuffle) - ) + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(model_folder) / "train", + ) if cfg["snapshotindex"] == "all": print( @@ -160,9 +144,6 @@ def load_model(cfg, shuffle=1, trainingsetindex=0, TFGPUinference=True, modelpre else: snapshotindex = cfg["snapshotindex"] - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] - #################################### ### Load and setup CNN part detector #################################### diff --git a/deeplabcut/pose_estimation_tensorflow/predict_videos.py b/deeplabcut/pose_estimation_tensorflow/predict_videos.py index 4dd0046024..fb54299052 100644 --- a/deeplabcut/pose_estimation_tensorflow/predict_videos.py +++ b/deeplabcut/pose_estimation_tensorflow/predict_videos.py @@ -115,20 +115,9 @@ def create_tracking_dataset( % (shuffle, trainFraction) ) - # Check which snapshots are available and sort them by # iterations - try: - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(modelfolder, "train")) - if "index" in fn - ] - ) - except FileNotFoundError: - raise FileNotFoundError( - "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Please train it before using it to analyze videos.\n Use the function 'train_network' to train the network for shuffle %s." - % (shuffle, shuffle) - ) + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(modelfolder) / "train", + ) if cfg["snapshotindex"] == "all": print( @@ -138,9 +127,6 @@ def create_tracking_dataset( else: snapshotindex = cfg["snapshotindex"] - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] - print("Using %s" % Snapshots[snapshotindex], "for model", modelfolder) ################################################## @@ -513,20 +499,9 @@ def analyze_videos( % (iteration, shuffle, trainFraction) ) - # Check which snapshots are available and sort them by # iterations - try: - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(modelfolder, "train")) - if "index" in fn - ] - ) - except FileNotFoundError: - raise FileNotFoundError( - "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Be sure you also have the intended iteration number set.\n Please train it before using it to analyze videos.\n Use the function 'train_network' to train the network for shuffle %s." - % (shuffle, shuffle) - ) + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(modelfolder) / "train", + ) if cfg["snapshotindex"] == "all": print( @@ -536,9 +511,6 @@ def analyze_videos( else: snapshotindex = cfg["snapshotindex"] - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] - print("Using %s" % Snapshots[snapshotindex], "for model", modelfolder) ################################################## @@ -1315,20 +1287,10 @@ def analyze_time_lapse_frames( "It seems the model for shuffle %s and trainFraction %s does not exist." % (shuffle, trainFraction) ) - # Check which snapshots are available and sort them by # iterations - try: - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(modelfolder, "train")) - if "index" in fn - ] - ) - except FileNotFoundError: - raise FileNotFoundError( - "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Please train it before using it to analyze videos.\n Use the function 'train_network' to train the network for shuffle %s." - % (shuffle, shuffle) - ) + + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(modelfolder) / "train", + ) if cfg["snapshotindex"] == "all": print( @@ -1338,9 +1300,6 @@ def analyze_time_lapse_frames( else: snapshotindex = cfg["snapshotindex"] - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] - print("Using %s" % Snapshots[snapshotindex], "for model", modelfolder) ################################################## @@ -1692,20 +1651,9 @@ def convert_detections2tracklets( # between trackers cannot be evaluated, resulting in empty tracklets. inferencecfg["boundingboxslack"] = max(inferencecfg["boundingboxslack"], 40) - # Check which snapshots are available and sort them by # iterations - try: - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(modelfolder, "train")) - if "index" in fn - ] - ) - except FileNotFoundError: - raise FileNotFoundError( - "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Please train it before using it to analyze videos.\n Use the function 'train_network' to train the network for shuffle %s." - % (shuffle, shuffle) - ) + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(modelfolder) / "train", + ) if cfg["snapshotindex"] == "all": print( @@ -1715,8 +1663,6 @@ def convert_detections2tracklets( else: snapshotindex = cfg["snapshotindex"] - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] print("Using %s" % Snapshots[snapshotindex], "for model", modelfolder) dlc_cfg["init_weights"] = os.path.join( modelfolder, "train", Snapshots[snapshotindex] diff --git a/deeplabcut/pose_estimation_tensorflow/visualizemaps.py b/deeplabcut/pose_estimation_tensorflow/visualizemaps.py index 5c417e5ef2..c6c64d70c3 100644 --- a/deeplabcut/pose_estimation_tensorflow/visualizemaps.py +++ b/deeplabcut/pose_estimation_tensorflow/visualizemaps.py @@ -157,26 +157,10 @@ def extract_maps( ), ) auxiliaryfunctions.attempt_to_make_folder(evaluationfolder, recursive=True) - # path_train_config = modelfolder / 'train' / 'pose_cfg.yaml' - - # Check which snapshots are available and sort them by # iterations - Snapshots = np.array( - [ - fn.split(".")[0] - for fn in os.listdir(os.path.join(str(modelfolder), "train")) - if "index" in fn - ] - ) - try: # check if any where found? - Snapshots[0] - except IndexError: - raise FileNotFoundError( - "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so." - % (shuffle, trainFraction) - ) - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] + Snapshots = auxiliaryfunctions.get_snapshots_from_folder( + train_folder=Path(modelfolder) / "train", + ) if cfg["snapshotindex"] == -1: snapindices = [-1] diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 806fe97f41..82a9fb5806 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -24,6 +24,8 @@ import pickle import warnings from pathlib import Path +from typing import List + import numpy as np import pandas as pd import ruamel.yaml.representer @@ -629,6 +631,29 @@ def get_evaluation_folder( ) +def get_snapshots_from_folder(train_folder: Path) -> List[str]: + """ + Returns an ordered list of existing snapshot names in the train folder, sorted by + increasing training iterations. + + Raises: + FileNotFoundError: if no snapshot_names are found in the train_folder. + """ + snapshot_names = [ + file.stem for file in train_folder.iterdir() if "index" in file.name + ] + + if len(snapshot_names) == 0: + raise FileNotFoundError( + f"No snapshots were found in {train_folder}! Please ensure the network has " + f"been trained and verify the iteration, shuffle and trainFraction are " + f"correct." + ) + + # sort in ascending order of iteration number + return sorted(snapshot_names, key=lambda name: int(name.split("-")[1])) + + def get_deeplabcut_path(): """Get path of where deeplabcut is currently running""" import importlib.util @@ -710,18 +735,13 @@ def get_scorer_name( snapshotindex = get_snapshot_index_for_scorer( "snapshotindex", cfg["snapshotindex"] ) - modelfolder = os.path.join( - cfg["project_path"], - str(get_model_folder(trainFraction, shuffle, cfg, engine=engine, modelprefix=modelprefix)), - "train", - ) - Snapshots = np.array( - [fn.split(".")[0] for fn in os.listdir(modelfolder) if "index" in fn] + model_folder = get_model_folder( + trainFraction, shuffle, cfg, engine=engine, modelprefix=modelprefix ) - increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots]) - Snapshots = Snapshots[increasing_indices] - SNP = Snapshots[snapshotindex] - trainingsiterations = (SNP.split(os.sep)[-1]).split("-")[-1] + train_folder = Path(cfg["project_path"]) / model_folder / "train" + snapshot_names = get_snapshots_from_folder(train_folder) + snapshot_name = snapshot_names[snapshotindex] + trainingsiterations = (snapshot_name.split(os.sep)[-1]).split("-")[-1] dlc_cfg = read_plainconfig( os.path.join( diff --git a/examples/testscript.py b/examples/testscript.py index bd4656e436..e68c532e49 100644 --- a/examples/testscript.py +++ b/examples/testscript.py @@ -71,7 +71,8 @@ else: augmenter_type3 = "tensorpack" # Does not work on WINDOWS - N_ITER = 5 + N_ITER = 6 + SAVE_ITER = 3 print("CREATING PROJECT") path_config_file = deeplabcut.create_new_project( @@ -167,7 +168,7 @@ ) DLC_config = deeplabcut.auxiliaryfunctions.read_plainconfig(posefile) - DLC_config["save_iters"] = N_ITER + DLC_config["save_iters"] = SAVE_ITER DLC_config["display_iters"] = 2 print("CHANGING training parameters to end quickly!") @@ -178,7 +179,10 @@ print("EVALUATE") deeplabcut.evaluate_network( - path_config_file, plotting=True, per_keypoint_evaluation=True + path_config_file, + plotting=True, + per_keypoint_evaluation=True, + snapshots_to_evaluate=["snapshot-3", "snapshot-5", "snapshot-6"], # snapshot-5 intentionally missing :) ) # deeplabcut.evaluate_network(path_config_file,plotting=True,trainingsetindex=33) print("CUT SHORT VIDEO AND ANALYZE (with dynamic cropping!)") @@ -302,7 +306,7 @@ def make_frame(t): "train/pose_cfg.yaml", ) DLC_config = deeplabcut.auxiliaryfunctions.read_plainconfig(posefile) - DLC_config["save_iters"] = N_ITER + DLC_config["save_iters"] = SAVE_ITER DLC_config["display_iters"] = 1 print("CHANGING training parameters to end quickly!") diff --git a/tests/test_auxiliaryfunctions.py b/tests/test_auxiliaryfunctions.py index 7397060897..9c6e55504f 100644 --- a/tests/test_auxiliaryfunctions.py +++ b/tests/test_auxiliaryfunctions.py @@ -230,3 +230,49 @@ def get_rglob_results(*args, **kwargs): monkeypatch.setattr(Path, "rglob", get_rglob_results) next_folder = auxiliaryfunctions.find_next_unlabeled_folder(fake_cfg) assert str(next_folder) == str(Path(data_folder / next_folder_name)) + + +@pytest.fixture +def mock_snapshot_folder(tmp_path): + """Mock folder with snapshots.""" + folder = tmp_path / "train" + folder.mkdir() + + # mock files + snapshot_files = ["snapshot-4.index", + "snapshot-5.index", + "snapshot-6.index", + "snapshot-3.data-00000-of-00001", + "snapshot-3.index", + "snapshot-3.meta", + ] + for file_name in snapshot_files: + (folder / file_name).touch() + + return folder + + +@pytest.fixture +def mock_no_snapshots_folder(tmp_path): + """Mock folder with no snapshots.""" + folder = tmp_path / "train" + folder.mkdir() + + # mock files + snapshot_files = ["log.txt", "pose_cfg.yaml"] + for file_name in snapshot_files: + (folder / file_name).touch() + + return folder + + +def test_get_snapshots_from_folder(mock_snapshot_folder): + """Test returns expected snapshots in order.""" + snapshot_names = auxiliaryfunctions.get_snapshots_from_folder(mock_snapshot_folder) + assert snapshot_names == ["snapshot-3", "snapshot-4", "snapshot-5", "snapshot-6"] + + +def test_get_snapshots_from_folder_none(mock_no_snapshots_folder): + """Test raises ValueError if no snapshots are found.""" + with pytest.raises(FileNotFoundError): + auxiliaryfunctions.get_snapshots_from_folder(mock_no_snapshots_folder) diff --git a/tests/test_evaluate.py b/tests/test_evaluate.py index e37e4822e0..ca04f0726b 100644 --- a/tests/test_evaluate.py +++ b/tests/test_evaluate.py @@ -13,6 +13,10 @@ import pytest import deeplabcut.pose_estimation_tensorflow as pet +from deeplabcut.pose_estimation_tensorflow.core.evaluate import ( + get_available_requested_snapshots, + get_snapshots_by_index, +) def make_single_animal_rmse_df( @@ -152,3 +156,74 @@ def test_evaluate_keypoint_error(inputs, expected_values): mean_error = mean_errors[1] assert keypoint_error.loc[error_name, bodypart] == mean_error + + +def test_get_available_requested_snapshots_ok(): + """Test that the correct snapshots are returned.""" + available = ["snapshot-1", "snapshot-2"] + requested = ["snapshot-2", "snapshot-3"] + + snapshots = get_available_requested_snapshots( + requested_snapshots=requested, + available_snapshots=available, + ) + assert snapshots == ['snapshot-2'] + + +def test_get_available_requested_snapshots_error(): + """Test that a ValueError is raised when requested snapshots are not available.""" + with pytest.raises(ValueError): + get_available_requested_snapshots( + requested_snapshots=["snapshot-2"], + available_snapshots=["snapshot-1", "snapshot-3"], + ) + + +def test_get_snapshots_by_index_int_ok(): + """Test that the correct snapshots are returned.""" + available = ["snapshot-1", "snapshot-2", "snapshot-3"] + + # positive int + snapshots = get_snapshots_by_index( + idx=2, + available_snapshots=available, + ) + assert snapshots == ['snapshot-3'] + + # negative int + snapshots = get_snapshots_by_index( + idx=-2, + available_snapshots=available, + ) + assert snapshots == ['snapshot-2'] + + # all snapshots + snapshots = get_snapshots_by_index( + idx="all", + available_snapshots=available, + ) + assert snapshots == ["snapshot-1", "snapshot-2", "snapshot-3"] + + +def test_get_snapshots_by_index_error(): + """Test that a ValueError is raised when the index is out of range or invalid str.""" + available = ["snapshot-1", "snapshot-2", "snapshot-3"] + + # positive int + with pytest.raises(IndexError): + get_snapshots_by_index( + idx=5, + available_snapshots=available, + ) + # negative int + with pytest.raises(IndexError): + get_snapshots_by_index( + idx=-4, + available_snapshots=available, + ) + # invalid str + with pytest.raises(IndexError): + get_snapshots_by_index( + idx="1", + available_snapshots=available, + ) From 83f1acb9c179caf46353cdb211318b29832ff6ec Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Tue, 16 Jul 2024 15:16:06 +0200 Subject: [PATCH 196/293] Handle internet connectivity issues (#2672) Co-authored-by: Mackenzie Mathis --- deeplabcut/gui/window.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index b096df1844..663e54725d 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -15,6 +15,7 @@ from functools import cached_property from pathlib import Path from typing import List +from urllib.error import URLError import qdarkstyle import deeplabcut @@ -40,8 +41,12 @@ def _check_for_updates(silent=True): - is_latest, latest_version = utils.is_latest_deeplabcut_version() - is_latest_plugin, latest_plugin_version = misc.is_latest_version() + try: + is_latest, latest_version = utils.is_latest_deeplabcut_version() + is_latest_plugin, latest_plugin_version = misc.is_latest_version() + except URLError: # Handle internet connectivity issues + is_latest = is_latest_plugin = True + if is_latest and is_latest_plugin: if not silent: msg = QtWidgets.QMessageBox( From 416322cbba23b443c6a80651dc987ca0ab102067 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 19 Jul 2024 13:27:44 +0200 Subject: [PATCH 197/293] merge main to `pytorch_dlc` (#2677) * Add feature to evaluate network for specfic, named snapshots. (#2508) * Add snapshot selection option to evaluate_network while preserving existing functionality. - Replace snapindex with more explicit snapshot_name. snapshotindex from config is used to identify the snapshot name instead. - Refactor logic for snapshotindex allowed values. Add limit to negative index values. * Update evaluate.py - Replace snapshots_to_evaluate numpy indexing with list loop - Raise error if no snapshots are found * Add snapshots_to_evaluate docstring * Remove unused distance function * Update evaluate.py * Improve snapshots_to_evaluate docstring * Get training_iterations from snapshot_name * Rename trainingsiterations -> training_iterations * Improve snapshot_to_evaluate typehint and docstring. * Refactor evaluate.py * Create get_available_requested_snapshots for obtaining specified snapshot names (new feature). * Create get_snapshots_by_index to handle the original implementation. * Minor improvements and fixes * Add tests for new snapshot functions in evaluate.py * Update evaluate_multianimal.py * Add snapshots_to_evaluate parameter to multi-animal evaluation * Minor renaming * Update python-package.yml * add evaluate_by_snapshot branch for testing purposes * Add snapshots_to_evaluate usage in testscript.py * Update get_snapshots_by_index function * Better typehint for int or str * Raise IndexError instead of ValueError * Add get_snapshots_by_index tests to test_evaluate.py * Update return_evaluate_network_data function with get_snapshots_by_index usage * Fix potential reference before assignment by raising ValueError * Refactor getting existing snapshot file names as sorted list. * Add list_sorted_existing_snapshots to auxiliaryfunctions.py. * Update several files to use the newly refactored function. * Preserve same exception handling, albeit with modified message to be more general and concise. * Refactor evaluate_multianimal.py * Add new snapshot auxiliary function usage. * Preserve continuation of loop if no snapshots are found. * Remove indentation level. * Rename list_sorted_exisiting_snapshots -> get_snapshots_in_folder * Update name for all usages * Minor change to function docstring * Remove redundant sorting in evaluate_multianimal.py * Port numpy array to list (not performance critical) * Add get_snapshots_from_folder tests * Test snapshots present and returned in order * Test snapshots not found * Fix deprecated function names * Improve snapshots_to_evaluate test in testscript.py * Correct spelling * Add per_keypoint_evaluation to evaluate_multianimal_full function call (woops!) * minor linting --------- Co-authored-by: Mackenzie Mathis Co-authored-by: Niels Poulsen * fixed file dialog not showing uppercase file extension video files (#2645) * fixed file dialog not showing uppercase file extension video files * bug fix: no shuffle_change slot in main window * bug fix --------- Co-authored-by: Niels Poulsen Co-authored-by: Mackenzie Mathis --------- Co-authored-by: jkopp <32641315+Tetra-quark@users.noreply.github.com> Co-authored-by: Mackenzie Mathis Co-authored-by: Terry Park <58895710+park-jsdev@users.noreply.github.com> From a7c0d5812d114dbdf397fffefabface80543f0d1 Mon Sep 17 00:00:00 2001 From: Zelin2001 Date: Fri, 19 Jul 2024 19:35:23 +0800 Subject: [PATCH 198/293] Update train.py: avoids error on 'unique_bodyparts' (#2668) Issue #2667 --- deeplabcut/pose_estimation_pytorch/runners/train.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index e1fe105ec9..a8712984f6 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -418,10 +418,15 @@ def _update_epoch_predictions( for batch_id in range(len(ground_truth)): # keypoints (num_kpts, 3) keypoints = ground_truth[batch_id] - for kpts in keypoints: - vis = kpts[-1] + if name == 'unique_bodyparts': + vis = keypoints[-1] if vis < 0: - kpts[-1] = 0 + keypoints[-1] = 0 + else: + for kpts in keypoints: + vis = kpts[-1] + if vis < 0: + kpts[-1] = 0 # rescale to the full image for TD or CTD gt_with_vis[..., :2] = (gt_with_vis[..., :2] * scale) + offset From 17ccb5fce7253d52cf028a35e473785778ff3aa1 Mon Sep 17 00:00:00 2001 From: Niels Poulsen Date: Fri, 19 Jul 2024 13:45:31 +0200 Subject: [PATCH 199/293] update the default PAF config --- .../config/base/head_bodyparts_with_paf.yaml | 9 ++++----- .../pose_estimation_pytorch/models/heads/dlcrnet.py | 4 ++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml index 833b045d29..ee08bc9144 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml @@ -1,5 +1,4 @@ type: DLCRNetHead -weight_init: normal predictor: type: PartAffinityFieldPredictor num_animals: "num_individuals" @@ -11,12 +10,12 @@ predictor: min_affinity: 0.05 graph: "paf_graph" edges_to_keep: "paf_edges_to_keep" - apply_sigmoid: false - clip_scores: true + apply_sigmoid: true + clip_scores: false target_generator: type: SequentialGenerator generators: - - type: HeatmapGaussianGenerator + - type: HeatmapPlateauGenerator num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT @@ -27,7 +26,7 @@ target_generator: width: 20 criterion: heatmap: - type: WeightedMSECriterion + type: WeightedBCECriterion weight: 1.0 locref: type: WeightedHuberCriterion diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py index b83a740316..6eeaf68df0 100644 --- a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py +++ b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py @@ -55,6 +55,7 @@ def __init__( in_refined_channels ) locref_config["channels"][0] = locref_config["channels"][-1] + super().__init__( predictor, target_generator, @@ -64,6 +65,9 @@ def __init__( locref_config, weight_init, ) + if num_stages > 0: + self.stride *= 2 # extra deconv layer where it's multi-stage + self.paf_head = DeconvModule(**paf_config) self.convt1 = self._make_layer_same_padding( From 35d10a1c1c07acf0addc4b3799710899da4f3aaf Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:32:24 +0200 Subject: [PATCH 200/293] New detector options and better detector integration (#2676) * added code for SSDLite and to give users option to choose detector * select detector from GUI * fix detector int32 bug on windows --- ...ple_individuals_trainingsetmanipulation.py | 16 +- .../trainingsetmanipulation.py | 29 +++- .../gui/tabs/create_training_dataset.py | 84 ++++++++- .../pose_estimation_pytorch/__init__.py | 5 +- .../config/__init__.py | 1 + .../fasterrcnn_mobilenet_v3_large_fpn.yaml | 48 ++++++ .../detectors/fasterrcnn_resnet50_fpn_v2.yaml | 48 ++++++ .../config/detectors/ssdlite.yaml | 47 +++++ .../config/make_pose_config.py | 30 ++-- .../pose_estimation_pytorch/config/utils.py | 19 ++ .../models/detectors/__init__.py | 1 + .../models/detectors/fasterRCNN.py | 114 ++---------- .../models/detectors/ssd.py | 70 ++++++++ .../models/detectors/torchvision.py | 162 ++++++++++++++++++ .../config/test_make_pose_config.py | 16 ++ 15 files changed, 561 insertions(+), 129 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_mobilenet_v3_large_fpn.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_resnet50_fpn_v2.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/config/detectors/ssdlite.yaml create mode 100644 deeplabcut/pose_estimation_pytorch/models/detectors/ssd.py create mode 100644 deeplabcut/pose_estimation_pytorch/models/detectors/torchvision.py diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index 7fe24f70c8..a6b4432003 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -107,6 +107,7 @@ def create_multianimaltraining_dataset( Shuffles=None, windows2linux=False, net_type=None, + detector_type=None, numdigits=2, crop_size=(400, 400), crop_sampling="hybrid", @@ -155,8 +156,8 @@ def create_multianimaltraining_dataset( * ``efficientnet-b4`` * ``efficientnet-b5`` * ``efficientnet-b6`` - PyTorch (call ``deeplabcut.pose_estimation.available_models()`` for a - complete list) + PyTorch (call ``deeplabcut.pose_estimation_pytorch.available_models()`` for + a complete list) * ``resnet_50`` * ``resnet_101`` * ``dekr_w18`` @@ -169,6 +170,16 @@ def create_multianimaltraining_dataset( * ``top_down_hrnet_w48`` * ``animaltokenpose_base`` + detector_type: string, optional, default=None + Only for the PyTorch engine. + When passing creating shuffles for top-down models, you can specify which + detector you want. If the detector_type is None, the ```ssdlite``` will be used. + The list of all available detectors can be obtained by calling + ``deeplabcut.pose_estimation_pytorch.available_detectors()``. Supported options: + * ``ssdlite`` + * ``fasterrcnn_mobilenet_v3_large_fpn`` + * ``fasterrcnn_resnet50_fpn_v2`` + numdigits: int, optional crop_size: tuple of int, optional @@ -583,6 +594,7 @@ def create_multianimaltraining_dataset( pose_config_path=path_train_config, net_type=net_type, top_down=top_down, + detector_type=detector_type, weight_init=weight_init, ) diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 9e33683d8f..2011af8ffe 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -777,6 +777,7 @@ def create_training_dataset( trainIndices=None, testIndices=None, net_type=None, + detector_type=None, augmenter_type=None, posecfg_template=None, superanimal_name="", @@ -830,8 +831,8 @@ def create_training_dataset( * ``efficientnet-b4`` * ``efficientnet-b5`` * ``efficientnet-b6`` - PyTorch (call ``deeplabcut.pose_estimation.available_models()`` for a - complete list) + PyTorch (call ``deeplabcut.pose_estimation_pytorch.available_models()`` for + a complete list) * ``resnet_50`` * ``resnet_101`` * ``hrnet_w18`` @@ -847,6 +848,16 @@ def create_training_dataset( * ``top_down_hrnet_w48`` * ``animaltokenpose_base`` + detector_type: string, optional, default=None + Only for the PyTorch engine. + When passing creating shuffles for top-down models, you can specify which + detector you want. If the detector_type is None, the ```ssdlite``` will be used. + The list of all available detectors can be obtained by calling + ``deeplabcut.pose_estimation_pytorch.available_detectors()``. Supported options: + * ``ssdlite`` + * ``fasterrcnn_mobilenet_v3_large_fpn`` + * ``fasterrcnn_resnet50_fpn_v2`` + augmenter_type: string, optional, default=None Type of augmenter. The options available depend on which engine is used. Currently supported options are: @@ -1272,6 +1283,7 @@ def create_training_dataset( pose_config_path=path_train_config, net_type=net_type, top_down=top_down, + detector_type=detector_type, weight_init=weight_init, ) @@ -1561,6 +1573,7 @@ def create_training_dataset_from_existing_split( shuffles: list[int] | None = None, userfeedback: bool = True, net_type: str | None = None, + detector_type: str | None = None, augmenter_type: str | None = None, posecfg_template: dict | None = None, superanimal_name: str = "", @@ -1609,6 +1622,17 @@ def create_training_dataset_from_existing_split( Currently supported options for engine=Engine.TF can be obtained by calling ``deeplabcut.pose_estimation_pytorch.available_models()``. + detector_type: string, optional, default=None + Only for the PyTorch engine. + When passing creating shuffles for top-down models, you can specify which + detector you want. If the detector_type is None, the ```ssdlite``` will be + used. The list of all available detectors can be obtained by calling + ``deeplabcut.pose_estimation_pytorch.available_detectors()``. Supported + options: + * ``ssdlite`` + * ``fasterrcnn_mobilenet_v3_large_fpn`` + * ``fasterrcnn_resnet50_fpn_v2`` + augmenter_type: Type of augmenter. Currently supported augmenters for engine=Engine.TF are * ``default`` @@ -1680,6 +1704,7 @@ def create_training_dataset_from_existing_split( trainIndices=[train_idx for _ in range(num_copies)], testIndices=[test_idx for _ in range(num_copies)], net_type=net_type, + detector_type=detector_type, augmenter_type=augmenter_type, posecfg_template=posecfg_template, superanimal_name=superanimal_name, diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index aa24b4c5db..7bed81a8fe 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -128,6 +128,18 @@ def _generate_layout_attributes(self, layout): lambda _: self.set_edit_table_visibility() ) + # Detector selection for top-down models + self.detector_label = QtWidgets.QLabel("Detector architecture") + self.detector_choice = QtWidgets.QComboBox() + self.detector_choice.setMinimumWidth(200) + self.update_detectors(engine=self.root.engine) + self.root.engine_change.connect( + lambda engine: self.update_detectors(engine=engine) + ) + self.net_choice.currentTextChanged.connect( + lambda new_net_choice: self.update_detectors(net_choice=new_net_choice) + ) + # Overwrite selection self.overwrite = QtWidgets.QCheckBox("Overwrite if exists") self.overwrite.setChecked(False) @@ -153,8 +165,11 @@ def _generate_layout_attributes(self, layout): layout.addWidget(augmentation_label, 1, 2) layout.addWidget(self.aug_choice, 1, 3) - layout.addWidget(self.overwrite, 2, 0) - layout.addWidget(self.data_split_selection, 3, 0) + layout.addWidget(self.detector_label, 2, 0) + layout.addWidget(self.detector_choice, 2, 1) + + layout.addWidget(self.overwrite, 3, 0) + layout.addWidget(self.data_split_selection, 4, 0) def log_net_choice(self, net): self.root.logger.info(f"Network architecture set to {net.upper()}") @@ -216,17 +231,22 @@ def create_training_dataset(self): else: try: engine = self.root.engine + net_type = self.net_choice.currentText() + detector_type = None if engine == Engine.TF: import tensorflow # try importing TF so they can't create shuffles for it if they # don't have it installed + elif engine == Engine.PYTORCH and "top_down" in net_type: + detector_type = self.detector_choice.currentText() if self.data_split_selection.selected: deeplabcut.create_training_dataset_from_existing_split( self.root.config, from_shuffle=self.data_split_selection.from_shuffle, shuffles=[self.shuffle.value()], - net_type=self.net_choice.currentText(), + net_type=net_type, + detector_type=detector_type, userfeedback=not overwrite, weight_init=weight_init, engine=engine, @@ -237,7 +257,8 @@ def create_training_dataset(self): self.root.config, shuffle, Shuffles=[self.shuffle.value()], - net_type=self.net_choice.currentText(), + net_type=net_type, + detector_type=detector_type, userfeedback=not overwrite, weight_init=weight_init, engine=engine, @@ -247,7 +268,8 @@ def create_training_dataset(self): self.root.config, shuffle, Shuffles=[self.shuffle.value()], - net_type=self.net_choice.currentText(), + net_type=net_type, + detector_type=detector_type, augmenter_type=self.aug_choice.currentText(), userfeedback=not overwrite, weight_init=weight_init, @@ -391,6 +413,42 @@ def update_nets(self, engine: Engine | None) -> None: if default_net in nets: self.net_choice.setCurrentIndex(nets.index(default_net)) + @Slot(Engine) + def update_detectors( + self, + engine: Engine | None = None, + net_choice: str | None = None, + ) -> None: + if engine is None: + engine = self.root.engine + + if engine == Engine.TF: + detectors = [] + else: + # FIXME: Circular imports make it impossible to import this at the top + from deeplabcut.pose_estimation_pytorch import available_detectors + detectors = available_detectors() + det_filter = self.get_detector_filter() + if det_filter is not None: + detectors = [d for d in detectors if d in det_filter] + + while self.detector_choice.count() > 0: + self.detector_choice.removeItem(0) + + self.detector_choice.addItems(detectors) + if "ssdlite" in detectors: + self.detector_choice.setCurrentIndex(detectors.index("ssdlite")) + + if net_choice is None: + net_choice = self.net_choice.currentText() + + if "top_down" in net_choice: + self.detector_label.show() + self.detector_choice.show() + else: + self.detector_label.hide() + self.detector_choice.hide() + @Slot(Engine) def update_aug_methods(self, engine: Engine) -> None: methods = compat.get_available_aug_methods(engine) @@ -422,6 +480,17 @@ def get_net_filter(self) -> list[str] | None: weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init] return weight_init_cfg["model_filter"] + def get_detector_filter(self) -> list[str] | None: + """Returns: the detectors that can be used based on weight initialization""" + if self.root.engine != Engine.PYTORCH: + return None + + if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS: + return None + + weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init] + return weight_init_cfg["detector_filter"] + def get_default_net(self) -> str | None: """Returns: the net type that can be used based on weight initialization""" if self.root.engine != Engine.PYTORCH: @@ -624,6 +693,7 @@ def _create_confirmation_box(title, description): _WEIGHT_INIT_OPTIONS = { # FIXME - Generate dynamically "Transfer Learning - ImageNet": { "model_filter": None, + "detector_filter": None, }, "Transfer Learning - SuperAnimal Quadruped": { "default_net": "top_down_hrnet_w32", @@ -631,6 +701,7 @@ def _create_confirmation_box(title, description): "dekr_w32", "hrnet_w32", ], + "detector_filter": ["fasterrcnn_resnet50_fpn_v2"], "super_animal": "superanimal_quadruped", }, "Transfer Learning - SuperAnimal TopViewMouse": { @@ -639,16 +710,19 @@ def _create_confirmation_box(title, description): "dekr_w32", "hrnet_w32", ], + "detector_filter": ["fasterrcnn_resnet50_fpn_v2"], "super_animal": "superanimal_topviewmouse", }, "Fine-tuning - SuperAnimal Quadruped": { "default_net": "top_down_hrnet_w32", "model_filter": ["hrnet_w32"], # FIXME - Add ResNet + "detector_filter": ["fasterrcnn_resnet50_fpn_v2"], "super_animal": "superanimal_quadruped", }, "Fine-tuning - SuperAnimal TopViewMouse": { "default_net": "top_down_hrnet_w32", "model_filter": ["hrnet_w32"], # FIXME - Add ResNet + "detector_filter": ["fasterrcnn_resnet50_fpn_v2"], "super_animal": "superanimal_topviewmouse", }, } diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py index 966cb7b390..2868f20228 100644 --- a/deeplabcut/pose_estimation_pytorch/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/__init__.py @@ -14,7 +14,10 @@ evaluate_network, train_network, ) -from deeplabcut.pose_estimation_pytorch.config import available_models +from deeplabcut.pose_estimation_pytorch.config import ( + available_detectors, + available_models, +) from deeplabcut.pose_estimation_pytorch.data.base import Loader from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader from deeplabcut.pose_estimation_pytorch.data.dataset import ( diff --git a/deeplabcut/pose_estimation_pytorch/config/__init__.py b/deeplabcut/pose_estimation_pytorch/config/__init__.py index 89d079ac75..5a67d87116 100644 --- a/deeplabcut/pose_estimation_pytorch/config/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/config/__init__.py @@ -12,6 +12,7 @@ make_pytorch_pose_config, ) from deeplabcut.pose_estimation_pytorch.config.utils import ( + available_detectors, available_models, pretty_print, read_config_as_dict, diff --git a/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_mobilenet_v3_large_fpn.yaml b/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_mobilenet_v3_large_fpn.yaml new file mode 100644 index 0000000000..ca9e121082 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_mobilenet_v3_large_fpn.yaml @@ -0,0 +1,48 @@ +data: + colormode: RGB + inference: + normalize_images: true + train: + affine: + p: 0.5 + rotation: 30 + scaling: [ 1.0, 1.0 ] + translation: 40 + collate: + type: ResizeFromDataSizeCollate + min_scale: 0.4 + max_scale: 1.0 + min_short_side: 128 + max_short_side: 1152 + multiple_of: 32 + to_square: false + hflip: true + normalize_images: true +device: auto +model: + type: FasterRCNN + freeze_bn_stats: true + freeze_bn_weights: false + variant: fasterrcnn_mobilenet_v3_large_fpn +runner: + type: DetectorTrainingRunner + eval_interval: 1 + optimizer: + type: AdamW + params: + lr: 1e-4 + scheduler: + type: LRListScheduler + params: + milestones: [ 160 ] + lr_list: [ [ 1e-5 ] ] + snapshots: + max_snapshots: 5 + save_epochs: 25 + save_optimizer_state: false +train_settings: + batch_size: 1 + dataloader_workers: 0 + dataloader_pin_memory: true + display_iters: 500 + epochs: 250 diff --git a/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_resnet50_fpn_v2.yaml b/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_resnet50_fpn_v2.yaml new file mode 100644 index 0000000000..5bbb416950 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_resnet50_fpn_v2.yaml @@ -0,0 +1,48 @@ +data: + colormode: RGB + inference: + normalize_images: true + train: + affine: + p: 0.5 + rotation: 30 + scaling: [ 1.0, 1.0 ] + translation: 40 + collate: + type: ResizeFromDataSizeCollate + min_scale: 0.4 + max_scale: 1.0 + min_short_side: 128 + max_short_side: 1152 + multiple_of: 32 + to_square: false + hflip: true + normalize_images: true +device: auto +model: + type: FasterRCNN + freeze_bn_stats: true + freeze_bn_weights: false + variant: fasterrcnn_resnet50_fpn_v2 +runner: + type: DetectorTrainingRunner + eval_interval: 1 + optimizer: + type: AdamW + params: + lr: 1e-4 + scheduler: + type: LRListScheduler + params: + milestones: [ 160 ] + lr_list: [ [ 1e-5 ] ] + snapshots: + max_snapshots: 5 + save_epochs: 25 + save_optimizer_state: false +train_settings: + batch_size: 1 + dataloader_workers: 0 + dataloader_pin_memory: true + display_iters: 500 + epochs: 250 diff --git a/deeplabcut/pose_estimation_pytorch/config/detectors/ssdlite.yaml b/deeplabcut/pose_estimation_pytorch/config/detectors/ssdlite.yaml new file mode 100644 index 0000000000..bd1989244e --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/config/detectors/ssdlite.yaml @@ -0,0 +1,47 @@ +data: + colormode: RGB + inference: + normalize_images: true + train: + affine: + p: 0.5 + rotation: 30 + scaling: [ 1.0, 1.0 ] + translation: 40 + collate: + type: ResizeFromDataSizeCollate + min_scale: 0.4 + max_scale: 1.0 + min_short_side: 128 + max_short_side: 1152 + multiple_of: 32 + to_square: false + hflip: true + normalize_images: true +device: auto +model: + type: SSDLite + freeze_bn_stats: true + freeze_bn_weights: false +runner: + type: DetectorTrainingRunner + eval_interval: 1 + optimizer: + type: AdamW + params: + lr: 1e-4 + scheduler: + type: LRListScheduler + params: + milestones: [ 160 ] + lr_list: [ [ 1e-5 ] ] + snapshots: + max_snapshots: 5 + save_epochs: 25 + save_optimizer_state: false +train_settings: + batch_size: 16 + dataloader_workers: 0 + dataloader_pin_memory: true + display_iters: 500 + epochs: 250 diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index 2719027c32..e5f8473fa0 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -31,6 +31,7 @@ def make_pytorch_pose_config( pose_config_path: str, net_type: str | None = None, top_down: bool = False, + detector_type: str | None = None, weight_init: WeightInitialization | None = None, ) -> dict: """Creates a PyTorch pose configuration file for a DeepLabCut project @@ -59,6 +60,8 @@ def make_pytorch_pose_config( by associating a detector to the pose model. Required for multi-animal projects when net_type is a backbone (as a backbone + heatmap head can only predict pose for single individuals). + detector_type: for top-down pose models, the architecture of the desired object + detection model weight_init: Specify how model weights should be initialized. If None, ImageNet pretrained weights from Timm will be loaded when training. @@ -114,22 +117,17 @@ def make_pytorch_pose_config( is_top_down = model_cfg.get("method", "BU").upper() == "TD" if is_top_down: - # FIXME(niels): Currently, the variant used is the default MobileNet. In the - # future, we want users to be able to choose which detector variant they use - # when creating the configuration file, instead of having to update it once - # created - variant = None if weight_init is not None: # FIXME(niels): We only have fasterrcnn_resnet50_fpn_v2 SuperAnimal weights. # This should be updated once more SuperAnimal detectors are uploaded, # so that users can choose which pre-trained detector they use. - variant = "fasterrcnn_resnet50_fpn_v2" + detector_type = "fasterrcnn_resnet50_fpn_v2" model_cfg = add_detector( configs_dir, model_cfg, len(individuals), - variant=variant, + detector_type=detector_type, ) # add the default augmentations to the config @@ -311,7 +309,7 @@ def add_detector( configs_dir: Path, config: dict, num_individuals: int, - variant: str | None = None + detector_type: str | None = None, ) -> dict: """Adds a detector to a model @@ -319,22 +317,24 @@ def add_detector( configs_dir: path to the DeepLabCut "configs" directory config: model configuration to update num_individuals: the maximum number of individuals the model should detect - variant: the detector variant to use (if None, uses the variant set in the - default detector.yaml config) + detector_type: the type of detector to use (if None, uses ``ssdlite``) Returns: the model configuration with an added detector config """ + if detector_type is None: + detector_type = "ssdlite" # default detector + + detector_type = detector_type.lower() config = copy.deepcopy(config) - detector_config = read_config_as_dict(configs_dir / "base" / "detector.yaml") + detector_config = read_config_as_dict( + configs_dir / "detectors" / f"{detector_type}.yaml" + ) detector_config = replace_default_values( detector_config, num_individuals=num_individuals, ) - if variant is not None: - detector_config["detector"]["model"]["variant"] = variant - - config = update_config(config, detector_config) + config["detector"] = detector_config return config diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py index 2898963205..23cebaa4c8 100644 --- a/deeplabcut/pose_estimation_pytorch/config/utils.py +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -175,6 +175,20 @@ def load_backbones(configs_dir: Path) -> list[str]: return backbones +def load_detectors(configs_dir: Path) -> list[str]: + """ + Args: + configs_dir: the Path to the folder containing the "configs" for PyTorch + DeepLabCut + + Returns: + all detectors that are available + """ + detector_dir = configs_dir / "detectors" + detectors = [p.stem for p in detector_dir.iterdir() if p.suffix == ".yaml"] + return detectors + + def read_config_as_dict(config_path: str | Path) -> dict: """ Args: @@ -252,3 +266,8 @@ def available_models() -> list[str]: models.add(variant) return list(sorted(models)) + + +def available_detectors() -> list[str]: + """Returns: all the possible detectors that can be used""" + return load_detectors(get_config_folder_path()) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py index f8633f009e..27f50f345a 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py @@ -13,3 +13,4 @@ BaseDetector, ) from deeplabcut.pose_estimation_pytorch.models.detectors.fasterRCNN import FasterRCNN +from deeplabcut.pose_estimation_pytorch.models.detectors.ssd import SSDLite diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py index 4b9cc634da..edfdbe8a23 100644 --- a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py @@ -10,17 +10,16 @@ # from __future__ import annotations -import torch import torchvision.models.detection as detection -from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( - DETECTORS, - BaseDetector, +from deeplabcut.pose_estimation_pytorch.models.detectors.base import DETECTORS +from deeplabcut.pose_estimation_pytorch.models.detectors.torchvision import ( + TorchvisionDetectorAdaptor, ) @DETECTORS.register_module -class FasterRCNN(BaseDetector): +class FasterRCNN(TorchvisionDetectorAdaptor): """A FasterRCNN detector Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks @@ -30,20 +29,12 @@ class FasterRCNN(BaseDetector): This class is a wrapper of the torchvision implementation of a FasterRCNN (source: https://github.com/pytorch/vision/blob/main/torchvision/models/detection/faster_rcnn.py). - Any variant implemented in torchvision can be used through this wrapper (see - available models at https://pytorch.org/vision/stable/models.html#object-detection). - Some of the variants (from fastest to most powerful) available: + Some of the available FasterRCNN variants (from fastest to most powerful): - fasterrcnn_mobilenet_v3_large_fpn - fasterrcnn_resnet50_fpn - fasterrcnn_resnet50_fpn_v2 - The torchvision implementation does not allow to get both predictions and losses - with a single forward pass. Therefore, during evaluation only bounding box metrics - (mAP, mAR) are available for the test set. See validation loss issue: - - https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12 - - https://stackoverflow.com/a/65347721 - Args: variant: The FasterRCNN variant to use (see all options at https://pytorch.org/vision/stable/models.html#object-detection). @@ -57,7 +48,7 @@ def __init__( freeze_bn_stats: bool = False, freeze_bn_weights: bool = False, variant: str = "fasterrcnn_mobilenet_v3_large_fpn", - pretrained: bool = True, + pretrained: bool = False, box_score_thresh: float = 0.01, ) -> None: if not variant.lower().startswith("fasterrcnn"): @@ -67,17 +58,13 @@ def __init__( ) super().__init__( + model=variant, + weights=("COCO_V1" if pretrained else None), + num_classes=None, freeze_bn_stats=freeze_bn_stats, freeze_bn_weights=freeze_bn_weights, - pretrained=pretrained, + box_score_thresh=box_score_thresh, ) - model_fn = getattr(detection, variant) - weights = None - if self._pretrained: - weights = "COCO_V1" - - # Load the model - self.model = model_fn(weights=weights, box_score_thresh=box_score_thresh) # Modify the base predictor to output the correct number of classes num_classes = 2 @@ -85,84 +72,3 @@ def __init__( self.model.roi_heads.box_predictor = detection.faster_rcnn.FastRCNNPredictor( in_features, num_classes ) - - # See source: https://stackoverflow.com/a/65347721 - self.model.eager_outputs = lambda losses, detections: (losses, detections) - - def forward( - self, x: torch.Tensor, targets: list[dict[str, torch.Tensor]] | None = None - ) -> tuple[dict[str, torch.Tensor], list[dict[str, torch.Tensor]]]: - """ - Forward pass of the Faster R-CNN - - Args: - x: images to be processed, of shape (b, c, h, w) - targets: ground-truth boxes present in the images - - Returns: - losses: {'loss_name': loss_value} - detections: for each of the b images, {"boxes": bounding_boxes} - """ - return self.model(x, targets) - - def get_target(self, labels: dict) -> list[dict[str, torch.Tensor]]: - """ - Returns target in a format FasterRCNN can handle - - Args: - labels: dict of annotations, must contain the keys: - area: tensor containing area information for each annotation - labels: tensor containing class labels for each annotation - is_crowd: tensor indicating if each annotation is a crowd (1) or not (0) - image_id: tensor containing image ids for each annotation - boxes: tensor containing bounding box information for each annotation - - Returns: - res: list of dictionaries, each representing target information for a single annotation. - Each dictionary contains the following keys: - 'area' - 'labels' - 'is_crowd' - 'boxes' - - Examples: - input: - annotations = {"area": torch.Tensor([100, 200]), - "labels": torch.Tensor([1, 2]), - "is_crowd": torch.Tensor([0, 1]), - "boxes": torch.Tensor([[10, 20, 30, 40], [50, 60, 70, 80]])} - output: - res = [ - { - 'area': tensor([100.]), - 'labels': tensor([1]), - 'image_id': tensor([1]), - 'is_crowd': tensor([0]), - 'boxes': tensor([[10., 20., 40., 60.]]) - }, - { - 'area': tensor([200.]), - 'labels': tensor([2]), - 'image_id': tensor([1]), - 'is_crowd': tensor([1]), - 'boxes': tensor([[50., 60., 70., 80.]]) - } - ] - """ - res = [] - for i, box_ann in enumerate(labels["boxes"]): - mask = (box_ann[:, 2] > 0.0) & (box_ann[:, 3] > 0.0) - box_ann = box_ann[mask] - # bbox format conversion (x, y, w, h) -> (x1, y1, x2, y2) - box_ann[:, 2] += box_ann[:, 0] - box_ann[:, 3] += box_ann[:, 1] - res.append( - { - "area": labels["area"][i][mask], - "labels": labels["labels"][i][mask], - "is_crowd": labels["is_crowd"][i][mask], - "boxes": box_ann, - } - ) - - return res diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/ssd.py b/deeplabcut/pose_estimation_pytorch/models/detectors/ssd.py new file mode 100644 index 0000000000..3c8a254b71 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/ssd.py @@ -0,0 +1,70 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from __future__ import annotations + +import torchvision.models.detection as detection + +from deeplabcut.pose_estimation_pytorch.models.detectors.base import DETECTORS +from deeplabcut.pose_estimation_pytorch.models.detectors.torchvision import ( + TorchvisionDetectorAdaptor, +) + + +@DETECTORS.register_module +class SSDLite(TorchvisionDetectorAdaptor): + """An SSD object detection model""" + + def __init__( + self, + freeze_bn_stats: bool = False, + freeze_bn_weights: bool = False, + pretrained: bool = False, + pretrained_from_imagenet: bool = False, + box_score_thresh: float = 0.01, + ) -> None: + model_kwargs = dict(weights_backbone=None) + if pretrained_from_imagenet: + model_kwargs["weights_backbone"] = "IMAGENET1K_V2" + + super().__init__( + model="ssdlite320_mobilenet_v3_large", + weights=None, + num_classes=2, + freeze_bn_stats=freeze_bn_stats, + freeze_bn_weights=freeze_bn_weights, + box_score_thresh=box_score_thresh, + model_kwargs=model_kwargs, + ) + + if pretrained and not pretrained_from_imagenet: + weights = detection.SSDLite320_MobileNet_V3_Large_Weights.verify("COCO_V1") + state_dict = weights.get_state_dict(progress=False, check_hash=True) + for k, v in state_dict.items(): + key_parts = k.split(".") + if ( + len(key_parts) == 6 + and key_parts[0] == "head" + and key_parts[1] == "classification_head" + and key_parts[2] == "module_list" + and key_parts[4] == "1" + and key_parts[5] in ("weight", "bias") + ): + # number of COCO classes: 90 + background (91) + # number of DLC classes: 1 + background (2) + # -> only keep weights for the background + first class + + # future improvement: find best-suited class for the project + # and use those weights, instead of naively taking the first + all_classes_size = v.shape[0] + two_classes_size = 2 * (all_classes_size // 91) + state_dict[k] = v[:two_classes_size] + + self.model.load_state_dict(state_dict) diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/torchvision.py b/deeplabcut/pose_estimation_pytorch/models/detectors/torchvision.py new file mode 100644 index 0000000000..6c700377f7 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/detectors/torchvision.py @@ -0,0 +1,162 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Module to adapt torchvision detectors for DeepLabCut""" +from __future__ import annotations + +import torch +import torchvision.models.detection as detection + +from deeplabcut.pose_estimation_pytorch.models.detectors.base import ( + BaseDetector, +) + + +class TorchvisionDetectorAdaptor(BaseDetector): + """An adaptor for torchvision detectors + + This class is an adaptor for torchvision detectors to DeepLabCut detectors. Some of + the models (from fastest to most powerful) available are: + - ssdlite320_mobilenet_v3_large + - fasterrcnn_mobilenet_v3_large_fpn + - fasterrcnn_resnet50_fpn_v2 + + This class should not be used out-of-the-box. Subclasses (such as FasterRCNN or + SSDLite) should be used instead. + + The torchvision implementation does not allow to get both predictions and losses + with a single forward pass. Therefore, during evaluation only bounding box metrics + (mAP, mAR) are available for the test set. See validation loss issue: + - https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12 + - https://stackoverflow.com/a/65347721 + + Args: + model: The torchvision model to use (see all options at + https://pytorch.org/vision/stable/models.html#object-detection). + weights: The weights to load for the model. If None, no pre-trained weights are + loaded. + num_classes: Number of classes that the model should output. If None, the number + of classes the model is pre-trained on is used. + freeze_bn_stats: Whether to freeze stats for BatchNorm layers. + freeze_bn_weights: Whether to freeze weights for BatchNorm layers. + box_score_thresh: during inference, only return proposals with a classification + score greater than box_score_thresh + """ + + def __init__( + self, + model: str, + weights: str | None = None, + num_classes: int | None = 2, + freeze_bn_stats: bool = False, + freeze_bn_weights: bool = False, + box_score_thresh: float = 0.01, + model_kwargs: dict | None = None, + ) -> None: + super().__init__( + freeze_bn_stats=freeze_bn_stats, + freeze_bn_weights=freeze_bn_weights, + pretrained=weights is not None, + ) + + # Load the model + model_fn = getattr(detection, model) + if model_kwargs is None: + model_kwargs = {} + + self.model = model_fn( + weights=weights, + box_score_thresh=box_score_thresh, + num_classes=num_classes, + **model_kwargs, + ) + + # See source: https://stackoverflow.com/a/65347721 + self.model.eager_outputs = lambda losses, detections: (losses, detections) + + def forward( + self, x: torch.Tensor, targets: list[dict[str, torch.Tensor]] | None = None + ) -> tuple[dict[str, torch.Tensor], list[dict[str, torch.Tensor]]]: + """ + Forward pass of the torchvision detector + + Args: + x: images to be processed, of shape (b, c, h, w) + targets: ground-truth boxes present in the images + + Returns: + losses: {'loss_name': loss_value} + detections: for each of the b images, {"boxes": bounding_boxes} + """ + return self.model(x, targets) + + def get_target(self, labels: dict) -> list[dict[str, torch.Tensor]]: + """ + Returns target in a format a torchvision detector can handle + + Args: + labels: dict of annotations, must contain the keys: + area: tensor containing area information for each annotation + labels: tensor containing class labels for each annotation + is_crowd: tensor indicating if each annotation is a crowd (1) or not (0) + image_id: tensor containing image ids for each annotation + boxes: tensor containing bounding box information for each annotation + + Returns: + res: list of dictionaries, each representing target information for a single + annotation. Each dictionary contains the following keys: + 'area' + 'labels' + 'is_crowd' + 'boxes' + + Examples: + input: + annotations = { + "area": torch.Tensor([100, 200]), + "labels": torch.Tensor([1, 2]), + "is_crowd": torch.Tensor([0, 1]), + "boxes": torch.Tensor([[10, 20, 30, 40], [50, 60, 70, 80]]) + } + output: + res = [ + { + 'area': tensor([100.]), + 'labels': tensor([1]), + 'image_id': tensor([1]), + 'is_crowd': tensor([0]), + 'boxes': tensor([[10., 20., 40., 60.]]) + }, + { + 'area': tensor([200.]), + 'labels': tensor([2]), + 'image_id': tensor([1]), + 'is_crowd': tensor([1]), + 'boxes': tensor([[50., 60., 70., 80.]]) + } + ] + """ + res = [] + for i, box_ann in enumerate(labels["boxes"]): + mask = (box_ann[:, 2] > 0.0) & (box_ann[:, 3] > 0.0) + box_ann = box_ann[mask] + # bbox format conversion (x, y, w, h) -> (x1, y1, x2, y2) + box_ann[:, 2] += box_ann[:, 0] + box_ann[:, 3] += box_ann[:, 1] + res.append( + { + "area": labels["area"][i][mask], + "labels": labels["labels"][i][mask].long(), + "is_crowd": labels["is_crowd"][i][mask].long(), + "boxes": box_ann, + } + ) + + return res diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py index d7b0b44239..aaa1f6566b 100644 --- a/tests/pose_estimation_pytorch/config/test_make_pose_config.py +++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py @@ -131,17 +131,28 @@ def test_backbone_plus_paf_config( assert id_head["target_generator"]["heatmap_mode"] == "INDIVIDUAL" +@pytest.mark.parametrize( + "detector", + [ + (None, "SSDLite"), + ("ssdlite", "SSDLite"), + ("fasterrcnn_mobilenet_v3_large_fpn", "FasterRCNN"), + ("fasterrcnn_resnet50_fpn_v2", "FasterRCNN"), + ] +) @pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]]) @pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]]) @pytest.mark.parametrize( "net_type", ["resnet_50", "resnet_101", "hrnet_w18", "hrnet_w32", "hrnet_w48"] ) def test_top_down_config( + detector: tuple[str, str], individuals: list[str], bodyparts: list[str], net_type: str, ): # Single animal projects can't have unique bodyparts + detector_type, expected_detector_type = detector project_config = _make_project_config( project_path="my/little/project", multianimal=True, @@ -155,6 +166,7 @@ def test_top_down_config( "pytorch_config.yaml", net_type=net_type, top_down=True, + detector_type=detector_type, ) pretty_print(pytorch_pose_config) @@ -167,6 +179,10 @@ def test_top_down_config( assert "bodypart" in pytorch_pose_config["model"]["heads"].keys() bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"] + # check detector is there + assert "detector" in pytorch_pose_config.keys() + assert pytorch_pose_config["detector"]["model"]["type"] == expected_detector_type + for name, output_channels in [ ("heatmap_config", len(bodyparts)), ]: From 7f15ffb30fcc41679ed18061f2f861a07b5d4dab Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:48:32 +0200 Subject: [PATCH 201/293] backwards compatibility (#2681) --- .../multiple_individuals_trainingsetmanipulation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index a6b4432003..366d283f52 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -580,6 +580,10 @@ def create_multianimaltraining_dataset( from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config from deeplabcut.pose_estimation_pytorch.modelzoo.config import make_super_animal_finetune_config + # backwards compatibility with version 2.X + if net_type == "dlcrnet_ms5": + net_type = "dlcrnet_stride16_ms5" + pose_cfg_path = path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml") if weight_init is not None and weight_init.with_decoder: pytorch_cfg = make_super_animal_finetune_config( From dc908b92a4c7224a1d5b3eb2497386f9ad02c5b3 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 19 Jul 2024 17:11:09 +0200 Subject: [PATCH 202/293] evaluation refactor (#2679) - Moved all metric computation code to a deeplabcut/core/metrics folder (as metrics are computed with numpy) - Cleaned metric computation code so the prediction/ground truth matching always happens - Refactored in a way such that no OOM errors should occur, even on very large datasets (>60k images) - Multi-animal RMSE: only compute RMSE using (ground-truth, detection) matches with non-zero RMSE - Add compute_detection_rmse to compute "detection" RMSE, matching the DeepLabCut 2.X implementation - Fixed the bug for PAF models documented in #2631 --- deeplabcut/core/metrics/__init__.py | 13 + deeplabcut/core/metrics/api.py | 161 +++++++ .../metrics/bbox.py | 24 +- deeplabcut/core/metrics/distance_metrics.py | 295 +++++++++++++ deeplabcut/core/metrics/identity.py | 86 ++++ deeplabcut/core/metrics/matching.py | 169 ++++++++ .../pose_estimation_pytorch/apis/evaluate.py | 19 +- .../metrics/__init__.py | 1 - .../metrics/scoring.py | 340 --------------- .../models/predictors/paf_predictor.py | 10 +- .../models/predictors/single_predictor.py | 26 +- .../models/predictors/utils.py | 15 +- .../match_predictions_to_gt.py | 21 +- .../pose_estimation_pytorch/runners/train.py | 78 +--- .../test_meitrcs_identity_accuracy.py} | 42 +- tests/core/metrics/test_metrics_api.py | 65 +++ .../metrics/test_metrics_map_computation.py | 403 ++++++++++++++++++ .../metrics/test_metrics_rmse_computation.py | 201 +++++++++ 18 files changed, 1500 insertions(+), 469 deletions(-) create mode 100644 deeplabcut/core/metrics/__init__.py create mode 100644 deeplabcut/core/metrics/api.py rename deeplabcut/{pose_estimation_pytorch => core}/metrics/bbox.py (79%) create mode 100644 deeplabcut/core/metrics/distance_metrics.py create mode 100644 deeplabcut/core/metrics/identity.py create mode 100644 deeplabcut/core/metrics/matching.py rename tests/{pose_estimation_pytorch/metrics/test_scoring.py => core/metrics/test_meitrcs_identity_accuracy.py} (84%) create mode 100644 tests/core/metrics/test_metrics_api.py create mode 100644 tests/core/metrics/test_metrics_map_computation.py create mode 100644 tests/core/metrics/test_metrics_rmse_computation.py diff --git a/deeplabcut/core/metrics/__init__.py b/deeplabcut/core/metrics/__init__.py new file mode 100644 index 0000000000..94397de57a --- /dev/null +++ b/deeplabcut/core/metrics/__init__.py @@ -0,0 +1,13 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +from .api import compute_metrics, prepare_evaluation_data +from .bbox import compute_bbox_metrics +from .identity import compute_identity_scores diff --git a/deeplabcut/core/metrics/api.py b/deeplabcut/core/metrics/api.py new file mode 100644 index 0000000000..c15fe2cc7e --- /dev/null +++ b/deeplabcut/core/metrics/api.py @@ -0,0 +1,161 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""API methods to get metrics for deep learning models""" +from __future__ import annotations + +import numpy as np + +import deeplabcut.core.metrics.distance_metrics as distance_metrics + + +def compute_metrics( + ground_truth: dict[str, np.ndarray], + predictions: dict[str, np.ndarray], + single_animal: bool = False, + unique_bodypart_gt: dict[str, np.ndarray] | None = None, + unique_bodypart_poses: dict[str, np.ndarray] | None = None, + pcutoff: float = -1, + oks_bbox_margin: int = 0, + oks_sigma: float = 0.1, +) -> dict: + """Computes pose estimation performance metrics + + Given ground truth pose labels and predictions on a dataset, computes RMSE and pose + mAP/mAR using OKS. + + The image paths in the ground_truth dict must be the same as the ones in the + predictions dict. + + Single animal RMSE is computed by simply calculating the Euclidean distance between + each ground truth keypoint and the corresponding prediction. + + Multi-animal RMSE is computed differently: predictions are first matched to ground + truth individuals using greedy OKS matching. OKS (or object keypoint similarity) is + a similarity metric for keypoints (you can read more about it and its definition + here: https://cocodataset.org/#keypoints-eval). RMSE is then computed only between + predictions and the ground truth pose they are matched to, only when the OKS is + greater than a small threshold. Predictions that cannot be matched to any ground + truth with non-zero OKS are not used to compute RMSE. + + Args: + ground_truth: The ground truth pose for which to compute metrics in the dataset. + This should be a dictionary mapping strings (image UIDs, such as image + paths) to ground truth pose for the image. The pose arrays should be + in the format (num_individuals, num_bodyparts, 3), where the 3 values are + x, y and visibility. The ``num_individuals`` corresponds to the number of + individuals labeled in each image. + predictions: The predicted poses for which to compute metrics in the dataset. + This should be a dictionary mapping strings (image UIDs, such as image + paths) to pose predictions for the image. The pose arrays should be + in the format (num_predictions, num_bodyparts, 3), where the 3 values are + x, y and score. The number of predictions can be different to the number of + ground truth individuals labeled for an image. + single_animal: Whether the metrics are being computed on a single-animal or + multi-animal dataset. This has an impact on RMSE computation. + unique_bodypart_gt: If unique bodyparts are defined for the dataset, they should + be contained in this dict in the same format as the ``ground_truth`` dict. + unique_bodypart_poses: If unique bodyparts are defined for the dataset, the + predictions should be contained in this dict in the same format as the + ``predictions`` dict. + pcutoff: The threshold to compute the "rmse_cutoff" score (RMSE of all + predictions with score above the cutoff). + oks_bbox_margin: The margin to add around keypoints to compute the area for OKS + computation. + oks_sigma: The OKS sigma to use to compute pose. + + Returns: + A dictionary containing keys "rmse", "rmse_cutoff", "mAP" and "mAR" mapping + to those metrics on the given dataset. + + If unique bodyparts are given, two extra keys "rmse_unique_bodyparts" and + "rmse_pcutoff_unique_bodyparts" are also returned, containing the metrics for + the unique bodyparts head. + + Examples: + >>> # Define the p-cutoff, prediction, and target DataFrames + >>> pcutoff = 0.5 + >>> ground_truth = {"img0": np.array([[[1.0, 1.0, 2.0], ...], ...]), ...} + >>> predictions = {"img0": np.array([[[2.0, 1.0, 0.4], ...], ...]), ...} + >>> scores = compute_metrics(ground_truth, predictions, pcutoff=pcutoff) + >>> print(scores) + { + "rmse": 1.0, + "rmse_pcutoff": 0.0, + 'mAP': 84.2, + 'mAR': 74.5 + } # Sample output scores + """ + data = prepare_evaluation_data(ground_truth, predictions) + rmse, rmse_pcutoff = distance_metrics.compute_rmse(data, single_animal, pcutoff) + oks_scores = distance_metrics.compute_oks( + data=data, + oks_sigma=oks_sigma, + oks_bbox_margin=oks_bbox_margin, + ) + results = dict(rmse=rmse, rmse_pcutoff=rmse_pcutoff, **oks_scores) + + if not single_animal: + det_rmse, det_rmse_p = distance_metrics.compute_detection_rmse(data, pcutoff) + results["rmse_detections"] = det_rmse + results["rmse_detections_pcutoff"] = det_rmse_p + + if unique_bodypart_gt is not None: + # TODO: We should integrate unique bodyparts to main RMSE computation + assert unique_bodypart_poses is not None + unique_bpt = prepare_evaluation_data(unique_bodypart_gt, unique_bodypart_poses) + unique_bpt_metrics = distance_metrics.compute_rmse(unique_bpt, True, pcutoff) + results["rmse_unique_bpts"] = unique_bpt_metrics[0] + results["rmse_unique_bpts_pcutoff"] = unique_bpt_metrics[1] + + return results + + +def prepare_evaluation_data( + ground_truth: dict[str, np.ndarray], + predictions: dict[str, np.ndarray], +) -> list[tuple[np.ndarray, np.ndarray]]: + """Prepares predictions and ground truth pose to compute metrics. + + Only keeps ground truth and predicted assemblies with at least 2 valid keypoints. + Sets the coordinates for all keypoints that aren't visible (for ground truth, + visibility <= 0 and for predictions score <= 0) to ``np.nan``. + + Sorts valid predictions by score. + + Args: + ground_truth: For each image, the GT of shape (n_idv, n_bpt, 3). + predictions: For each image, the pose predictions of shape (n_pred, n_bpt, 3). + + Returns: + A list containing (ground truth pose, predicted pose) for each image in the + dataset, where the predicted pose is sorted from highest to lowest score. + """ + pose_data = [] + for image, gt in ground_truth.items(): + gt = gt.copy() + gt[gt[..., 2] <= 0] = np.nan + + # only keep ground truth pose with at least one keypoint + gt_mask = np.any(np.all(~np.isnan(gt), axis=-1), axis=-1) + gt = gt[gt_mask] + + pred = predictions[image][..., :3].copy() # PAF have 5 values; keep xy + score + pred[pred[..., 2] < 0] = np.nan + + # only keep predicted pose with at least two keypoints + pred_mask = np.any(np.all(~np.isnan(pred), axis=-1), axis=-1) + pred = pred[pred_mask] + + scores = np.nanmean(pred[:, :, 2], axis=-1) + pred_order = np.argsort(-scores, kind="mergesort") + pose_data.append((gt, pred[pred_order])) + + return pose_data diff --git a/deeplabcut/pose_estimation_pytorch/metrics/bbox.py b/deeplabcut/core/metrics/bbox.py similarity index 79% rename from deeplabcut/pose_estimation_pytorch/metrics/bbox.py rename to deeplabcut/core/metrics/bbox.py index c3edf73a60..d2385a7ff1 100644 --- a/deeplabcut/pose_estimation_pytorch/metrics/bbox.py +++ b/deeplabcut/core/metrics/bbox.py @@ -20,6 +20,7 @@ try: from pycocotools.coco import COCO from pycocotools.cocoeval import COCOeval + with_pycocotools = True except ModuleNotFoundError as err: with_pycocotools = False @@ -29,14 +30,23 @@ def compute_bbox_metrics( ground_truth: dict[str, dict], detections: dict[str, dict], ) -> dict[str, float]: - """Computes bbox mAP and mAR metrics + """Computes bbox mAP and mAR metrics for bounding boxes. Args: - ground_truth: - detections: + ground_truth: A dictionary mapping image UIDs (such as image paths or filenames) + to a ground truth labels dict. The labels dict should contain the keys + "width" (image width), "height" (image height) and "bboxes" (a numpy array + of shape (num_gt_bboxes, 4) containing the ground truth bounding boxes in + format xywh). + detections: A dictionary mapping image UIDs (such as image paths or filenames) + to a predicted bounding box dict. The detections dict should contain the + keys "bboxes" (a numpy array of shape (num_detected_bboxes, 4) containing + the predicted bounding boxes in format xywh) and "scores" (a numpy array of + length num_detected_bboxes containing the confidence score for each + predicted bounding box). Returns: - the bounding box metrics + The bounding box mAP/mAR metrics in a dictionary. Raises: ModuleNotFoundError: if ``pycocotools`` is not installed @@ -115,7 +125,7 @@ def _get_metric( area_rng: str = "all", max_dets: int = 100, ) -> tuple[str, float]: - metric_name = 'mAR' if recall else 'mAP' + metric_name = "mAR" if recall else "mAP" if iou_threshold is not None: thresh = f"{int(100 * iou_threshold)}" else: @@ -125,13 +135,13 @@ def _get_metric( aind = [i for i, aRng in enumerate(coco_eval.params.areaRngLbl) if aRng == area_rng] mind = [i for i, mDet in enumerate(coco_eval.params.maxDets) if mDet == max_dets] if recall: - s = coco_eval.eval['recall'] + s = coco_eval.eval["recall"] if iou_threshold is not None: t = np.where(iou_threshold == coco_eval.params.iouThrs)[0] s = s[t] s = s[:, :, aind, mind] else: - s = coco_eval.eval['precision'] + s = coco_eval.eval["precision"] if iou_threshold is not None: t = np.where(iou_threshold == coco_eval.params.iouThrs)[0] s = s[t] diff --git a/deeplabcut/core/metrics/distance_metrics.py b/deeplabcut/core/metrics/distance_metrics.py new file mode 100644 index 0000000000..85c52ff9e0 --- /dev/null +++ b/deeplabcut/core/metrics/distance_metrics.py @@ -0,0 +1,295 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Implementations of methods to compute distance metrics such as RMSE or OKS""" +from __future__ import annotations + +import numpy as np + +import deeplabcut.core.metrics.matching as matching +from deeplabcut.core.crossvalutils import find_closest_neighbors +from deeplabcut.core.inferenceutils import calc_object_keypoint_similarity + + +def compute_oks_matrix( + ground_truth: np.ndarray, + predictions: np.ndarray, + oks_sigma: float, + oks_bbox_margin: float = 0.0, +) -> np.ndarray: + """Computes the OKS score for each (prediction, gt) pair in an image + + Args: + ground_truth: The GT poses for an image, shape (n_individuals, n_kpts, 2) + predictions: The predicted poses in the image, shape (n_pred, n_kpts, 2) + oks_sigma: The sigma value to use to compute OKS + oks_bbox_margin: The margin to add around keypoints when computing the area. + FIXME(niels) We should allow the use of ground truth bboxes to get area + + Returns: + A matrix of shape (n_pred, n_kpts) where entry (i, j) is the OKS between + prediction i and ground truth j. + """ + oks_matrix = np.zeros((len(predictions), len(ground_truth))) + for pred_idx, pred in enumerate(predictions): + for gt_idx, gt in enumerate(ground_truth): + oks_matrix[pred_idx, gt_idx] = calc_object_keypoint_similarity( + pred[:, :2], + gt[:, :2], + sigma=oks_sigma, + margin=oks_bbox_margin, + ) + + return oks_matrix + + +def compute_oks( + data: list[tuple[np.ndarray, np.ndarray]], + oks_bbox_margin: float = 0.0, + oks_sigma: float = 0.1, + oks_thresholds: np.ndarray | None = None, + oks_recall_thresholds: np.ndarray | None = None, +) -> dict[str, float]: + """Computes the OKS for pose at different thresholds. + + Args: + data: The data for which to compute OKS mAP: a list containing (gt_poses, + predicted_poses) tuples, where gt_pose is an array of shape + (num_gt_individuals, num_bpts, 3) and predicted_poses is an array of shape + (num_predictions, num_bpts, 3). For the GT, the 3 coordinates are (x, y, + visibility) while for the pose they are (x, y, confidence score). + oks_sigma: The OKS sigma to use to compute pose. + oks_bbox_margin: The margin to add around keypoints to compute the area for OKS + computation. + oks_thresholds: The OKS thresholds at which to compute AP. If None, defaults to + (0.5, 0.55, 0.6, ..., 0.9, 0.95). + oks_recall_thresholds: The recall thresholds to use to compute mAP. If None, + defaults to the same default values used in pycocotools. + + Returns: + A dictionary containing mAP and mAR scores. + """ + if oks_thresholds is None: + oks_thresholds = np.linspace(0.5, 0.95, 10) + + if oks_recall_thresholds is None: + oks_recall_thresholds = np.linspace( + start=0.0, + stop=1.00, + num=int(np.round((1.00 - 0.0) / 0.01)) + 1, + endpoint=True, + ) + + total_gt = 0 + pose_data = [] + for gt, pred in data: + # filter data to only keep individuals with at least 2 valid keypoints + gt = gt[np.sum(np.all(~np.isnan(gt), axis=-1), axis=-1) > 1] + pred = pred[np.sum(np.all(~np.isnan(pred), axis=-1), axis=-1) > 1] + + oks_matrix = compute_oks_matrix( + gt[:, :, :2], + pred[:, :, :2], + oks_sigma=oks_sigma, + oks_bbox_margin=oks_bbox_margin, + ) + + total_gt += len(gt) + pose_data.append((gt, pred, oks_matrix)) + + precisions, recalls = [], [] + for oks_threshold in oks_thresholds: + matches = [] + for gt, pred, oks_matrix in pose_data: + image_matches = matching.match_greedy_oks( + gt, + pred, + oks_matrix=oks_matrix, + oks_threshold=oks_threshold, + ) + matches.extend(image_matches) + + if len(matches) == 0: # no predictions -> precision 0, recall 0 + return {"mAP": 0, "mAR": 0} + + scores = np.asarray([m.score for m in matches]) + match_order = np.argsort(-scores, kind="mergesort") + oks_values = np.asarray([m.oks for m in matches]) + oks_values = oks_values[match_order] + + tp = np.cumsum(oks_values >= oks_threshold) + fp = np.cumsum(oks_values < oks_threshold) + rc = tp / total_gt + pr = tp / (fp + tp + np.spacing(1)) + recall = rc[-1] + + # Guarantee precision decreases monotonically, see + # https://jonathan-hui.medium.com/map-mean-average-precision-for-object-detection-45c121a31173 + for i in range(len(pr) - 1, 0, -1): + if pr[i] > pr[i - 1]: + pr[i - 1] = pr[i] + + inds_rc = np.searchsorted(rc, oks_recall_thresholds, side="left") + precision = np.zeros(inds_rc.shape) + valid = inds_rc < len(pr) + precision[valid] = pr[inds_rc[valid]] + + precisions.append(precision) + recalls.append(recall) + + precisions = np.asarray(precisions) + recalls = np.asarray(recalls) + return { + "mAP": 100 * precisions.mean().item(), + "mAR": 100 * recalls.mean().item(), + } + + +def compute_rmse( + data: list[tuple[np.ndarray, np.ndarray]], + single_animal: bool, + pcutoff: float, + oks_bbox_margin: float = 0.0, +) -> tuple[float, float]: + """Computes the RMSE for pose predictions. + + Single animal RMSE is computed by simply calculating the distance between each + ground truth keypoint and the corresponding prediction. + + Multi-animal RMSE is computed differently: predictions are first matched to ground + truth individuals using greedy OKS matching. RMSE is then computed only between + predictions and the ground truth pose they are matched to, only when the OKS is + non-zero (greater than a small threshold). Predictions that cannot be matched to + any ground truth with non-zero OKS are not used to compute RMSE. + + Args: + data: The data for which to compute RMSE. This is a list containing (gt_poses, + predicted_poses), where gt_pose is an array of shape (num_gt_individuals, + num_bpts, 3) and predicted_poses is an array of shape (num_predictions, + num_bpts, 3). For the GT, the 3 coordinates are (x, y, visibility) while for + the pose they are (x, y, confidence score). + single_animal: Whether this is a single animal dataset. + pcutoff: The p-cutoff to use to compute RMSE. + oks_bbox_margin: When single_animal is False, predictions are matched to GT + using OKS. This is the margin used to apply when computing the bbox from + the pose to compute OKS. + + Returns: + The RMSE and RMSE after removing all detections with a score below the pcutoff. + + Raises: + AssertionError + """ + matches = [] + for gt, pred in data: + if single_animal: + if gt.shape[0] > 1 or pred.shape[0] > 1: + raise ValueError( + "At most 1 individual and 1 prediction can be given when computing " + f"single animal RMSE. Found gt={gt.shape}, pred={pred.shape}" + ) + + image_matches = [] + if gt.shape[0] == 1 and pred.shape[0] == 1: + match = matching.PotentialMatch.from_pose(pred[0]) + match.match(gt[0], oks=float("nan")) # OKS not needed for RMSE + image_matches.append(match) + else: + oks_matrix = compute_oks_matrix( + gt[:, :, :2], + pred[:, :, :2], + oks_sigma=0.1, + oks_bbox_margin=oks_bbox_margin, + ) + image_matches = matching.match_greedy_oks( + gt, + pred, + oks_matrix=oks_matrix, + oks_threshold=1e-6, + ) + + matches.extend(image_matches) + + rmse, rmse_cutoff = float("nan"), float("nan") + if len(matches) == 0: + return rmse, rmse_cutoff + + pixel_errors = np.stack([m.pixel_errors() for m in matches]) + if np.any(~np.isnan(pixel_errors)): + rmse = np.nanmean(pixel_errors).item() + + keypoint_scores = np.stack([m.keypoint_scores() for m in matches]) + pixel_errors_cutoff = pixel_errors[keypoint_scores >= pcutoff] + if np.any(~np.isnan(pixel_errors_cutoff)): + rmse_cutoff = np.nanmean(pixel_errors_cutoff).item() + + return rmse, rmse_cutoff + + +def compute_detection_rmse( + data: list[tuple[np.ndarray, np.ndarray]], + pcutoff: float, +) -> tuple[float, float]: + """Computes the detection RMSE for pose predictions. + + The detection RMSE score does not take individual assemblies into account. It only + judges the performance of the detections, matching each predicted keypoint to the + closest ground truth for each bodypart. + + This is the same way multi-animal RMSE was computed in DeepLabCut 2.X. + + Args: + data: The data for which to compute RMSE. This is a list containing (gt_poses, + predicted_poses), where gt_pose is an array of shape (num_gt_individuals, + num_bpts, 3) and predicted_poses is an array of shape (num_predictions, + num_bpts, 3). For the GT, the 3 coordinates are (x, y, visibility) while for + the pose they are (x, y, confidence score). + pcutoff: The p-cutoff to use to compute RMSE. + + Returns: + The detection RMSE and detection RMSE after removing all detections with a + score below the pcutoff. + """ + distances = [] + scores = [] + for image_gt, image_pred in data: + image_gt = image_gt.transpose((1, 0, 2)) # to (num_bpts, num_gt_individuals, 3) + image_pred = image_pred.transpose((1, 0, 2)) # to (num_bpts, num_pred, 3) + + for bpt_index, (bpt_gt, bpt_pred) in enumerate(zip(image_gt, image_pred)): + # filter NaNs and invalid values + bpt_gt = bpt_gt[~np.any(np.isnan(bpt_gt), axis=1)] + bpt_pred = bpt_pred[~np.any(np.isnan(bpt_pred), axis=1)] + if len(bpt_gt) == 0 or len(bpt_pred) == 0: + continue + + # assignment of predicted bodyparts to ground truth + neighbors = find_closest_neighbors(bpt_gt, bpt_pred, k=3) + for gt_index, pred_index in enumerate(neighbors): + if pred_index != -1: + gt = bpt_gt[gt_index] + pred = bpt_pred[pred_index] + distances.append(np.linalg.norm(gt[:2] - pred[:2])) + scores.append(bpt_pred[pred_index, 2]) + + rmse, rmse_cutoff = float("nan"), float("nan") + if len(distances) == 0: + return rmse, rmse_cutoff + + distances = np.stack(distances) + if np.any(~np.isnan(distances)): + rmse = np.nanmean(distances).item() + + keypoint_scores = np.stack(scores) + pixel_errors_cutoff = distances[keypoint_scores >= pcutoff] + if np.any(~np.isnan(pixel_errors_cutoff)): + rmse_cutoff = np.nanmean(pixel_errors_cutoff).item() + + return rmse, rmse_cutoff diff --git a/deeplabcut/core/metrics/identity.py b/deeplabcut/core/metrics/identity.py new file mode 100644 index 0000000000..9b9b1f2399 --- /dev/null +++ b/deeplabcut/core/metrics/identity.py @@ -0,0 +1,86 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Implementations of methods to compute identity prediction accuracy""" +from __future__ import annotations + +import numpy as np +from sklearn.metrics import accuracy_score + +from deeplabcut.core.crossvalutils import find_closest_neighbors + + +def compute_identity_scores( + individuals: list[str], + bodyparts: list[str], + predictions: dict[str, np.ndarray], + identity_scores: dict[str, np.ndarray], + ground_truth: dict[str, np.ndarray], +) -> dict[str, float]: + """ + FIXME: With DLCRNet all heatmap "peaks" above 0.01 were kept, with 1 keypoint and + 1 identity score map per peak. Then, for each ground truth keypoint, we selected + the prediction closest to it, and evaluated the identity score in that position. + This is no longer the case, as we're now evaluating after assembly. So we only + have num_individuals assemblies. + + Args: + individuals: + bodyparts: + predictions: (num_assemblies, num_bodyparts, 3) + identity_scores: (num_assemblies, num_bodyparts, num_individuals) + ground_truth: (num_individuals, num_bodyparts, 3) + + Returns: + + """ + if not len(predictions) == len(ground_truth): + raise ValueError("Mismatch between number of predictions and ground truth") + + all_bpts = np.asarray(len(individuals) * bodyparts) + ids = np.full((len(predictions), len(all_bpts), 2), np.nan) + for i, (image, pred) in enumerate(predictions.items()): + for j in range(len(individuals)): + for k in range(len(bodyparts)): + bpt_idx = len(bodyparts) * j + k + ids[i, bpt_idx, 0] = j + + # set keypoints that aren't visible to NaN + gt = ground_truth[image].copy() + gt[gt[..., 2] <= 0, :2] = np.nan + gt = gt[..., :2] + + id_scores = identity_scores[image] + + # reorder to (bodypart, individual, ...) + gt = gt.transpose((1, 0, 2)) + pred = pred.transpose((1, 0, 2))[..., :2] + id_scores = id_scores.transpose((1, 0, 2)) + for bpt, bpt_gt, bpt_pred, bpt_id_scores in zip(bodyparts, gt, pred, id_scores): + # assign ground truth keypoints to the closest prediction, so the ID score + # is the closest possible to the ID score computed with "ground truth" + indices_gt = np.flatnonzero(np.all(~np.isnan(bpt_gt), axis=1)) + neighbors = find_closest_neighbors(bpt_gt[indices_gt], bpt_pred, k=3) + found = neighbors != -1 + indices = np.flatnonzero(all_bpts == bpt) + # Get the predicted identity of each bodypart by taking the argmax + ids[i, indices[indices_gt[found]], 1] = np.argmax( + bpt_id_scores[neighbors[found]], axis=1 + ) + + ids = ids.reshape((len(predictions), len(individuals), len(bodyparts), 2)) + results = {} + for i, bpt in enumerate(bodyparts): + temp = ids[:, :, i].reshape((-1, 2)) + valid = np.isfinite(temp).all(axis=1) + y_true, y_pred = temp[valid].T + results[f"{bpt}_accuracy"] = accuracy_score(y_true, y_pred) + + return results diff --git a/deeplabcut/core/metrics/matching.py b/deeplabcut/core/metrics/matching.py new file mode 100644 index 0000000000..95b28ebe5b --- /dev/null +++ b/deeplabcut/core/metrics/matching.py @@ -0,0 +1,169 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Algorithms to match predictions to ground truth labels""" +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass +class PotentialMatch: + """A potential match between predicted pose and ground truth pose. + + Args: + pose: An array of shape (num_bodyparts, 3) + score: The score for the prediction. This could be the mean of the confidence + score for each bodypart, or another value representing how confident the + model is that this assembly is correct. + gt: None if no ground truth pose was matched to the prediction. If defined, the + ground truth to which the prediction is matched. It should be of shape + (num_bodyparts, 3), where the 3 values are x, y and visibility. + oks: The OKS score between the pose and the ground truth. + """ + + pose: np.ndarray + score: float + gt: np.ndarray | None = None + oks: float = 0.0 + + def keypoint_scores(self) -> np.ndarray: + """Returns: The confidence score for each bodypart in the predicted pose.""" + return self.pose[:, 2].copy() + + def pixel_errors(self) -> np.ndarray: + """ + Returns: + The distance (in pixels) between each predicted and ground truth bodypart. + If this prediction is unmatched, returns an array of length num_bodyparts + containing all NaNs. + """ + if self.gt is None: + return np.full(len(self.pose), np.nan) + + return np.linalg.norm(self.pose[:, :2] - self.gt[:, :2], axis=1) + + def match(self, gt: np.ndarray, oks: float) -> None: + """Adds a ground truth match to this PotentialMatch + + Args: + gt: The ground truth to which the prediction is matched. The ground truth + pose should be of shape (num_bodyparts, 3), where the 3 values are x, y + and visibility. + oks: The OKS similarity between the ground truth and this. + """ + self.gt = gt + self.oks = oks + + @classmethod + def from_pose(cls, pose: np.ndarray) -> "PotentialMatch": + assert len(pose.shape) == 2 # Must be pose for a single individual + scores = pose[:, 2] + if np.all(np.isnan(scores)): + raise ValueError( + "Cannot create a Match from a pose prediction where all scores are nan " + f"(pose={pose})" + ) + + return PotentialMatch(pose=pose, score=np.nanmean(scores).item()) + + +def match_greedy_oks( + ground_truth: np.ndarray, + predictions: np.ndarray, + oks_matrix: np.ndarray, + oks_threshold: float = 0.0, +) -> list[PotentialMatch]: + """Greedy matching of ground truth individuals to predicted individuals using OKS + + This is done in the same way as done in pycocotools. The predictions must be sorted + by score before being passed to this function. + + Args: + ground_truth: The ground truth labels for an image, of shape (n_idv, n_bpt, 2) + predictions: The predictions for an image, of shape (n_idv, n_bpt, 2) + oks_matrix: A matrix of shape (n_pred, n_kpts) where entry (i, j) is the OKS + between prediction i and ground truth j. + oks_threshold: The min. OKS for a prediction to be matched to a GT pose + + Returns: + A list containing a PotentialMatch for each predicted pose in the given + predictions. + """ + matches = [PotentialMatch.from_pose(pose=pred) for pred in predictions] + matched_gt_indices = set() + for idx, pred in enumerate(predictions): + oks = oks_matrix[idx] + if np.all(np.isnan(oks)): + continue + + ind_best = np.nanargmax(oks) + + # if this gt already matched, continue + if ind_best in matched_gt_indices: + continue + + # Only match the pred to the GT if the OKS value is above a given threshold + if oks[ind_best] < oks_threshold: + continue + + matched_gt_indices.add(ind_best) + matches[idx].match(gt=ground_truth[ind_best], oks=oks[ind_best]) + + return matches + + +def match_greedy_rmse( + ground_truth: np.ndarray, + predictions: np.ndarray, + keep_assemblies: bool = True, +) -> list[PotentialMatch]: + """Greedy matching of ground truth individuals to predicted individuals using RMSE + + The predictions must be sorted by score before being passed to this function. + + Args: + ground_truth: The ground truth labels for an image, of shape (n_idv, n_bpt, 2) + predictions: The predictions for an image, of shape (n_idv, n_bpt, 2) + keep_assemblies: Whether to match predicted keypoints to ground truth keypoints + while enforcing that all bodyparts for a predicted individual are matched + to bodyparts from the same ground truth assembly. When set to False, this + corresponds to detection RMSE score. + + Returns: + A list containing a PotentialMatch for each predicted pose in the given + predictions. + """ + if not keep_assemblies: + raise NotImplementedError() + + matches = [PotentialMatch.from_pose(pose=pred) for pred in predictions] + matched_gt_indices = set() + for idx, pred in enumerate(predictions): + bpt_distances = np.linalg.norm(pred[:, :2] - ground_truth[:, :, :2], axis=-1) + if np.all(np.isnan(bpt_distances)): + continue + + distances = np.nanmean(bpt_distances, axis=-1) + ind_best = np.nanargmin(distances) + + # if this gt already matched, continue + if ind_best in matched_gt_indices: + continue + + matched_gt_indices.add(ind_best) + matches[idx].match( + gt=ground_truth[ind_best], + oks=float("nan"), # don't compute OKS here + ) + + return matches diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index c11a69ad7f..ff52e40fe3 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -11,7 +11,6 @@ from __future__ import annotations import argparse -import logging from pathlib import Path from typing import Iterable @@ -20,6 +19,7 @@ import pandas as pd from tqdm import tqdm +import deeplabcut.core.metrics as metrics from deeplabcut.pose_estimation_pytorch import utils from deeplabcut.pose_estimation_pytorch.apis.utils import ( build_predictions_dataframe, @@ -31,11 +31,6 @@ ) from deeplabcut.pose_estimation_pytorch.data import DLCLoader, Loader from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters -from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( - compute_identity_scores, - get_scores, - pair_predicted_individuals_with_gt, -) from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner from deeplabcut.pose_estimation_pytorch.runners.snapshots import Snapshot from deeplabcut.pose_estimation_pytorch.task import Task @@ -134,9 +129,6 @@ def evaluate( poses = {filename: pred["bodyparts"] for filename, pred in predictions.items()} gt_keypoints = loader.ground_truth_keypoints(mode) - if parameters.max_num_animals > 1: - poses = pair_predicted_individuals_with_gt(poses, gt_keypoints) - unique_poses = None gt_unique_keypoints = None if parameters.num_unique_bpts > 1: @@ -145,21 +137,20 @@ def evaluate( } gt_unique_keypoints = loader.ground_truth_keypoints(mode, unique_bodypart=True) - # TODO: Check single animal mAP computation - results = get_scores( - poses, + results = metrics.compute_metrics( gt_keypoints, + poses, + single_animal=parameters.max_num_animals == 1, pcutoff=pcutoff, unique_bodypart_poses=unique_poses, unique_bodypart_gt=gt_unique_keypoints, ) - # TODO: Evaluate identity predictions if loader.model_cfg["metadata"]["with_identity"]: pred_id_scores = { filename: pred["identity_scores"] for filename, pred in predictions.items() } - id_scores = compute_identity_scores( + id_scores = metrics.compute_identity_scores( individuals=parameters.individuals, bodyparts=parameters.bodyparts, predictions=poses, diff --git a/deeplabcut/pose_estimation_pytorch/metrics/__init__.py b/deeplabcut/pose_estimation_pytorch/metrics/__init__.py index c1a595de47..117d127147 100644 --- a/deeplabcut/pose_estimation_pytorch/metrics/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/metrics/__init__.py @@ -8,4 +8,3 @@ # # Licensed under GNU Lesser General Public License v3.0 # -from deeplabcut.pose_estimation_pytorch.metrics.bbox import compute_bbox_metrics diff --git a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py index 360601b228..317bc87c13 100644 --- a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py +++ b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py @@ -15,277 +15,9 @@ from sklearn.metrics import accuracy_score from deeplabcut.core.crossvalutils import find_closest_neighbors -from deeplabcut.core.inferenceutils import ( - Assembly, - evaluate_assembly, -) -from deeplabcut.pose_estimation_pytorch.post_processing import ( - rmse_match_prediction_to_gt, -) from deeplabcut.utils.auxiliaryfunctions import read_config -def get_scores( - poses: dict[str, np.ndarray], - ground_truth: dict[str, np.ndarray], - unique_bodypart_poses: dict[str, np.ndarray] | None = None, - unique_bodypart_gt: dict[str, np.ndarray] | None = None, - pcutoff: float = -1, - bbox_margin: int = 0, -) -> dict[str, float]: - """Computes for the different scores given the ground truth and the predictions. - - The poses and ground truth should already be aligned to the ground truth (the scores - will be computed assuming individual i in the poses matches to individual i in the - ground truth) - - The different scores computed are based on the COCO metrics: https://cocodataset.org/#keypoints-eval - RMSE (Root Mean Square Error) - OKS mAP (Mean Average Precision) - OKS mAR (Mean Average Recall) - - Args: - poses: the predicted poses for each image in the format - {'image': keypoints with shape (num_individuals, num_keypoints, 3)} - ground_truth: ground truth keypoints for each image in the format - {'image': keypoints with shape (num_individuals, num_keypoints, 3)} - pcutoff: the pcutoff used to use - unique_bodypart_poses: the predicted poses for unique bodyparts - unique_bodypart_gt: the ground truth for unique bodyparts - bbox_margin: the margin used to create bounding boxes from keypoints to compute - keypoint mAP. - - Returns: - a dictionary of scores containign the following keys - ['rmse', 'rmse_pcutoff', 'mAP', 'mAR', 'mAP_pcutoff', 'mAR_pcutoff'] - - Examples: - >>> # Define the p-cutoff, prediction, and target DataFrames - >>> pcutoff = 0.5 - >>> prediction = {"img0": [[[0.1, 0.5, 0.4], [5.2, 3.3, 0.9]], ...], ...} - >>> ground_truth = {"img0": [[[0, 0], [5, 3]], ...], ...} - >>> # Compute the scores - >>> scores = get_scores(poses, ground_truth, pcutoff) - >>> print(scores) - { - 'rmse': 0.156, - 'rmse_pcutoff': 0.115, - 'mAP': 84.2, - 'mAR': 74.5, - 'mAP_pcutoff': 91.3, - 'mAR_pcutoff': 82.5 - } # Sample output scores - """ - if not len(poses) == len(ground_truth): - raise ValueError( - "The prediction and ground truth dicts must contain the same number of " - f"images (poses={len(poses)}, gt={len(ground_truth)})" - ) - - ground_truth = { - image: mask_invisible(gt_pose, mask_value=np.nan) - for image, gt_pose in ground_truth.items() - } - - image_paths = list(poses) - pred_poses = build_keypoint_array(poses, image_paths)[..., :3].reshape((-1, 3)) - gt_poses = build_keypoint_array(ground_truth, image_paths).reshape((-1, 2)) - if unique_bodypart_poses is not None: - unique_bodypart_gt = { - image: mask_invisible(gt_pose, mask_value=np.nan) - for image, gt_pose in unique_bodypart_gt.items() - } - pred_poses = np.concatenate( - [ - pred_poses, - build_keypoint_array(unique_bodypart_poses, image_paths)[ - ..., :3 - ].reshape((-1, 3)), - ] - ) - gt_poses = np.concatenate( - [ - gt_poses, - build_keypoint_array(unique_bodypart_gt, image_paths).reshape((-1, 2)), - ] - ) - - pred_poses[pred_poses == -1] = np.nan - rmse, rmse_pcutoff = compute_rmse(pred_poses, gt_poses, pcutoff=pcutoff) - - oks = compute_oks(poses, ground_truth, margin=bbox_margin, pcutoff=None) - oks_pcutoff = compute_oks(poses, ground_truth, margin=bbox_margin, pcutoff=pcutoff) - - return { - "rmse": rmse, - "rmse_pcutoff": rmse_pcutoff, - "mAP": 100 * oks["mAP"], - "mAR": 100 * oks["mAR"], - "mAP_pcutoff": 100 * oks_pcutoff["mAP"], - "mAR_pcutoff": 100 * oks_pcutoff["mAR"], - } - - -def build_keypoint_array( - keypoints: dict[str, np.ndarray], keys: list[str] -) -> np.ndarray: - """Stacks arrays of keypoints in a given order - - Args: - keypoints: the keypoint arrays to stack - keys: the order of keys to use to stack the arrays - - Returns: - the stacked arrays - """ - image_keypoints = [] - for image_key in keys: - image_keypoints.append(keypoints[image_key]) - return np.stack(image_keypoints) - - -def compute_rmse( - pred: np.ndarray, ground_truth: np.ndarray, pcutoff: float = -1 -) -> tuple[float, float]: - """Computes the root mean square error (rmse) for predictions vs the ground truth labels - - Assumes that poses have been aligned to ground truth (keypoint i in the pred array - corresponds to keypoint i in the ground_truth array) - - Args: - pred: (n, 3) the predicted keypoints in format x, y, score - ground_truth: (n, 2) the ground truth keypoints - pcutoff: the pcutoff score - - Returns: - the RMSE and RMSE with pcutoff values - """ - if pred.shape[0] != ground_truth.shape[0]: - raise ValueError( - "Prediction and target arrays must have same number of elements!" - ) - - mask = pred[:, 2] >= pcutoff - square_distances = (pred[:, :2] - ground_truth) ** 2 - mean_square_errors = np.sum(square_distances, axis=1) - rmse = np.nanmean(np.sqrt(mean_square_errors)).item() - rmse_p = np.nan - if len(mean_square_errors[mask]) > 0: - rmse_p = np.nanmean(np.sqrt(mean_square_errors[mask])).item() - return rmse, rmse_p - - -def compute_oks( - pred: dict[str, np.array], - ground_truth: dict[str, np.array], - oks_sigma=0.1, - margin=0, - symmetric_kpts=None, - pcutoff: float | None = None, -) -> dict: - """Computes the - - Assumes that poses have been aligned to ground truth (for an image, individual i in - the pred array corresponds to individual i in the ground_truth array) - - Args: - pred: the predicted poses for each image in the format - {'image': keypoints with shape (num_individuals, num_keypoints, 3)} - ground_truth: ground truth keypoints for each image in the format - {'image': keypoints with shape (num_individuals, num_keypoints, 3)} - oks_sigma: sigma for OKS computation. - margin: margin used for bbox computation. - symmetric_kpts: TODO: not supported yet - pcutoff: the pcutoff used to use - - Returns: - the OKS scores - """ - masked_pred = {} - for image_path, keypoints_with_scores in pred.items(): - keypoints = keypoints_with_scores[:, :, :2].copy() - if pcutoff is not None: - keypoints[keypoints_with_scores[:, :, 2] < pcutoff] = np.nan - masked_pred[image_path] = keypoints - - assemblies_pred = build_assemblies(masked_pred) - assemblies_gt = build_assemblies(ground_truth) - return evaluate_assembly( - assemblies_pred, - assemblies_gt, - oks_sigma, - margin=margin, - symmetric_kpts=symmetric_kpts, - greedy_matching=True, - with_tqdm=False, - ) - - -def compute_identity_scores( - individuals: list[str], - bodyparts: list[str], - predictions: dict[str, np.ndarray], - identity_scores: dict[str, np.ndarray], - ground_truth: dict[str, np.ndarray], -) -> dict[str, float]: - """ - FIXME: With DLCRNet all heatmap "peaks" above 0.01 were kept, with 1 keypoint and - 1 identity score map per peak. Then, for each ground truth keypoint, we selected - the prediction closest to it, and evaluated the identity score in that position. - This is no longer the case, as we're now evaluating after assembly. So we only - have num_individuals assemblies. - - Args: - individuals: - bodyparts: - predictions: (num_assemblies, num_bodyparts, 3) - identity_scores: (num_assemblies, num_bodyparts, num_individuals) - ground_truth: (num_individuals, num_bodyparts, 3) - - Returns: - - """ - if not len(predictions) == len(ground_truth): - raise ValueError("Mismatch between number of predictions and ground truth") - - all_bpts = np.asarray(len(individuals) * bodyparts) - ids = np.full((len(predictions), len(all_bpts), 2), np.nan) - for i, (image, pred) in enumerate(predictions.items()): - for j in range(len(individuals)): - for k in range(len(bodyparts)): - bpt_idx = len(bodyparts) * j + k - ids[i, bpt_idx, 0] = j - - gt = mask_invisible(ground_truth[image], mask_value=np.nan) - id_scores = identity_scores[image] - - # reorder to (bodypart, individual, ...) - gt = gt.transpose((1, 0, 2)) - pred = pred.transpose((1, 0, 2))[..., :2] - id_scores = id_scores.transpose((1, 0, 2)) - for bpt, bpt_gt, bpt_pred, bpt_id_scores in zip(bodyparts, gt, pred, id_scores): - # assign ground truth keypoints to the closest prediction, so the ID score - # is the closest possible to the ID score computed with "ground truth" - indices_gt = np.flatnonzero(np.all(~np.isnan(bpt_gt), axis=1)) - neighbors = find_closest_neighbors(bpt_gt[indices_gt], bpt_pred, k=3) - found = neighbors != -1 - indices = np.flatnonzero(all_bpts == bpt) - # Get the predicted identity of each bodypart by taking the argmax - ids[i, indices[indices_gt[found]], 1] = np.argmax( - bpt_id_scores[neighbors[found]], axis=1 - ) - - ids = ids.reshape((len(predictions), len(individuals), len(bodyparts), 2)) - results = {} - for i, bpt in enumerate(bodyparts): - temp = ids[:, :, i].reshape((-1, 2)) - valid = np.isfinite(temp).all(axis=1) - y_true, y_pred = temp[valid].T - results[f"{bpt}_accuracy"] = accuracy_score(y_true, y_pred) - - return results - - def _match_identity_preds_to_gt( config_path: str, full_pickle_path: str ) -> tuple[np.ndarray, list]: @@ -341,75 +73,3 @@ def compute_id_accuracy(ids: np.ndarray, mask_test: np.ndarray) -> np.ndarray: ac_test = accuracy_score(y_true[mask], y_pred[mask]) accu[i] = ac_train, ac_test return accu - - -def build_assemblies(poses: dict[str, np.ndarray]) -> dict[str, list[Assembly]]: - """ - Builds assemblies from a pose array - - Args: - poses: {image: keypoints with shape (num_individuals, num_keypoints, 2)} - - Returns: - the assemblies for each image - """ - assemblies = {} - for image_path, keypoints in poses.items(): - image_assemblies = [] - for idv_bodyparts in keypoints: - assembly = Assembly.from_array(idv_bodyparts) - if len(assembly): - image_assemblies.append(assembly) - - assemblies[image_path] = image_assemblies - - return assemblies - - -def pair_predicted_individuals_with_gt( - predictions: dict[str, np.ndarray], ground_truth: dict[str, np.ndarray] -) -> dict[str, np.ndarray]: - """TODO: implement with OKS as well - Uses RMSE to match predicted individuals to frame annotations for a batch of - frames. This method is preferred to OKS, as OKS needs at least 2 annotated - keypoints per animal (to compute area) - - The poses array is modified in-place, where the order of elements are - swapped in 2nd dimension (individuals) such that the keypoints in predictions[img][i] - is matched to the ground truth annotations of df_target[img][i] - - Args: - predictions: {image_path: predicted pose of shape (individual, keypoints, 3)} - ground_truth: the ground truth annotations to align - - Returns: - the same dictionary as the input predictions, but where the "individual" axis - for each prediction is aligned with the ground truth data - """ - matched_poses = {} - for image, pose in predictions.items(): - match_individuals = rmse_match_prediction_to_gt(pose, ground_truth[image]) - matched_poses[image] = pose[match_individuals] - - return matched_poses - - -def mask_invisible( - keypoints: np.ndarray, mask_value: int | float | np.nan = -1.0 -) -> np.ndarray: - """ - Masks keypoints that are not visible in an array. - - Args: - keypoints: a keypoint array of shape (..., 3), where the last axis contains - the x, y and visibility values (0 == invisible) - mask_value: the value to give to the keypoints that are masked - - Returns: - a keypoint array of shape (..., 2) with the coordinates of the keypoints marked - as invisible replaced with the mask value - """ - keypoints = keypoints.copy() - not_visible = keypoints[..., 2] <= 0 - keypoints[not_visible, :2] = mask_value - return keypoints[..., :2] diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index d4b622e644..17892831ac 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -152,7 +152,11 @@ def forward( heatmaps, self.nms_radius, threshold=0.01 ) if ~torch.any(peaks): - return {"poses": torch.zeros((batch_size, 0, self.num_multibodyparts, 5))} + return { + "poses": -torch.ones( + (batch_size, self.num_animals, self.num_multibodyparts, 5) + ) + } locrefs = locrefs.reshape(batch_size, n_channels, 2, height, width) locrefs = locrefs * self.locref_stdev @@ -169,8 +173,8 @@ def forward( scale_factors, n_id_channels=0, # FIXME Handle identity training ) - poses = torch.empty((batch_size, self.num_animals, self.num_multibodyparts, 5)) - poses_unique = torch.empty((batch_size, 1, self.num_uniquebodyparts, 4)) + poses = -torch.ones((batch_size, self.num_animals, self.num_multibodyparts, 5)) + poses_unique = -torch.ones((batch_size, 1, self.num_uniquebodyparts, 4)) for i, data_dict in enumerate(preds): assemblies, unique = self.assembler._assemble(data_dict, ind_frame=0) for j, assembly in enumerate(assemblies): diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py index 5027f02074..c96bd74720 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py @@ -69,9 +69,9 @@ def forward( Example: >>> predictor = HeatmapPredictor(location_refinement=True, locref_std=7.2801) - >>> inputs = torch.rand((1, 3, 256, 256)) + >>> stride = 8 >>> output = {"heatmap": torch.rand(32, 17, 64, 64), "locref": torch.rand(32, 17, 64, 64)} - >>> poses = predictor.forward(inputs, output) + >>> poses = predictor.forward(stride, output) """ heatmaps = outputs["heatmap"] scale_factors = stride, stride @@ -139,24 +139,24 @@ def get_pose_prediction( >>> scale_factors = (0.5, 0.5) >>> poses = predictor.get_pose_prediction(heatmap, locref, scale_factors) """ - Y, X = self.get_top_values(heatmap) + y, x = self.get_top_values(heatmap) - batch_size, num_joints = X.shape + batch_size, num_joints = x.shape - DZ = torch.zeros((batch_size, 1, num_joints, 3)).to(X.device) + dz = torch.zeros((batch_size, 1, num_joints, 3)).to(x.device) for b in range(batch_size): for j in range(num_joints): - DZ[b, 0, j, 2] = heatmap[b, Y[b, j], X[b, j], j] + dz[b, 0, j, 2] = heatmap[b, y[b, j], x[b, j], j] if locref is not None: - DZ[b, 0, j, :2] = locref[b, Y[b, j], X[b, j], j, :] + dz[b, 0, j, :2] = locref[b, y[b, j], x[b, j], j, :] - X, Y = torch.unsqueeze(X, 1), torch.unsqueeze(Y, 1) + x, y = torch.unsqueeze(x, 1), torch.unsqueeze(y, 1) - X = X * scale_factors[1] + 0.5 * scale_factors[1] + DZ[:, :, :, 0] - Y = Y * scale_factors[0] + 0.5 * scale_factors[0] + DZ[:, :, :, 1] + x = x * scale_factors[1] + 0.5 * scale_factors[1] + dz[:, :, :, 0] + y = y * scale_factors[0] + 0.5 * scale_factors[0] + dz[:, :, :, 1] pose = torch.empty((batch_size, 1, num_joints, 3)) - pose[:, :, :, 0] = X - pose[:, :, :, 1] = Y - pose[:, :, :, 2] = DZ[:, :, :, 2] + pose[:, :, :, 0] = x + pose[:, :, :, 1] = y + pose[:, :, :, 2] = dz[:, :, :, 2] return pose diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py index ba349ac713..b0df0ecdb4 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/utils.py @@ -20,12 +20,9 @@ from torch.utils.data import DataLoader from tqdm import tqdm +import deeplabcut.core.metrics as metrics from deeplabcut.core.crossvalutils import find_closest_neighbors from deeplabcut.pose_estimation_pytorch import Loader -from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( - get_scores, - pair_predicted_individuals_with_gt, -) from deeplabcut.pose_estimation_pytorch.models import PoseModel from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import Graph @@ -184,7 +181,13 @@ def benchmark_paf_graphs( poses_.extend(preds["poses"]) poses_ = torch.stack(poses_).detach().cpu().numpy() poses_ = dict(zip(paths, poses_)) - poses_ = pair_predicted_individuals_with_gt(poses_, poses_gt) poses.append(poses_) - results.append(get_scores(poses_, poses_gt)) + results.append( + metrics.compute_metrics( + poses_gt, + poses_, + single_animal=train_dataset.parameters.max_num_animals == 1, + pcutoff=0.6, + ) + ) return results, poses, best_paf_edges diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py index babe368b39..7113303e3c 100644 --- a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py +++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py @@ -31,7 +31,7 @@ def rmse_match_prediction_to_gt( ValueError: if `gt_kpts.shape != pred_kpts.shape` Args: - pred_kpts: shape (num_individuals, num_keypoints, 3), ground truth keypoints for + pred_kpts: shape (num_predictions, num_keypoints, 3), ground truth keypoints for an image, where the 3 values are (x,y,score) for each keypoint gt_kpts: shape (num_individuals, num_keypoints, 3), ground truth keypoints for an image, where the 3 values are (x,y,visibility) for each keypoint @@ -48,12 +48,6 @@ def rmse_match_prediction_to_gt( else: raise ValueError("Shape mismatch between ground truth and predictions") - if num_pred != num_idv: - raise ValueError( - "Must have the same number of GT and predicted individuals, found " - f"pred_kpts={pred_kpts.shape} and gt_kpts={gt_kpts.shape}" - ) - valid_gt = np.any(gt_kpts[..., 2] > 0, axis=1) valid_gt_indices = np.nonzero(valid_gt)[0] if len(valid_gt_indices) == 0: @@ -64,15 +58,24 @@ def rmse_match_prediction_to_gt( if len(valid_pred_indices) == 0: return np.arange(num_idv) - distance_matrix = np.full((len(valid_gt_indices), len(valid_pred_indices)), np.inf) + distance_matrix = np.full((len(valid_gt_indices), len(valid_pred_indices)), np.nan) for i, gt_idx in enumerate(valid_gt_indices): gt_idv = gt_kpts[gt_idx] mask = gt_idv[:, 2] > 0 for j, pred_idx in enumerate(valid_pred_indices): pred_idv = pred_kpts[pred_idx] d = (gt_idv[mask, :2] - pred_idv[mask, :2]) ** 2 - distance_matrix[i, j] = np.nanmean(d) + if np.any(~np.isnan(d)): + distance_matrix[i, j] = np.nanmean(d) + + if np.all(np.isnan(distance_matrix)): + return np.arange(num_idv) + # np.inf and np.nan in linear_sum_assigment raises error; so when a prediction + # cannot be assigned to a ground truth (e.g. with PAFs, where predicted bodyparts + # can be NaN) set the distance to a distance greater than the maximum distance + max_dist = np.nanmax(distance_matrix) + distance_matrix = np.nan_to_num(distance_matrix, nan=100 * max_dist) _, col_ind = linear_sum_assignment(distance_matrix) # len == len(valid_gt_indices) gt_idx_to_pred_idx = { diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py index a8712984f6..5595c83939 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/train.py +++ b/deeplabcut/pose_estimation_pytorch/runners/train.py @@ -22,11 +22,7 @@ from torch.utils.data import DataLoader from torch.nn.parallel import DataParallel -from deeplabcut.pose_estimation_pytorch.metrics import compute_bbox_metrics -from deeplabcut.pose_estimation_pytorch.metrics.scoring import ( - get_scores, - pair_predicted_individuals_with_gt, -) +import deeplabcut.core.metrics as metrics from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector from deeplabcut.pose_estimation_pytorch.models.model import PoseModel from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner @@ -336,7 +332,6 @@ def step( self._update_epoch_predictions( name="bodyparts", - paths=batch["path"], gt_keypoints=ground_truth, pred_keypoints=predictions["bodypart"]["poses"], offsets=batch["offsets"], @@ -345,7 +340,6 @@ def step( if "unique_bodypart" in predictions: self._update_epoch_predictions( name="unique_bodyparts", - paths=batch["path"], gt_keypoints=batch["annotations"]["keypoints_unique"], pred_keypoints=predictions["unique_bodypart"]["poses"], offsets=batch["offsets"], @@ -359,29 +353,12 @@ def _compute_epoch_metrics(self) -> dict[str, float]: Returns: A dictionary containing the different losses for the step """ - num_animals = max( - [len(kpts) for kpts in self._epoch_ground_truth["bodyparts"].values()] - ) - poses = pair_predicted_individuals_with_gt( - self._epoch_predictions["bodyparts"], self._epoch_ground_truth["bodyparts"] - ) - - # pad predictions if there are any missing (needed for top-down models) - gt, pred = {}, {} - for path, img_gt in self._epoch_ground_truth["bodyparts"].items(): - for kpt_dict, kpts in [(gt, img_gt), (pred, poses[path])]: - if len(kpts) < num_animals: - padded_kpts = -np.ones((num_animals, *kpts.shape[1:])) - padded_kpts[: len(kpts)] = kpts - kpt_dict[path] = padded_kpts - else: - kpt_dict[path] = kpts - - scores = get_scores( - poses=pred, - ground_truth=gt, - unique_bodypart_poses=self._epoch_predictions.get("unique_bodyparts"), + scores = metrics.compute_metrics( + ground_truth=self._epoch_ground_truth["bodyparts"], + predictions=self._epoch_predictions["bodyparts"], + single_animal=False, # FIXME(niels): Get this value from the dataset unique_bodypart_gt=self._epoch_ground_truth.get("unique_bodyparts"), + unique_bodypart_poses=self._epoch_predictions.get("unique_bodyparts"), pcutoff=0.6, ) return {f"metrics/test.{metric}": value for metric, value in scores.items()} @@ -389,7 +366,6 @@ def _compute_epoch_metrics(self) -> dict[str, float]: def _update_epoch_predictions( self, name: str, - paths: torch.Tensor, gt_keypoints: torch.Tensor, pred_keypoints: torch.Tensor, scales: torch.Tensor, @@ -398,50 +374,28 @@ def _update_epoch_predictions( """Updates the stored predictions with a new batch""" epoch_gt_metric = self._epoch_ground_truth.get(name, {}) epoch_metric = self._epoch_predictions.get(name, {}) - assert len(paths) == len(gt_keypoints) == len(pred_keypoints) - assert len(paths) == len(offsets) == len(scales) + assert len(gt_keypoints) == len(pred_keypoints) + assert len(offsets) == len(scales) scales = scales.detach().cpu().numpy() offsets = offsets.detach().cpu().numpy() - for path, gt, pred, scale, offset in zip( - paths, + for gt, pred, scale, offset in zip( gt_keypoints, pred_keypoints, scales, offsets, ): - # ground_truth now should already have visibility flag ground_truth = gt.detach().cpu().numpy() - gt_with_vis = ground_truth - - # FIXME: convert (-1, -2) to 0. Else error is incorrectly calculated - for batch_id in range(len(ground_truth)): - # keypoints (num_kpts, 3) - keypoints = ground_truth[batch_id] - if name == 'unique_bodyparts': - vis = keypoints[-1] - if vis < 0: - keypoints[-1] = 0 - else: - for kpts in keypoints: - vis = kpts[-1] - if vis < 0: - kpts[-1] = 0 + pred = pred.copy() # rescale to the full image for TD or CTD - gt_with_vis[..., :2] = (gt_with_vis[..., :2] * scale) + offset - pred = pred.copy() + ground_truth[..., :2] = (ground_truth[..., :2] * scale) + offset pred[..., :2] = (pred[..., :2] * scale) + offset - # for TD models, individuals are predicted separately - if path in epoch_gt_metric: - epoch_gt_metric[path] = np.concatenate( - [epoch_gt_metric[path], gt_with_vis], axis=0 - ) - epoch_metric[path] = np.concatenate([epoch_metric[path], pred], axis=0) - else: - epoch_gt_metric[path] = gt_with_vis - epoch_metric[path] = pred + # we don't care about image paths here - use a default index + index = len(epoch_metric) + 1 + epoch_gt_metric[f"sample{index:09}"] = ground_truth + epoch_metric[f"sample{index:09}"] = pred self._epoch_ground_truth[name] = epoch_gt_metric self._epoch_predictions[name] = epoch_metric @@ -532,7 +486,7 @@ def _compute_epoch_metrics(self) -> dict[str, float]: try: return { f"metrics/test.{k}": v - for k, v in compute_bbox_metrics( + for k, v in metrics.compute_bbox_metrics( self._epoch_ground_truth, self._epoch_predictions ).items() } diff --git a/tests/pose_estimation_pytorch/metrics/test_scoring.py b/tests/core/metrics/test_meitrcs_identity_accuracy.py similarity index 84% rename from tests/pose_estimation_pytorch/metrics/test_scoring.py rename to tests/core/metrics/test_meitrcs_identity_accuracy.py index 649e15bf48..1bde7edb88 100644 --- a/tests/pose_estimation_pytorch/metrics/test_scoring.py +++ b/tests/core/metrics/test_meitrcs_identity_accuracy.py @@ -12,7 +12,7 @@ import numpy as np import pytest -import deeplabcut.pose_estimation_pytorch.metrics.scoring as scoring +import deeplabcut.core.metrics.identity @pytest.mark.parametrize( @@ -23,17 +23,20 @@ "bodyparts": ["arm"], "predictions": { "img0.png": [ # (num_assemblies, num_bodyparts, 3) - [[2.0, 2.0, 0.8]], [[1.0, 1.0, 0.7]], # x, y, score + [[2.0, 2.0, 0.8]], + [[1.0, 1.0, 0.7]], # x, y, score ], }, "identity_scores": { "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) - [[0.8, 0.5]], [[0.51, 0.49]], + [[0.8, 0.5]], + [[0.51, 0.49]], ], }, "ground_truth": { "img0.png": [ # (num_individuals, num_bodyparts, 3) - [[1.0, 1.0, 2]], [[0, 0, 0]] # x, y, visibility + [[1.0, 1.0, 2]], + [[0, 0, 0]], # x, y, visibility ] }, "accuracy": { @@ -45,17 +48,20 @@ "bodyparts": ["arm"], "predictions": { "img0.png": [ # (num_assemblies, num_bodyparts, 3) - [[1.0, 1.0, 0.7]], [[2.0, 2.0, 0.7]], # x, y, score + [[1.0, 1.0, 0.7]], + [[2.0, 2.0, 0.7]], # x, y, score ], }, "identity_scores": { "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) - [[0.4, 0.6]], [[0.6, 0.4]] + [[0.4, 0.6]], + [[0.6, 0.4]], ], }, "ground_truth": { "img0.png": [ # (num_individuals, num_bodyparts, 3) - [[2.0, 2.0, 2]], [[1.0, 1.0, 2]], # x, y, visibility + [[2.0, 2.0, 2]], + [[1.0, 1.0, 2]], # x, y, visibility ] }, "accuracy": { @@ -67,17 +73,20 @@ "bodyparts": ["arm"], "predictions": { "img0.png": [ # (num_assemblies, num_bodyparts, 3) - [[1.0, 1.0, 0.7]], [[2.0, 2.0, 0.7]], # x, y, score + [[1.0, 1.0, 0.7]], + [[2.0, 2.0, 0.7]], # x, y, score ], }, "identity_scores": { "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) - [[0.6, 0.4]], [[0.6, 0.4]] # both assemblies assigned to idv 1 + [[0.6, 0.4]], + [[0.6, 0.4]], # both assemblies assigned to idv 1 ], }, "ground_truth": { "img0.png": [ # (num_individuals, num_bodyparts, 3) - [[2.0, 2.0, 2]], [[1.0, 1.0, 2]], # x, y, visibility + [[2.0, 2.0, 2]], + [[1.0, 1.0, 2]], # x, y, visibility ] }, "accuracy": { @@ -89,12 +98,14 @@ "bodyparts": ["arm"], "predictions": { "img0.png": [ # (num_assemblies, num_bodyparts, 3) - [[1.0, 1.0, 0.7]], [[2.0, 2.0, 0.7]], # x, y, score + [[1.0, 1.0, 0.7]], + [[2.0, 2.0, 0.7]], # x, y, score ], }, "identity_scores": { "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) - [[0.6, 0.4]], [[0.4, 0.6]] # both assigned to wrong ID + [[0.6, 0.4]], + [[0.4, 0.6]], # both assigned to wrong ID ], }, "ground_truth": { @@ -176,7 +187,10 @@ "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals) [[0.7, 0.3, 0.1], [0.6, 0.2, 0.1]], # assigned to correct ID [[0.1, 0.2, 0.7], [0.4, 0.3, 0.2]], # 1st correct, 2nd wrong - [[0.6, 0.3, 0.5], [0.6, 0.2, 0.4]], # should not matter, not assigned to GT + [ + [0.6, 0.3, 0.5], + [0.6, 0.2, 0.4], + ], # should not matter, not assigned to GT ], }, "ground_truth": { @@ -194,7 +208,7 @@ ], ) def test_id_accuracy(data) -> None: - scores = scoring.compute_identity_scores( + scores = deeplabcut.core.metrics.identity.compute_identity_scores( individuals=data["individuals"], bodyparts=data["bodyparts"], predictions={k: np.array(v) for k, v in data["predictions"].items()}, diff --git a/tests/core/metrics/test_metrics_api.py b/tests/core/metrics/test_metrics_api.py new file mode 100644 index 0000000000..48e5497dcb --- /dev/null +++ b/tests/core/metrics/test_metrics_api.py @@ -0,0 +1,65 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""General tests for the metrics API""" +import numpy as np +import pytest +from numpy.testing import assert_almost_equal + +import deeplabcut.core.metrics as metrics + + +def test_computing_metrics_with_no_predictions(): + gt = np.arange(5 * 6 * 3).astype(float).reshape((5, 6, 3)) + gt[..., 2] = 2 + metrics.compute_metrics( + ground_truth={"image": gt}, + predictions={"image": np.zeros((0, 12, 3))}, + unique_bodypart_gt=None, + unique_bodypart_poses=None, + ) + + +@pytest.mark.parametrize("error", [0.5, 1, 2]) +def test_computing_metrics_with_constant_error(error): + # only works for small errors: otherwise another matching can be found + gt = np.arange(5 * 6 * 3).astype(float).reshape((5, 6, 3)) + gt[..., 2] = 2 + predictions = gt.copy() + predictions[..., 2] = 0.9 + predictions[..., :2] += error + results = metrics.compute_metrics( + ground_truth={"image": gt}, + predictions={"image": predictions}, + unique_bodypart_gt=None, + unique_bodypart_poses=None, + ) + assert_almost_equal(results["rmse"], np.sqrt(2) * error) + assert_almost_equal(results["rmse_pcutoff"], np.sqrt(2) * error) + + +@pytest.mark.parametrize("error", [0.5, 1, 2]) +def test_computing_metrics_single_animal(error): + # only works for small errors: otherwise another matching can be found + gt = np.arange(6 * 3).astype(float).reshape((1, 6, 3)) + gt[..., 2] = 2 + predictions = gt.copy() + predictions[..., 2] = 0.9 + predictions[..., :2] += error + results = metrics.compute_metrics( + ground_truth={"image": gt}, + predictions={"image": predictions}, + single_animal=True, + unique_bodypart_gt=None, + unique_bodypart_poses=None, + ) + assert_almost_equal(results["rmse"], np.sqrt(2) * error) + assert_almost_equal(results["rmse_pcutoff"], np.sqrt(2) * error) + diff --git a/tests/core/metrics/test_metrics_map_computation.py b/tests/core/metrics/test_metrics_map_computation.py new file mode 100644 index 0000000000..18c3296607 --- /dev/null +++ b/tests/core/metrics/test_metrics_map_computation.py @@ -0,0 +1,403 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Tests that mAP computation is correct""" + +from __future__ import annotations + +import numpy as np +import pytest +from numpy.testing import assert_almost_equal + +from deeplabcut.core.metrics.api import prepare_evaluation_data +from deeplabcut.core.metrics.distance_metrics import compute_oks +from deeplabcut.pose_estimation_pytorch.data.utils import bbox_from_keypoints + + +@pytest.mark.parametrize( + "ground_truth", + [ + { + "img0": [ + [ + [100.0, 10.0, 2], + [150.0, 15.0, 2], + [202.0, 20.0, 2], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 2], + [140.0, 17.0, 2], + [192.0, 22.0, 2], + ], + ], + }, + ], +) +@pytest.mark.parametrize( + "predictions", + [ + { + "img0": [ + [ + [100.0, 10.0, 0.9], + [150.0, 15.0, 0.7], + [202.0, 20.0, 0.8], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 0.9], + [140.0, 17.0, 0.7], + [192.0, 22.0, 0.8], + ], + [ + [97.0, 11.0, 0.5], + [148.0, 14.0, 0.2], + [202.0, 21.0, 0.3], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 0.9], + [np.nan, np.nan, 0.0], + [192.0, 22.0, 0.8], + ], + [ + [97.0, 11.0, 0.5], + [148.0, 14.0, 0.2], + [202.0, 21.0, 0.3], + ], + ], + }, + ], +) +def test_map_single_image_simple(ground_truth: dict, predictions: dict): + gt = {k: np.array(v) for k, v in ground_truth.items()} + pred = {k: np.array(v) for k, v in predictions.items()} + _evaluate(gt, pred) + + +@pytest.mark.parametrize( + "ground_truth", + [ + { + "img0": [ + [ + [100.0, 10.0, 2], + [150.0, 15.0, 2], + [202.0, 20.0, 2], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 2], + [140.0, 17.0, 2], + [192.0, 22.0, 2], + ], + [ + [726.0, 325.0, 2], + [326.0, 236.0, 2], + [457.0, 832.0, 2], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 2], + [140.0, 17.0, 2], + [192.0, 22.0, 2], + ], + [ + [726.0, 325.0, 2], + [0.0, 0.0, 0], + [457.0, 832.0, 2], + ], + ], + }, + { + "img0": [ + [ + [90.0, 12.0, 2], + [140.0, 17.0, 2], + [192.0, 22.0, 2], + ], + [ + [726.0, 325.0, 2], + [0, 0, 0], + [457.0, 832.0, 2], + ], + [ + [452.0, 321.0, 2], + [213.0, 387.0, 2], + [213.0, 832.0, 2], + ], + [ + [253.0, 238.0, 2], + [213.0, 238.0, 2], + [457.0, 832.0, 2], + ], + ], + }, + ], +) +def test_map_single_image_random_errors(ground_truth: dict): + rng = np.random.default_rng(seed=0) + + gt = {k: np.array(v) for k, v in ground_truth.items()} + pred = {} + for k, gt_kpts in gt.items(): + num_idv, num_bpt = gt_kpts.shape[:2] + + error = rng.integers(low=-30, high=30, size=(num_idv, num_bpt, 2)) + scores = rng.random(size=(num_idv, num_bpt)) + + pred[k] = np.zeros(shape=(num_idv, num_bpt, 3)) + pred[k][..., :2] = np.clip(gt_kpts[..., :2] + error, 0, 1024) + pred[k][..., 2] = scores + + _evaluate(gt, pred) + + +@pytest.mark.parametrize("num_images", [1, 2, 5, 10]) +@pytest.mark.parametrize("num_joints", [2, 5, 8, 20]) +@pytest.mark.parametrize("max_error", [1, 2, 5, 20, 40]) +def test_random_map_computation(num_images, num_joints, max_error): + rng = np.random.default_rng(seed=0) + + num_individuals = rng.integers(low=0, high=20, size=(num_images, 2)) + + gt, pred = {}, {} + for i, (gt_idv, pred_idv) in enumerate(num_individuals): + gt_kpts = 2 * np.ones((gt_idv, num_joints, 3)) + gt_kpts[..., :2] = rng.integers(low=0, high=1024, size=(gt_idv, num_joints, 2)) + gt[f"img_{i}"] = gt_kpts + + # create predictions array + pred_kpts = np.zeros((pred_idv, num_joints, 3)) + # set scores + pred_kpts[..., 2] = rng.random(size=(pred_idv, num_joints)) + + # predictions that are ground truth + error + matched = min(gt_idv, pred_idv) + if matched > 0: + error = rng.integers( + low=-max_error, high=max_error, size=(matched, num_joints, 2) + ) + matched_pred = gt_kpts[:matched, :, :2] + error + pred_kpts[:matched, :, :2] = np.clip(matched_pred, 0, 1024) + + # random predictions + unmatched = pred_idv - matched + if unmatched > 0: + pred_kpts[matched:, :, :2] = rng.integers( + low=0, high=1024, size=(unmatched, num_joints, 2) + ) + + pred[f"img_{i}"] = pred_kpts + + _evaluate(gt, pred) + + +@pytest.mark.parametrize("num_images", [1, 2, 5, 10]) +@pytest.mark.parametrize("num_joints", [2, 5, 8, 20]) +@pytest.mark.parametrize("max_error", [1, 2, 5, 20, 40]) +def test_random_map_computation_with_missing_kpts(num_images, num_joints, max_error): + rng = np.random.default_rng(seed=0) + num_individuals = rng.integers(low=0, high=20, size=(num_images, 2)) + + gt, pred = {}, {} + for i, (gt_idv, pred_idv) in enumerate(num_individuals): + gt_kpts = 2 * np.ones((gt_idv, num_joints, 3)) + gt_kpts[..., :2] = rng.integers(low=0, high=1024, size=(gt_idv, num_joints, 2)) + gt[f"img_{i}"] = gt_kpts + + # drop some ground truth keypoints + gt_vis_mask = rng.random(size=(gt_idv, num_joints)) < 0.2 + gt_kpts[gt_vis_mask, 2] = 0 + + # generate predicted keypoints + pred_kpts = np.zeros((pred_idv, num_joints, 3)) + pred_kpts[:pred_idv, :, 2] = rng.random(size=(pred_idv, num_joints)) + + # predictions that are ground truth + error + matched = min(gt_idv, pred_idv) + if matched > 0: + error = rng.integers( + low=-max_error, high=max_error, size=(matched, num_joints, 2) + ) + matched_pred = gt_kpts[:matched, :, :2] + error + pred_kpts[:matched, :, :2] = np.clip(matched_pred, 0, 1024) + + # random predictions + unmatched = pred_idv - matched + if unmatched > 0: + pred_kpts[matched:, :, :2] = rng.integers( + low=0, high=1024, size=(unmatched, num_joints, 2) + ) + + pred[f"img_{i}"] = pred_kpts + + _evaluate(gt, pred) + + +def _evaluate(gt: dict[str, np.ndarray], pred: dict[str, np.ndarray]): + for k, v in gt.items(): + print(20 * "-") + print(k) + print("GT") + print(v) + print("PR") + print(pred[k]) + + data = prepare_evaluation_data(gt, pred) + oks = compute_oks(data, oks_bbox_margin=0) + + num_joints = gt[list(gt.keys())[0]].shape[1] + coco_gt = _to_coco_ground_truth(gt, num_joints, bbox_margin=0) + coco_pred = _to_coco_predictions(coco_gt, pred, bbox_margin=0) + coco_oks = eval_coco(coco_gt, coco_pred, num_joints) + print(20 * "-") + print(f"dlc mAP:") + for k, v in oks.items(): + print(k) + print(v) + print(20 * "-") + print(f"pycocotools mAP: {coco_oks}") + print() + dlc_map = oks["mAP"] / 100 + assert_almost_equal(dlc_map, coco_oks) + + +def _to_coco_ground_truth( + data: dict[str, np.ndarray], + num_joints: int, + bbox_margin: int = 0, + image_size: tuple[int, int] = (1024, 1024), +) -> dict[str, list[dict]]: + w, h = image_size + anns, images = [], [] + for path, image_keypoints in data.items(): + id_ = len(images) + 1 + images.append(dict(id=id_, file_name=path, width=w, height=h)) + + assert image_keypoints.shape[1] == num_joints + for idv_id, kpts in enumerate(image_keypoints): + visible = kpts[:, 2] > 0 + num_keypoints = visible.sum() + + if num_keypoints > 1: + bbox = bbox_from_keypoints( + keypoints=kpts, + image_h=h, + image_w=w, + margin=bbox_margin, + ) + area = bbox[2].item() * bbox[3].item() + anns.append( + { + "id": len(anns) + 1, + "image_id": id_, + "category_id": 1, + "area": area, + "bbox": bbox.tolist(), + "keypoints": kpts.reshape(-1).tolist(), + "iscrowd": 0, + "num_keypoints": num_keypoints, + } + ) + + keypoints = [f"bpt{i}" for i in range(num_joints)] + category = dict(id=1, name="animal", supercategory="animal", keypoints=keypoints) + return {"annotations": anns, "categories": [category], "images": images} + + +def _to_coco_predictions( + ground_truth: dict, + predictions: dict[str, np.ndarray], + bbox_margin: int = 0, + image_size: tuple[int, int] = (1024, 1024), +) -> list[dict]: + w, h = image_size + num_joints = len(ground_truth["categories"][0]["keypoints"]) + path_to_id = {img["file_name"]: img["id"] for img in ground_truth["images"]} + + coco_predictions = [] + for path, image_keypoints in predictions.items(): + assert image_keypoints.shape[1] == num_joints + + img_id = path_to_id[path] + valid_predictions = [ + kpt for kpt in image_keypoints if np.any(np.all(~np.isnan(kpt), axis=-1)) + ] + for kpts in valid_predictions: + score = float(np.nanmean(kpts[:, 2]).item()) + kpts = kpts.copy() + kpts[:, 2] = 2 + + # NaN predictions to infinity + kpts[np.isnan(kpts)] = np.inf + + bbox = bbox_from_keypoints( + keypoints=kpts, + image_h=h, + image_w=w, + margin=bbox_margin, + ) + area = bbox[2].item() * bbox[3].item() + coco_predictions.append( + { + "image_id": img_id, + "category_id": 1, + "keypoints": kpts.reshape(-1).tolist(), + "bbox": bbox.tolist(), + "area": area, + "score": score, + } + ) + + return coco_predictions + + +def eval_coco( + ground_truth: dict, + predictions: list[dict], + num_joints: int, +) -> float | None: + try: + from pycocotools.coco import COCO + from pycocotools.cocoeval import COCOeval + + coco = COCO() + coco.dataset["annotations"] = ground_truth["annotations"] + coco.dataset["categories"] = ground_truth["categories"] + coco.dataset["images"] = ground_truth["images"] + coco.createIndex() + + coco_det = coco.loadRes(predictions) + coco_eval = COCOeval(coco, coco_det, iouType="keypoints") + coco_eval.params.kpt_oks_sigmas = np.array(num_joints * [0.1]) + coco_eval.evaluate() + coco_eval.accumulate() + coco_eval.summarize() + return float(coco_eval.stats[0]) + + except ModuleNotFoundError as err: + print(f"pycocotools is not installed") diff --git a/tests/core/metrics/test_metrics_rmse_computation.py b/tests/core/metrics/test_metrics_rmse_computation.py new file mode 100644 index 0000000000..61fed3d8e2 --- /dev/null +++ b/tests/core/metrics/test_metrics_rmse_computation.py @@ -0,0 +1,201 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Tests RMSE computation""" +import numpy as np +import pytest +from numpy.testing import assert_almost_equal + +from deeplabcut.core.metrics.distance_metrics import ( + compute_detection_rmse, + compute_rmse, +) + + +@pytest.mark.parametrize( + "gt, pred, result", + [ + ( + [ # ground truth pose + [[100.0, 10.0, 2], [150.0, 15.0, 2], [200.0, 20.0, 2]], + ], + [ # predicted pose + [[100.0, 10.0, 0.9], [150.0, 15.0, 0.8], [200.0, 20.0, 0.8]], + ], + (0, 0), + ), + ( + [ # ground truth pose + [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]], + [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]], + ], + [ # predicted pose + [[12.0, 10.0, 0.9], [12.0, 10.0, 0.9], [12.0, 10.0, 0.9]], + [[22.0, 20.0, 0.9], [22.0, 20.0, 0.9], [22.0, 20.0, 0.9]], + ], + (2, 2), + ), + ( + [ # ground truth pose + [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]], + [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]], + ], + [ # predicted pose + [[10.0, 12.0, 0.9], [10.0, 12.0, 0.9], [10.0, 12.0, 0.9]], + [[20.0, 22.0, 0.9], [20.0, 22.0, 0.9], [20.0, 22.0, 0.9]], + ], + (2, 2), + ), + ], +) +def test_rmse_single_image(gt: list, pred: list, result: tuple[float, float]): + data = [(np.asarray(gt), np.asarray(pred))] + rmse, rmse_cutoff = compute_rmse(data, False, pcutoff=0.6, oks_bbox_margin=10.0) + expected_rmse, expected_rmse_cutoff = result + assert_almost_equal(rmse, expected_rmse) + assert_almost_equal(rmse_cutoff, expected_rmse_cutoff) + + +@pytest.mark.parametrize( + "gt, pred, result", + [ + ( + [ # ground truth pose + [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]], + [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]], + ], + [ # predicted pose + [[10.0, 10.0, 0.9], [10.0, 10.0, 0.9], [10.0, 10.0, 0.9]], + [[20.0, 22.0, 0.2], [20.0, 22.0, 0.2], [20.0, 22.0, 0.2]], + ], + (1, 0), # 2 pixel error on half of keypoints, 0 on the other half + ), + ], +) +def test_rmse_pcutoff(gt: list, pred: list, result: tuple[float, float]): + data = [(np.asarray(gt), np.asarray(pred))] + expected_rmse, expected_rmse_cutoff = result + + rmse, rmse_cutoff = compute_rmse(data, False, pcutoff=0.6, oks_bbox_margin=10.0) + assert_almost_equal(rmse, expected_rmse) + assert_almost_equal(rmse_cutoff, expected_rmse_cutoff) + + +@pytest.mark.parametrize( + "gt, pred, result", + [ + ( + [ # ground truth pose + [[10.0, 10.0, 2], [float("nan"), float("nan"), 0], [10.0, 10.0, 2]], + ], + [ # predicted pose + [[12.0, 10.0, 0.9], [10.0, 10.0, 0.4], [10.0, 10.0, 0.9]], + ], + (1, 1), # only 2 valid ground truth bodyparts + ), + ( + [ # ground truth pose + [[10.0, 10.0, 2], [10.0, 10.0, 2], [float("nan"), float("nan"), 0]], + [[float("nan"), float("nan"), 0], [20.0, 20.0, 2], [20.0, 20.0, 2]], + ], + [ # predicted pose, swapped prediction order + [[20.0, 20.0, 0.9], [21.0, 20.0, 0.9], [21.0, 20.0, 0.9]], + [[15.0, 10.0, 0.4], [15.0, 10.0, 0.4], [10.0, 10.0, 0.9]], + ], + (3, 1), # only 2 valid GT bodyparts + ), + ], +) +def test_rmse_with_nans(gt: list, pred: list, result: tuple[float, float]): + data = [(np.asarray(gt), np.asarray(pred))] + expected_rmse, expected_rmse_cutoff = result + + rmse, rmse_cutoff = compute_rmse(data, False, pcutoff=0.6, oks_bbox_margin=10.0) + assert_almost_equal(rmse, expected_rmse) + assert_almost_equal(rmse_cutoff, expected_rmse_cutoff) + + +@pytest.mark.parametrize( + "gt, pred, result", + [ + ( + [ # ground truth pose + [[10.0, 10.0, 2], [np.nan, np.nan, 0], [10.0, 10.0, 2]], + ], + [ # predicted pose + [[12.0, 10.0, 0.9], [10.0, 10.0, 0.4], [10.0, 10.0, 0.9]], + ], + (1, 1), # error 2 on one, 0 on the other; only 2 valid GT + ), + ( + [ # ground truth pose + [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]], + [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]], + ], + [ # predicted pose, perfect detections but mis-assembled + [[10.0, 10.0, 0.9], [50.0, 50.0, 0.9], [30.0, 30.0, 0.9]], + [[40.0, 40.0, 0.9], [20.0, 20.0, 0.4], [60.0, 60.0, 0.9]], + ], + (0, 0), # all pose perfect + ), + ( + [ # ground truth pose + [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]], + [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]], + ], + [ # predicted pose, small error in pose and mis-assembled + [[12.0, 10.0, 0.9], [52.0, 50.0, 0.9], [32.0, 30.0, 0.9]], + [[42.0, 40.0, 0.9], [18.0, 20.0, 0.4], [62.0, 60.0, 0.9]], + ], + (2, 2), # pixel error of 2 on x-axis for all predictions + ), + ( + [ # ground truth pose + [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]], + [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]], + ], + [ # predicted pose, small error in low-conf pose and mis-assembled + [[12.0, 10.0, 0.4], [50.0, 50.0, 0.9], [30.0, 30.0, 0.9]], + [[40.0, 40.0, 0.9], [22.0, 20.0, 0.4], [62.0, 60.0, 0.4]], + ], + (1, 0), # error of 2 on half, 0 on the other half (with good conf) + ), + ( # more ground truth than detections + [ # ground truth pose + [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]], + [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]], + [[70.0, 70.0, 2], [80.0, 80.0, 2], [90.0, 90.0, 2]], + ], + [ # predicted pose, no error + [[70.0, 70.0, 2], [80.0, 80.0, 2], [90.0, 90.0, 2]], + [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]], + ], + (0, 0), + ), + ( # more detections than GT + [ # predicted pose, no error + [[70.0, 70.0, 2], [80.0, 80.0, 2], [90.0, 90.0, 2]], + [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]], + ], + [ # ground truth pose + [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]], + [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]], + [[70.0, 70.0, 2], [80.0, 80.0, 2], [90.0, 90.0, 2]], + ], + (0, 0), + ), + ], +) +def test_detection_rmse(gt: list, pred: list, result: tuple[float, float]): + data = [(np.asarray(gt), np.asarray(pred))] + expected_rmse, expected_rmse_cutoff = result + rmse, rmse_cutoff = compute_detection_rmse(data, pcutoff=0.6) + assert_almost_equal(rmse, expected_rmse) + assert_almost_equal(rmse_cutoff, expected_rmse_cutoff) From 7232eac466c9497e971db6e51d108772bfab2874 Mon Sep 17 00:00:00 2001 From: Zelin2001 Date: Tue, 23 Jul 2024 14:56:34 +0800 Subject: [PATCH 203/293] modified: deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py (#2685) --- .../apis/analyze_videos.py | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index a74c4fee0c..9f3b3c5804 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -380,7 +380,7 @@ def analyze_videos( trainingsetindex=trainingsetindex, overwrite=False, identity_only=identity_only, - destfolder=str(destfolder), + destfolder=str(output_path), ) stitch_tracklets( config, @@ -388,7 +388,7 @@ def analyze_videos( videotype, shuffle, trainingsetindex, - destfolder=str(destfolder), + destfolder=str(output_path), ) print( @@ -436,14 +436,25 @@ def create_df_from_prediction( ) if pred_unique_bodyparts is not None: coordinate_labels_unique = ["x", "y", "likelihood"] - results_unique_df_index = pd.MultiIndex.from_product( - [ - [dlc_scorer], - auxiliaryfunctions.get_unique_bodyparts(cfg), - coordinate_labels_unique, - ], - names=["scorer", "bodyparts", "coords"], - ) + if n_individuals > 1: + results_unique_df_index = pd.MultiIndex.from_product( + [ + [dlc_scorer], + ['single'], + auxiliaryfunctions.get_unique_bodyparts(cfg), + coordinate_labels_unique, + ], + names=["scorer", "individuals", "bodyparts", "coords"], + ) + else: + results_unique_df_index = pd.MultiIndex.from_product( + [ + [dlc_scorer], + auxiliaryfunctions.get_unique_bodyparts(cfg), + coordinate_labels_unique, + ], + names=["scorer", "bodyparts", "coords"], + ) df_u = pd.DataFrame( pred_unique_bodyparts.reshape((len(pred_unique_bodyparts), -1)), columns=results_unique_df_index, From 59901977563c5e0036a6da2d3b6a538978dd4af3 Mon Sep 17 00:00:00 2001 From: AlexEMG Date: Wed, 24 Jul 2024 20:51:51 +0200 Subject: [PATCH 204/293] rc 3 release --- deeplabcut/version.py | 2 +- examples/test.sh | 2 +- reinstall.sh | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deeplabcut/version.py b/deeplabcut/version.py index 4dba388b42..314ba990e9 100644 --- a/deeplabcut/version.py +++ b/deeplabcut/version.py @@ -9,5 +9,5 @@ # Licensed under GNU Lesser General Public License v3.0 # -__version__ = "3.0.0rc2" +__version__ = "3.0.0rc3" VERSION = __version__ diff --git a/examples/test.sh b/examples/test.sh index d284165a0e..e0bcd07871 100755 --- a/examples/test.sh +++ b/examples/test.sh @@ -6,7 +6,7 @@ rm -r OUT cd .. pip uninstall deeplabcut python3 setup.py sdist bdist_wheel -pip install dist/deeplabcut-2.3.10-py3-none-any.whl +pip install dist/deeplabcut-3.0.0rc3-none-any.whl cd examples diff --git a/reinstall.sh b/reinstall.sh index 17ddef1897..0ab1caa9a6 100755 --- a/reinstall.sh +++ b/reinstall.sh @@ -1,3 +1,3 @@ pip uninstall deeplabcut python3 setup.py sdist bdist_wheel -pip install dist/deeplabcut-3.0.0rc2-py3-none-any.whl +pip install dist/deeplabcut-3.0.0rc3-py3-none-any.whl diff --git a/setup.py b/setup.py index 4f15e36a8a..3b1409d296 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def pytorch_config_paths() -> list[str]: setuptools.setup( name="deeplabcut", - version="3.0.0rc2", + version="3.0.0rc3", author="A. & M.W. Mathis Labs", author_email="alexander@deeplabcut.org", description="Markerless pose-estimation of user-defined features with deep learning", From e5d9d542529a4b544edd6b60e2126efc5203d701 Mon Sep 17 00:00:00 2001 From: Jessy Lauer <30733203+jeylau@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:43:17 +0200 Subject: [PATCH 205/293] Fix import (#2691) --- deeplabcut/pose_tracking_pytorch/apis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/pose_tracking_pytorch/apis.py b/deeplabcut/pose_tracking_pytorch/apis.py index 0e3671e16c..2b98fcaa0b 100644 --- a/deeplabcut/pose_tracking_pytorch/apis.py +++ b/deeplabcut/pose_tracking_pytorch/apis.py @@ -94,7 +94,7 @@ def transformer_reID( modelprefix=modelprefix, ) - deeplabcut.pose_estimation_tensorflow.create_tracking_dataset( + deeplabcut.compat.create_tracking_dataset( config, videos, track_method, From 6e1bca20def176192df9b9b9706a61275f8671e4 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 2 Aug 2024 11:28:15 +0200 Subject: [PATCH 206/293] Location Refinement for Top-Down models (#2699) * add locref to top-down model heads * use ischecked in analyze videos --- deeplabcut/gui/tabs/analyze_videos.py | 13 ++++++------- .../config/base/head_topdown.yaml | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/deeplabcut/gui/tabs/analyze_videos.py b/deeplabcut/gui/tabs/analyze_videos.py index 5e1fa88194..6d289430d2 100644 --- a/deeplabcut/gui/tabs/analyze_videos.py +++ b/deeplabcut/gui/tabs/analyze_videos.py @@ -330,13 +330,13 @@ def run_enabled(self): shuffle = self.root.shuffle_value videos = list(self.files) - save_as_csv = self.save_as_csv.checkState() == Qt.Checked - save_as_nwb = self.save_as_nwb.checkState() == Qt.Checked - filter_data = self.filter_predictions.checkState() == Qt.Checked + save_as_csv = self.save_as_csv.isChecked() + save_as_nwb = self.save_as_nwb.isChecked() + filter_data = self.filter_predictions.isChecked() videotype = self.video_selection_widget.videotype_widget.currentText() try: create_video_all_detections = ( - self.create_detections_video_checkbox.checkState() == Qt.Checked + self.create_detections_video_checkbox.isChecked() ) except AttributeError: create_video_all_detections = False @@ -361,12 +361,11 @@ def run_enabled(self): track_method=track_method, ) - if self.plot_trajectories.checkState() == Qt.Checked: + if self.plot_trajectories.isChecked(): bdpts = self.bodyparts_list_widget.selected_bodyparts self.root.logger.debug( f"Selected body parts for plot_trajectories: {bdpts}" ) - showfig = self.show_trajectory_plots.checkState() == Qt.Checked deeplabcut.plot_trajectories( config, videos=videos, @@ -374,7 +373,7 @@ def run_enabled(self): videotype=videotype, shuffle=shuffle, filtered=filter_data, - showfigures=showfig, + showfigures=self.show_trajectory_plots.isChecked(), track_method=track_method, ) diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml index 8e87759825..d848a2eb50 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml @@ -4,17 +4,22 @@ predictor: type: HeatmapPredictor apply_sigmoid: false clip_scores: true - location_refinement: false + location_refinement: true + locref_std: 7.2801 target_generator: type: HeatmapGaussianGenerator num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT - generate_locref: false + generate_locref: true + locref_std: 7.2801 criterion: heatmap: type: WeightedMSECriterion weight: 1.0 + locref: + type: WeightedHuberCriterion + weight: 0.05 heatmap_config: channels: - "backbone_output_channels" @@ -23,3 +28,11 @@ heatmap_config: final_conv: out_channels: "num_bodyparts" kernel_size: 1 +locref_config: + channels: + - "backbone_output_channels" + kernel_size: [] + strides: [] + final_conv: + out_channels: "num_bodyparts x 2" + kernel_size: 1 From 24ba11153eecb1d05258c503dcf0d01cfde1542e Mon Sep 17 00:00:00 2001 From: AlexEMG Date: Tue, 6 Aug 2024 19:31:52 +0200 Subject: [PATCH 207/293] rc4 --- deeplabcut/version.py | 2 +- examples/test.sh | 2 +- reinstall.sh | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deeplabcut/version.py b/deeplabcut/version.py index 314ba990e9..fedb976547 100644 --- a/deeplabcut/version.py +++ b/deeplabcut/version.py @@ -9,5 +9,5 @@ # Licensed under GNU Lesser General Public License v3.0 # -__version__ = "3.0.0rc3" +__version__ = "3.0.0rc4" VERSION = __version__ diff --git a/examples/test.sh b/examples/test.sh index e0bcd07871..23073df43a 100755 --- a/examples/test.sh +++ b/examples/test.sh @@ -6,7 +6,7 @@ rm -r OUT cd .. pip uninstall deeplabcut python3 setup.py sdist bdist_wheel -pip install dist/deeplabcut-3.0.0rc3-none-any.whl +pip install dist/deeplabcut-3.0.0rc4-none-any.whl cd examples diff --git a/reinstall.sh b/reinstall.sh index 0ab1caa9a6..08d698910f 100755 --- a/reinstall.sh +++ b/reinstall.sh @@ -1,3 +1,3 @@ pip uninstall deeplabcut python3 setup.py sdist bdist_wheel -pip install dist/deeplabcut-3.0.0rc3-py3-none-any.whl +pip install dist/deeplabcut-3.0.0rc4-py3-none-any.whl diff --git a/setup.py b/setup.py index 3b1409d296..4d7760608c 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def pytorch_config_paths() -> list[str]: setuptools.setup( name="deeplabcut", - version="3.0.0rc3", + version="3.0.0rc4", author="A. & M.W. Mathis Labs", author_email="alexander@deeplabcut.org", description="Markerless pose-estimation of user-defined features with deep learning", From 9421aad8ba884faa9299bbba98827496f137e32f Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Thu, 8 Aug 2024 11:21:34 +0200 Subject: [PATCH 208/293] learning identity - add identity predictor (#2707) --- .../config/base/head_identity.yaml | 4 +- .../models/predictors/__init__.py | 3 + .../models/predictors/identity_predictor.py | 69 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml index 6f62e9f927..a5417b21a5 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml @@ -1,7 +1,7 @@ type: HeatmapHead predictor: - type: HeatmapPredictor - location_refinement: false + type: IdentityPredictor + apply_sigmoid: true target_generator: type: HeatmapPlateauGenerator num_heatmaps: "num_individuals" diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py index c3acf11f9b..4db634c896 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py @@ -15,6 +15,9 @@ from deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor import ( DEKRPredictor, ) +from deeplabcut.pose_estimation_pytorch.models.predictors.identity_predictor import ( + IdentityPredictor, +) from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import ( PartAffinityFieldPredictor, ) diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py new file mode 100644 index 0000000000..9f209df4e7 --- /dev/null +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py @@ -0,0 +1,69 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Predictor to generate identity maps from head outputs""" +import torch +import torch.nn as nn +import torchvision.transforms.functional as F + +from deeplabcut.pose_estimation_pytorch.models.predictors.base import ( + BasePredictor, + PREDICTORS, +) + + +@PREDICTORS.register_module +class IdentityPredictor(BasePredictor): + """Predictor to generate identity maps from head outputs + + Attributes: + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + """ + + def __init__(self, apply_sigmoid: bool = True): + """ + Args: + apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True. + """ + super().__init__() + self.apply_sigmoid = apply_sigmoid + self.sigmoid = nn.Sigmoid() + + def forward( + self, stride: float, outputs: dict[str, torch.Tensor] + ) -> dict[str, torch.Tensor]: + """ + Swaps the dimensions so the heatmap are (batch_size, h, w, num_individuals), + optionally applies a sigmoid to the heatmaps, and rescales it to be the size + of the original image (so that the identity scores of keypoints can be computed) + + Args: + stride: the stride of the model + outputs: output of the model identity head, of shape (b, num_idv, w', h') + + Returns: + A dictionary containing a "heatmap" key with the identity heatmap tensor as + value. + """ + heatmaps = outputs["heatmap"] + h_out, w_out = heatmaps.shape[2:] + h_in, w_in = int(h_out * stride), int(w_out * stride) + heatmaps = F.resize( + heatmaps, + size=[h_in, w_in], + interpolation=F.InterpolationMode.BILINEAR, + antialias=True, + ) + if self.apply_sigmoid: + heatmaps = self.sigmoid(heatmaps) + + # permute to have shape (batch_size, h, w, num_individuals) + heatmaps = heatmaps.permute((0, 2, 3, 1)) + return {"heatmap": heatmaps} From 8a294c622478b3cb34cab354947261c5bde8b849 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 9 Aug 2024 18:15:51 +0200 Subject: [PATCH 209/293] batched inference (#2708) * implemented batched video analysis * fix typo * added batch size for SuperAnimal video analysis * added detector batch size option * added more verbose info when running video inference * bug fix: no bounding boxes predicted * fixed typos * removed extra import --- deeplabcut/compat.py | 2 +- deeplabcut/create_project/new.py | 1 + deeplabcut/modelzoo/video_inference.py | 13 ++ .../apis/analyze_videos.py | 25 ++- .../pose_estimation_pytorch/apis/utils.py | 6 + .../modelzoo/inference.py | 8 + .../runners/inference.py | 168 ++++++++++++------ deeplabcut/utils/auxiliaryfunctions.py | 7 + .../runners/test_runners_inference.py | 144 +++++++++++++++ 9 files changed, 314 insertions(+), 60 deletions(-) create mode 100644 tests/pose_estimation_pytorch/runners/test_runners_inference.py diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index 0519693a27..9822859022 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -861,7 +861,7 @@ def analyze_videos( trainingsetindex=trainingsetindex, save_as_csv=save_as_csv, destfolder=destfolder, - batchsize=batchsize, + batch_size=batchsize, modelprefix=modelprefix, auto_track=auto_track, identity_only=identity_only, diff --git a/deeplabcut/create_project/new.py b/deeplabcut/create_project/new.py index 7423155fa8..d366a67eb8 100644 --- a/deeplabcut/create_project/new.py +++ b/deeplabcut/create_project/new.py @@ -280,6 +280,7 @@ def create_new_project( cfg_file[ "batch_size" ] = 8 # batch size during inference (video - analysis); see https://www.biorxiv.org/content/early/2018/10/30/457242 + cfg_file["detector_batch_size"] = 1 cfg_file["corner2move2"] = (50, 50) cfg_file["move2corner"] = True cfg_file["skeleton_color"] = "black" diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py index f5701b115a..8f304c3380 100644 --- a/deeplabcut/modelzoo/video_inference.py +++ b/deeplabcut/modelzoo/video_inference.py @@ -40,6 +40,8 @@ def video_inference_superanimal( dest_folder: Optional[str] = None, video_adapt: bool = False, plot_trajectories: bool = False, + batch_size: int = 1, + detector_batch_size: int = 1, pcutoff: float = 0.1, adapt_iterations: int = 1000, pseudo_threshold: float = 0.1, @@ -87,9 +89,16 @@ def video_inference_superanimal( video_adapt (bool): Whether to perform video adaptation. The default is False. You only need to perform it on one video because the adaptation generalizes to all videos that are similar. + plot_trajectories (bool): Whether to plot the trajectories. The default is False. + batch_size (int): + The batch size to use for video inference. Only for PyTorch models. + + detector_batch_size (int): + The batch size to use for the detector during video inference. Only for PyTorch. + pcutoff (float): The p-value cutoff for the confidence of the prediction. The default is 0.1. @@ -290,6 +299,8 @@ def video_inference_superanimal( max_individuals, pcutoff, device=device, + batch_size=batch_size, + detector_batch_size=detector_batch_size, dest_folder=dest_folder, customized_pose_checkpoint=customized_pose_checkpoint, customized_detector_checkpoint=customized_detector_checkpoint, @@ -430,6 +441,8 @@ def video_inference_superanimal( max_individuals, pcutoff, device=device, + batch_size=batch_size, + detector_batch_size=detector_batch_size, dest_folder=dest_folder, customized_pose_checkpoint=customized_pose_checkpoint, customized_detector_checkpoint=customized_detector_checkpoint, diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 9f3b3c5804..c850f62502 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -118,12 +118,11 @@ def video_inference( if detector_runner is None: raise ValueError("Must use a detector for top-down video analysis") - print("Running Detector") + print(f"Running detector with batch size {detector_runner.batch_size}") bbox_predictions = detector_runner.inference(images=tqdm(video)) - video.set_context(bbox_predictions) - print("Running Pose Prediction") + print(f"Running pose prediction with batch size {pose_runner.batch_size}") predictions = pose_runner.inference(images=tqdm(video)) if with_identity: @@ -161,7 +160,8 @@ def analyze_videos( detector_snapshot_index: int | str | None = None, device: str | None = None, destfolder: str | None = None, - batchsize: int | None = None, + batch_size: int | None = None, + detector_batch_size: int | None = None, modelprefix: str = "", transform: A.Compose | None = None, auto_track: bool | None = True, @@ -171,7 +171,6 @@ def analyze_videos( """Makes prediction based on a trained network. # TODO: - - allow batch size greater than 1 - other options missing options such as shelve - pass detector path or detector runner @@ -206,8 +205,10 @@ def analyze_videos( snapshot to use, used in the same way as ``snapshot_index`` modelprefix: directory containing the deeplabcut models to use when evaluating the network. By default, they are assumed to exist in the project folder. - batchsize: the batch size to use for inference. Takes the value from the - PyTorch config as a default + batch_size: the batch size to use for inference. Takes the value from the + project config as a default. + detector_batch_size: the batch size to use for detector inference. Takes the + value from the project config as a default. transform: Optional custom transforms to apply to the video overwrite: Overwrite any existing videos auto_track: By default, tracking and stitching are automatically performed, @@ -261,6 +262,9 @@ def analyze_videos( if device is not None: model_cfg["device"] = device + if batch_size is None: + batch_size = cfg["batch_size"] + snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0] print(f"Analyzing videos with {snapshot.path}") detector_path, detector_snapshot = None, None @@ -272,6 +276,9 @@ def analyze_videos( "project's configuration file." ) + if detector_batch_size is None: + detector_batch_size = cfg.get("detector_batch_size", 1) + detector_snapshot = get_model_snapshots( detector_snapshot_index, train_folder, Task.DETECT )[0] @@ -291,8 +298,10 @@ def analyze_videos( max_individuals=max_num_animals, num_bodyparts=len(bodyparts), num_unique_bodyparts=len(unique_bodyparts), + batch_size=batch_size, with_identity=with_identity, transform=transform, + detector_batch_size=detector_batch_size, detector_path=detector_path, detector_transform=None, ) @@ -326,7 +335,7 @@ def analyze_videos( pytorch_config=model_cfg, dlc_scorer=dlc_scorer, train_fraction=train_fraction, - batch_size=batchsize, + batch_size=batch_size, runtime=(runtime[0], runtime[1]), video=VideoReader(str(video)), ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py index c7d7d5f116..709f43fcb3 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/utils.py +++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py @@ -372,9 +372,11 @@ def get_inference_runners( max_individuals: int, num_bodyparts: int, num_unique_bodyparts: int, + batch_size: int = 1, device: str | None = None, with_identity: bool = False, transform: A.BaseCompose | None = None, + detector_batch_size: int = 1, detector_path: str | Path | None = None, detector_transform: A.BaseCompose | None = None, ) -> tuple[InferenceRunner, InferenceRunner | None]: @@ -386,10 +388,12 @@ def get_inference_runners( max_individuals: the maximum number of individuals per image num_bodyparts: the number of bodyparts predicted by the model num_unique_bodyparts: the number of unique_bodyparts predicted by the model + batch_size: the batch size to use for the pose model. with_identity: whether the pose model has an identity head device: if defined, overwrites the device selection from the model config transform: the transform for pose estimation. if None, uses the transform defined in the config. + detector_batch_size: the batch size to use for the detector detector_path: the path to the detector snapshot from which to load weights, for top-down models (if a detector runner is needed) detector_transform: the transform for object detection. if None, uses the @@ -450,6 +454,7 @@ def get_inference_runners( model=DETECTORS.build(detector_config), device=detector_device, snapshot_path=detector_path, + batch_size=detector_batch_size, preprocessor=build_bottom_up_preprocessor( color_mode=model_config["detector"]["data"]["colormode"], transform=detector_transform, @@ -464,6 +469,7 @@ def get_inference_runners( model=PoseModel.build(model_config["model"]), device=device, snapshot_path=snapshot_path, + batch_size=batch_size, preprocessor=pose_preprocessor, postprocessor=pose_postprocessor, ) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py index 0a5cf9fae0..681e32b8f5 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py @@ -55,6 +55,8 @@ def _video_inference_superanimal( model_name: str, max_individuals: int, pcutoff: float, + batch_size: int = 1, + detector_batch_size: int = 1, device: Optional[str] = None, dest_folder: Optional[str] = None, customized_pose_checkpoint: Optional[str] = None, @@ -86,6 +88,10 @@ def _video_inference_superanimal( `select_device` function. Defaults to None, which triggers automatic device selection. + batch_size: The batch size to use for video inference. + + detector_batch_size: The batch size to use for the detector for video inference. + dest_folder: Destination folder for the results. If not specified, the results are saved in the same folder as the video. Defaults to None. @@ -142,6 +148,8 @@ def _video_inference_superanimal( max_individuals=max_individuals, num_bodyparts=len(config["bodyparts"]), num_unique_bodyparts=0, + batch_size=batch_size, + detector_batch_size=detector_batch_size, detector_path=detector_path, ) pose_task = Task(config.get("method", "BU")) diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py index 32cad872ef..76f35dfe17 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -35,6 +35,7 @@ class InferenceRunner(Runner, Generic[ModelType], metaclass=ABCMeta): def __init__( self, model: ModelType, + batch_size: int = 1, device: str = "cpu", snapshot_path: str | Path | None = None, preprocessor: Preprocessor | None = None, @@ -49,12 +50,21 @@ def __init__( postprocessor: the postprocessor to use on images after inference """ super().__init__(model=model, device=device, snapshot_path=snapshot_path) + if not isinstance(batch_size, int) or batch_size <= 0: + raise ValueError(f"batch_size must be a positive integer; is {batch_size}") + + self.batch_size = batch_size self.preprocessor = preprocessor self.postprocessor = postprocessor if self.snapshot_path is not None and self.snapshot_path != "": self.load_snapshot(self.snapshot_path, self.device, self.model) + self._batch: torch.Tensor | None = None + self._contexts: list[dict] = [] + self._image_batch_sizes: list[int] = [] + self._predictions: list = [] + @abstractmethod def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: """Makes predictions from a model input and output @@ -94,25 +104,91 @@ def inference( results = [] for data in images: - if isinstance(data, (str, np.ndarray)): - input_image, context = data, {} - else: - input_image, context = data + self._prepare_inputs(data) + self._process_full_batches() + results += self._extract_results() + + # Process the last batch even if not full + if self._inputs_waiting_for_processing(): + self._process_batch() + results += self._extract_results() + + return results - if self.preprocessor is not None: - # TODO: input batch should also be able to be a dict[str, torch.Tensor] - input_image, context = self.preprocessor(input_image, context) + def _prepare_inputs( + self, data: str | np.ndarray | tuple[str | np.ndarray, dict], + ) -> None: + """ + Prepares inputs for an image and adds them to the data ready to be processed + """ + if isinstance(data, (str, np.ndarray)): + inputs, context = data, {} + else: + inputs, context = data + + if self.preprocessor is not None: + inputs, context = self.preprocessor(inputs, context) + else: + inputs = torch.as_tensor(inputs) + + self._contexts.append(context) + self._image_batch_sizes.append(len(inputs)) + + # skip when there are no inputs for an image + if len(inputs) == 0: + return + + if self._batch is None: + self._batch = inputs + else: + self._batch = torch.cat([self._batch, inputs], dim=0) + + def _process_full_batches(self) -> None: + """Processes prepared inputs in batches of the desired batch size.""" + while self._batch is not None and len(self._batch) >= self.batch_size: + self._process_batch() + + def _extract_results(self) -> list: + """Obtains results that were obtained from processing a batch.""" + results = [] + while ( + len(self._image_batch_sizes) > 0 + and len(self._predictions) >= self._image_batch_sizes[0] + ): + num_predictions = self._image_batch_sizes[0] + image_predictions = self._predictions[:num_predictions] + context = self._contexts[0] - image_predictions = self.predict(input_image) if self.postprocessor is not None: # TODO: Should we return context? # TODO: typing update - the post-processor can remove a dict level image_predictions, _ = self.postprocessor(image_predictions, context) + self._contexts = self._contexts[1:] + self._image_batch_sizes = self._image_batch_sizes[1:] + self._predictions = self._predictions[num_predictions:] results.append(image_predictions) return results + def _process_batch(self) -> None: + """ + Processes a batch. There must be inputs waiting to be processed before this is + called, otherwise this method will raise an error. + """ + batch = self._batch[:self.batch_size] + self._predictions += self.predict(batch) + + # remove processed inputs from batch + if len(self._batch) <= self.batch_size: + self._batch = None + else: + self._batch = self._batch[self.batch_size:] + + def _inputs_waiting_for_processing(self) -> bool: + """Returns: Whether there are inputs which have not yet been processed""" + return self._batch is not None and len(self._batch) > 0 + class PoseInferenceRunner(InferenceRunner[PoseModel]): """Runner for pose estimation inference""" @@ -134,24 +210,19 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] "unique_bodypart": "poses": np.ndarray}, ] """ - # TODO: iterates over batch one element at a time - batch_size = 1 - batch_predictions = [] - for i in range(0, len(inputs), batch_size): - batch_inputs = inputs[i : i + batch_size] - batch_inputs = batch_inputs.to(self.device) - batch_outputs = self.model(batch_inputs) - raw_predictions = self.model.get_predictions(batch_outputs) - - for b in range(batch_size): - image_predictions = {} - for head, head_outputs in raw_predictions.items(): - image_predictions[head] = {} - for pred_name, pred in head_outputs.items(): - image_predictions[head][pred_name] = pred[b].cpu().numpy() - batch_predictions.append(image_predictions) - - return batch_predictions + outputs = self.model(inputs.to(self.device)) + raw_predictions = self.model.get_predictions(outputs) + predictions = [ + { + head: { + pred_name: pred[b].cpu().numpy() + for pred_name, pred in head_outputs.items() + } + for head, head_outputs in raw_predictions.items() + } + for b in range(len(inputs)) + ] + return predictions class DetectorInferenceRunner(InferenceRunner[BaseDetector]): @@ -179,31 +250,23 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] "unique_bodypart": "poses": np.ndarray}, ] """ - # TODO: iterates over batch one element at a time - batch_size = 1 - batch_predictions = [] - for i in range(0, len(inputs), batch_size): - batch_inputs = inputs[i : i + batch_size] - batch_inputs = batch_inputs.to(self.device) - _, raw_predictions = self.model(batch_inputs) - for b, item in enumerate(raw_predictions): - # take the top-k bounding boxes as individuals - batch_predictions.append( - { - "detection": { - "bboxes": item["boxes"] - .cpu() - .numpy() - .reshape(-1, 4), - "scores": item["scores"] - .cpu() - .numpy() - .reshape(-1), - } - } - ) - - return batch_predictions + _, raw_predictions = self.model(inputs.to(self.device)) + predictions = [ + { + "detection": { + "bboxes": item["boxes"] + .cpu() + .numpy() + .reshape(-1, 4), + "scores": item["scores"] + .cpu() + .numpy() + .reshape(-1), + } + } + for item in raw_predictions + ] + return predictions def build_inference_runner( @@ -211,6 +274,7 @@ def build_inference_runner( model: nn.Module, device: str, snapshot_path: str | Path, + batch_size: int = 1, preprocessor: Preprocessor | None = None, postprocessor: Postprocessor | None = None, ) -> InferenceRunner: @@ -222,6 +286,7 @@ def build_inference_runner( model: the model to run device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'}) snapshot_path: the snapshot from which to load the weights + batch_size: the batch size to use to run inference preprocessor: the preprocessor to use on images before inference postprocessor: the postprocessor to use on images after inference @@ -232,6 +297,7 @@ def build_inference_runner( model=model, device=device, snapshot_path=snapshot_path, + batch_size=batch_size, preprocessor=preprocessor, postprocessor=postprocessor, ) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 82a9fb5806..914c440ece 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -141,6 +141,7 @@ def create_config_template(multianimal=False): snapshotindex: detector_snapshotindex: batch_size: +detector_batch_size: \n # Cropping Parameters (for analysis and outlier frame detection) cropping: @@ -212,6 +213,12 @@ def read_config(configname): cfg["engine"] = Engine.TF.aliases[0] write_config(configname, cfg) + if cfg.get("detector_snapshotindex") is None: + cfg["detector_snapshotindex"] = -1 + + if cfg.get("detector_batch_size") is None: + cfg["detector_batch_size"] = 1 + if cfg["project_path"] != curr_dir: cfg["project_path"] = curr_dir write_config(configname, cfg) diff --git a/tests/pose_estimation_pytorch/runners/test_runners_inference.py b/tests/pose_estimation_pytorch/runners/test_runners_inference.py new file mode 100644 index 0000000000..dae2b04777 --- /dev/null +++ b/tests/pose_estimation_pytorch/runners/test_runners_inference.py @@ -0,0 +1,144 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Tests inference runners""" +from unittest.mock import Mock + +import numpy as np +import pytest +import torch + +import deeplabcut.pose_estimation_pytorch.data.postprocessor as post +import deeplabcut.pose_estimation_pytorch.data.preprocessor as prep +import deeplabcut.pose_estimation_pytorch.runners.inference as inference + + +class MockInferenceRunner(inference.InferenceRunner): + """Mocks the predict function for an inference runner""" + + def __init__( + self, + batch_size: int = 1, + preprocessor: prep.Preprocessor | None = None, + postprocessor: post.Postprocessor | None = None, + ) -> None: + super().__init__( + model=Mock(), + batch_size=batch_size, + preprocessor=preprocessor, + postprocessor=postprocessor, + ) + self.batch_shapes = [] + + def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]: + self.batch_shapes.append(tuple(inputs.shape)) + return [ # return first elem of input + {"mock": {"index": i[0, 0, 0].detach().numpy()}} + for i in inputs + ] + + +@pytest.mark.parametrize("batch_size", [1, 2, 4, 8]) +def test_mock_bottom_up(batch_size): + h, w = 640, 480 + images = [i * np.ones((1, 3, h, w)) for i in range(10)] + + runner = MockInferenceRunner(batch_size=batch_size) + predictions = runner.inference(images) + + print() + print(f"Num images: {len(predictions)}") + print(f"Num predictions: {len(predictions)}") + print(f"Batch shapes: {runner.batch_shapes}") + print(80 * "-") + for i in images: + print(i[0, 0, 0, 0]) + print("----") + print(80 * "-") + for p in predictions: + print(p) + print("----") + + _check_batch_shapes(batch_size, h, w, runner.batch_shapes) + assert len(images) == len(predictions) + for i, p in zip(images, predictions): + assert len(p) == 1 # only 1 output per image + assert i[0, 0, 0, 0] == p[0]["mock"]["index"] + + +@pytest.mark.parametrize("batch_size", [1, 2, 4, 8]) +@pytest.mark.parametrize( + "detections_per_image", + [ + [1, 1, 1, 1, 1], + [0, 1, 0, 1, 1], # some frames might not have predictions + [0, 0, 0, 5, 2], + [1, 2, 3, 4], + [3, 4, 2, 1, 4], + [4, 23, 5, 20, 64, 100] + ] +) +def test_mock_top_down(batch_size, detections_per_image): + h, w = 8, 8 + images = [] + for index, num_detections in enumerate(detections_per_image): + if num_detections == 0: + detections = np.zeros((0, 3, 1, 1)) # random shape when no detections + else: + detections = np.concatenate( + [ + (1_000_000 * (index + 1) + i) * np.ones((1, 3, h, w)) + for i in range(num_detections) + ], + axis=0, + ) + + images.append(detections) + + runner = MockInferenceRunner(batch_size=batch_size) + predictions = runner.inference(images) + + print() + print(f"Num images: {len(predictions)}") + print(f"Num predictions: {len(predictions)}") + print(80 * "-") + for i in images: + for i_det in i: + print(i_det.shape) + print(i_det[0, 0, 0]) + print("----") + + print(80 * "-") + for p in predictions: + print(p) + print("----") + + _check_batch_shapes(batch_size, h, w, runner.batch_shapes) + + assert len(images) == len(predictions) + for i, p in zip(images, predictions): + assert len(p) == len(i) # one prediction per input + for i_det, p_det in zip(i, p): + print(i_det.shape) + print(p_det["mock"]["index"]) + assert i_det[0, 0, 0] == p_det["mock"]["index"] + + +def _check_batch_shapes(batch_size, h, w, batch_shapes) -> None: + for b in batch_shapes[:-1]: + assert b[0] == batch_size + assert b[1] == 3 + assert b[2] == h + assert b[3] == w + + assert batch_shapes[-1][0] <= batch_size + assert batch_shapes[-1][1] <= 3 + assert batch_shapes[-1][2] <= h + assert batch_shapes[-1][3] <= w From ed9492b194896842d18359909128b18727e846e1 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Sat, 10 Aug 2024 17:11:50 +0200 Subject: [PATCH 210/293] bug fixes - identity evaluation and ellipse tracking (#2709) --- deeplabcut/core/crossvalutils.py | 2 +- deeplabcut/core/trackingutils.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/deeplabcut/core/crossvalutils.py b/deeplabcut/core/crossvalutils.py index 4468b6e191..97813b04fc 100644 --- a/deeplabcut/core/crossvalutils.py +++ b/deeplabcut/core/crossvalutils.py @@ -75,7 +75,7 @@ def find_closest_neighbors( dist, inds = tree.query(query, k=k) idx = np.argsort(dist[:, 0]) neighbors = np.full(len(query), -1, dtype=int) - picked = set() + picked = {tree.n} for i, ind in enumerate(inds[idx]): for j in ind: if j not in picked: diff --git a/deeplabcut/core/trackingutils.py b/deeplabcut/core/trackingutils.py index 5ce20325f4..eff7c1ab6a 100644 --- a/deeplabcut/core/trackingutils.py +++ b/deeplabcut/core/trackingutils.py @@ -112,6 +112,9 @@ def calc_similarity_with(self, other_ellipse): max_dist = max( self.height, self.width, other_ellipse.height, other_ellipse.width ) + if max_dist == 0: + return 0 + dist = math.sqrt( (self.x - other_ellipse.x) ** 2 + (self.y - other_ellipse.y) ** 2 ) From 02fa64aac995d2d12a9387294156c30b2b1f1491 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Mon, 26 Aug 2024 11:48:38 +0200 Subject: [PATCH 211/293] bug fix: need to pick one more neighbor as picked contains the n+1th element (#2717) --- deeplabcut/core/crossvalutils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/core/crossvalutils.py b/deeplabcut/core/crossvalutils.py index 97813b04fc..e95b2c7591 100644 --- a/deeplabcut/core/crossvalutils.py +++ b/deeplabcut/core/crossvalutils.py @@ -82,7 +82,7 @@ def find_closest_neighbors( picked.add(j) neighbors[idx[i]] = j break - if len(picked) == n_preds: + if len(picked) == (n_preds + 1): break return neighbors From f86856c7afc92c90d494a80a76d4953bd9b163c9 Mon Sep 17 00:00:00 2001 From: Alexander Mathis Date: Mon, 26 Aug 2024 11:52:08 +0200 Subject: [PATCH 212/293] Minimal backward compatibility (#2713) * implemented batched video analysis * fix typo * added batch size for SuperAnimal video analysis * added detector batch size option * added more verbose info when running video inference * bug fix: no bounding boxes predicted * fixed typos * Default added for backward compat --------- Co-authored-by: Niels Poulsen --- deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py | 2 +- deeplabcut/pose_estimation_pytorch/runners/inference.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index c850f62502..81af1b85c2 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -263,7 +263,7 @@ def analyze_videos( model_cfg["device"] = device if batch_size is None: - batch_size = cfg["batch_size"] + batch_size = cfg.get("batch_size", 1) snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0] print(f"Analyzing videos with {snapshot.path}") diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py index 76f35dfe17..60b512c3dd 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -11,6 +11,7 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod +from collections import defaultdict from pathlib import Path from typing import Any, Generic, Iterable From a618192c0ce8d31441f35e3566de50aadb8c150a Mon Sep 17 00:00:00 2001 From: Alexander Mathis Date: Mon, 26 Aug 2024 12:07:46 +0200 Subject: [PATCH 213/293] Documentation Improvements - DeepLabCut PyTorch Code (#2714) * started reworking docs * small fixes * fix mem issue * added information about data loading * model configuration files * added model registry docs * added examples * doc fix * improved doc styling * Updated readme of expert guide * added head example, small bug fix when listing models * bug fix in doc --------- Co-authored-by: Niels Poulsen --- deeplabcut/compat.py | 10 +- deeplabcut/pose_estimation_pytorch/README.md | 498 ++++++++++++++++-- .../pose_estimation_pytorch/apis/train.py | 8 +- .../config/base/base.yaml | 2 +- .../config/make_pose_config.py | 8 +- .../pose_estimation_pytorch/config/utils.py | 2 +- .../pose_estimation_pytorch/data/dlcloader.py | 10 + .../runners/inference.py | 5 +- deeplabcut/utils/auxfun_multianimal.py | 2 +- deeplabcut/utils/pseudo_label.py | 1 + 10 files changed, 479 insertions(+), 67 deletions(-) diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index 9822859022..ddf74552f0 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -853,6 +853,15 @@ def analyze_videos( f"The 'use_shelve' option is not yet implemented with {engine}" ) + if batchsize is not None: + if "batch_size" in torch_kwargs: + print( + f"You called analyze_videos with parameters ``batchsize={batchsize}" + f"`` and batch_size={torch_kwargs['batch_size']}. Only one is " + f"needed/used. Using batch size {torch_kwargs['batch_size']}") + else: + torch_kwargs["batch_size"] = batchsize + return analyze_videos( config, videos=videos, @@ -861,7 +870,6 @@ def analyze_videos( trainingsetindex=trainingsetindex, save_as_csv=save_as_csv, destfolder=destfolder, - batch_size=batchsize, modelprefix=modelprefix, auto_track=auto_track, identity_only=identity_only, diff --git a/deeplabcut/pose_estimation_pytorch/README.md b/deeplabcut/pose_estimation_pytorch/README.md index acffd9454b..bd416b6dd0 100644 --- a/deeplabcut/pose_estimation_pytorch/README.md +++ b/deeplabcut/pose_estimation_pytorch/README.md @@ -1,82 +1,478 @@ # PyTorch DeepLabCut API -This is written primarily for maintainers and expert users. It details the logic for the DLC3.0 pytorch code. It is a WIP and will be expanded before the full release in 2024. -#teamDLC Dec 2023 +This overview is primarily written for maintainers and expert users. -**Structure of the pytorch DLC code:** +Here we detail the logic and structure for the DLC3.* PyTorch code. Furthermore, we +provide many practical examples to illustrate the usage of the code for developers. -This repo contains: models (architectures), solvers (losses and optimizers), and data (data loaders). +## Structure of the PyTorch DLC code -[Models](#models) -[Solvers](#solvers) -[Data](#data) -[API](#API) +[API](#API) -## Models +[Models](#models) -- [models](models): -The `deeplabcut.pose_estimations_pytorch.models` package contains all components related to building a model with `backbone`, `neck` (optional) and `head`. +[Data](#data) -We provide state-of-the-art models such as DLCRNet, HRNet, BUCTD, TransPose, ... more are coming! +[Runners](#runners) -If you want to add a novel model, you need to divide it into a model backbone, neck and head. Often the 'neck' will be just the identity function. +### API -For instance, a [standard pose estimation HRNet](https://github.com/HRNet/HRNet-Human-Pose-Estimation) consists of HRNet backbone, an identity neck and a deconvolution head (Simple Head). +High-level API methods are implemented in `deeplabcut.pose_estimations_pytorch.apis`. +This folder includes methods to train and evaluate models on DeepLabCut projects, and +analyze videos or folders (of images). While some of the methods are implemented to work +directly from DeepLabCut projects (i.e. by specifying the path to the project config +file and the shuffle number), internally they call methods that allow more flexibility. +Thus, they are also ideally suited for developers. +### Models -## Solvers +We provide state-of-the-art pose estimation models such as DLCRNet, HRNet, DEKR, BUCTD +and more are coming! Object detection models are also available (and implemented in +`deeplabcut.pose_estimations_pytorch.models.detectors`). -- [solvers](solvers): The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model training and validation. +The `deeplabcut.pose_estimations_pytorch.models` package contains all components related +to building a model. Models are flexibly build from modular components: `backbone`, +`neck` (optional) and `head` (as discussed below). -## Data +You can check available models by running: -- [data](data/project.py#L7): -The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch dataset creation and test/train splitting. - - `Project` class provides train and test splitting and converts dataset to required format. For instance, to [COCO](https://medium.com/@manuktiwary/coco-format-what-and-how-5c7d22cf5301) format. +```python +import deeplabcut.pose_estimation_pytorch - Example: +# Available pose estimation models +print(deeplabcut.pose_estimation_pytorch.available_models()) - ```python3 - import deeplabcut.pose_estimation_pytorch as dlc +# Available object detection models +print(deeplabcut.pose_estimation_pytorch.available_detectors()) +``` - project = dlc.Project(proj_root=config['project_root']) - project.train_test_split() - ``` - - `PoseDataset` class is an instance of [torch.utils.Dataset](https://pytorch.org/docs/stable/data.html), which converts raw images and keypoints to a tensor dataset for training and evaluation. +#### Model Configuration Files - Example: +Model architectures are built according to a configuration specified in a `yaml` file. +This file (named `pytorch_cfg.yaml`) describes the architecture of the model you want to +train (but also hyperparameters, optimizer, ...). All code to manipulate PyTorch +configuration files is in `deeplabcut.pose_estimations_pytorch.config`. - ```python3 - transform = None - train_dataset = dlc.PoseDataset(project, - transform=transform, - mode='train') - valid_dataset = dlc.PoseDataset(project, - transform=transform, - mode='test') - ``` +To generate a model configuration, you can call `make_pytorch_pose_config`. Note that +this does not save the configuration to a given filepath - it just returns it as a +dictionary. However, you can save it with `write_config`. - > **Note** - > `transform` is a `List` of transformations to be applied to images and keypoints sequentially, `None` by default. +During a typical DeepLabCut project management workflow, these methods don't need to be +called, as `create_training_dataset` will create this configuration file and save it to +disk. - Example: +```python +from pathlib import Path - ```python3 - import albumentations as A +from deeplabcut.pose_estimation_pytorch.config import ( + make_pytorch_pose_config, + write_config, +) - transform = A.Compose([ - A.Resize(width=256, height=256), - ], keypoint_params=A.KeypointParams(format='xy')) +project_cfg = { "Task": "mice", ... } # the configuration for your DLC project +pose_config_path = Path("/path/to/my/config/pytorch_cfg.yaml") +model_cfg = make_pytorch_pose_config( + project_config=project_cfg, + pose_config_path=pose_config_path, + net_type="hrnet_w32", + top_down=True, +) +write_config(pose_config_path, model_cfg) +``` - ``` +#### Adding Models - > **Warning** - > By now supports only [albumentations](https://albumentations.ai), will be extended in the future. +If you want to add a novel model, you'll ideally build them from the following +implemented parts: +- a backbone (such as a ResNet or HRNet) +- a head (such as a HeatmapHead) +- a predictor (transforming model outputs into keypoint locations) +- a target generator (creating the targets for your head outputs from your labels) -## API +Some models can also define a neck (model components between the backbone and the head). +You'll also need some loss criterions, but usually you'll be able to use existing ones. -- [API](API): The `deeplabcut.pose_estimations_pytorch.apis` contains functionalities for training and testing as well as the corresponding configuration file [config.yaml](apis/config.yaml). +You can either use existing classes and only replace some elements, or rewrite +everything you need for your model. We use Model Registries to simplify the process of +adding models. -## Registry +#### Model Registry -- WIP \ No newline at end of file +Registries are created for all model building blocks to make it easy to add new models. +All you need to do is add the decorator `REGISTRY.register_module` to be able to load +your model from a configuration file. Available registries are `BACKBONES`, `NECKS`, +`HEADS`, `PREDICTORS` and `TARGET_GENERATORS`. Each building block has a base class +that should be inherited by the class added to the model registry (`BaseBackbone`, +`BaseNeck`, `BaseHead`, `BasePredictor` and `BaseGenerator` respectively). + +Let's illustrate that with a small example. We'll create a dummy backbone, which simply +applies a max-pool to the input: + +```python +import torch +import torch.nn.functional as F + +from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES, BaseBackbone + + +@BACKBONES.register_module +class DummyBackbone(BaseBackbone): + """A dummy backbone, simply max-pooling the input""" + + def __init__(self, kernel_size: int = 2): + super().__init__(stride=kernel_size) + self.kernel_size = kernel_size + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return F.max_pool2d(x, kernel_size=self.kernel_size) + + +backbone_config = dict(type="DummyBackbone", kernel_size=3) +backbone = BACKBONES.build(backbone_config) # will create a DummyBackbone +``` + +Another example would be creating a custom head for our model. In this case, let's make +a head which takes as input the output of a backbone (which has shape `(num_channels, +H', W')`) and put it through a kernel-size 1 convolution, simply changing the number of +channels. + +Heads can output multiple tensors (such as heatmaps and location refinement fields). +Therefore, their `forward(...)` method outputs a dictionary mapping strings to tensors. +Here, we return the `heatmap` and `locref` tensors. + +A head must contain different: a `target_generator` to generate targets for +its outputs and a `predictor` to convert model outputs to pose. Make sure that the keys +output by the `target_generator` and the `head` match! Some `criterion` also needs to be +defined to compute the loss between the outputs and targets. When more than one output +is specified (such as in this case, where we're generating heatmaps and location +refinement fields), a loss aggregator must also be given to combine all losses into one +(this should simply be a `WeightedLossAggregator`, indicating the weight for each loss). + +```python +import torch +import torch.nn as nn + +from deeplabcut.pose_estimation_pytorch.models.criterions import ( + BaseCriterion, + BaseLossAggregator, + WeightedHuberCriterion, + WeightedLossAggregator, + WeightedMSECriterion, +) +from deeplabcut.pose_estimation_pytorch.models.heads import HEADS, BaseHead +from deeplabcut.pose_estimation_pytorch.models.predictors import ( + BasePredictor, + HeatmapPredictor, +) +from deeplabcut.pose_estimation_pytorch.models.target_generators import ( + BaseGenerator, + HeatmapGaussianGenerator, +) + + +@HEADS.register_module +class DummyHead(BaseHead): + """A dummy backbone, simply max-pooling the input""" + + def __init__( + self, + num_input_channels: int, + num_bodyparts: int, + predictor: BasePredictor, + target_generator: BaseGenerator, + criterion: dict[str, BaseCriterion], + aggregator: BaseLossAggregator, + ): + super().__init__( + stride=1, + predictor=predictor, + target_generator=target_generator, + criterion=criterion, + aggregator=aggregator + ) + self.conv_heatmap = nn.Conv2d( + in_channels=num_input_channels, + out_channels=num_bodyparts, + kernel_size=1, + stride=1, + ) + self.locref_heatmap = nn.Conv2d( + in_channels=num_input_channels, + out_channels=2 * num_bodyparts, + kernel_size=1, + stride=1, + ) + + def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]: + return { + "heatmap": self.conv_heatmap(x), + "locref": self.locref_heatmap(x), + } + + +head_config = dict( + type="DummyHead", + num_input_channels=2048, + num_bodyparts=5, + predictor=HeatmapPredictor(location_refinement=True, locref_std= 7.2801), + target_generator=HeatmapGaussianGenerator( + num_heatmaps=5, + pos_dist_thresh=17, + heatmap_mode=HeatmapGaussianGenerator.Mode.KEYPOINT, + generate_locref=True, + ), + criterion={ + "heatmap": WeightedMSECriterion(), + "locref": WeightedHuberCriterion(), + }, + aggregator=WeightedLossAggregator(weights={"heatmap": 1, "locref": 0.05}), +) +head = HEADS.build(head_config) +``` + +### Data + +The `deeplabcut.pose_estimations_pytorch.data` package contains all code for PyTorch +dataset creation and test/train splitting. The `DLCLoader` class is used to load the +labeled data for a specific shuffle. + +```python3 +from deeplabcut.pose_estimation_pytorch.data import DLCLoader + +loader = DLCLoader( + config="/path/to/my/project/config.yaml", + trainset_index=0, + shuffle=1, +) + +# print the path to the model folder (where the config file is stored) +print(loader.model_folder) +# print the path to the evaluation folder +print(loader.evaluation_folder) + +# display the DataFrame containing the dataset +print(loader.df) + +# display the DataFrames containing the train/test data respectively +print(loader.df_train) +print(loader.df_test) +``` + +The `PoseDataset` class is an instance of +[torch.utils.Dataset](https://pytorch.org/docs/stable/data.html), which converts raw +images and keypoints to a tensor dataset for training and evaluation. You can generate +an instance of training/test dataset with your `DLCLoader`: + +```python3 +from deeplabcut.pose_estimation_pytorch.data import ( + build_transforms, + DLCLoader, +) +from deeplabcut.pose_estimation_pytorch.task import Task + +loader = DLCLoader( + config="/path/to/my/project/config.yaml", + trainset_index=0, + shuffle=1, +) +train_dataset = loader.create_dataset( + transform=build_transforms(loader.model_cfg["data"]["train"]), + mode="train", + task=Task.BOTTOM_UP, +) +valid_dataset = loader.create_dataset( + transform=build_transforms(loader.model_cfg["data"]["train"]), + mode="test", + task=Task.BOTTOM_UP, +) +``` + +A `COCOLoader` is also available, and allows you train models in DeepLabCut on +[COCO-format](https://medium.com/@manuktiwary/coco-format-what-and-how-5c7d22cf5301) +datasets. This essentially consists of having a folder containing your dataset in the +format: + +``` +COCOProject +└───annotations +│ │ train.json +│ │ test.json +│ +└───images + │ img0000.png + │ img0001.png + │ ... +``` + +In your `train.json` and `test.json` files, you can either specify your image +`"file_name"` with a relative path or with an absolute path. If a relative path is +used (e.g. `img0000.png` or `subfolder/img0000.png`), it will be resolved to the +`images` folder in your project (i.e. `/path/to/COCOProject/images/img0000.png` or +`/path/to/COCOProject/images/subfolder/img0000.png`). + +If you specify an absolute path, the path to the image will not be resolved, and the +image will be loaded from the specified path. This allows you to keep data on different +disks, or reuse the same images in different projects without having to duplicate them. + +To train a DeepLabCut model on a COCO-format dataset, you'll need to specify a model +configuration file (as described in [#model_configuration_files]). + +```python3 +from pathlib import Path + +from deeplabcut.pose_estimation_pytorch.config import ( + make_pytorch_pose_config, + write_config, +) +from deeplabcut.pose_estimation_pytorch.data import ( + build_transforms, + COCOLoader, +) +from deeplabcut.pose_estimation_pytorch.task import Task + +# Specify project paths +project_root = Path("/path/to/my/COCOProject") +train_json_filename = "train.json" +test_json_filename = "test.json" + +# Parse information about the project +train_dict = COCOLoader.load_json(project_root, filename=train_json_filename) +max_num_individuals, bodyparts = COCOLoader.get_project_parameters(train_dict) + +# Generate a configuration file for your PyTorch model +# In this case, it's for a Top-Down HRNet_w32 +experiment_path = project_root / "experiments" / "hrnet_w32" +model_cfg_path = experiment_path / "train" / "pytorch_cfg.yaml" +model_cfg = make_pytorch_pose_config( + project_config={ + "project_path": str(project_root.resolve()), + "multianimalproject": max_num_individuals > 1, + "bodyparts": bodyparts, + "multianimalbodyparts": bodyparts, + "uniquebodyparts": [], + "individuals": [f"idv{i}" for i in range(max_num_individuals)], + }, + pose_config_path=experiment_path, + net_type="hrnet_w32", + top_down=True, +) +write_config(config_path=model_cfg_path, config=model_cfg) +task = Task(model_cfg["method"]) + +# Create the loader for the COCO dataset +loader = COCOLoader( + project_root=project_root, + model_config_path="/path/to/my/project/experiments/pytorch_config.yaml", + train_json_filename=train_json_filename, + test_json_filename=test_json_filename, +) +train_dataset = loader.create_dataset( + transform=build_transforms(loader.model_cfg["data"]["train"]), + mode="train", + task=task, +) +valid_dataset = loader.create_dataset( + transform=build_transforms(loader.model_cfg["data"]["train"]), + mode="test", + task=task, +) +``` + +### Runners + +The `deeplabcut.pose_estimations_pytorch.runners` contains code to get models, load +pretrained weights, and either train them or run inference with them. + +## Code Examples + +### Training a Model on a COCO Dataset + +```python +from pathlib import Path + +from deeplabcut.pose_estimation_pytorch.apis.train import train +from deeplabcut.pose_estimation_pytorch.data import COCOLoader +from deeplabcut.pose_estimation_pytorch.task import Task + +# Specify project paths +project_root = Path("/path/to/my/COCOProject") +train_json_filename = "train.json" +test_json_filename = "test.json" + +loader = COCOLoader( + project_root=project_root, + model_config_path="/path/to/my/project/experiments/pytorch_config.yaml", + train_json_filename=train_json_filename, + test_json_filename=test_json_filename, +) +train( + loader=loader, + run_config=loader.model_cfg, + task=Task(loader.model_cfg["method"]), + device="cuda:2", + logger_config=dict( + type="WandbLogger", + project_name="MyWandbProject", + tags=["model=hrnet_w32"], + ), + snapshot_path=None, +) +``` + +### Running Video Analysis outside a DeepLabCut Project + +DeepLabCut provides high-level APIs (via the GUI or the python package) to analyze your data. The usage of this API assumes the existance of a DLC project (with `config.yaml` file, etc.). + +Sometimes it might be more convenient to just run a model on your data via a low-level API. We also use this API under the hood, in particular for the Model Zoo. Check out the example below: + +```python +from pathlib import Path + +from deeplabcut.pose_estimation_pytorch.apis.analyze_videos import video_inference +from deeplabcut.pose_estimation_pytorch.config import read_config_as_dict +from deeplabcut.pose_estimation_pytorch.task import Task +from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners + +train_dir = Path("/Users/Jaylen/my-dlc-models/train") +pytorch_config_path = train_dir / "pytorch_config.yaml" +snapshot_path = train_dir / "snapshot-100.pt" + +# for top-down models, otherwise None +detector_snapshot_path = train_dir / "detector-snapshot-100.pt" + +# video and inference parameters +video_path = Path("/Users/Jaylen/my-dlc-models/videos/test-video.mp4") +max_num_animals = 5 +batch_size = 16 +detector_batch_size = 8 + +# read model configuration +model_cfg = read_config_as_dict(pytorch_config_path) +bodyparts = model_cfg["metadata"]["bodyparts"] +unique_bodyparts = model_cfg["metadata"]["unique_bodyparts"] +with_identity = model_cfg["metadata"].get("with_identity", False) + +pose_task = Task(model_cfg["method"]) +pose_runner, detector_runner = get_inference_runners( + model_config=model_cfg, + snapshot_path=snapshot_path, + max_individuals=max_num_animals, + num_bodyparts=len(bodyparts), + num_unique_bodyparts=len(unique_bodyparts), + batch_size=batch_size, + with_identity=with_identity, + transform=None, + detector_batch_size=detector_batch_size, + detector_path=detector_snapshot_path, + detector_transform=None, +) + +predictions = video_inference( + video_path=video_path, + task=pose_task, + pose_runner=pose_runner, + detector_runner=detector_runner, + with_identity=False, +) +``` diff --git a/deeplabcut/pose_estimation_pytorch/apis/train.py b/deeplabcut/pose_estimation_pytorch/apis/train.py index efec0b6db0..fce693348b 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/train.py +++ b/deeplabcut/pose_estimation_pytorch/apis/train.py @@ -150,13 +150,7 @@ def train( num_workers=num_workers, pin_memory=pin_memory, ) - valid_dataloader = DataLoader( - valid_dataset, - batch_size=1, - shuffle=False, - num_workers=num_workers, - pin_memory=pin_memory, - ) + valid_dataloader = DataLoader(valid_dataset, batch_size=1, shuffle=False) if ( loader.model_cfg["model"].get("freeze_bn_stats", False) diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml index 5121ee9d97..0e8a125bd0 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml @@ -22,7 +22,7 @@ runner: train_settings: batch_size: 1 dataloader_workers: 0 - dataloader_pin_memory: true + dataloader_pin_memory: false display_iters: 500 epochs: 200 seed: 42 diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py index e5f8473fa0..804a4ed68a 100644 --- a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py +++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py @@ -28,7 +28,7 @@ def make_pytorch_pose_config( project_config: dict, - pose_config_path: str, + pose_config_path: str | Path, net_type: str | None = None, top_down: bool = False, detector_type: str | None = None, @@ -180,7 +180,9 @@ def make_pytorch_pose_config( return dict(sorted(pose_config.items())) -def add_metadata(project_config: dict, config: dict, pose_config_path: str) -> dict: +def add_metadata( + project_config: dict, config: dict, pose_config_path: str | Path +) -> dict: """Adds metadata to a pytorch pose configuration Args: @@ -194,7 +196,7 @@ def add_metadata(project_config: dict, config: dict, pose_config_path: str) -> d config = copy.deepcopy(config) config["metadata"] = { "project_path": project_config["project_path"], - "pose_config_path": pose_config_path, + "pose_config_path": str(pose_config_path), "bodyparts": auxiliaryfunctions.get_bodyparts(project_config), "unique_bodyparts": auxiliaryfunctions.get_unique_bodyparts(project_config), "individuals": project_config.get("individuals", ["animal"]), diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py index 23cebaa4c8..e53bbe6759 100644 --- a/deeplabcut/pose_estimation_pytorch/config/utils.py +++ b/deeplabcut/pose_estimation_pytorch/config/utils.py @@ -258,7 +258,7 @@ def available_models() -> list[str]: other_architectures = [ p for p in configs_folder_path.iterdir() - if p.is_dir() and not p.name in ("backbones", "base") + if p.is_dir() and not p.name in ("backbones", "base", "detectors") ] for folder in other_architectures: variants = [p.stem for p in folder.iterdir() if p.suffix == ".yaml"] diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py index 992f54f798..344b5c3583 100644 --- a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py +++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py @@ -81,6 +81,16 @@ def df(self) -> pd.DataFrame: """Returns: The ground truth dataframe. Should not be modified.""" return self._dfs["full"] + @property + def df_test(self) -> pd.DataFrame: + """Returns: A copy of the DataFrame containing the test data.""" + return self._dfs["test"].copy() + + @property + def df_train(self) -> pd.DataFrame: + """Returns: A copy of the DataFrame containing the training data.""" + return self._dfs["train"].copy() + def image_resolutions(self) -> set[tuple[int, int]]: """Returns: The collection of image resolutions present in the dataset""" return self._resolutions diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py index 60b512c3dd..d09f8648e3 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/inference.py +++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py @@ -96,7 +96,7 @@ def inference( [ { "bodypart": {"poses": np.array}, - "unique_bodypart": "poses": np.array}, + "unique_bodypart": {"poses": np.array}, } ] """ @@ -208,7 +208,8 @@ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]] [ { "bodypart": {"poses": np.ndarray}, - "unique_bodypart": "poses": np.ndarray}, + "unique_bodypart": {"poses": np.ndarray}, + } ] """ outputs = self.model(inputs.to(self.device)) diff --git a/deeplabcut/utils/auxfun_multianimal.py b/deeplabcut/utils/auxfun_multianimal.py index 1053e07e93..5f0314f887 100644 --- a/deeplabcut/utils/auxfun_multianimal.py +++ b/deeplabcut/utils/auxfun_multianimal.py @@ -147,7 +147,7 @@ def prune_paf_graph(list_of_edges, desired_n_edges=None, average_degree=None): ) while True: - g = nx.Graph(random.sample(G.edges, desired_n_edges)) + g = nx.Graph(random.sample(list(G.edges), desired_n_edges)) if len(g.nodes) == n_nodes and nx.is_connected(g): print("Valid subgraph found...") break diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py index a44f922b40..c14f2c6305 100644 --- a/deeplabcut/utils/pseudo_label.py +++ b/deeplabcut/utils/pseudo_label.py @@ -8,6 +8,7 @@ # # Licensed under GNU Lesser General Public License v3.0 # +from __future__ import annotations import glob import json From 55de11b7d65a8583565606098a9d70466e27c7b3 Mon Sep 17 00:00:00 2001 From: Zelin2001 Date: Tue, 10 Sep 2024 00:38:48 +0800 Subject: [PATCH 214/293] Make 3D Triangulate possible with pytorch_dlc branch and MA project (#2729) * Bugfix: triangulation * Compute triangulated data with pose_estimation_pytorch * Make correct MultiIndex with unique bodyparts * adapt to DataFrame.to_hdf() changes * use analyze_videos from the compact module * remove logging lines --- .../pose_estimation_3d/triangulation.py | 62 +++++++++++-------- deeplabcut/utils/auxiliaryfunctions_3d.py | 15 +++-- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/deeplabcut/pose_estimation_3d/triangulation.py b/deeplabcut/pose_estimation_3d/triangulation.py index 1e02b9860f..a0ebf98a16 100644 --- a/deeplabcut/pose_estimation_3d/triangulation.py +++ b/deeplabcut/pose_estimation_3d/triangulation.py @@ -4,25 +4,21 @@ # https://github.com/DeepLabCut/DeepLabCut # # Please see AUTHORS for contributors. -# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS # # Licensed under GNU Lesser General Public License v3.0 # import os from pathlib import Path - import cv2 import numpy as np import pandas as pd -from matplotlib.axes._axes import _log as matplotlib_axes_logger from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions from deeplabcut.utils import auxiliaryfunctions_3d from deeplabcut.core.trackingutils import TRACK_METHODS -matplotlib_axes_logger.setLevel("ERROR") - def triangulate( config, @@ -88,7 +84,7 @@ def triangulate( To analyze only a few pair of videos: >>> deeplabcut.triangulate(config,[['C:\\yourusername\\rig-95\\Videos\\video1-camera-1.avi','C:\\yourusername\\rig-95\\Videos\\video1-camera-2.avi'],['C:\\yourusername\\rig-95\\Videos\\video2-camera-1.avi','C:\\yourusername\\rig-95\\Videos\\video2-camera-2.avi']]) """ - from deeplabcut.pose_estimation_tensorflow import predict_videos + from deeplabcut.compat import analyze_videos from deeplabcut.post_processing import filtering cfg_3d = auxiliaryfunctions.read_config(config) @@ -263,7 +259,7 @@ def triangulate( scorer_name[cam_names[j]] = DLCscorer else: # Analyze video if score name is different - DLCscorer = predict_videos.analyze_videos( + DLCscorer = analyze_videos( config_2d, [video], videotype=videotype, @@ -293,7 +289,7 @@ def triangulate( ) else: # need to do the whole jam. - DLCscorer = predict_videos.analyze_videos( + DLCscorer = analyze_videos( config_2d, [video], videotype=videotype, @@ -446,18 +442,32 @@ def triangulate( } # Create 3D DataFrame column and row indices - axis_labels = ("x", "y", "z") + cols = [ + [scorer_3d], + list(auxiliaryfunctions.get_bodyparts(cfg)), + ["x", "y", "z"], + ] + cols_names = ["scorer", "bodyparts", "coords"] + flag_indiv_single = False if cfg.get("multianimalproject"): - columns = pd.MultiIndex.from_product( - [[scorer_3d], individuals, bodyparts, axis_labels], - names=["scorer", "individuals", "bodyparts", "coords"], - ) - - else: - columns = pd.MultiIndex.from_product( - [[scorer_3d], bodyparts, axis_labels], - names=["scorer", "bodyparts", "coords"], - ) + cols_names.insert(1, "individuals") + if "single" == individuals[-1]: + individuals = individuals[:-1] + columns_unique = pd.MultiIndex.from_product( + [ + [scorer_3d], + ["single"], + auxiliaryfunctions.get_unique_bodyparts(cfg), + ["x", "y", "z"], + ], + names=cols_names, + ) + flag_indiv_single = True + cols.insert(1, individuals) + columns = pd.MultiIndex.from_product(cols, names=cols_names) + if flag_indiv_single: + columns = columns.append(columns_unique) + individuals.append("single") inds = range(num_frames) @@ -468,10 +478,10 @@ def triangulate( df_3d = pd.DataFrame(triangulate, columns=columns, index=inds) df_3d.to_hdf( - str(output_filename + ".h5"), - "df_with_missing", - format="table", + str(output_filename) + ".h5", + key="df_with_missing", mode="w", + format="table", ) # Reorder 2D dataframe in view 2 to match order of view 1 @@ -483,19 +493,19 @@ def triangulate( ) df_2d_view2.to_hdf( dataname[1], - "tracks", + key="tracks", format="table", mode="w", ) auxiliaryfunctions_3d.SaveMetadata3d( - str(output_filename + "_meta.pickle"), metadata + str(output_filename) + "_meta.pickle", metadata ) if save_as_csv: - df_3d.to_csv(str(output_filename + ".csv")) + df_3d.to_csv(str(output_filename) + ".csv") - print("Triangulated data for video", video_list[i]) + print("Triangulated data for video", video) print("Results are saved under: ", destfolder) # have to make the dest folder none so that it can be updated for a new pair of videos if destfolder == str(Path(video).parents[0]): diff --git a/deeplabcut/utils/auxiliaryfunctions_3d.py b/deeplabcut/utils/auxiliaryfunctions_3d.py index 22d31b4555..2483623d31 100644 --- a/deeplabcut/utils/auxiliaryfunctions_3d.py +++ b/deeplabcut/utils/auxiliaryfunctions_3d.py @@ -322,12 +322,17 @@ def _associate_paired_view_tracks(tracklets1, tracklets2, F): _t1 = np.c_[_t1, np.ones((*_t1.shape[:2], 1))] _t2 = np.c_[_t2, np.ones((*_t2.shape[:2], 1))] - # cost for any point in time of t1 being the same - # any point in time of t2 - cost = np.abs(np.nansum(np.matmul(_t1, F) * _t2, axis=2)) + try: + # cost for any point in time of t1 being the same + # any point in time of t2 + cost = np.abs(np.nansum(np.matmul(_t1, F) * _t2, axis=2)) + + # Get average cost of the entire track + cost = cost.mean() + except: + # typically when dim 2 differs, with uniquebodyparts + cost = 100000.0 - # Get average cost of the entire track - cost = cost.mean() costs[i, j] = cost match_inds = linear_sum_assignment(np.abs(costs)) From 597cf6b801d2acafeeb138339eb5598c39c9aed5 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:13:58 +0200 Subject: [PATCH 215/293] make parenting video name changes backwards compatible; add code to results (#2740) --- deeplabcut/benchmark/__init__.py | 6 +++++- deeplabcut/benchmark/base.py | 5 ++++- deeplabcut/benchmark/benchmarks.py | 10 ++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/deeplabcut/benchmark/__init__.py b/deeplabcut/benchmark/__init__.py index 2e70eae786..a5ddd3c4a9 100644 --- a/deeplabcut/benchmark/__init__.py +++ b/deeplabcut/benchmark/__init__.py @@ -85,7 +85,11 @@ def evaluate( continue benchmark = benchmark_cls() for name in benchmark.names(): - if Result(method_name=name, benchmark_name=benchmark_cls.name) in results: + if Result( + code=benchmark.code, + method_name=name, + benchmark_name=benchmark_cls.name, + ) in results: continue else: result = benchmark.evaluate(name, on_error=on_error) diff --git a/deeplabcut/benchmark/base.py b/deeplabcut/benchmark/base.py index 49140c4436..ef6894ac3b 100644 --- a/deeplabcut/benchmark/base.py +++ b/deeplabcut/benchmark/base.py @@ -59,7 +59,7 @@ def get_predictions(self): raise NotImplementedError() def __init__(self): - keys = ["name", "keypoints", "ground_truth", "metadata"] + keys = ["code", "name", "keypoints", "ground_truth", "metadata"] for key in keys: if not hasattr(self, key): raise NotImplementedError( @@ -110,6 +110,7 @@ def evaluate(self, name: str, on_error="raise"): else: raise NotImplementedError() from exception return Result( + code=self.code, method_name=name, benchmark_name=self.name, mean_avg_precision=mean_avg_precision, @@ -139,6 +140,7 @@ def _validate_predictions(self, name: str, predictions: dict) -> dict: class Result: """Benchmark result.""" + code: str method_name: str benchmark_name: str root_mean_squared_error: float = float("nan") @@ -146,6 +148,7 @@ class Result: benchmark_version: str = __version__ _export_mapping = dict( + code="code", benchmark_name="benchmark", method_name="method", benchmark_version="version", diff --git a/deeplabcut/benchmark/benchmarks.py b/deeplabcut/benchmark/benchmarks.py index ee18e215c6..4068c29cf2 100644 --- a/deeplabcut/benchmark/benchmarks.py +++ b/deeplabcut/benchmark/benchmarks.py @@ -96,6 +96,16 @@ def compute_pose_map(self, results_objects): symmetric_kpts=[(0, 4), (1, 3)], ) + def _validate_predictions(self, name: str, predictions: dict) -> dict: + """Fixes filenames for predictions made on old versions of the dataset""" + return super()._validate_predictions( + name, + { + k.replace("Dummy", "D").replace("Dead pup", "DP"): v + for k, v in predictions.items() + }, + ) + class MarmosetBenchmark(deeplabcut.benchmark.base.Benchmark): """Dataset with two marmosets. From 5f418127e6e95c8ee427a8a244b4cf545581acfe Mon Sep 17 00:00:00 2001 From: Yanko <28831006+YankoFelipe@users.noreply.github.com> Date: Mon, 30 Sep 2024 16:11:54 +0200 Subject: [PATCH 216/293] Adding cropping to pose_estimation_pytorch/apis/analyze_videos.py (#2737) * Adding cropping to pose_estimation_pytorch/apis/analyze_videos.py * Adding docstring plus addressing comments --- deeplabcut/compat.py | 1 + .../apis/analyze_videos.py | 20 +++++++++-- deeplabcut/utils/auxfun_videos.py | 36 +++++++++---------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index ddf74552f0..02798087ec 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -874,6 +874,7 @@ def analyze_videos( auto_track=auto_track, identity_only=identity_only, overwrite=False, + cropping=cropping, **torch_kwargs, ) diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 81af1b85c2..7e61f4b470 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -45,11 +45,14 @@ class VideoIterator(VideoReader): """A class to iterate over videos, with possible added context""" def __init__( - self, video_path: str | Path, context: list[dict[str, Any]] | None = None + self, video_path: str | Path, context: list[dict[str, Any]] | None = None, cropping: list[int] | None = None ) -> None: super().__init__(str(video_path)) self._context = context self._index = 0 + self._crop = cropping is not None + if self._crop: + self.set_bbox(*cropping) def get_context(self) -> list[dict[str, Any]] | None: if self._context is None: @@ -68,7 +71,7 @@ def __iter__(self): return self def __next__(self) -> np.ndarray | tuple[str, dict[str, Any]]: - frame = self.read_frame() + frame = self.read_frame(crop=self._crop) if frame is None: self._index = 0 self.reset() @@ -94,9 +97,10 @@ def video_inference( detector_runner: InferenceRunner | None = None, with_identity: bool = False, return_video_metadata: bool = False, + cropping: list[int] | None = None, ) -> list[dict[str, np.ndarray]]: """Runs inference on a video""" - video = VideoIterator(str(video_path)) + video = VideoIterator(str(video_path), cropping=cropping) n_frames = video.get_n_frames() vid_w, vid_h = video.dimensions print(f"Starting to analyze {video_path}") @@ -167,6 +171,7 @@ def analyze_videos( auto_track: bool | None = True, identity_only: bool | None = False, overwrite: bool = False, + cropping: list[int] | None = None, ) -> str: """Makes prediction based on a trained network. @@ -220,6 +225,11 @@ def analyze_videos( identity_only: sub-call for auto_track. If ``True`` and animal identity was learned by the model, assembly and tracking rely exclusively on identity prediction. + cropping: list or None, optional, default=None + List of cropping coordinates as [x1, x2, y1, y2]. + Note that the same cropping parameters will then be used for all videos. + If different video crops are desired, run ``analyze_videos`` on individual + videos with the corresponding cropping coordinates. Returns: The scorer used to analyze the videos @@ -252,6 +262,9 @@ def analyze_videos( cfg, model_cfg, snapshot_index, detector_snapshot_index, ) + if cropping is None and cfg.get("cropping", False): + cropping = cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"] + # Get general project parameters bodyparts = model_cfg["metadata"]["bodyparts"] unique_bodyparts = model_cfg["metadata"]["unique_bodyparts"] @@ -328,6 +341,7 @@ def analyze_videos( pose_runner=pose_runner, task=pose_task, detector_runner=detector_runner, + cropping=cropping, ) runtime.append(time.time()) metadata = _generate_metadata( diff --git a/deeplabcut/utils/auxfun_videos.py b/deeplabcut/utils/auxfun_videos.py index e4d8c033ea..6a937d1263 100644 --- a/deeplabcut/utils/auxfun_videos.py +++ b/deeplabcut/utils/auxfun_videos.py @@ -159,6 +159,24 @@ def get_bbox(self, relative=False): y2 = int(self._height * y2) return x1, x2, y1, y2 + def set_bbox(self, x1, x2, y1, y2, relative=False): + if x2 <= x1 or y2 <= y1: + raise ValueError( + f"Coordinates look wrong... " f"Ensure {x1} < {x2} and {y1} < {y2}." + ) + if not relative: + x1 /= self._width + x2 /= self._width + y1 /= self._height + y2 /= self._height + bbox = x1, x2, y1, y2 + if any(coord > 1 for coord in bbox): + warnings.warn( + "Bounding box larger than the video... " "Clipping to video dimensions." + ) + bbox = tuple(map(lambda x: min(x, 1), bbox)) + self._bbox = bbox + @property def fps(self): return self._fps @@ -205,24 +223,6 @@ def __init__(self, video_path, codec="h264", dpi=100, fps=None): if fps: self.fps = fps - def set_bbox(self, x1, x2, y1, y2, relative=False): - if x2 <= x1 or y2 <= y1: - raise ValueError( - f"Coordinates look wrong... " f"Ensure {x1} < {x2} and {y1} < {y2}." - ) - if not relative: - x1 /= self._width - x2 /= self._width - y1 /= self._height - y2 /= self._height - bbox = x1, x2, y1, y2 - if any(coord > 1 for coord in bbox): - warnings.warn( - "Bounding box larger than the video... " "Clipping to video dimensions." - ) - bbox = tuple(map(lambda x: min(x, 1), bbox)) - self._bbox = bbox - def shorten( self, start, end, suffix="short", dest_folder=None, validate_inputs=True ): From 56cd632c96f65ff306378327ea23977fa002600f Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Tue, 1 Oct 2024 13:07:07 +0200 Subject: [PATCH 217/293] pytorch_dlc - Small updates and bug fixes (#2747) * added option for gradient masking * added grad masking parameters for all configs * default: do not teleport keypoints to heatmaps for DEKR * PAF predictor bug fix: assemblies None if no predictions * eval network API - use the config path that was given * add logger tests * add detector type when creating training dataset * fix image logger NaN bug * add cropping parameters to metadata * edit detector eval interval --- .../trainingsetmanipulation.py | 1 + .../apis/analyze_videos.py | 16 ++-- .../pose_estimation_pytorch/apis/evaluate.py | 2 +- .../config/base/detector.yaml | 2 +- .../config/base/head_bodyparts.yaml | 1 + .../config/base/head_bodyparts_with_paf.yaml | 1 + .../config/base/head_identity.yaml | 1 + .../config/base/head_topdown.yaml | 2 + .../config/dekr/dekr_w18.yaml | 1 + .../config/dekr/dekr_w32.yaml | 1 + .../config/dekr/dekr_w48.yaml | 1 + .../models/predictors/paf_predictor.py | 7 +- .../target_generators/heatmap_targets.py | 49 ++++++++---- .../models/target_generators/pafs_targets.py | 18 ++--- .../pose_estimation_pytorch/runners/logger.py | 5 +- docs/pytorch/pytorch_config.md | 10 +-- .../runners/test_logger.py | 75 +++++++++++++++++++ 17 files changed, 152 insertions(+), 41 deletions(-) create mode 100644 tests/pose_estimation_pytorch/runners/test_logger.py diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 2011af8ffe..31d9a4455a 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -969,6 +969,7 @@ def create_training_dataset( num_shuffles, Shuffles, net_type=net_type, + detector_type=detector_type, trainIndices=trainIndices, testIndices=testIndices, userfeedback=userfeedback, diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py index 7e61f4b470..4ddcb2abbc 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py +++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_videos.py @@ -350,6 +350,7 @@ def analyze_videos( dlc_scorer=dlc_scorer, train_fraction=train_fraction, batch_size=batch_size, + cropping=cropping, runtime=(runtime[0], runtime[1]), video=VideoReader(str(video)), ) @@ -543,15 +544,20 @@ def _generate_metadata( dlc_scorer: str, train_fraction: int, batch_size: int, + cropping: list[int] | None, runtime: tuple[float, float], video: VideoReader, ) -> dict: w, h = video.dimensions - cropping = cfg.get("cropping", False) - if cropping: - cropping_parameters = [cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"]] - else: + if cropping is None: cropping_parameters = [0, w, 0, h] + else: + if not len(cropping) == 4: + raise ValueError( + "The cropping parameters should be exactly 4 values: [x_min, x_max, " + f"y_min, y_max]. Found {cropping}" + ) + cropping_parameters = cropping metadata = { "start": runtime[0], @@ -565,7 +571,7 @@ def _generate_metadata( "nframes": video.get_n_frames(), "iteration (active-learning)": cfg["iteration"], "training set fraction": train_fraction, - "cropping": cropping, + "cropping": cropping is not None, "cropping_parameters": cropping_parameters, } return {"data": metadata} diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py index ff52e40fe3..d26624d51f 100644 --- a/deeplabcut/pose_estimation_pytorch/apis/evaluate.py +++ b/deeplabcut/pose_estimation_pytorch/apis/evaluate.py @@ -391,7 +391,7 @@ def evaluate_network( for train_set_index in train_set_indices: for shuffle in shuffles: loader = DLCLoader( - config=Path(cfg["project_path"]) / "config.yaml", + config=config, shuffle=shuffle, trainset_index=train_set_index, modelprefix=modelprefix, diff --git a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml index 7cd0f7387e..cbb55b94a1 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/detector.yaml @@ -27,7 +27,7 @@ detector: variant: fasterrcnn_mobilenet_v3_large_fpn runner: type: DetectorTrainingRunner - eval_interval: 1 + eval_interval: 10 optimizer: type: AdamW params: diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml index f0734d4e3c..04501208b2 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml @@ -11,6 +11,7 @@ target_generator: num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT + gradient_masking: false generate_locref: true locref_std: 7.2801 criterion: diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml index ee08bc9144..183a8ad260 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml @@ -19,6 +19,7 @@ target_generator: num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT + gradient_masking: false generate_locref: true locref_std: 7.2801 - type: PartAffinityFieldGenerator diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml index a5417b21a5..eb9c253929 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml @@ -7,6 +7,7 @@ target_generator: num_heatmaps: "num_individuals" pos_dist_thresh: 17 heatmap_mode: INDIVIDUAL + gradient_masking: false generate_locref: false criterion: heatmap: diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml index d848a2eb50..57d5fa483d 100644 --- a/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml @@ -11,6 +11,8 @@ target_generator: num_heatmaps: "num_bodyparts" pos_dist_thresh: 17 heatmap_mode: KEYPOINT + gradient_masking: true + background_weight: 0.0 generate_locref: true locref_std: 7.2801 criterion: diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml index 148c6b3f9b..8623c0a3df 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml @@ -31,6 +31,7 @@ model: predictor: type: DEKRPredictor apply_sigmoid: false + use_heatmap: false clip_scores: true num_animals: "num_individuals" keypoint_score_type: combined diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml index 79abd74821..a92ff01f54 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml @@ -31,6 +31,7 @@ model: predictor: type: DEKRPredictor apply_sigmoid: false + use_heatmap: false clip_scores: true num_animals: "num_individuals" keypoint_score_type: combined diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml index aa543d5587..98ecf5d331 100644 --- a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml +++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml @@ -31,6 +31,7 @@ model: predictor: type: DEKRPredictor apply_sigmoid: false + use_heatmap: false clip_scores: true num_animals: "num_individuals" keypoint_score_type: combined diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py index 17892831ac..474733eaf6 100644 --- a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py +++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py @@ -177,9 +177,10 @@ def forward( poses_unique = -torch.ones((batch_size, 1, self.num_uniquebodyparts, 4)) for i, data_dict in enumerate(preds): assemblies, unique = self.assembler._assemble(data_dict, ind_frame=0) - for j, assembly in enumerate(assemblies): - poses[i, j, :, :4] = torch.from_numpy(assembly.data) - poses[i, j, :, 4] = assembly.affinity + if assemblies is not None: + for j, assembly in enumerate(assemblies): + poses[i, j, :, :4] = torch.from_numpy(assembly.data) + poses[i, j, :, 4] = assembly.affinity if unique is not None: poses_unique[i, 0, :, :4] = torch.from_numpy(unique) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py index 8f3688757d..3c8d8e2f61 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py @@ -55,6 +55,8 @@ def __init__( num_heatmaps: int, pos_dist_thresh: int, heatmap_mode: str | Mode = Mode.KEYPOINT, + gradient_masking: bool = False, + background_weight: float = 0.1, generate_locref: bool = True, locref_std: float = 7.2801, **kwargs, @@ -65,6 +67,15 @@ def __init__( pos_dist_thresh: 3*std of the gaussian. We think of dist_thresh as a radius and std is a 'diameter'. mode: the mode to generate heatmaps for + gradient_masking: Whether to mask the gradient when a bodypart is undefined + (has visibility ``0`` in the dataset). WARNING: Do not set this option + for bottom-up models, as a keypoint missing for one animal means the + gradients for all animals will be set to 0 for that image. + Gradients for inputs that have the visibility flag ``-1`` will always be + masked, as this flag indicates that the keypoint is not defined for the + image. + background_weight: If ``gradient_masking == True`, the weight to apply to + the loss for background pixels. learned_id_target: whether to generate the heatmap for keypoints or for learned IDs generate_locref: whether to generate location refinement maps @@ -86,6 +97,9 @@ def __init__( heatmap_mode = HeatmapGenerator.Mode(heatmap_mode) self.heatmap_mode = heatmap_mode + self.gradient_masking = gradient_masking + self.background_weight = background_weight + self.generate_locref = generate_locref self.locref_scale = 1.0 / locref_std @@ -116,11 +130,24 @@ def forward( Examples: input: - annotations = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))} - prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))] + annotations = { + "keypoints": torch.randint( + 1, min(image_size), (batch_size, num_animals, num_joints, 2) + ) + } image_size = (256, 256) + model_stride = 4 output: - targets = {'heatmaps':scmap, 'locref_map':locref_map, 'locref_masks':locref_masks} + targets = { + "heatmap": { + "target": array of shape (batch_size, 64, 64, num_joints), + "weights": array of shape (batch_size, 64, 64, num_joints), + }, + "locref": { + "target": array of shape (batch_size, 64, 64, num_joints), + "weights": array of shape (batch_size, 64, 64, num_joints), + } + } """ stride_y, stride_x = stride, stride batch_size, _, height, width = outputs["heatmap"].shape @@ -143,10 +170,9 @@ def forward( map_size = batch_size, height, width heatmap = np.zeros((*map_size, self.num_heatmaps), dtype=np.float32) - - # coords shape: (batch_size, n_keypoints, 1, 2) weights = np.ones( - (batch_size, coords.shape[1], height, width), dtype=np.float32 + (batch_size, self.num_heatmaps, height, width), + dtype=np.float32, ) locref_map, locref_mask = None, None @@ -163,14 +189,12 @@ def forward( for b in range(batch_size): for heatmap_idx, group_keypoints in enumerate(coords[b]): for keypoint in group_keypoints: - # FIXME: Gradient masking weights should be parameters - if keypoint[-1] == 0: - # full gradient masking - weights[b, heatmap_idx] = 0.0 + if self.gradient_masking and keypoint[-1] == 0: + # apply background weight if keypoints are missing + weights[b, heatmap_idx] = self.background_weight elif keypoint[-1] == -1: - # full gradient masking + # always mask weights when the keypoint is undefined weights[b, heatmap_idx] = 0.0 - elif keypoint[-1] > 0: # keypoint visible self.update( @@ -190,7 +214,6 @@ def forward( } } - # we don't handle masking for locref if self.generate_locref: locref_map = locref_map.transpose((0, 3, 1, 2)) locref_mask = locref_mask.transpose((0, 3, 1, 2)) diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py index a0a372293c..ba017fdf93 100644 --- a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py +++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py @@ -29,18 +29,12 @@ class PartAffinityFieldGenerator(BaseGenerator): """ def __init__(self, graph: list[list[int, int]], width: float): - """Summary: - Constructor of the PartAffinityFieldGenerator class. - Loads the data. - + """ Args: graph: list of pairs of keypoint indices forming the graph edges width: width of the vector field in pixels - Returns: - None - Examples: input: graph = [(0, 1), (0, 2), (1, 2)] @@ -58,7 +52,7 @@ def forward( batch_size, _, height, width = outputs["heatmap"].shape coords = labels[self.label_keypoint_key].cpu().numpy() - partaffinityfield_map = np.zeros( + paf_map = np.zeros( (batch_size, height, width, self.num_limbs * 2), dtype=np.float32 ) grid = np.mgrid[:height, :width].transpose((1, 2, 0)) @@ -102,14 +96,14 @@ def forward( mask2 = distance_across_abs <= 1 mask = mask1 & mask2 temp = 1 - distance_across_abs[mask] - partaffinityfield_map[b, mask, l * 2 + 0] = vec_x_norm * temp - partaffinityfield_map[b, mask, l * 2 + 1] = vec_y_norm * temp + paf_map[b, mask, l * 2 + 0] = vec_x_norm * temp + paf_map[b, mask, l * 2 + 1] = vec_y_norm * temp - partaffinityfield_map = partaffinityfield_map.transpose((0, 3, 1, 2)) + paf_map = paf_map.transpose((0, 3, 1, 2)) return { "paf": { "target": torch.tensor( - partaffinityfield_map, device=outputs["paf"].device + paf_map, device=outputs["paf"].device ) } } diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py index 2d70fa7bc9..6e5270c7d5 100644 --- a/deeplabcut/pose_estimation_pytorch/runners/logger.py +++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py @@ -194,7 +194,10 @@ def _prepare_image( image = F.convert_image_dtype(image.detach().cpu(), dtype=torch.uint8) if keypoints is not None and len(keypoints) > 0: assert len(keypoints.shape) == 3 - keypoints[keypoints < 0] = np.nan + # Use visibility and force torchvision >= 0.18 + # pytorch.org/vision/0.18/generated/torchvision.utils.draw_keypoints.html + # pytorch.org/vision/0.17/generated/torchvision.utils.draw_keypoints.html + keypoints[torch.any(torch.isnan(keypoints), dim=-1)] = -1 image = draw_keypoints( image, keypoints=keypoints[..., :2], colors="red", radius=5 ) diff --git a/docs/pytorch/pytorch_config.md b/docs/pytorch/pytorch_config.md index 4c9db14f10..6377a85fa2 100644 --- a/docs/pytorch/pytorch_config.md +++ b/docs/pytorch/pytorch_config.md @@ -379,10 +379,7 @@ default. Additionally, you can log results to [Weights and Biases](https://wandb.ai/site), by adding a `WandbLogger`. Just make sure you're logged in to your `wandb` account before starting your training run (with `wandb login` from your shell). For more information, see their -[tutorials](https://docs.wandb.ai/tutorials) and their documentation for -[`wandb.init`](https://docs.wandb.ai/ref/python/init). You can also log images as they are seen by the model to `wandb` -with the `image_log_interval`. This logs a random train and test image, as well as the -targets and heatmaps for that image. +[tutorials](https://docs.wandb.ai/tutorials) and their documentation for [`wandb.init`](https://docs.wandb.ai/ref/python/init). Logging to `wandb` is a good way to keep track of what you've run, including performance and metrics. @@ -390,12 +387,15 @@ and metrics. ```yaml logger: type: WandbLogger - image_log_interval: 5 # how often images are logged to wandb (in epochs) project_name: my-dlc3-project # the name of the project where the run should be logged run_name: dekr-w32-shuffle0 # the name of the run to log ... # any other argument you can pass to `wandb.init`, such as `tags: ["dekr", "split=0"]` ``` +You can also log images as they are seen by the model to `wandb` +with the `image_log_interval`. This logs a random train and test image, as well as the +targets and heatmaps for that image. + ## Training Top-Down Models Top-down models are split into two main elements: a detector (localizing individuals in diff --git a/tests/pose_estimation_pytorch/runners/test_logger.py b/tests/pose_estimation_pytorch/runners/test_logger.py new file mode 100644 index 0000000000..6f40615e07 --- /dev/null +++ b/tests/pose_estimation_pytorch/runners/test_logger.py @@ -0,0 +1,75 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# © A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# +"""Tests loggers""" +from typing import Any + +import pytest +import torch + +import deeplabcut.pose_estimation_pytorch.runners.logger as logging + + +class MockImageLogger(logging.ImageLoggerMixin): + """Mock image logger""" + + def log_images( + self, + inputs: dict[str, Any], + outputs: dict[str, torch.Tensor], + targets: dict[str, dict[str, torch.Tensor]], + step: int, + ) -> None: + pass + + +@pytest.mark.parametrize( + "keypoints", + [ + [ + [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + ], + [ + [[float("nan"), float("nan")], [float("nan"), float("nan")]], + ], + [ + [[0.0, 0.0], [1, 1], [2, 2]], + ], + [[[float("nan"), 0.0], [1, 1], [2, 2]]], + [[[-1.0, -1.0], [1, 1], [2, 2]]], + [ + [[-1.0, -1.0], [-1.0, -1.0]], + ], + [ + [[-1.0, -1.0], [-1.0, -1.0]], + [[1.0, 1.0], [1.0, 1.0]], + ], + ], +) +@pytest.mark.parametrize("denormalize", [True, False]) +def test_prepare_image(keypoints: list[list[float]], denormalize: bool) -> None: + image = torch.ones((3, 256, 256)) + keypoints = torch.tensor(keypoints) + + print() + print(f"IMAGE: {image.shape}") + print(f"KEYPOINTS: {keypoints.shape}") + for k in keypoints: + print(k) + print() + print() + + logger = MockImageLogger() + logger._prepare_image( + image=image, + denormalize=denormalize, + keypoints=keypoints, + bboxes=None, + ) From 6f2986d0aaa9acceba1422ec2da47e1f5769652c Mon Sep 17 00:00:00 2001 From: SowonKIMM Date: Tue, 1 Oct 2024 22:34:08 +0900 Subject: [PATCH 218/293] calc_similarity_with .py ZeroDivisionError: float division by zeros via upload (#2688) --- deeplabcut/core/trackingutils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/deeplabcut/core/trackingutils.py b/deeplabcut/core/trackingutils.py index eff7c1ab6a..5dff9804db 100644 --- a/deeplabcut/core/trackingutils.py +++ b/deeplabcut/core/trackingutils.py @@ -112,12 +112,13 @@ def calc_similarity_with(self, other_ellipse): max_dist = max( self.height, self.width, other_ellipse.height, other_ellipse.width ) - if max_dist == 0: - return 0 - dist = math.sqrt( (self.x - other_ellipse.x) ** 2 + (self.y - other_ellipse.y) ** 2 ) + + if max_dist == 0: + max_dist = 1 + cost1 = 1 - min(dist / max_dist, 1) cost2 = abs(math.cos(self.theta - other_ellipse.theta)) return 0.8 * cost1 + 0.2 * cost2 * cost1 From 28840b4ba380b4148e5aadf66e36b55259b661b0 Mon Sep 17 00:00:00 2001 From: Alexander Mathis Date: Wed, 2 Oct 2024 14:43:51 +0200 Subject: [PATCH 219/293] update version (#2748) --- deeplabcut/version.py | 2 +- examples/test.sh | 2 +- reinstall.sh | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deeplabcut/version.py b/deeplabcut/version.py index fedb976547..cf6f3ee7e4 100644 --- a/deeplabcut/version.py +++ b/deeplabcut/version.py @@ -9,5 +9,5 @@ # Licensed under GNU Lesser General Public License v3.0 # -__version__ = "3.0.0rc4" +__version__ = "3.0.0rc5" VERSION = __version__ diff --git a/examples/test.sh b/examples/test.sh index 23073df43a..42178f3724 100755 --- a/examples/test.sh +++ b/examples/test.sh @@ -6,7 +6,7 @@ rm -r OUT cd .. pip uninstall deeplabcut python3 setup.py sdist bdist_wheel -pip install dist/deeplabcut-3.0.0rc4-none-any.whl +pip install dist/deeplabcut-3.0.0rc5-none-any.whl cd examples diff --git a/reinstall.sh b/reinstall.sh index 08d698910f..fba2949d47 100755 --- a/reinstall.sh +++ b/reinstall.sh @@ -1,3 +1,3 @@ pip uninstall deeplabcut python3 setup.py sdist bdist_wheel -pip install dist/deeplabcut-3.0.0rc4-py3-none-any.whl +pip install dist/deeplabcut-3.0.0rc5-py3-none-any.whl diff --git a/setup.py b/setup.py index 4d7760608c..279c4ff558 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def pytorch_config_paths() -> list[str]: setuptools.setup( name="deeplabcut", - version="3.0.0rc4", + version="3.0.0rc5", author="A. & M.W. Mathis Labs", author_email="alexander@deeplabcut.org", description="Markerless pose-estimation of user-defined features with deep learning", From ffcd2908cdf6d5a6cd36ae980433a7a895b183d1 Mon Sep 17 00:00:00 2001 From: n-poulsen <45132115+n-poulsen@users.noreply.github.com> Date: Fri, 18 Oct 2024 18:16:45 +0200 Subject: [PATCH 220/293] SuperAnimal Model Updates (#2756) --- deeplabcut/compat.py | 8 +- deeplabcut/core/trackingutils.py | 4 +- deeplabcut/core/weight_init.py | 153 ++++++---- ...ple_individuals_trainingsetmanipulation.py | 202 ++++++------ .../trainingsetmanipulation.py | 124 ++++---- .../gui/displays/selected_shuffle_display.py | 4 +- .../gui/tabs/create_training_dataset.py | 102 +++++-- deeplabcut/gui/tabs/modelzoo.py | 85 ++++-- deeplabcut/modelzoo/__init__.py | 1 + .../fasterrcnn_mobilenet_v3_large_fpn.yaml | 49 +++ .../fasterrcnn_resnet50_fpn_v2.yaml | 49 +++ .../{hrnetw32.yaml => hrnet_w32.yaml} | 40 +-- .../modelzoo/model_configs/resnet_50.yaml | 88 ++++++ .../modelzoo/model_configs/ssdlite.yaml | 48 +++ deeplabcut/modelzoo/models_to_framework.json | 5 +- .../project_configs/superanimal_bird.yaml | 105 +++++++ .../superanimal_quadruped.yaml | 4 +- .../superanimal_topviewmouse.yaml | 3 +- deeplabcut/modelzoo/utils.py | 87 +++++- deeplabcut/modelzoo/video_inference.py | 288 ++++++++++-------- deeplabcut/modelzoo/webapp/inference.py | 25 +- deeplabcut/modelzoo/weight_initialization.py | 108 +++++++ deeplabcut/pose_estimation_pytorch/README.md | 2 +- .../apis/analyze_images.py | 150 ++++----- .../apis/analyze_videos.py | 84 +++-- .../apis/convert_detections_to_tracklets.py | 4 +- .../pose_estimation_pytorch/apis/evaluate.py | 36 +-- .../pose_estimation_pytorch/apis/train.py | 30 +- .../config/__init__.py | 1 + .../fasterrcnn_mobilenet_v3_large_fpn.yaml | 2 +- .../detectors/fasterrcnn_resnet50_fpn_v2.yaml | 2 +- .../config/detectors/ssdlite.yaml | 2 +- .../config/make_pose_config.py | 48 ++- .../pose_estimation_pytorch/data/base.py | 1 - .../models/detectors/base.py | 14 +- .../pose_estimation_pytorch/models/model.py | 19 +- .../modelzoo/__init__.py | 8 + .../modelzoo/_mmpose_to_dlc3.py | 142 --------- .../modelzoo/config.py | 123 +++++--- .../modelzoo/inference.py | 154 ++++------ .../modelzoo/memory_replay.py | 185 +++++------ .../modelzoo/train_from_coco.py | 2 +- .../pose_estimation_pytorch/modelzoo/utils.py | 225 ++++++++------ .../modelzoo/api/spatiotemporal_adapt.py | 4 +- .../modelzoo/api/superanimal_inference.py | 4 - deeplabcut/utils/make_labeled_video.py | 42 ++- deeplabcut/utils/pseudo_label.py | 86 +++--- docs/pytorch/pytorch_config.md | 24 +- docs/pytorch/user_guide.md | 32 +- .../COLAB/COLAB_YOURDATA_SuperAnimal.ipynb | 103 +++++-- examples/SUPERANIMAL/eval_zeroshot.py | 10 +- examples/SUPERANIMAL/memory_replay_example.py | 25 +- .../superanimal_image_inference.py | 4 +- examples/SUPERANIMAL/video_adapt_example.py | 4 +- .../openfield-Pranav-2018-10-30/config.yaml | 5 + setup.py | 23 +- .../modelzoo/test_modelzoo_utils.py | 35 +-- 57 files changed, 1920 insertions(+), 1297 deletions(-) create mode 100644 deeplabcut/modelzoo/model_configs/fasterrcnn_mobilenet_v3_large_fpn.yaml create mode 100644 deeplabcut/modelzoo/model_configs/fasterrcnn_resnet50_fpn_v2.yaml rename deeplabcut/modelzoo/model_configs/{hrnetw32.yaml => hrnet_w32.yaml} (68%) create mode 100644 deeplabcut/modelzoo/model_configs/resnet_50.yaml create mode 100644 deeplabcut/modelzoo/model_configs/ssdlite.yaml create mode 100644 deeplabcut/modelzoo/project_configs/superanimal_bird.yaml create mode 100644 deeplabcut/modelzoo/weight_initialization.py delete mode 100644 deeplabcut/pose_estimation_pytorch/modelzoo/_mmpose_to_dlc3.py diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py index 02798087ec..43fba81ef1 100644 --- a/deeplabcut/compat.py +++ b/deeplabcut/compat.py @@ -58,7 +58,7 @@ def get_available_aug_methods(engine: Engine) -> tuple[str, ...]: def train_network( - config: str, + config: str | Path, shuffle: int = 1, trainingsetindex: int = 0, max_snapshots_to_keep: int | None = None, @@ -221,7 +221,7 @@ def train_network( max_snapshots_to_keep = 5 return train_network( - config, + str(config), shuffle=shuffle, trainingsetindex=trainingsetindex, max_snapshots_to_keep=max_snapshots_to_keep, @@ -317,7 +317,7 @@ def return_train_network_path( def evaluate_network( - config, + config: str | Path, Shuffles: Iterable[int] = (1,), trainingsetindex: int | str = 0, plotting: bool | str = False, @@ -458,7 +458,7 @@ def evaluate_network( if engine == Engine.TF: from deeplabcut.pose_estimation_tensorflow import evaluate_network return evaluate_network( - config, + str(config), Shuffles=Shuffles, trainingsetindex=trainingsetindex, plotting=plotting, diff --git a/deeplabcut/core/trackingutils.py b/deeplabcut/core/trackingutils.py index 5dff9804db..565582dcd1 100644 --- a/deeplabcut/core/trackingutils.py +++ b/deeplabcut/core/trackingutils.py @@ -766,11 +766,11 @@ def fill_tracklets(tracklets, trackers, animals, imname): if tracklet_id not in tracklets: tracklets[tracklet_id] = {} if pred_id != -1: - tracklets[tracklet_id][imname] = animals[pred_id] + tracklets[tracklet_id][imname] = np.asarray(animals[pred_id]) else: # Resort to the tracker prediction xy = np.asarray(content[:-2]) pred = np.insert(xy, range(2, len(xy) + 1, 2), 1) - tracklets[tracklet_id][imname] = pred + tracklets[tracklet_id][imname] = np.asarray(pred) def calc_bboxes_from_keypoints(data, slack=0, offset=0): diff --git a/deeplabcut/core/weight_init.py b/deeplabcut/core/weight_init.py index d671f09459..8a6d374e8a 100644 --- a/deeplabcut/core/weight_init.py +++ b/deeplabcut/core/weight_init.py @@ -11,63 +11,46 @@ """Classes to configure how to initialize model weights""" from __future__ import annotations +import warnings from dataclasses import dataclass +from pathlib import Path import numpy as np -import deeplabcut.modelzoo.utils as modelzoo_utils - @dataclass class WeightInitialization: - """The dataset from which to initialize weights - - To build a WeightInitialization instance for a project using the conversion table - specified in the project configuration file, use: - - ``` - from pathlib import Path - from deeplabcut.utils.auxiliaryfunctions import read_config - - project_cfg = read_config("/path/to/my/project/config.yaml") - super_animal = "superanimal_quadruped" - weight_init = WeightInitialization.build( - cfg=project_cfg, - super_animal="superanimal_quadruped", - with_decoder=True, - memory_replay=True, - ) - ``` + """Configures weights initialization when transfer learning or fine-tuning models Args: - dataset: The dataset on which the model weights were trained. Must be one of the - SuperAnimal weights. + snapshot_path: The path to the snapshot used to initialize pose model weights + when training a model. + detector_snapshot_path: The path to the snapshot used to initialize detector + weights when training a model. + dataset: Optionally, the dataset on which the snapshots were trained. Required + when fine-tuning SuperAnimal models. with_decoder: Whether to load the decoder weights as well. memory_replay: Only when ``with_decoder=True``. Whether to train the model with - memory replay, so that it predicts all SuperAnimal bodyparts. - conversion_array: The mapping from SuperAnimal to project bodyparts. Required - when `with_decoder=True`. + memory replay, so that it predicts all SuperAnimal (or previous project) + bodyparts. + conversion_array: The mapping from SuperAnimal (or other project, on which the + weights were trained) to project bodyparts. Required when + `with_decoder=True`. An array [7, 0, 1] means the project has 3 bodyparts, where the 1st bodypart corresponds to the 8th bodypart in the pretrained model, the 2nd to the 1st and the 3rd to the 2nd (as arrays are 0-indexed). bodyparts: Optionally, the name of each bodypart entry in the conversion array. - customized_pose_checkpoint: A customized SuperAnimal pose checkpoint, as an - alternative to the Hugging Face one - customized_detector_checkpoint: A customized SuperAnimal detector - checkpoint, as an alternative to the Hugging Face one """ - dataset: str + snapshot_path: Path + detector_snapshot_path: Path | None = None + dataset: str | None = None with_decoder: bool = False memory_replay: bool = False conversion_array: np.ndarray | None = None bodyparts: list[str] | None = None - customized_pose_checkpoint: str | None = None - customized_detector_checkpoint: str | None = None def __post_init__(self): - # check that the dataset exists; raises a ValueError if it doesn't - _ = modelzoo_utils.get_super_animal_project_cfg(self.dataset) if self.memory_replay and not self.with_decoder: raise ValueError( "You cannot train a model with memory replay if you do not keep the " @@ -97,41 +80,78 @@ def __post_init__(self): def to_dict(self) -> dict: """Returns: the weight initialization as a dict""" - data = { - "dataset": self.dataset, - "with_decoder": self.with_decoder, - "memory_replay": self.memory_replay, - } + data = dict() + if self.dataset is not None: + data["dataset"] = self.dataset + + data["snapshot_path"] = str(self.snapshot_path) + if self.detector_snapshot_path is not None: + data["detector_snapshot_path"] = str(self.detector_snapshot_path) + + data["with_decoder"] = self.with_decoder + data["memory_replay"] = self.memory_replay if self.conversion_array is not None: data["conversion_array"] = self.conversion_array.tolist() - if self.customized_pose_checkpoint is not None: - data["customized_pose_checkpoint"] = self.customized_pose_checkpoint - if self.customized_detector_checkpoint is not None: - data["customized_detector_checkpoint"] = self.customized_detector_checkpoint + + if self.bodyparts is not None: + data["bodyparts"] = self.bodyparts return data @staticmethod def from_dict(data: dict) -> "WeightInitialization": + if "snapshot_path" not in data: + return WeightInitialization.from_dict_legacy(data) + + detector_snapshot_path = data.get("detector_snapshot_path") + if detector_snapshot_path is not None: + detector_snapshot_path = Path(detector_snapshot_path) + conversion_array = data.get("conversion_array") if conversion_array is not None: + conversion_array = np.array(conversion_array, dtype=int) + return WeightInitialization( + snapshot_path=Path(data["snapshot_path"]), + detector_snapshot_path=detector_snapshot_path, + dataset=data.get("dataset"), + with_decoder=data["with_decoder"], + memory_replay=data["memory_replay"], + conversion_array=conversion_array, + bodyparts=data.get("bodyparts"), + ) + + @staticmethod + def from_dict_legacy(data: dict) -> "WeightInitialization": + """Deals with weight initialization that were created before 3.0.0rc5""" + import deeplabcut.pose_estimation_pytorch.modelzoo.utils as utils + + conversion_array = data.get("conversion_array") + if conversion_array is not None: conversion_array = np.array(conversion_array, dtype=int) return WeightInitialization( - dataset=data["dataset"], + snapshot_path=utils.get_super_animal_snapshot_path( + dataset=data["dataset"], + model_name="hrnet_w32", + ), + detector_snapshot_path=utils.get_super_animal_snapshot_path( + dataset=data["dataset"], + model_name="fasterrcnn_resnet50_fpn_v2", + ), with_decoder=data["with_decoder"], memory_replay=data["memory_replay"], conversion_array=conversion_array, - customized_pose_checkpoint=data.get("customized_pose_checkpoint"), - customized_detector_checkpoint=data.get("customized_detector_checkpoint"), + bodyparts=data.get("bodyparts"), ) @staticmethod def build( cfg: dict, super_animal: str, + model_name: str = "hrnet_w32", + detector_name: str = "fasterrcnn_resnet50_fpn_v2", with_decoder: bool = False, memory_replay: bool = False, customized_pose_checkpoint: str | None = None, @@ -139,9 +159,18 @@ def build( ) -> "WeightInitialization": """Builds a WeightInitialization for a project + `WeightInitialization.build` is deprecated and will be removed in a future + version of DeepLabCut. Please use `build_weight_init` from `deeplabcut.modelzoo` + instead. + Args: cfg: The project's configuration. super_animal: The SuperAnimal model with which to initialize weights. + model_name: The name of the model architecture for which to load the weights + (defaults to "hrnet_w32" for backwards compatibility). + detector_name: The name of the detector architecture for which to load the + weights (defaults to "fasterrcnn_resnet50_fpn_v2" for backwards + compatibility). with_decoder: Whether to load the decoder weights as well. If this is true, a conversion table must be specified for the given SuperAnimal in the project configuration file. See @@ -157,19 +186,21 @@ def build( Returns: The built WeightInitialization. """ - conversion_array = None - bodyparts = None - if with_decoder: - conversion_table = modelzoo_utils.get_conversion_table(cfg, super_animal) - conversion_array = conversion_table.to_array() - bodyparts = conversion_table.converted_bodyparts() - - return WeightInitialization( - dataset=super_animal, - with_decoder=with_decoder, - memory_replay=memory_replay, - conversion_array=conversion_array, - bodyparts=bodyparts, - customized_pose_checkpoint=customized_pose_checkpoint, - customized_detector_checkpoint=customized_detector_checkpoint, + from deeplabcut.modelzoo import build_weight_init + deprecation_warning = ( + "The `WeightInitialization.build` is deprecated and will be removed in a " + "future version of DeepLabCut. Please use `build_weight_init` from " + "`deeplabcut.modelzoo` instead." + ) + warnings.warn(deprecation_warning, DeprecationWarning) + + return build_weight_init( + cfg, + super_animal, + model_name, + detector_name, + with_decoder, + memory_replay, + customized_pose_checkpoint, + customized_detector_checkpoint, ) diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py index 8340b719f0..1095dfef6f 100755 --- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py @@ -293,10 +293,7 @@ def create_multianimaltraining_dataset( multi_stage = False ### dlcnet_ms5: backbone resnet50 + multi-fusion & multi-stage module ### dlcr101_ms5/dlcr152_ms5: backbone resnet101/152 + multi-fusion & multi-stage module - if ( - all(net in net_type for net in ("dlcr", "_ms5")) - and engine != Engine.PYTORCH - ): + if all(net in net_type for net in ("dlcr", "_ms5")) and engine != Engine.PYTORCH: num_layers = re.findall("dlcr([0-9]*)", net_type)[0] if num_layers == "": num_layers = 50 @@ -394,7 +391,7 @@ def create_multianimaltraining_dataset( top_down = False if engine == Engine.PYTORCH and net_type.startswith("top_down_"): top_down = True - net_type = net_type[len("top_down_"):] + net_type = net_type[len("top_down_") :] for trainFraction, shuffle, (trainIndices, testIndices) in splits: #################################################### @@ -455,7 +452,10 @@ def create_multianimaltraining_dataset( ################################################################################# modelfoldername = auxiliaryfunctions.get_model_folder( - trainFraction, shuffle, cfg, engine=engine, + trainFraction, + shuffle, + cfg, + engine=engine, ) auxiliaryfunctions.attempt_to_make_folder( Path(config).parents[0] / modelfoldername, recursive=True @@ -492,118 +492,126 @@ def create_multianimaltraining_dataset( ) ) - jointnames = [str(bpt) for bpt in multianimalbodyparts] - jointnames.extend([str(bpt) for bpt in uniquebodyparts]) - items2change = { - "dataset": datafilename, - "engine": engine.aliases[0], - "metadataset": metadatafilename, - "num_joints": len(multianimalbodyparts) - + len(uniquebodyparts), # cfg["uniquebodyparts"]), - "all_joints": [ - [i] for i in range(len(multianimalbodyparts) + len(uniquebodyparts)) - ], # cfg["uniquebodyparts"]))], - "all_joints_names": jointnames, - "init_weights": str(model_path), - "project_path": str(cfg["project_path"]), - "net_type": net_type, - "multi_stage": multi_stage, - "pairwise_loss_weight": 0.1, - "pafwidth": 20, - "partaffinityfield_graph": partaffinityfield_graph, - "partaffinityfield_predict": partaffinityfield_predict, - "weigh_only_present_joints": False, - "num_limbs": len(partaffinityfield_graph), - "dataset_type": dataset_type, - "optimizer": "adam", - "batch_size": 8, - "multi_step": [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 200000]], - "save_iters": 10000, - "display_iters": 500, - "num_idchannel": ( - len(cfg["individuals"]) if cfg.get("identity", False) else 0 - ), - "crop_size": list(crop_size), - "crop_sampling": crop_sampling, - } - - trainingdata = MakeTrain_pose_yaml( - items2change, path_train_config, defaultconfigfile - ) - keys2save = [ - "dataset", - "num_joints", - "all_joints", - "all_joints_names", - "net_type", - "multi_stage", - "init_weights", - "global_scale", - "location_refinement", - "locref_stdev", - "dataset_type", - "partaffinityfield_predict", - "pairwise_predict", - "partaffinityfield_graph", - "num_limbs", - "dataset_type", - "num_idchannel", - ] - - MakeTest_pose_yaml( - trainingdata, - keys2save, - path_test_config, - nmsradius=5.0, - minconfidence=0.01, - sigma=1, - locref_smooth=False, - ) # setting important def. values for inference - - # Setting inference cfg file: - defaultinference_configfile = os.path.join( - dlcparent_path, "inference_cfg.yaml" - ) - items2change = { - "minimalnumberofconnections": int(len(cfg["multianimalbodyparts"]) / 2), - "topktoretain": len(cfg["individuals"]), - "withid": cfg.get("identity", False), - } - MakeInference_yaml( - items2change, path_inference_config, defaultinference_configfile - ) - - # Populate the pytorch config yaml file - if engine == Engine.PYTORCH: - from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config - from deeplabcut.pose_estimation_pytorch.modelzoo.config import make_super_animal_finetune_config + if engine == Engine.TF: + jointnames = [str(bpt) for bpt in multianimalbodyparts] + jointnames.extend([str(bpt) for bpt in uniquebodyparts]) + items2change = { + "dataset": datafilename, + "engine": engine.aliases[0], + "metadataset": metadatafilename, + "num_joints": len(multianimalbodyparts) + + len(uniquebodyparts), # cfg["uniquebodyparts"]), + "all_joints": [ + [i] + for i in range(len(multianimalbodyparts) + len(uniquebodyparts)) + ], # cfg["uniquebodyparts"]))], + "all_joints_names": jointnames, + "init_weights": str(model_path), + "project_path": str(cfg["project_path"]), + "net_type": net_type, + "multi_stage": multi_stage, + "pairwise_loss_weight": 0.1, + "pafwidth": 20, + "partaffinityfield_graph": partaffinityfield_graph, + "partaffinityfield_predict": partaffinityfield_predict, + "weigh_only_present_joints": False, + "num_limbs": len(partaffinityfield_graph), + "dataset_type": dataset_type, + "optimizer": "adam", + "batch_size": 8, + "multi_step": [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 200000]], + "save_iters": 10000, + "display_iters": 500, + "num_idchannel": ( + len(cfg["individuals"]) if cfg.get("identity", False) else 0 + ), + "crop_size": list(crop_size), + "crop_sampling": crop_sampling, + } + + trainingdata = MakeTrain_pose_yaml( + items2change, + path_train_config, + defaultconfigfile, + save=(engine == Engine.TF), + ) + keys2save = [ + "dataset", + "num_joints", + "all_joints", + "all_joints_names", + "net_type", + "multi_stage", + "init_weights", + "global_scale", + "location_refinement", + "locref_stdev", + "dataset_type", + "partaffinityfield_predict", + "pairwise_predict", + "partaffinityfield_graph", + "num_limbs", + "dataset_type", + "num_idchannel", + ] + + MakeTest_pose_yaml( + trainingdata, + keys2save, + path_test_config, + nmsradius=5.0, + minconfidence=0.01, + sigma=1, + locref_smooth=False, + ) # setting important def. values for inference + elif engine == Engine.PYTORCH: + from deeplabcut.pose_estimation_pytorch.config.make_pose_config import ( + make_pytorch_pose_config, + make_pytorch_test_config, + ) + from deeplabcut.pose_estimation_pytorch.modelzoo.config import ( + make_super_animal_finetune_config, + ) # backwards compatibility with version 2.X if net_type == "dlcrnet_ms5": net_type = "dlcrnet_stride16_ms5" - pose_cfg_path = path_train_config.replace("pose_cfg.yaml", "pytorch_config.yaml") + config_path = Path(path_train_config).with_name(engine.pose_cfg_name) if weight_init is not None and weight_init.with_decoder: pytorch_cfg = make_super_animal_finetune_config( project_config=cfg, - pose_config_path=path_train_config, - net_type=net_type, + pose_config_path=config_path, + model_name=net_type, + detector_name=detector_type, weight_init=weight_init, + save=True, ) else: pytorch_cfg = make_pytorch_pose_config( project_config=cfg, - pose_config_path=path_train_config, + pose_config_path=config_path, net_type=net_type, top_down=top_down, detector_type=detector_type, weight_init=weight_init, + save=True, ) - auxiliaryfunctions.write_plainconfig(pose_cfg_path, pytorch_cfg) + make_pytorch_test_config(pytorch_cfg, path_test_config, save=True) + + # Setting inference cfg file: + default_inf_path = Path(dlcparent_path) / "inference_cfg.yaml" + inf_updates = dict( + minimalnumberofconnections=int(len(cfg["multianimalbodyparts"]) / 2), + topktoretain=len(cfg["individuals"]), + withid=cfg.get("identity", False), + ) + MakeInference_yaml(inf_updates, path_inference_config, default_inf_path) print( - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!" + "The training dataset is successfully created. Use the function " + "'train_network' to start training. Happy training!" ) else: pass diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py index 31d9a4455a..1cf154c8aa 100755 --- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py +++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py @@ -398,19 +398,26 @@ def ParseYaml(configfile): def MakeTrain_pose_yaml( - itemstochange, saveasconfigfile, defaultconfigfile, items2drop={} + itemstochange, + saveasconfigfile, + defaultconfigfile, + items2drop: dict | None = None, + save: bool = True, ): + if items2drop is None: + items2drop = {} + docs = ParseYaml(defaultconfigfile) for key in items2drop.keys(): - # print(key, "dropping?") if key in docs[0].keys(): docs[0].pop(key) for key in itemstochange.keys(): docs[0][key] = itemstochange[key] - with open(saveasconfigfile, "w") as f: - yaml.dump(docs[0], f) + if save: + with open(saveasconfigfile, "w") as f: + yaml.dump(docs[0], f) return docs[0] @@ -1205,7 +1212,7 @@ def create_training_dataset( cfg["project_path"], Path(modelfoldername), "train", - "pose_cfg.yaml", + engine.pose_cfg_name, ) ) path_test_config = str( @@ -1216,67 +1223,75 @@ def create_training_dataset( "pose_cfg.yaml", ) ) - # str(cfg['proj_path']+'/'+Path(modelfoldername) / 'test' / 'pose_cfg.yaml') - items2change = { - "dataset": datafilename, - "engine": engine.aliases[0], - "metadataset": metadatafilename, - "num_joints": len(bodyparts), - "all_joints": [[i] for i in range(len(bodyparts))], - "all_joints_names": [str(bpt) for bpt in bodyparts], - "init_weights": model_path, - "project_path": str(cfg["project_path"]), - "net_type": net_type, - "dataset_type": augmenter_type, - } - - items2drop = {} - if augmenter_type == "scalecrop": - # these values are dropped as scalecrop - # doesn't have rotation implemented - items2drop = {"rotation": 0, "rotratio": 0.0} - # Also drop maDLC smart cropping augmentation parameters - for key in ["pre_resize", "crop_size", "max_shift", "crop_sampling"]: - items2drop[key] = None - - trainingdata = MakeTrain_pose_yaml( - items2change, path_train_config, defaultconfigfile, items2drop - ) - - keys2save = [ - "dataset", - "num_joints", - "all_joints", - "all_joints_names", - "net_type", - "init_weights", - "global_scale", - "location_refinement", - "locref_stdev", - ] - MakeTest_pose_yaml(trainingdata, keys2save, path_test_config) - print( - "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!" - ) + if engine == Engine.TF: + items2change = { + "dataset": datafilename, + "engine": engine.aliases[0], + "metadataset": metadatafilename, + "num_joints": len(bodyparts), + "all_joints": [[i] for i in range(len(bodyparts))], + "all_joints_names": [str(bpt) for bpt in bodyparts], + "init_weights": model_path, + "project_path": str(cfg["project_path"]), + "net_type": net_type, + "dataset_type": augmenter_type, + } + + items2drop = {} + if augmenter_type == "scalecrop": + # these values are dropped as scalecrop + # doesn't have rotation implemented + items2drop = {"rotation": 0, "rotratio": 0.0} + # Also drop maDLC smart cropping augmentation parameters + for key in [ + "pre_resize", + "crop_size", + "max_shift", + "crop_sampling", + ]: + items2drop[key] = None + + trainingdata = MakeTrain_pose_yaml( + items2change, + path_train_config, + defaultconfigfile, + items2drop, + save=(engine == Engine.TF), + ) - # Populate the pytorch config yaml file - if engine == Engine.PYTORCH: + keys2save = [ + "dataset", + "num_joints", + "all_joints", + "all_joints_names", + "net_type", + "init_weights", + "global_scale", + "location_refinement", + "locref_stdev", + ] + MakeTest_pose_yaml(trainingdata, keys2save, path_test_config) + print( + "The training dataset is successfully created. Use the function" + "'train_network' to start training. Happy training!" + ) + elif engine == Engine.PYTORCH: from deeplabcut.pose_estimation_pytorch.config.make_pose_config import ( make_pytorch_pose_config, + make_pytorch_test_config, ) from deeplabcut.pose_estimation_pytorch.modelzoo.config import ( make_super_animal_finetune_config, ) - pose_cfg_path = path_train_config.replace( - "pose_cfg.yaml", "pytorch_config.yaml" - ) if weight_init is not None and weight_init.with_decoder: pytorch_cfg = make_super_animal_finetune_config( project_config=cfg, pose_config_path=path_train_config, - net_type=net_type, + model_name=net_type, + detector_name=detector_type, weight_init=weight_init, + save=True, ) else: pytorch_cfg = make_pytorch_pose_config( @@ -1286,9 +1301,10 @@ def create_training_dataset( top_down=top_down, detector_type=detector_type, weight_init=weight_init, + save=True, ) - auxiliaryfunctions.write_plainconfig(pose_cfg_path, pytorch_cfg) + make_pytorch_test_config(pytorch_cfg, path_test_config, save=True) return splits diff --git a/deeplabcut/gui/displays/selected_shuffle_display.py b/deeplabcut/gui/displays/selected_shuffle_display.py index fd370a76cf..13e6db1094 100644 --- a/deeplabcut/gui/displays/selected_shuffle_display.py +++ b/deeplabcut/gui/displays/selected_shuffle_display.py @@ -85,7 +85,9 @@ def _update_display(self, new_index: int) -> None: return if not pose_cfg_path.exists(): - self._set_text_error() + self._set_text_error( + f"The model configuration file {pose_cfg_path} was not created" + ) return self._read_pose_config(pose_cfg_path) diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py index 7bed81a8fe..15c06418c3 100644 --- a/deeplabcut/gui/tabs/create_training_dataset.py +++ b/deeplabcut/gui/tabs/create_training_dataset.py @@ -13,6 +13,7 @@ import os from pathlib import Path +import dlclibrary from PySide6 import QtWidgets from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QIcon @@ -32,6 +33,7 @@ _create_label_widget, ) from deeplabcut.gui.widgets import launch_napari +from deeplabcut.modelzoo import build_weight_init from deeplabcut.utils.auxiliaryfunctions import ( get_data_and_metadata_filenames, get_training_set_folder, @@ -70,10 +72,14 @@ def __init__(self, root, parent, h1_description): self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft) def set_edit_table_visibility(self) -> None: - has_conversion_tables = bool(self.root.cfg.get("SuperAnimalConversionTables", {})) + has_conversion_tables = bool( + self.root.cfg.get("SuperAnimalConversionTables", {}) + ) is_pytorch_engine = self.root.engine == Engine.PYTORCH is_finetuning = self.weight_init_selector.with_decoder - self.mapping_button.setVisible(has_conversion_tables & is_pytorch_engine & is_finetuning) + self.mapping_button.setVisible( + has_conversion_tables & is_pytorch_engine & is_finetuning + ) def show_help_dialog(self): dialog = QtWidgets.QDialog(self) @@ -179,7 +185,6 @@ def log_augmentation_choice(self, augmentation): def edit_conversion_table(self): # Test beforehand whether a conversion table exists - weight_init = self.weight_init_selector.get_weight_init() memory_replay_folder = Path(self.root.project_folder) / "memory_replay" conversion_matrix_out_path = str(memory_replay_folder / "confusion_matrix.png") files = [self.root.config] @@ -213,12 +218,6 @@ def create_training_dataset(self): self.root.writer.write("Training dataset creation failed.") return - try: - weight_init = self.weight_init_selector.get_weight_init() - except ValueError as err: - print(f"The training dataset could not be created: {err}.") - return - if self.model_comparison: raise NotImplementedError # TODO: finish model_comparison @@ -235,11 +234,23 @@ def create_training_dataset(self): detector_type = None if engine == Engine.TF: import tensorflow + # try importing TF so they can't create shuffles for it if they # don't have it installed elif engine == Engine.PYTORCH and "top_down" in net_type: detector_type = self.detector_choice.currentText() + try: + weight_init = ( + self.weight_init_selector.get_super_animal_weight_init( + net_type, + detector_type, + ) + ) + except ValueError as err: + print(f"The training dataset could not be created: {err}.") + return + if self.data_split_selection.selected: deeplabcut.create_training_dataset_from_existing_split( self.root.config, @@ -427,6 +438,7 @@ def update_detectors( else: # FIXME: Circular imports make it impossible to import this at the top from deeplabcut.pose_estimation_pytorch import available_detectors + detectors = available_detectors() det_filter = self.get_detector_filter() if det_filter is not None: @@ -436,7 +448,10 @@ def update_detectors( self.detector_choice.removeItem(0) self.detector_choice.addItems(detectors) - if "ssdlite" in detectors: + default_detector = self.get_default_detector() + if default_detector in detectors: + self.detector_choice.setCurrentIndex(detectors.index(default_detector)) + elif "ssdlite" in detectors: self.detector_choice.setCurrentIndex(detectors.index("ssdlite")) if net_choice is None: @@ -478,7 +493,10 @@ def get_net_filter(self) -> list[str] | None: return None weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init] - return weight_init_cfg["model_filter"] + if "super_animal" in weight_init_cfg: + return dlclibrary.get_available_models(weight_init_cfg["super_animal"]) + + return None def get_detector_filter(self) -> list[str] | None: """Returns: the detectors that can be used based on weight initialization""" @@ -489,7 +507,10 @@ def get_detector_filter(self) -> list[str] | None: return None weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init] - return weight_init_cfg["detector_filter"] + if "super_animal" in weight_init_cfg: + return dlclibrary.get_available_detectors(weight_init_cfg["super_animal"]) + + return None def get_default_net(self) -> str | None: """Returns: the net type that can be used based on weight initialization""" @@ -502,6 +523,17 @@ def get_default_net(self) -> str | None: weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init] return weight_init_cfg.get("default_net") + def get_default_detector(self) -> str | None: + """Returns: the detector type that can be used based on weight initialization""" + if self.root.engine != Engine.PYTORCH: + return None + + if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS: + return None + + weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init] + return weight_init_cfg.get("default_detector") + def view_shuffles(self) -> None: viewer = ShuffleMetadataViewer(root=self.root, parent=self) viewer.show() @@ -551,8 +583,18 @@ def update_choices(self, choices: list[str]) -> None: self.weight_init_choice.removeItem(0) self.weight_init_choice.addItems(choices) - def get_weight_init(self) -> WeightInitialization | None: + def get_super_animal_weight_init( + self, + net_type: str, + detector_type: str, + ) -> WeightInitialization | None: """ + Args: + net_type: The architecture of the pose model from which to fine-tune a + SuperAnimal model. + detector_type: The architecture of the detector from which to fine-tune a + SuperAnimal model. + Raises: ValueError if WeightInitialization should be defined but could not be created (e.g. if there's no conversion table). @@ -566,10 +608,14 @@ def get_weight_init(self) -> WeightInitialization | None: weight_init_data = _WEIGHT_INIT_OPTIONS[weight_init_choice] super_animal = weight_init_data["super_animal"] + if net_type.startswith("top_down_"): + net_type = net_type[len("top_down_") :] try: - weight_init = WeightInitialization.build( + weight_init = build_weight_init( self.root.cfg, super_animal=super_animal, + model_name=net_type, + detector_name=detector_type, with_decoder=self.with_decoder, memory_replay=self.memory_replay, ) @@ -695,34 +741,34 @@ def _create_confirmation_box(title, description): "model_filter": None, "detector_filter": None, }, + "Transfer Learning - SuperAnimal Bird": { + "default_net": "top_down_resnet_50", + "default_detector": "fasterrcnn_mobilenet_v3_large_fpn", + "super_animal": "superanimal_bird", + }, "Transfer Learning - SuperAnimal Quadruped": { "default_net": "top_down_hrnet_w32", - "model_filter": [ - "dekr_w32", - "hrnet_w32", - ], - "detector_filter": ["fasterrcnn_resnet50_fpn_v2"], + "default_detector": "fasterrcnn_mobilenet_v3_large_fpn", "super_animal": "superanimal_quadruped", }, "Transfer Learning - SuperAnimal TopViewMouse": { "default_net": "top_down_hrnet_w32", - "model_filter": [ - "dekr_w32", - "hrnet_w32", - ], - "detector_filter": ["fasterrcnn_resnet50_fpn_v2"], + "default_detector": "fasterrcnn_mobilenet_v3_large_fpn", "super_animal": "superanimal_topviewmouse", }, + "Fine-tuning - SuperAnimal Bird": { + "default_net": "top_down_resnet_50", + "default_detector": "fasterrcnn_mobilenet_v3_large_fpn", + "super_animal": "superanimal_bird", + }, "Fine-tuning - SuperAnimal Quadruped": { "default_net": "top_down_hrnet_w32", - "model_filter": ["hrnet_w32"], # FIXME - Add ResNet - "detector_filter": ["fasterrcnn_resnet50_fpn_v2"], + "default_detector": "fasterrcnn_mobilenet_v3_large_fpn", "super_animal": "superanimal_quadruped", }, "Fine-tuning - SuperAnimal TopViewMouse": { "default_net": "top_down_hrnet_w32", - "model_filter": ["hrnet_w32"], # FIXME - Add ResNet - "detector_filter": ["fasterrcnn_resnet50_fpn_v2"], + "default_detector": "fasterrcnn_mobilenet_v3_large_fpn", "super_animal": "superanimal_topviewmouse", }, } diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py index a47ba18788..13039517f2 100644 --- a/deeplabcut/gui/tabs/modelzoo.py +++ b/deeplabcut/gui/tabs/modelzoo.py @@ -12,7 +12,7 @@ import webbrowser from functools import partial -from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS +import dlclibrary from PySide6 import QtWidgets from PySide6.QtCore import QRegularExpression, Qt, QTimer, Signal, Slot from PySide6.QtGui import QIcon, QPixmap, QRegularExpressionValidator @@ -28,7 +28,6 @@ ) from deeplabcut.gui.utils import move_to_separate_thread from deeplabcut.gui.widgets import ClickableLabel -from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS class RegExpValidator(QRegularExpressionValidator): @@ -47,6 +46,8 @@ def __init__(self, root, parent, h1_description): self._set_page() self.root.engine_change.connect(self._on_engine_change) self.root.engine_change.connect(self._update_available_models) + self._update_pose_models(self.model_combo.currentText()) + self._update_detectors(self.model_combo.currentText()) self._destfolder = None @property @@ -94,9 +95,20 @@ def _build_common_attributes(self) -> None: self.model_combo = QtWidgets.QComboBox() self.model_combo.setMinimumWidth(250) + net_type_text = QtWidgets.QLabel("Net Type") + net_type_text.setMinimumWidth(300) + self.net_type_selector = QtWidgets.QComboBox() + + self.detector_type_text = QtWidgets.QLabel("Detector Type") + self.detector_type_text.setMinimumWidth(300) + self.detector_type_selector = QtWidgets.QComboBox() + loc_label = ClickableLabel("Folder to store results:", parent=self) loc_label.signal.connect(self.select_folder) - self.loc_line = QtWidgets.QLineEdit(self.root.project_folder, self) + self.loc_line = QtWidgets.QLineEdit( + "