lightning-pose 2.4.2__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.2 → lightning_pose-2.4.3}/PKG-INFO +1 -1
  2. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/api/model.py +28 -2
  3. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/predict.py +18 -2
  4. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/bboxes.py +111 -3
  5. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/video/__init__.py +6 -0
  6. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/video/dali.py +85 -107
  7. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/video/factory.py +6 -3
  8. lightning_pose-2.4.3/lightning_pose/data/video/opencv.py +203 -0
  9. lightning_pose-2.4.3/lightning_pose/data/video/pynvvc.py +298 -0
  10. lightning_pose-2.4.3/lightning_pose/data/video/utils.py +339 -0
  11. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_dino.py +3 -1
  12. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/heads/heatmap.py +5 -1
  13. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/utils/cropzoom.py +4 -6
  14. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/utils/io.py +19 -1
  15. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/utils/predictions.py +51 -13
  16. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/pyproject.toml +1 -1
  17. lightning_pose-2.4.2/lightning_pose/data/video/opencv.py +0 -422
  18. lightning_pose-2.4.2/lightning_pose/data/video/pynvvc.py +0 -489
  19. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/LICENSE +0 -0
  20. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/README.md +0 -0
  21. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/__init__.py +0 -0
  22. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/api/__init__.py +0 -0
  23. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/api/model_config.py +0 -0
  24. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/api/model_runtime.py +0 -0
  25. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/callbacks.py +0 -0
  26. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/__init__.py +0 -0
  27. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/__init__.py +0 -0
  28. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/convert.py +0 -0
  29. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/create_bbox.py +0 -0
  30. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/crop.py +0 -0
  31. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/export.py +0 -0
  32. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/recommend.py +0 -0
  33. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/remap.py +0 -0
  34. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/run_app.py +0 -0
  35. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/smooth_bbox.py +0 -0
  36. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/commands/train.py +0 -0
  37. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/friendly.py +0 -0
  38. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/main.py +0 -0
  39. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/cli/types.py +0 -0
  40. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/converters/__init__.py +0 -0
  41. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/converters/dlc.py +0 -0
  42. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/converters/sleap.py +0 -0
  43. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/__init__.py +0 -0
  44. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/augmentations.py +0 -0
  45. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/cameras.py +0 -0
  46. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/datamodules.py +0 -0
  47. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/datasets.py +0 -0
  48. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/datatypes.py +0 -0
  49. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/extractor.py +0 -0
  50. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/factory.py +0 -0
  51. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/heatmaps.py +0 -0
  52. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/data/utils.py +0 -0
  53. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/losses/__init__.py +0 -0
  54. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/losses/factory.py +0 -0
  55. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/losses/losses.py +0 -0
  56. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/metrics.py +0 -0
  57. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/migrations/__init__.py +0 -0
  58. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/migrations/migrations.py +0 -0
  59. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/migrations/rename_time_directories.py +0 -0
  60. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/__init__.py +0 -0
  61. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/backbones/__init__.py +0 -0
  62. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/backbones/factory.py +0 -0
  63. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit.py +0 -0
  64. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_sam.py +0 -0
  65. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/backbones/vit_sam2.py +0 -0
  66. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/base.py +0 -0
  67. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/datatypes.py +0 -0
  68. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/factory.py +0 -0
  69. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/heads/__init__.py +0 -0
  70. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/heads/heatmap_mhcrnn.py +0 -0
  71. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/heads/regression.py +0 -0
  72. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker.py +0 -0
  73. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker_mhcrnn.py +0 -0
  74. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/heatmap_tracker_multiview.py +0 -0
  75. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/models/regression_tracker.py +0 -0
  76. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/train.py +0 -0
  77. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/utils/__init__.py +0 -0
  78. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/utils/device.py +0 -0
  79. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/utils/inference_types.py +0 -0
  80. {lightning_pose-2.4.2 → lightning_pose-2.4.3}/lightning_pose/utils/pca.py +0 -0
  81. {lightning_pose-2.4.2 → 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.2
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):
@@ -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')
@@ -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
@@ -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.
@@ -29,10 +29,14 @@ Standard mode (no ``bbox_df``):
29
29
 
30
30
  Bbox-crop mode (``bbox_df`` supplied to ``PrepareDALI``):
31
31
  DALI delivers full-resolution frames (``resize_dims=None`` in the predict pipe) so that
