lightning-pose 2.4.1__tar.gz → 2.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/PKG-INFO +1 -1
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/api/model.py +29 -3
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/api/model_config.py +51 -12
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/api/model_runtime.py +1 -1
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/predict.py +18 -2
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/converters/sleap.py +15 -2
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/bboxes.py +111 -3
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/datasets.py +38 -3
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/factory.py +24 -7
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/video/__init__.py +6 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/video/dali.py +85 -107
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/video/factory.py +26 -12
- lightning_pose-2.4.3/lightning_pose/data/video/opencv.py +203 -0
- lightning_pose-2.4.3/lightning_pose/data/video/pynvvc.py +298 -0
- lightning_pose-2.4.3/lightning_pose/data/video/utils.py +339 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/losses/factory.py +5 -1
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/losses/losses.py +11 -6
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_dino.py +3 -1
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/factory.py +5 -1
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heads/heatmap.py +5 -1
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker_multiview.py +5 -2
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/train.py +3 -2
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/__init__.py +2 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/cropzoom.py +9 -9
- lightning_pose-2.4.3/lightning_pose/utils/device.py +82 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/io.py +41 -10
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/predictions.py +59 -15
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/pyproject.toml +1 -1
- lightning_pose-2.4.1/lightning_pose/data/video/opencv.py +0 -422
- lightning_pose-2.4.1/lightning_pose/data/video/pynvvc.py +0 -489
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/LICENSE +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/README.md +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/api/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/callbacks.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/convert.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/create_bbox.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/crop.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/export.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/recommend.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/remap.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/run_app.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/smooth_bbox.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/train.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/friendly.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/main.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/types.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/converters/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/converters/dlc.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/augmentations.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/cameras.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/datamodules.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/datatypes.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/extractor.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/heatmaps.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/utils.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/losses/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/metrics.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/migrations/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/migrations/migrations.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/migrations/rename_time_directories.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/factory.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_sam.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_sam2.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/base.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/datatypes.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heads/__init__.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heads/heatmap_mhcrnn.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heads/regression.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker_mhcrnn.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/regression_tracker.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/inference_types.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/pca.py +0 -0
- {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/recommender.py +0 -0
|
@@ -728,7 +728,8 @@ class Model(_RuntimeMixin): # pyright: ignore[reportGeneralTypeIssues]
|
|
|
728
728
|
bbox_file (str | Path, optional): Path to a per-frame bbox CSV (columns x, y, h, w;
|
|
729
729
|
one row per frame). When provided, each frame is cropped to its bounding box
|
|
730
730
|
before being passed to the model, and predictions are returned in the original
|
|
731
|
-
coordinate space. Single-view only
|
|
731
|
+
coordinate space. Single-view only; for multiview models use
|
|
732
|
+
``predict_on_video_file_multiview(bbox_file_per_view=...)``. Defaults to None.
|
|
732
733
|
|
|
733
734
|
Returns:
|
|
734
735
|
PredictionResult: A PredictionResult object containing the predictions and metrics.
|
|
@@ -798,6 +799,7 @@ class Model(_RuntimeMixin): # pyright: ignore[reportGeneralTypeIssues]
|
|
|
798
799
|
generate_labeled_video: bool = False,
|
|
799
800
|
progress_file: Path | None = None,
|
|
800
801
|
reader: _Reader | None = None,
|
|
802
|
+
bbox_file_per_view: list[str] | list[Path] | None = None,
|
|
801
803
|
) -> MultiviewPredictionResult:
|
|
802
804
|
"""Version of ``predict_on_video_file`` that accesses multiple camera views of each frame.
|
|
803
805
|
|
|
@@ -813,10 +815,20 @@ class Model(_RuntimeMixin): # pyright: ignore[reportGeneralTypeIssues]
|
|
|
813
815
|
reader: which video-reading backend to use, "dali", "pynvvc", or "opencv". None
|
|
814
816
|
(default) auto-selects pynvvc if it's usable on this machine for this video,
|
|
815
817
|
else dali if it's installed, else opencv (the portable fallback, always
|
|
816
|
-
available).
|
|
818
|
+
available). Multiview bbox cropping currently requires (and defaults to)
|
|
819
|
+
"dali".
|
|
820
|
+
bbox_file_per_view: optional list of per-frame bbox CSVs (columns x, y, h, w; one
|
|
821
|
+
row per frame), in the same order as ``video_file_per_view``. When provided,
|
|
822
|
+
each view's frames are cropped to that view's bboxes before being passed to
|
|
823
|
+
the model, and predictions are returned in the original coordinate space.
|
|
817
824
|
|
|
818
825
|
Returns:
|
|
819
826
|
object containing the predictions and metrics for each view.
|
|
827
|
+
|
|
828
|
+
Raises:
|
|
829
|
+
ValueError: if the model is not multiview, the number of video or bbox files does
|
|
830
|
+
not match the number of views, or a bbox CSV's row count differs from its
|
|
831
|
+
video's frame count.
|
|
820
832
|
"""
|
|
821
833
|
if not self.config.is_multi_view():
|
|
822
834
|
raise ValueError('predict_on_video_file_multiview requires a multi-view model')
|
|
@@ -830,6 +842,16 @@ class Model(_RuntimeMixin): # pyright: ignore[reportGeneralTypeIssues]
|
|
|
830
842
|
)
|
|
831
843
|
|
|
832
844
|
video_file_per_view = [Path(f) for f in video_file_per_view]
|
|
845
|
+
if bbox_file_per_view is not None and len(bbox_file_per_view) != len(view_names):
|
|
846
|
+
raise ValueError(
|
|
847
|
+
f'expected {len(view_names)} bbox files (one per view), '
|
|
848
|
+
f'got {len(bbox_file_per_view)}'
|
|
849
|
+
)
|
|
850
|
+
# pair each bbox file with its video before the videos are put in view order
|
|
851
|
+
video_to_bbox_file = (
|
|
852
|
+
dict(zip(video_file_per_view, map(Path, bbox_file_per_view), strict=True))
|
|
853
|
+
if bbox_file_per_view is not None else None
|
|
854
|
+
)
|
|
833
855
|
|
|
834
856
|
if output_dir == self.__class__.UNSPECIFIED:
|
|
835
857
|
output_dir = self.video_preds_dir()
|
|
@@ -859,6 +881,10 @@ class Model(_RuntimeMixin): # pyright: ignore[reportGeneralTypeIssues]
|
|
|
859
881
|
output_pred_file=prediction_csv_file_list,
|
|
860
882
|
progress_file=progress_file,
|
|
861
883
|
reader=reader,
|
|
884
|
+
bbox_file=(
|
|
885
|
+
[str(video_to_bbox_file[v]) for v in video_file_per_view]
|
|
886
|
+
if video_to_bbox_file is not None else None
|
|
887
|
+
),
|
|
862
888
|
)
|
|
863
889
|
if generate_labeled_video:
|
|
864
890
|
for video_file, preds_df in zip(video_file_per_view, df_list, strict=True):
|
|
@@ -907,7 +933,7 @@ def _build_datamodule_pred(cfg: DictConfig | ListConfig) -> BaseDataModule | Unl
|
|
|
907
933
|
with open_dict(cfg_pred.training):
|
|
908
934
|
cfg_pred.training.imgaug = "default"
|
|
909
935
|
cfg_pred.training.imgaug_hflip = False
|
|
910
|
-
imgaug_transform_pred = get_imgaug_transform(cfg=cfg_pred)
|
|
936
|
+
imgaug_transform_pred = get_imgaug_transform(cfg=cfg_pred, data_dir=cfg_pred.data.data_dir)
|
|
911
937
|
dataset_pred = get_dataset(
|
|
912
938
|
cfg=cfg_pred,
|
|
913
939
|
data_dir=cfg_pred.data.data_dir,
|
|
@@ -8,6 +8,7 @@ from typing import get_args
|
|
|
8
8
|
|
|
9
9
|
from omegaconf import DictConfig, ListConfig, OmegaConf, open_dict
|
|
10
10
|
|
|
11
|
+
from lightning_pose.data.datasets import has_calibration_files
|
|
11
12
|
from lightning_pose.models import ALLOWED_MODEL_TYPES
|
|
12
13
|
from lightning_pose.utils.io import (
|
|
13
14
|
check_video_paths,
|
|
@@ -220,14 +221,60 @@ class ModelConfig:
|
|
|
220
221
|
|
|
221
222
|
self._validate_steps_vs_epochs()
|
|
222
223
|
|
|
224
|
+
def _validate_camera_dependent_loss(self, loss_name: str) -> None:
|
|
225
|
+
"""Validate a single camera-dependent multi-view loss, if active.
|
|
226
|
+
|
|
227
|
+
Both ``supervised_reprojection_heatmap_mse`` and ``supervised_pairwise_projections``
|
|
228
|
+
require the same setup: ``model.model_type == "heatmap_multiview_transformer"`` (only
|
|
229
|
+
that class's ``get_loss_inputs_labeled`` populates the 3D/reprojected keypoints these
|
|
230
|
+
losses need -- any other model type raises a confusing ``TypeError`` from deep inside
|
|
231
|
+
:class:`~lightning_pose.losses.factory.LossFactory` instead), a 3D-safe augmentation
|
|
232
|
+
pipeline (``training.imgaug == "dlc"``, which gets silently promoted to ``dlc-mv`` —
|
|
233
|
+
see :func:`lightning_pose.data.factory.get_imgaug_transform`), ``training.imgaug_3d``
|
|
234
|
+
set to ``true``, and resolvable camera parameters. Without all of these, the loss
|
|
235
|
+
either never gets added (see :func:`lightning_pose.losses.factory.get_loss_factories`)
|
|
236
|
+
or is trained against geometrically-inconsistent per-view augmentations.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
loss_name: config key under ``losses``, e.g. ``"supervised_reprojection_heatmap_mse"``.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
AssertionError: if the loss is active and any requirement is not met.
|
|
243
|
+
"""
|
|
244
|
+
loss_cfg = self.cfg.losses.get(loss_name)
|
|
245
|
+
if loss_cfg is None or loss_cfg.get('log_weight') is None:
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
assert self.cfg.model.model_type == 'heatmap_multiview_transformer', (
|
|
249
|
+
f"model.model_type must be 'heatmap_multiview_transformer' when losses.{loss_name} "
|
|
250
|
+
"is active -- only that model class populates the 3D keypoints this loss requires"
|
|
251
|
+
)
|
|
252
|
+
assert self.cfg.training.imgaug == 'dlc', (
|
|
253
|
+
f"training.imgaug must be 'dlc' when losses.{loss_name} is active"
|
|
254
|
+
)
|
|
255
|
+
assert self.cfg.training.get('imgaug_3d') is True, (
|
|
256
|
+
f"training.imgaug_3d must be true when losses.{loss_name} is active"
|
|
257
|
+
)
|
|
258
|
+
has_cam_params = bool(
|
|
259
|
+
self.cfg.data.get('camera_params_file')
|
|
260
|
+
or has_calibration_files(self.cfg.data.data_dir)
|
|
261
|
+
)
|
|
262
|
+
assert has_cam_params, (
|
|
263
|
+
f"losses.{loss_name} is active but no camera parameters were found: set "
|
|
264
|
+
"data.camera_params_file, or provide a calibration.toml (or "
|
|
265
|
+
"calibrations/<session>.toml) at data.data_dir for auto-discovery. Without "
|
|
266
|
+
"camera parameters this loss is silently never added at train time."
|
|
267
|
+
)
|
|
268
|
+
|
|
223
269
|
def _validate_model(self) -> None:
|
|
224
270
|
"""Validate the ``model`` config section.
|
|
225
271
|
|
|
226
272
|
Checks:
|
|
227
273
|
- ``model_type`` is a recognised value.
|
|
228
274
|
- Multi-view models use ``heatmap_multiview_transformer``.
|
|
229
|
-
- When ``losses.supervised_reprojection_heatmap_mse``
|
|
230
|
-
|
|
275
|
+
- When ``losses.supervised_reprojection_heatmap_mse`` or
|
|
276
|
+
``losses.supervised_pairwise_projections`` is active, see
|
|
277
|
+
:meth:`_validate_camera_dependent_loss`.
|
|
231
278
|
|
|
232
279
|
Raises:
|
|
233
280
|
AssertionError: if any check fails.
|
|
@@ -251,16 +298,8 @@ class ModelConfig:
|
|
|
251
298
|
stacklevel=2,
|
|
252
299
|
)
|
|
253
300
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
assert self.cfg.training.imgaug == 'dlc', (
|
|
257
|
-
"training.imgaug must be 'dlc' when "
|
|
258
|
-
"losses.supervised_reprojection_heatmap_mse is active"
|
|
259
|
-
)
|
|
260
|
-
assert self.cfg.training.get('imgaug_3d') is True, (
|
|
261
|
-
"training.imgaug_3d must be true when "
|
|
262
|
-
"losses.supervised_reprojection_heatmap_mse is active"
|
|
263
|
-
)
|
|
301
|
+
self._validate_camera_dependent_loss('supervised_reprojection_heatmap_mse')
|
|
302
|
+
self._validate_camera_dependent_loss('supervised_pairwise_projections')
|
|
264
303
|
|
|
265
304
|
def _validate_losses(self) -> None:
|
|
266
305
|
"""Validate the ``losses`` config section.
|
|
@@ -118,7 +118,7 @@ def load_model_from_checkpoint(
|
|
|
118
118
|
if not data_module and not skip_data_module:
|
|
119
119
|
delete_extras = True
|
|
120
120
|
data_dir, video_dir = return_absolute_data_paths(data_cfg=cfg.data)
|
|
121
|
-
imgaug_transform = get_imgaug_transform(cfg=cfg)
|
|
121
|
+
imgaug_transform = get_imgaug_transform(cfg=cfg, data_dir=data_dir)
|
|
122
122
|
dataset = get_dataset(cfg=cfg, data_dir=data_dir, imgaug_transform=imgaug_transform)
|
|
123
123
|
data_module = get_data_module(cfg=cfg, dataset=dataset, video_dir=video_dir)
|
|
124
124
|
if not data_module:
|
|
@@ -120,7 +120,8 @@ def register_parser(subparsers: Any) -> argparse.ArgumentParser:
|
|
|
120
120
|
"directory containing bbox CSV files produced by ``litpose create_bbox`` or a "
|
|
121
121
|
"compatible external source. For CSV inputs, looks for ``bbox.csv`` inside this "
|
|
122
122
|
"directory. For video inputs, looks for ``<video_stem>_bbox.csv`` inside this "
|
|
123
|
-
"directory
|
|
123
|
+
"directory; for multiview models, one ``<video_stem>_bbox.csv`` per view video. "
|
|
124
|
+
"When provided, each frame is cropped to its bounding box before "
|
|
124
125
|
"being passed to the model, and predictions are saved in the original coordinate "
|
|
125
126
|
"space."
|
|
126
127
|
),
|
|
@@ -235,6 +236,7 @@ def handle(args: argparse.Namespace) -> None:
|
|
|
235
236
|
_predict_multi_type_multi_view(
|
|
236
237
|
model, input_paths, args.skip_viz, not args.overwrite,
|
|
237
238
|
progress_file=args.progress_file, reader=args.reader,
|
|
239
|
+
bbox_dir=args.bbox_dir,
|
|
238
240
|
)
|
|
239
241
|
else:
|
|
240
242
|
for p in input_paths:
|
|
@@ -320,6 +322,7 @@ def _predict_multi_type_multi_view(
|
|
|
320
322
|
skip_existing: bool,
|
|
321
323
|
progress_file: Path | None = None,
|
|
322
324
|
reader: _Reader | None = None,
|
|
325
|
+
bbox_dir: Path | None = None,
|
|
323
326
|
) -> None:
|
|
324
327
|
"""Run multi-view prediction on a list of paths (videos or directories).
|
|
325
328
|
|
|
@@ -329,6 +332,9 @@ def _predict_multi_type_multi_view(
|
|
|
329
332
|
skip_viz: if True, skip generating labeled visualization outputs.
|
|
330
333
|
skip_existing: if True, skip sessions for which output CSVs already exist.
|
|
331
334
|
progress_file: optional path to write prediction progress as JSON.
|
|
335
|
+
reader: optional video-reading backend; None auto-selects.
|
|
336
|
+
bbox_dir: optional directory containing a ``<video_stem>_bbox.csv`` for every view's
|
|
337
|
+
video. When provided, each view is cropped to its bboxes before inference.
|
|
332
338
|
"""
|
|
333
339
|
# delay this import because it's slow
|
|
334
340
|
from lightning_pose.utils.io import (
|
|
@@ -362,11 +368,21 @@ def _predict_multi_type_multi_view(
|
|
|
362
368
|
logger.info(f'skipping {session_name} (prediction file already exists)')
|
|
363
369
|
continue
|
|
364
370
|
|
|
371
|
+
bbox_file_per_view = None
|
|
372
|
+
if bbox_dir is not None:
|
|
373
|
+
bbox_file_per_view = [
|
|
374
|
+
bbox_dir / f'{Path(video).stem}_bbox.csv' for video in video_file_per_view
|
|
375
|
+
]
|
|
376
|
+
missing = [f for f in bbox_file_per_view if not f.exists()]
|
|
377
|
+
if missing:
|
|
378
|
+
raise FileNotFoundError(f'bbox file(s) not found: {missing}')
|
|
379
|
+
|
|
365
380
|
model.predict_on_video_file_multiview(
|
|
366
381
|
video_file_per_view,
|
|
367
382
|
generate_labeled_video=not skip_viz,
|
|
368
383
|
progress_file=progress_file,
|
|
369
384
|
reader=reader,
|
|
385
|
+
bbox_file_per_view=bbox_file_per_view,
|
|
370
386
|
)
|
|
371
387
|
# if we have a list of directories, we process the videos in each separately
|
|
372
388
|
elif all(path.is_dir() for path in paths):
|
|
@@ -379,7 +395,7 @@ def _predict_multi_type_multi_view(
|
|
|
379
395
|
|
|
380
396
|
_predict_multi_type_multi_view(
|
|
381
397
|
model, video_files, skip_viz, skip_existing,
|
|
382
|
-
progress_file=progress_file, reader=reader,
|
|
398
|
+
progress_file=progress_file, reader=reader, bbox_dir=bbox_dir,
|
|
383
399
|
)
|
|
384
400
|
else:
|
|
385
401
|
logger.info(f'skipping {path}: no videos found')
|
|
@@ -19,6 +19,19 @@ from PIL import Image
|
|
|
19
19
|
logger = logging.getLogger(__name__)
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _decode_json_attr(raw: object) -> dict:
|
|
23
|
+
"""Decode an h5py ``json`` attribute, which may come back as ``str`` or ``bytes``.
|
|
24
|
+
|
|
25
|
+
h5py returns ``numpy.bytes_`` for fixed-length HDF5 string attributes and native ``str``
|
|
26
|
+
for variable-length UTF-8 ones, depending on how the writer encoded them. Calling ``str()``
|
|
27
|
+
on the former yields its Python repr (e.g. ``"b'{...}'"``), not the decoded text, so bytes
|
|
28
|
+
must be decoded explicitly rather than passed through ``str()``.
|
|
29
|
+
"""
|
|
30
|
+
if isinstance(raw, bytes):
|
|
31
|
+
return json.loads(raw.decode('utf-8'))
|
|
32
|
+
return json.loads(str(raw))
|
|
33
|
+
|
|
34
|
+
|
|
22
35
|
def _extract_video_names(pkg_slp_file: Path) -> dict[str, str]:
|
|
23
36
|
"""Map each ``video<N>`` group in a ``.pkg.slp`` file to its source video filename."""
|
|
24
37
|
video_names = {}
|
|
@@ -28,7 +41,7 @@ def _extract_video_names(pkg_slp_file: Path) -> dict[str, str]:
|
|
|
28
41
|
source_video_path = f'{video_group_name}/source_video'
|
|
29
42
|
if source_video_path in hdf_file:
|
|
30
43
|
source_video_ds = cast(h5py.Dataset, hdf_file[source_video_path])
|
|
31
|
-
source_video_dict =
|
|
44
|
+
source_video_dict = _decode_json_attr(source_video_ds.attrs['json'])
|
|
32
45
|
video_names[video_group_name] = source_video_dict['backend']['filename']
|
|
33
46
|
return video_names
|
|
34
47
|
|
|
@@ -130,7 +143,7 @@ def _extract_labels(pkg_slp_file: Path) -> pd.DataFrame | None:
|
|
|
130
143
|
|
|
131
144
|
if data:
|
|
132
145
|
metadata_ds = cast(h5py.Dataset, hdf_file['metadata'])
|
|
133
|
-
metadata_dict =
|
|
146
|
+
metadata_dict = _decode_json_attr(metadata_ds.attrs['json'])
|
|
134
147
|
keypoints = [node['name'] for node in metadata_dict['nodes']]
|
|
135
148
|
columns = pd.MultiIndex.from_product(
|
|
136
149
|
[['lightning_tracker'], keypoints, ['x', 'y']],
|
|
@@ -24,6 +24,15 @@ dimensions; handle both single-view and multi-view):
|
|
|
24
24
|
|
|
25
25
|
- :func:`frame_to_model_batch` — frame → model (called before loss computation)
|
|
26
26
|
- :func:`model_to_frame_batch` — model → frame (called after inference)
|
|
27
|
+
|
|
28
|
+
Video-reader helpers (used by all three readers in ``lightning_pose.data.video`` to crop
|
|
29
|
+
per-frame bboxes out of full-resolution frames before inference):
|
|
30
|
+
|
|
31
|
+
- :func:`as_bbox_df_list` — normalize a reader's ``bbox_df`` to one DataFrame per view
|
|
32
|
+
- :func:`slice_bbox_rows` — select (and pad) the bbox rows for one batch
|
|
33
|
+
- :func:`crop_and_resize_frames` — crop + resize one view's frames to its bboxes
|
|
34
|
+
- :func:`crop_views` — apply the above to every view of a batch independently
|
|
35
|
+
- :func:`full_frame_bbox` — default bbox covering the whole decoded frame
|
|
27
36
|
"""
|
|
28
37
|
|
|
29
38
|
import pandas as pd
|
|
@@ -288,6 +297,24 @@ def model_to_frame_batch(
|
|
|
288
297
|
return model_keypoints_.reshape((-1, num_targets))
|
|
289
298
|
|
|
290
299
|
|
|
300
|
+
def slice_bbox_rows(bbox_df: pd.DataFrame, idx_start: int, seq_len: int) -> pd.DataFrame:
|
|
301
|
+
"""Select the bbox rows for one batch, padding a short final batch with the last row.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
bbox_df: DataFrame with columns ``["x", "y", "h", "w"]``, one row per video frame.
|
|
305
|
+
idx_start: index of the first frame in the batch.
|
|
306
|
+
seq_len: number of frames in the batch.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
DataFrame with exactly ``seq_len`` rows, ready for :func:`crop_and_resize_frames`.
|
|
310
|
+
"""
|
|
311
|
+
rows = bbox_df.iloc[idx_start:idx_start + seq_len]
|
|
312
|
+
if len(rows) < seq_len:
|
|
313
|
+
last_row = bbox_df.iloc[[-1]]
|
|
314
|
+
rows = pd.concat([rows] + [last_row] * (seq_len - len(rows)), ignore_index=True)
|
|
315
|
+
return rows
|
|
316
|
+
|
|
317
|
+
|
|
291
318
|
def crop_and_resize_frames(
|
|
292
319
|
frames: Float[torch.Tensor, "seq_len rgb h w"],
|
|
293
320
|
bbox_rows: pd.DataFrame,
|
|
@@ -299,9 +326,10 @@ def crop_and_resize_frames(
|
|
|
299
326
|
"""Crop each frame to its per-frame bounding box and resize to a common size.
|
|
300
327
|
|
|
301
328
|
Shared by all video-decoding backends: each hands this function a full-resolution ``frames``
|
|
302
|
-
tensor plus one already-sliced-and-padded bbox row per frame
|
|
303
|
-
|
|
304
|
-
see
|
|
329
|
+
tensor plus one already-sliced-and-padded bbox row per frame. Row selection is done by
|
|
330
|
+
:func:`slice_bbox_rows` (via :func:`crop_views`); the cursor/step bookkeeping that decides
|
|
331
|
+
where a batch starts lives in each reader's wrapper, not here (see
|
|
332
|
+
``LitDaliWrapper._dali_output_to_tensors`` and ``LitPredictWrapperBase.__next__``).
|
|
305
333
|
|
|
306
334
|
Args:
|
|
307
335
|
frames: full-resolution frames, shape (seq_len, 3, H, W).
|
|
@@ -341,3 +369,83 @@ def crop_and_resize_frames(
|
|
|
341
369
|
)
|
|
342
370
|
)
|
|
343
371
|
return torch.stack(cropped_frames), torch.stack(bboxes)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def as_bbox_df_list(
|
|
375
|
+
bbox_df: pd.DataFrame | list[pd.DataFrame] | None,
|
|
376
|
+
num_views: int,
|
|
377
|
+
) -> list[pd.DataFrame] | None:
|
|
378
|
+
"""Normalize a video reader's ``bbox_df`` to a list with one DataFrame per view.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
bbox_df: a single DataFrame (single-view), a list with one DataFrame per view
|
|
382
|
+
(multiview), or None when no bbox cropping is configured.
|
|
383
|
+
num_views: number of views in the batch being built (1 for single-view).
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
list of per-view bbox DataFrames, or None if ``bbox_df`` is None.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
ValueError: if the number of DataFrames does not match ``num_views``.
|
|
390
|
+
"""
|
|
391
|
+
if bbox_df is None:
|
|
392
|
+
return None
|
|
393
|
+
bbox_dfs = [bbox_df] if isinstance(bbox_df, pd.DataFrame) else list(bbox_df)
|
|
394
|
+
if len(bbox_dfs) != num_views:
|
|
395
|
+
raise ValueError(f'expected {num_views} bbox DataFrame(s), got {len(bbox_dfs)}')
|
|
396
|
+
return bbox_dfs
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def full_frame_bbox(
|
|
400
|
+
height: int,
|
|
401
|
+
width: int,
|
|
402
|
+
seq_len: int,
|
|
403
|
+
device: torch.device | str,
|
|
404
|
+
) -> Float[torch.Tensor, "seq_len xyhw"]:
|
|
405
|
+
"""Bbox covering an entire decoded frame (x=0, y=0, h=height, w=width), per frame.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
height: height of the decoded (pre-resize) frame in pixels.
|
|
409
|
+
width: width of the decoded (pre-resize) frame in pixels.
|
|
410
|
+
seq_len: number of frames in the batch.
|
|
411
|
+
device: device to create the tensor on.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
float32 tensor of shape (seq_len, 4) in [x, y, h, w] order.
|
|
415
|
+
"""
|
|
416
|
+
return torch.tensor([0, 0, height, width], dtype=torch.float32, device=device).repeat(
|
|
417
|
+
seq_len, 1,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def crop_views(
|
|
422
|
+
frames_list: list[torch.Tensor],
|
|
423
|
+
bbox_dfs: list[pd.DataFrame],
|
|
424
|
+
idx_start: int,
|
|
425
|
+
resize_dims: list[int],
|
|
426
|
+
) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
|
|
427
|
+
"""Crop and resize each view of a batch to its own per-frame bboxes.
|
|
428
|
+
|
|
429
|
+
Views are processed independently, so they may have different native resolutions; after
|
|
430
|
+
the crop+resize every view has shape ``resize_dims`` and the results can be stacked.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
frames_list: full-resolution frames, one tensor of shape (seq_len, 3, H, W) per view.
|
|
434
|
+
bbox_dfs: one bbox DataFrame per view (columns ``["x", "y", "h", "w"]``, one row per
|
|
435
|
+
video frame), in the same order as ``frames_list``.
|
|
436
|
+
idx_start: video frame index of the first frame in this batch.
|
|
437
|
+
resize_dims: target ``[height, width]`` to resize each crop to.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Tuple of (cropped+resized frames per view, actual bbox coordinates used per view); each
|
|
441
|
+
frames tensor has shape (seq_len, 3, *resize_dims) and each bbox tensor (seq_len, 4).
|
|
442
|
+
"""
|
|
443
|
+
seq_len = frames_list[0].shape[0]
|
|
444
|
+
cropped_list = []
|
|
445
|
+
bbox_list = []
|
|
446
|
+
for frames, bbox_df in zip(frames_list, bbox_dfs, strict=True):
|
|
447
|
+
rows = slice_bbox_rows(bbox_df, idx_start, seq_len)
|
|
448
|
+
cropped, bboxes = crop_and_resize_frames(frames, rows, resize_dims)
|
|
449
|
+
cropped_list.append(cropped)
|
|
450
|
+
bbox_list.append(bboxes)
|
|
451
|
+
return cropped_list, bbox_list
|
|
@@ -550,6 +550,27 @@ class HeatmapDataset(BaseTrackingDataset):
|
|
|
550
550
|
return example_dict # type: ignore[return-value]
|
|
551
551
|
|
|
552
552
|
|
|
553
|
+
def has_calibration_files(root_directory: str | Path) -> bool:
|
|
554
|
+
"""Check whether a project directory contains any camera calibration files.
|
|
555
|
+
|
|
556
|
+
Used both by :meth:`MultiviewHeatmapDataset._discover_cam_params_from_image_paths` (to
|
|
557
|
+
skip labeled-data folder-name parsing when there is nothing to discover) and by
|
|
558
|
+
:func:`lightning_pose.data.factory.get_dataset` (to decide, before the dataset is built,
|
|
559
|
+
whether camera params will end up available via auto-discovery).
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
root_directory: project root directory (``cfg.data.data_dir``).
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
True if ``root_directory/calibrations/`` (a directory) or
|
|
566
|
+
``root_directory/calibration.toml`` exists.
|
|
567
|
+
"""
|
|
568
|
+
return (
|
|
569
|
+
(Path(root_directory) / 'calibrations').is_dir()
|
|
570
|
+
or (Path(root_directory) / 'calibration.toml').exists()
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
553
574
|
class MultiviewHeatmapDataset(torch.utils.data.Dataset):
|
|
554
575
|
"""Heatmap dataset that aggregates one :class:`HeatmapDataset` per camera view.
|
|
555
576
|
|
|
@@ -735,6 +756,12 @@ class MultiviewHeatmapDataset(torch.utils.data.Dataset):
|
|
|
735
756
|
tuple of (cam_params_df, cam_params_file_to_camgroup); both None if no calibration
|
|
736
757
|
files are found
|
|
737
758
|
"""
|
|
759
|
+
# nothing to discover if the project has no calibration files at all; skip parsing
|
|
760
|
+
# labeled-data folder names so <session>_<view> naming is only enforced when it's
|
|
761
|
+
# actually needed
|
|
762
|
+
if not has_calibration_files(self.root_directory):
|
|
763
|
+
return None, None
|
|
764
|
+
|
|
738
765
|
image_names = self.dataset[self.view_names[0]].image_names
|
|
739
766
|
cam_params_file_to_camgroup = {}
|
|
740
767
|
calib_files = []
|
|
@@ -760,7 +787,10 @@ class MultiviewHeatmapDataset(torch.utils.data.Dataset):
|
|
|
760
787
|
calib_by_session = Path(self.root_directory) / 'calibrations' / f'{session_id}.toml'
|
|
761
788
|
calib_fallback = Path(self.root_directory) / 'calibration.toml'
|
|
762
789
|
if calib_by_session.exists():
|
|
763
|
-
|
|
790
|
+
# .as_posix() (not str()) -- calib_file is a portable identifier stored in
|
|
791
|
+
# cam_params_df and used as a dict key, not a filesystem call, so it must
|
|
792
|
+
# stay forward-slash on every OS like image_names elsewhere in this class.
|
|
793
|
+
calib_file = (Path('calibrations') / f'{session_id}.toml').as_posix()
|
|
764
794
|
elif calib_fallback.exists():
|
|
765
795
|
calib_file = 'calibration.toml'
|
|
766
796
|
else:
|
|
@@ -780,8 +810,13 @@ class MultiviewHeatmapDataset(torch.utils.data.Dataset):
|
|
|
780
810
|
return cam_params_df, cam_params_file_to_camgroup
|
|
781
811
|
|
|
782
812
|
if cam_params_file_to_camgroup and not all_found:
|
|
783
|
-
|
|
784
|
-
'calibration file not found for some
|
|
813
|
+
raise ValueError(
|
|
814
|
+
'calibration file not found for some, but not all, sessions in this dataset. '
|
|
815
|
+
'Partial calibration coverage is always treated as an error, since some '
|
|
816
|
+
'calibration files existing usually means calibration was intended for the '
|
|
817
|
+
'whole dataset. Ensure every session referenced by the dataset has a '
|
|
818
|
+
'corresponding calibrations/<session>.toml (or a calibration.toml fallback at '
|
|
819
|
+
'the project root), or remove calibration entirely if it is not needed.'
|
|
785
820
|
)
|
|
786
821
|
return None, None
|
|
787
822
|
|
|
@@ -23,6 +23,7 @@ model-side steps):
|
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
import warnings
|
|
26
|
+
from pathlib import Path
|
|
26
27
|
|
|
27
28
|
import imgaug.augmenters as iaa
|
|
28
29
|
import numpy as np
|
|
@@ -38,13 +39,14 @@ from lightning_pose.data.datasets import (
|
|
|
38
39
|
BaseTrackingDataset,
|
|
39
40
|
HeatmapDataset,
|
|
40
41
|
MultiviewHeatmapDataset,
|
|
42
|
+
has_calibration_files,
|
|
41
43
|
)
|
|
42
44
|
|
|
43
45
|
# to ignore imports for sphinx-autoapidoc
|
|
44
46
|
__all__: list[str] = []
|
|
45
47
|
|
|
46
48
|
|
|
47
|
-
def get_imgaug_transform(cfg: DictConfig | ListConfig) -> iaa.Sequential:
|
|
49
|
+
def get_imgaug_transform(cfg: DictConfig | ListConfig, data_dir: str | Path) -> iaa.Sequential:
|
|
48
50
|
"""Create simple and flexible data transform pipeline that augments images and keypoints.
|
|
49
51
|
|
|
50
52
|
Args:
|
|
@@ -63,7 +65,9 @@ def get_imgaug_transform(cfg: DictConfig | ListConfig) -> iaa.Sequential:
|
|
|
63
65
|
- "cfg.training.imgaug_3d":
|
|
64
66
|
boolean flag to control 3D-compatible augmentations for multiview models;
|
|
65
67
|
set to False to disable automatic "dlc-mv" enforcement;
|
|
66
|
-
set to True to enable 3D augmentations for when camera params
|
|
68
|
+
set to True to enable 3D augmentations for when camera params exist.
|
|
69
|
+
data_dir: root directory that camera calibration files are resolved relative to (used
|
|
70
|
+
to check for auto-discoverable calibration files; see ``has_calibration_files``).
|
|
67
71
|
|
|
68
72
|
Returns:
|
|
69
73
|
imgaug pipeline
|
|
@@ -75,12 +79,18 @@ def get_imgaug_transform(cfg: DictConfig | ListConfig) -> iaa.Sequential:
|
|
|
75
79
|
# Check if user explicitly wants to use 3D augmentations for multiview models
|
|
76
80
|
imagug_3d = cfg.training.get('imgaug_3d', None)
|
|
77
81
|
|
|
82
|
+
# camera params may come from an explicit camera_params_file, or be auto-discovered
|
|
83
|
+
# from a calibration.toml/calibrations/ dir at data_dir (see has_calibration_files)
|
|
84
|
+
has_cam_params = bool(
|
|
85
|
+
cfg.data.get('camera_params_file') or has_calibration_files(data_dir)
|
|
86
|
+
)
|
|
87
|
+
|
|
78
88
|
# enforce "dlc-mv" imgaug pipeline for multiview models (no 2D geometric transforms)
|
|
79
89
|
# only if explicitly requested or if no preference is set and camera params exist
|
|
80
90
|
if (
|
|
81
91
|
params not in ['default', 'none']
|
|
82
92
|
and cfg.model.model_type.find('multiview') > -1
|
|
83
|
-
and
|
|
93
|
+
and has_cam_params
|
|
84
94
|
and (imagug_3d is True or imagug_3d is None)
|
|
85
95
|
):
|
|
86
96
|
params = 'dlc-mv'
|
|
@@ -158,10 +168,12 @@ def get_dataset(
|
|
|
158
168
|
'No precautions regarding the size of the images were considered here, '
|
|
159
169
|
'images will be resized accordingly to configs!'
|
|
160
170
|
)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
# camera params may come from an explicit camera_params_file, or be auto-discovered
|
|
172
|
+
# from a calibration.toml/calibrations/ dir at data_dir (see has_calibration_files)
|
|
173
|
+
has_cam_params = bool(
|
|
174
|
+
cfg.data.get('camera_params_file') or has_calibration_files(data_dir)
|
|
175
|
+
)
|
|
176
|
+
if cfg.training.imgaug in ['default', 'none'] or not has_cam_params:
|
|
165
177
|
# we are either
|
|
166
178
|
# 1. running inference on un-augmented data, and need to make sure to resize
|
|
167
179
|
# 2. using a multiview model w/o camera params, and need to take care of resizing
|
|
@@ -236,6 +248,8 @@ def get_data_module(
|
|
|
236
248
|
Raises:
|
|
237
249
|
ValidationError: if a context model is requested but
|
|
238
250
|
``dali.context.train.batch_size < 5 * num_gpus``.
|
|
251
|
+
RuntimeError: if semi-supervised losses are requested but no CUDA device is
|
|
252
|
+
available, or the ``nvidia-dali`` package isn't installed.
|
|
239
253
|
"""
|
|
240
254
|
|
|
241
255
|
# Old configs may have num_gpus: 0. We will remove support in a future release.
|
|
@@ -255,7 +269,10 @@ def get_data_module(
|
|
|
255
269
|
val_batch_size = int(np.ceil(cfg.training.val_batch_size / cfg.training.num_gpus))
|
|
256
270
|
|
|
257
271
|
from lightning_pose.models import check_if_semi_supervised
|
|
272
|
+
from lightning_pose.utils.device import require_cuda_for_semi_supervised
|
|
273
|
+
|
|
258
274
|
semi_supervised = check_if_semi_supervised(cfg.model.losses_to_use)
|
|
275
|
+
require_cuda_for_semi_supervised(cfg.model.losses_to_use)
|
|
259
276
|
if not semi_supervised:
|
|
260
277
|
data_module = BaseDataModule(
|
|
261
278
|
dataset=dataset,
|
|
@@ -26,6 +26,12 @@ Backends
|
|
|
26
26
|
``opencv-python-headless``, an unconditional cross-platform dependency -- works on any
|
|
27
27
|
platform/hardware, including macOS, Windows, and GPU-less machines. Slower than DALI/pynvvc on
|
|
28
28
|
a supported NVIDIA box; the guaranteed fallback everywhere else. Prediction only.
|
|
29
|
+
- ``utils.py`` -- :class:`~lightning_pose.data.video.utils.PreparePredictBase` /
|
|
30
|
+
:class:`~lightning_pose.data.video.utils.LitPredictWrapperBase`, the validation, windowing,
|
|
31
|
+
normalization, bbox-crop and batch-assembly logic shared by the two prediction-only backends
|
|
32
|
+
(``pynvvc.py`` and ``opencv.py`` implement only decoding); also the batch-assembly and
|
|
33
|
+
normalization helper functions that ``dali.py`` reuses. Imports neither DALI nor
|
|
34
|
+
PyNvVideoCodec.
|
|
29
35
|
- ``factory.py`` -- :func:`~lightning_pose.data.video.factory.build_video_reader`, the reader-
|
|
30
36
|
selection dispatch used by ``predict_video``. See its docstring for the fallback chain and
|
|
31
37
|
the recipe for adding a new backend.
|