From f10570983eafe705549fbc92c06c5d4765145248 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter Date: Wed, 21 Jan 2026 16:20:26 +0100 Subject: [PATCH 1/4] Infer n_individuals and and n_bodyparts from config metadata. - add fields for n_individuals and n_bodyparts read from the pytorch model config. - add option to infer single_animal mode from n_individuals in model config. This commit does not change default behaviour. Only when passing single_animal = None explicitly to PyTorchRunner, the single_animal mode will be inferred from the model config. --- dlclive/pose_estimation_pytorch/runner.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/dlclive/pose_estimation_pytorch/runner.py b/dlclive/pose_estimation_pytorch/runner.py index 2c59605..a11738c 100644 --- a/dlclive/pose_estimation_pytorch/runner.py +++ b/dlclive/pose_estimation_pytorch/runner.py @@ -118,7 +118,10 @@ class PyTorchRunner(BaseRunner): path: The path to the model to run inference with. device: The device on which to run inference, e.g. "cpu", "cuda", "cuda:0" precision: The precision of the model. One of "FP16" or "FP32". - single_animal: This option is only available for single-animal pose estimation + single_animal: bool | None, default=True + Set to True if the model is a single-animal model, False if it is a multi-animal model. + If set to None, single_animal mode will be inferred from the model configuration. + This option is introduced for single-animal pose estimation models. It makes the code behave in exactly the same way as DeepLabCut-Live with version < 3.0.0. This ensures backwards compatibility with any Processors that were implemented. @@ -131,7 +134,7 @@ def __init__( path: str | Path, device: str = "auto", precision: Literal["FP16", "FP32"] = "FP32", - single_animal: bool = True, + single_animal: bool | None = None, dynamic: dict | dynamic_cropping.DynamicCropper | None = None, top_down_config: dict | TopDownConfig | None = None, ) -> None: @@ -139,7 +142,8 @@ def __init__( self.device = _parse_device(device) self.precision = precision self.single_animal = single_animal - + self.n_individuals = None + self.n_bodyparts = None self.cfg = None self.detector = None self.model = None @@ -259,6 +263,16 @@ def load_model(self) -> None: raw_data = torch.load(self.path, map_location="cpu", weights_only=True) self.cfg = raw_data["config"] + + # Infer n_bodyparts and n_individuals from model configuration + individuals = self.cfg.get("metadata", {}).get("individuals", ['idv1']) + bodyparts = self.cfg.get("metadata", {}).get("bodyparts", []) + self.n_individuals = len(individuals) + self.n_bodyparts = len(bodyparts) + # If single_animal is not set, infer it from n_individuals in model configuration + if self.single_animal is None: + self.single_animal = self.n_individuals == 1 + self.model = models.PoseModel.build(self.cfg["model"]) self.model.load_state_dict(raw_data["pose"]) self.model = self.model.to(self.device) From 6a2e70ee349436857c7a61d94be273d2912f0a96 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter Date: Wed, 21 Jan 2026 16:20:56 +0100 Subject: [PATCH 2/4] Return zero-pose for top-down models in absence of detections. When the detector does not detect any crops (with a supra-threshold confidence), no pose-detection is applied and and a zero-vector is returned for the pose-prediction. This solves #137 and copies the already existing intended behavior of returning zeros for empty pose-predictions in single animals. --- dlclive/pose_estimation_pytorch/runner.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dlclive/pose_estimation_pytorch/runner.py b/dlclive/pose_estimation_pytorch/runner.py index a11738c..adcfaac 100644 --- a/dlclive/pose_estimation_pytorch/runner.py +++ b/dlclive/pose_estimation_pytorch/runner.py @@ -195,7 +195,13 @@ def get_pose(self, frame: np.ndarray) -> np.ndarray: frame_batch, offsets_and_scales = self._prepare_top_down(tensor, detections) if len(frame_batch) == 0: - offsets_and_scales = [(0, 0), 1] + # Determine output shape based on single_animal parameter and n_individuals + if self.single_animal or self.n_individuals == 1: + zero_pose = np.zeros((self.n_bodyparts, 3)) + else: + zero_pose = np.zeros((self.n_individuals, self.n_bodyparts, 3)) + return zero_pose + tensor = frame_batch # still CHW, batched if self.dynamic is not None: From c618a7f11bb28232a12022e91b74cace87181e7c Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter Date: Wed, 21 Jan 2026 16:52:05 +0100 Subject: [PATCH 3/4] Handle zero-detections in SkipFrames counter. - For zero detections, the age is still incremented - `_detections` attrubute is set to None --- dlclive/pose_estimation_pytorch/runner.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dlclive/pose_estimation_pytorch/runner.py b/dlclive/pose_estimation_pytorch/runner.py index adcfaac..bc88e71 100644 --- a/dlclive/pose_estimation_pytorch/runner.py +++ b/dlclive/pose_estimation_pytorch/runner.py @@ -82,6 +82,11 @@ def update(self, pose: torch.Tensor, w: int, h: int) -> None: self._detections = dict(boxes=bboxes, scores=torch.ones(num_det)) self._age += 1 + def next_frame(self) -> None: + """Increment the frame counter and set detections to None (to handle no detections)""" + self._detections = None + self._age += 1 + @dataclass class TopDownConfig: @@ -199,7 +204,10 @@ def get_pose(self, frame: np.ndarray) -> np.ndarray: if self.single_animal or self.n_individuals == 1: zero_pose = np.zeros((self.n_bodyparts, 3)) else: - zero_pose = np.zeros((self.n_individuals, self.n_bodyparts, 3)) + zero_pose = np.zeros((self.n_individuals, self.n_bodyparts, 3)) + # Update skip_frames even when returning early to maintain frame counter + if self.top_down_config.skip_frames is not None: + self.top_down_config.skip_frames.next_frame() return zero_pose tensor = frame_batch # still CHW, batched From 849eac776491197fa6a30054d699e27868142163 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter Date: Wed, 21 Jan 2026 17:03:38 +0100 Subject: [PATCH 4/4] Add informative error when model is not loaded. ( + improve docstring) --- dlclive/pose_estimation_pytorch/runner.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dlclive/pose_estimation_pytorch/runner.py b/dlclive/pose_estimation_pytorch/runner.py index bc88e71..63d07e0 100644 --- a/dlclive/pose_estimation_pytorch/runner.py +++ b/dlclive/pose_estimation_pytorch/runner.py @@ -132,6 +132,16 @@ class PyTorchRunner(BaseRunner): Processors that were implemented. dynamic: Whether to use dynamic cropping. top_down_config: Only for top-down models running with a detector. + + returns: + pose: The pose of the animal(s) in the frame. + shape: + (n_bodyparts, 3) if single_animal is True + (n_individuals, n_bodyparts, 3) if single_animal is False. + If no detections are found, the pose consists of zeros. + + Raises: + ValueError: If the model is not loaded. Call load_model() or init_inference() before calling get_pose(). """ def __init__( @@ -182,6 +192,10 @@ def close(self) -> None: @torch.inference_mode() def get_pose(self, frame: np.ndarray) -> np.ndarray: + if self.model is None: + raise ValueError( + "Model not loaded. Call load_model() or init_inference() before calling get_pose()." + ) c, h, w = frame.shape tensor = torch.from_numpy(frame).permute(2, 0, 1) # CHW, still on CPU