32
- ``LitDaliWrapper._apply_bbox_crop`` can crop each frame to its per-frame bounding box
33
- and resize to the original ``resize_dims`` using ``torch.nn.functional.interpolate``.
32
+ ``LitDaliWrapper._dali_output_to_tensors`` can crop each frame to its per-frame bounding
33
+ box and resize to the original ``resize_dims`` using ``torch.nn.functional.interpolate``.
34
34
  The ``bbox`` field of each batch contains the actual crop coordinates, so downstream
35
- code can remap predictions back to the original coordinate space.
35
+ code can remap predictions back to the original coordinate space. For multiview, one
36
+ bbox DataFrame per view is supplied and each view is cropped independently, so views may
37
+ have different native resolutions. The crop and batch assembly use the helpers shared with
38
+ the other readers (``lightning_pose.data.bboxes.crop_views``,
39
+ ``lightning_pose.data.video.utils.build_batch_dict``).
36
40
  """
37
41
 
38
42
  import os
@@ -55,12 +59,13 @@ except ImportError as e:
55
59
  ) from e
56
60
 
57
61
  from lightning_pose.data import _IMAGENET_MEAN, _IMAGENET_STD
58
- from lightning_pose.data.bboxes import crop_and_resize_frames
62
+ from lightning_pose.data.bboxes import as_bbox_df_list, crop_views, full_frame_bbox
59
63
  from lightning_pose.data.datatypes import (
60
64
  MultiviewUnlabeledBatchDict,
61
65
  UnlabeledBatchDict,
62
66
  )
63
67
  from lightning_pose.data.utils import count_frames
68
+ from lightning_pose.data.video.utils import build_batch_dict
64
69
 
65
70
  # to ignore imports for sphinx-autoapidoc
66
71
  __all__: list[str] = []
@@ -206,7 +211,7 @@ class LitDaliWrapper(DALIGenericIterator):
206
211
  to the model's input dimensions before being returned.
207
212
 
208
213
  ``_frame_idx`` tracks the iterator's position in the video (by frame number) so that
209
- ``_apply_bbox_crop`` reads the correct rows from ``bbox_df``. It advances by
214
+ ``_dali_output_to_tensors`` reads the correct rows from ``bbox_df``. It advances by
210
215
  ``seq_len`` for base models (non-overlapping windows) and by ``seq_len - 4`` for
211
216
  context models, because the DALI reader for context prediction uses a step of
212
217
  ``seq_len - 4`` so that consecutive 5-frame windows overlap by 4 frames.
@@ -214,7 +219,7 @@ class LitDaliWrapper(DALIGenericIterator):
214
219
  Testing without a GPU
215
220
  +++++++++++++++++++++
216
221
  This class can be instantiated without a real DALI pipeline or GPU for unit tests
217
- that only exercise the PyTorch post-processing logic (e.g. ``_apply_bbox_crop``)::
222
+ that only exercise the PyTorch post-processing logic (e.g. ``_dali_output_to_tensors``)::
218
223
 
219
224
  wrapper = object.__new__(LitDaliWrapper)
220
225
  wrapper.do_context = False
@@ -229,7 +234,7 @@ class LitDaliWrapper(DALIGenericIterator):
229
234
  eval_mode: Literal["train", "predict"],
230
235
  num_iters: int = 1,
231
236
  do_context: bool = False,
232
- bbox_df: pd.DataFrame | None = None,
237
+ bbox_df: pd.DataFrame | list[pd.DataFrame] | None = None,
233
238
  resize_dims: list[int] | None = None,
234
239
  **kwargs: Any,
235
240
  ) -> None:
@@ -241,7 +246,8 @@ class LitDaliWrapper(DALIGenericIterator):
241
246
  should be fixed by lightning/dali teams)
242
247
  do_context: whether model/loader use 5-frame context or not
243
248
  bbox_df: optional DataFrame with columns ``["x", "y", "h", "w"]``, one row per
244
- frame. When provided, each batch's frames are cropped per-frame and resized
249
+ frame (or, for multiview, a list with one such DataFrame per view, in view
250
+ order). When provided, each batch's frames are cropped per-frame and resized
245
251
  to ``resize_dims`` before being returned, and the ``bbox`` field of the
246
252
  batch dict is populated with the actual bbox coordinates.
247
253
  resize_dims: target ``[height, width]`` for post-crop resize; required when
@@ -264,11 +270,30 @@ class LitDaliWrapper(DALIGenericIterator):
264
270
  return self.num_iters
265
271
 
266
272
  @staticmethod
273
+ def _frame_size_hw(frame_size: torch.Tensor) -> tuple[int, int]:
274
+ """Extract (height, width) of the decoded frames from DALI's ``frame_size`` output.
275
+
276
+ Args:
277
+ frame_size: DALI ``frame_size`` output for one view, shape (1, 4) with either
278
+ ``seq_len,H,W,C`` or ``seq_len,C,H,W`` ordering.
279
+
280
+ Returns:
281
+ Tuple of (height, width) in pixels.
282
+ """
283
+ if frame_size[0, -1] == 3: # order is seq_len,H,W,C
284
+ return int(frame_size[0, 1]), int(frame_size[0, 2])
285
+ return int(frame_size[0, 2]), int(frame_size[0, 3]) # order is seq_len,C,H,W
286
+
267
287
  def _dali_output_to_tensors(
268
- batch: list
288
+ self, batch: list
269
289
  ) -> UnlabeledBatchDict | MultiviewUnlabeledBatchDict:
270
290
  """Convert a raw DALI batch output to a typed batch dictionary.
