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.
Files changed (81) hide show
  1. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/PKG-INFO +1 -1
  2. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/api/model.py +29 -3
  3. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/api/model_config.py +51 -12
  4. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/api/model_runtime.py +1 -1
  5. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/predict.py +18 -2
  6. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/converters/sleap.py +15 -2
  7. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/bboxes.py +111 -3
  8. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/datasets.py +38 -3
  9. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/factory.py +24 -7
  10. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/video/__init__.py +6 -0
  11. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/video/dali.py +85 -107
  12. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/video/factory.py +26 -12
  13. lightning_pose-2.4.3/lightning_pose/data/video/opencv.py +203 -0
  14. lightning_pose-2.4.3/lightning_pose/data/video/pynvvc.py +298 -0
  15. lightning_pose-2.4.3/lightning_pose/data/video/utils.py +339 -0
  16. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/losses/factory.py +5 -1
  17. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/losses/losses.py +11 -6
  18. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_dino.py +3 -1
  19. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/factory.py +5 -1
  20. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heads/heatmap.py +5 -1
  21. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker_multiview.py +5 -2
  22. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/train.py +3 -2
  23. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/__init__.py +2 -0
  24. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/cropzoom.py +9 -9
  25. lightning_pose-2.4.3/lightning_pose/utils/device.py +82 -0
  26. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/io.py +41 -10
  27. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/predictions.py +59 -15
  28. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/pyproject.toml +1 -1
  29. lightning_pose-2.4.1/lightning_pose/data/video/opencv.py +0 -422
  30. lightning_pose-2.4.1/lightning_pose/data/video/pynvvc.py +0 -489
  31. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/LICENSE +0 -0
  32. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/README.md +0 -0
  33. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/__init__.py +0 -0
  34. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/api/__init__.py +0 -0
  35. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/callbacks.py +0 -0
  36. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/__init__.py +0 -0
  37. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/__init__.py +0 -0
  38. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/convert.py +0 -0
  39. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/create_bbox.py +0 -0
  40. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/crop.py +0 -0
  41. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/export.py +0 -0
  42. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/recommend.py +0 -0
  43. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/remap.py +0 -0
  44. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/run_app.py +0 -0
  45. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/smooth_bbox.py +0 -0
  46. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/commands/train.py +0 -0
  47. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/friendly.py +0 -0
  48. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/main.py +0 -0
  49. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/cli/types.py +0 -0
  50. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/converters/__init__.py +0 -0
  51. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/converters/dlc.py +0 -0
  52. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/__init__.py +0 -0
  53. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/augmentations.py +0 -0
  54. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/cameras.py +0 -0
  55. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/datamodules.py +0 -0
  56. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/datatypes.py +0 -0
  57. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/extractor.py +0 -0
  58. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/heatmaps.py +0 -0
  59. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/data/utils.py +0 -0
  60. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/losses/__init__.py +0 -0
  61. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/metrics.py +0 -0
  62. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/migrations/__init__.py +0 -0
  63. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/migrations/migrations.py +0 -0
  64. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/migrations/rename_time_directories.py +0 -0
  65. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/__init__.py +0 -0
  66. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/__init__.py +0 -0
  67. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/factory.py +0 -0
  68. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit.py +0 -0
  69. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_sam.py +0 -0
  70. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_sam2.py +0 -0
  71. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/base.py +0 -0
  72. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/datatypes.py +0 -0
  73. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heads/__init__.py +0 -0
  74. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heads/heatmap_mhcrnn.py +0 -0
  75. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heads/regression.py +0 -0
  76. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker.py +0 -0
  77. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker_mhcrnn.py +0 -0
  78. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/models/regression_tracker.py +0 -0
  79. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/inference_types.py +0 -0
  80. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/pca.py +0 -0
  81. {lightning_pose-2.4.1 → lightning_pose-2.4.3}/lightning_pose/utils/recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightning-pose
3
- Version: 2.4.1
3
+ Version: 2.4.3
4
4
  Summary: Semi-supervised pose estimation using pytorch lightning
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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. Defaults to None.
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`` is active, ``training.imgaug``
230
- must be ``"dlc"`` and ``training.imgaug_3d`` must be ``true``.
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
- reprojection = self.cfg.losses.get('supervised_reprojection_heatmap_mse')
255
- if reprojection is not None and reprojection.get('log_weight') is not None:
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. When provided, each frame is cropped to its bounding box before "
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 = json.loads(str(source_video_ds.attrs['json']))
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 = json.loads(str(metadata_ds.attrs['json']))
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 (cursor/step bookkeeping to select
303
- those rows lives in each backend's own wrapper, not here —
304
- see ``LitDaliWrapper._apply_bbox_crop``).
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
- calib_file = str(Path('calibrations') / f'{session_id}.toml')
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
- logger.warning(
784
- 'calibration file not found for some frames; disabling 3D for entire dataset'
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 file exist.
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 cfg.data.get('camera_params_file')
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
- if (
162
- cfg.training.imgaug in ['default', 'none']
163
- or not cfg.data.get('camera_params_file')
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.