271
291
 
292
+ When ``self.bbox_df`` is set, each view is cropped to its per-frame bbox and resized
293
+ to ``self.resize_dims`` independently, so views may have different native resolutions.
294
+ Otherwise DALI has already resized the frames, and the ``bbox`` field spans the full
295
+ decoded frame (x=0, y=0, h=H, w=W) of each view.
296
+
272
297
  Args:
273
298
  batch: raw output list from DALI's ``DALIGenericIterator.__next__``.
274
299
 
@@ -277,107 +302,50 @@ class LitDaliWrapper(DALIGenericIterator):
277
302
  ``MultiviewUnlabeledBatchDict`` for multi-view pipelines.
278
303
  """
279
304
  # always batch_size=1
280
-
281
- if len(batch[0].keys()) == 3: # single view pipeline
282
-
283
- # shape (sequence_length, 3, H, W)
284
- frames = batch[0]["frames"][0, :, :, :, :]
285
- # shape (1,) or (2, 3)
286
- transforms = batch[0]["transforms"][0]
287
- # get frame size
288
- if batch[0]["frame_size"][0, -1] == 3: # order is seq_len,H,W,C
289
- height = batch[0]["frame_size"][0, 1]
290
- width = batch[0]["frame_size"][0, 2]
291
- else: # order is seq_len,C,H,W
292
- height = batch[0]["frame_size"][0, 2]
293
- width = batch[0]["frame_size"][0, 3]
294
- bbox = torch.tensor([0, 0, height, width], device=frames.device).repeat(
295
- (frames.shape[0], 1))
296
-
297
- return UnlabeledBatchDict(
298
- frames=frames, transforms=transforms, bbox=bbox, is_multiview=False,
299
- )
300
-
301
- else: # multiview pipeline
302
-
303
- # final shape: ("seq_len", "num_views", "RGB":3, "image_height", "image_width")
304
- frames = torch.stack(
305
- [batch[0][key][0, :, :, :, :] for key in batch[0].keys() if
306
- "transforms" not in key and "frame_size" not in key],
307
- dim=1,
308
- )
309
-
305
+ output = batch[0]
306
+ is_multiview = len(output.keys()) != 3
307
+
308
+ # per-view lists of frames (each of shape (seq_len, 3, H, W)) and decoded frame sizes
309
+ if is_multiview:
310
+ frames_list = [
311
+ output[key][0] for key in output.keys()
312
+ if 'transforms' not in key and 'frame_size' not in key
313
+ ]
314
+ frame_sizes = [
315
+ self._frame_size_hw(output[key]) for key in output.keys() if 'frame_size' in key
316
+ ]
310
317
  # final shape: ("num_views", "h":2, "w":3)
311
318
  transforms = torch.stack(
312
- [batch[0][key] for key in batch[0].keys() if "transforms" in key],
313
- dim=0,
319
+ [output[key] for key in output.keys() if 'transforms' in key], dim=0,
314
320
  )
315
-
316
- # final shape: ("seq_len", "num_views * xyhw")
317
- bbox = torch.cat([
318
- torch.tensor([
319
- 0,
320
- 0,
321
- batch[0][key][0, 1],
322
- batch[0][key][0, 2],
323
- ],
324
- device=frames.device
325
- ) for key in batch[0].keys() if "frame_size" in key
326
- ], dim=0).repeat(frames.shape[0], 1)
327
-
328
- return MultiviewUnlabeledBatchDict(
329
- frames=frames, transforms=transforms, bbox=bbox, is_multiview=True,
330
- )
331
-
332
- def _apply_bbox_crop(self, batch_dict: UnlabeledBatchDict) -> UnlabeledBatchDict:
333
- """Crop frames to per-frame bboxes and resize to the model's input dimensions.
334
-
335
- Called only in bbox-crop mode (when ``bbox_df`` is not None). DALI has already
336
- delivered full-resolution frames; this method crops each frame individually,
337
- resizes to ``self.resize_dims``, and advances ``_frame_idx`` by the same step
338
- that the DALI reader used, so the two cursors stay in sync.
339
-
340
- Args:
341
- batch_dict: single-view unlabeled batch with full-resolution frames from DALI.
342
-
343
- Returns:
344
- new ``UnlabeledBatchDict`` with cropped+resized frames and the actual bbox
345
- coordinates in the ``bbox`` field (shape ``(seq_len, 4)``, order x y h w).
346
- """
347
- assert self.bbox_df is not None # guarded by caller; assertion narrows type
348
- frames = batch_dict['frames'] # (seq_len, 3, H, W)
349
- seq_len = frames.shape[0]
350
- # context-model DALI reader uses step=seq_len-4 so consecutive windows overlap by 4
351
- # frames; _frame_idx must advance by the same amount to stay in sync with the reader
352
- step = seq_len - 4 if self.do_context else seq_len
353
-
354
- # slice bbox rows for this batch; pad last partial batch with the final row
355
- rows = self.bbox_df.iloc[self._frame_idx:self._frame_idx + seq_len]
356
- if len(rows) < seq_len:
357
- last_row = self.bbox_df.iloc[[-1]]
358
- rows = pd.concat(
359
- [rows] + [last_row] * (seq_len - len(rows)),
360
- ignore_index=True,
321
+ else:
322
+ frames_list = [output['frames'][0]]
323
+ frame_sizes = [self._frame_size_hw(output['frame_size'])]
324
+ # shape (1,) or (2, 3)
325
+ transforms = output['transforms'][0]
326
+
327
+ seq_len = frames_list[0].shape[0]
328
+ bbox_dfs = as_bbox_df_list(self.bbox_df, len(frames_list))
329
+ if bbox_dfs is None:
330
+ bbox_list = [
331
+ full_frame_bbox(height, width, seq_len, frames_list[0].device)
332
+ for height, width in frame_sizes
333
+ ]
334
+ else:
335
+ assert self.resize_dims is not None # required when bbox_df set, see __init__
336
+ frames_list, bbox_list = crop_views(
337
+ frames_list, bbox_dfs, self._frame_idx, self.resize_dims,
361
338
  )
339
+ # context-model DALI reader uses step=seq_len-4 so consecutive windows overlap by 4
340
+ # frames; _frame_idx must advance by the same amount to stay in sync with the reader
341
+ self._frame_idx += seq_len - 4 if self.do_context else seq_len
362
342
 
363
- assert self.resize_dims is not None # required when bbox_df set, see docstring
364
- cropped_frames, bboxes = crop_and_resize_frames(frames, rows, self.resize_dims)
365
- self._frame_idx += step
366
-
367
- return UnlabeledBatchDict(
368
- frames=cropped_frames,
369
- transforms=batch_dict['transforms'],
370
- bbox=bboxes,
371
- is_multiview=False,
372
- )
343
+ return build_batch_dict(frames_list, bbox_list, transforms, is_multiview)
373
344
 
374
345
  def __next__(self) -> UnlabeledBatchDict | MultiviewUnlabeledBatchDict:
375
346
  """Fetch the next batch, applying per-frame bbox crop+resize when configured."""
376
347
  batch = super().__next__()
377
- batch_dict = self._dali_output_to_tensors(batch=batch)
378
- if self.bbox_df is not None and not batch_dict['is_multiview']:
379
- return self._apply_bbox_crop(batch_dict) # type: ignore[arg-type]
380
- return batch_dict
348
+ return self._dali_output_to_tensors(batch=batch)
381
349
 
382
350
 
383
351
  class PrepareDALI:
@@ -405,7 +373,7 @@ class PrepareDALI:
405
373
  dali_config: dict | DictConfig | ListConfig | None = None,
406
374
  imgaug: str | None = "default",
407
375
  num_threads: int = 1,
408
- bbox_df: pd.DataFrame | None = None,
376
+ bbox_df: pd.DataFrame | list[pd.DataFrame] | None = None,
409
377
  ) -> None:
410
378
  """Initialize DALI pipelines and dataloaders for training or prediction.
411
379
 
@@ -422,15 +390,17 @@ class PrepareDALI:
422
390
  ``"dlc"``); pass ``"default"`` for resize-only or ``None`` to disable.
423
391
  num_threads: number of CPU threads used by DALI pipelines.
424
392
  bbox_df: optional DataFrame with columns ``["x", "y", "h", "w"]``, one row per
425
- frame. When provided, the predict pipeline loads full-resolution frames
426
- (DALI resize is disabled) and ``LitDaliWrapper`` crops each frame to its
427
- bbox before resizing to ``resize_dims``.
393
+ frame; for multiview, a list with one such DataFrame per view, in the same
394
+ order as ``filenames``. When provided, the predict pipeline loads
395
+ full-resolution frames (DALI resize is disabled) and ``LitDaliWrapper``
396
+ crops each frame to its bbox before resizing to ``resize_dims``.
428
397
 
429
398
  Raises:
430
399
  FileNotFoundError: if any path in ``filenames`` does not exist or is not a file.
431
400
  ValueError: for multiview inputs, if views have differing numbers of sessions or if a
432
401
  session has differing frame counts across views (which would desynchronize the
433
- per-view readers).
402
+ per-view readers); if the number of ``bbox_df`` DataFrames does not match the
403
+ number of views.
434
404
  """
435
405
  # determine if we have a multiview pipeline
436
406
  if isinstance(filenames, list) and isinstance(filenames[0], list):
@@ -449,6 +419,14 @@ class PrepareDALI:
449
419
  if not os.path.exists(vid) or not os.path.isfile(vid):
450
420
  raise FileNotFoundError(f"{vid} is not a video file!")
451
421
 
422
+ if bbox_df is not None:
423
+ num_bbox_dfs = 1 if isinstance(bbox_df, pd.DataFrame) else len(bbox_df)
424
+ if num_bbox_dfs != len(filenames_2d):
425
+ raise ValueError(
426
+ f'got {num_bbox_dfs} bbox DataFrame(s) for {len(filenames_2d)} view(s); '
427
+ 'multiview requires a list with one bbox DataFrame per view'
428
+ )
429
+
452
430
  # frame counts of view 0, per session; reused below for `self.frame_count`
453
431
  view0_frame_counts = list(map(count_frames, filenames_2d[0]))
454
432
 
@@ -549,7 +527,7 @@ class PrepareDALI:
549
527
 
550
528
  When ``bbox_df`` is set, predict entries have ``resize_dims`` forced to ``None``
551
529
  so DALI delivers full-resolution frames. The per-frame resize happens in PyTorch
552
- inside ``LitDaliWrapper._apply_bbox_crop`` after cropping, using the original
530
+ inside ``LitDaliWrapper._dali_output_to_tensors`` after cropping, using the original
553
531
  ``resize_dims`` stored on the wrapper.
554
532
  """
555
533
  assert self.dali_config is not None
@@ -9,7 +9,9 @@ Adding a new video reader
9
9
  -------------------------
10
10
 
11
11
  Every backend module (``dali.py``, ``pynvvc.py``, ``opencv.py``, ...) implements the
12
- same contract. To add one:
12
+ same contract. A prediction-only backend can subclass ``PreparePredictBase`` and
13
+ ``LitPredictWrapperBase`` from ``utils.py`` and implement only decoding (see ``opencv.py``).
14
+ To add one:
13
15
 
14
16
  1. **Two-phase prepare class**: ``Prepare<Name>.__init__(model_type, filenames,
15
17
  resize_dims, dali_config, bbox_df=None, ...)`` validates inputs and precomputes
@@ -83,7 +85,7 @@ def build_video_reader(
83
85
  dali_config: dict | DictConfig | ListConfig,
84
86
  filenames: list[str] | list[list[str]],
85
87
  resize_dims: list[int],
86
- bbox_df: pd.DataFrame | None,
88
+ bbox_df: pd.DataFrame | list[pd.DataFrame] | None,
87
89
  ) -> LitDaliWrapper | LitPynvvcWrapper | LitOpenCVWrapper:
88
90
  """Resolve which video-reading backend to use and build its dataloader.
89
91
 
@@ -109,7 +111,8 @@ def build_video_reader(
109
111
  multiview, one single-element list per view (see each backend's
110
112
  ``Prepare<Name>`` class for the exact shape).
111
113
  resize_dims: ``[height, width]`` the model expects as input.
112
- bbox_df: optional per-frame bbox DataFrame; single-view only.
114
+ bbox_df: optional per-frame bbox DataFrame; for multiview, a list with one
115
+ DataFrame per view (same order as ``filenames``).
113
116
 
114
117
  Returns:
115
118
  A ready-to-iterate loader (``LitDaliWrapper``, ``LitPynvvcWrapper``, or