lightning-pose 2.4.0__tar.gz → 2.4.2__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 (78) hide show
  1. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/PKG-INFO +1 -1
  2. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/api/model.py +10 -8
  3. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/api/model_config.py +51 -12
  4. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/api/model_runtime.py +1 -1
  5. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/__init__.py +7 -1
  6. lightning_pose-2.4.2/lightning_pose/cli/commands/convert.py +72 -0
  7. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/predict.py +4 -3
  8. lightning_pose-2.4.2/lightning_pose/cli/commands/recommend.py +120 -0
  9. lightning_pose-2.4.2/lightning_pose/converters/__init__.py +18 -0
  10. lightning_pose-2.4.2/lightning_pose/converters/dlc.py +123 -0
  11. lightning_pose-2.4.2/lightning_pose/converters/sleap.py +193 -0
  12. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/__init__.py +4 -4
  13. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/bboxes.py +4 -4
  14. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/datamodules.py +1 -1
  15. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/datasets.py +38 -3
  16. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/factory.py +24 -7
  17. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/utils.py +0 -1
  18. lightning_pose-2.4.2/lightning_pose/data/video/__init__.py +34 -0
  19. {lightning_pose-2.4.0/lightning_pose/data → lightning_pose-2.4.2/lightning_pose/data/video}/dali.py +1 -1
  20. lightning_pose-2.4.2/lightning_pose/data/video/factory.py +203 -0
  21. lightning_pose-2.4.2/lightning_pose/data/video/opencv.py +422 -0
  22. {lightning_pose-2.4.0/lightning_pose/data → lightning_pose-2.4.2/lightning_pose/data/video}/pynvvc.py +62 -25
  23. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/losses/factory.py +5 -1
  24. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/losses/losses.py +11 -6
  25. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/factory.py +5 -1
  26. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heatmap_tracker_multiview.py +5 -2
  27. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/train.py +3 -2
  28. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/__init__.py +2 -0
  29. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/cropzoom.py +5 -3
  30. lightning_pose-2.4.2/lightning_pose/utils/device.py +82 -0
  31. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/inference_types.py +1 -1
  32. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/io.py +22 -9
  33. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/predictions.py +27 -52
  34. lightning_pose-2.4.2/lightning_pose/utils/recommender.py +839 -0
  35. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/pyproject.toml +1 -1
  36. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/LICENSE +0 -0
  37. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/README.md +0 -0
  38. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/__init__.py +0 -0
  39. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/api/__init__.py +0 -0
  40. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/callbacks.py +0 -0
  41. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/__init__.py +0 -0
  42. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/create_bbox.py +0 -0
  43. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/crop.py +0 -0
  44. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/export.py +0 -0
  45. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/remap.py +0 -0
  46. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/run_app.py +0 -0
  47. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/smooth_bbox.py +0 -0
  48. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/train.py +0 -0
  49. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/friendly.py +0 -0
  50. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/main.py +0 -0
  51. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/types.py +0 -0
  52. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/augmentations.py +0 -0
  53. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/cameras.py +0 -0
  54. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/datatypes.py +0 -0
  55. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/extractor.py +0 -0
  56. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/heatmaps.py +0 -0
  57. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/losses/__init__.py +0 -0
  58. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/metrics.py +0 -0
  59. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/migrations/__init__.py +0 -0
  60. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/migrations/migrations.py +0 -0
  61. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/migrations/rename_time_directories.py +0 -0
  62. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/__init__.py +0 -0
  63. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/__init__.py +0 -0
  64. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/factory.py +0 -0
  65. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/vit.py +0 -0
  66. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/vit_dino.py +0 -0
  67. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/vit_sam.py +0 -0
  68. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/vit_sam2.py +0 -0
  69. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/base.py +0 -0
  70. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/datatypes.py +0 -0
  71. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heads/__init__.py +0 -0
  72. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heads/heatmap.py +0 -0
  73. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heads/heatmap_mhcrnn.py +0 -0
  74. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heads/regression.py +0 -0
  75. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heatmap_tracker.py +0 -0
  76. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heatmap_tracker_mhcrnn.py +0 -0
  77. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/regression_tracker.py +0 -0
  78. {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/pca.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightning-pose
3
- Version: 2.4.0
3
+ Version: 2.4.2
4
4
  Summary: Semi-supervised pose estimation using pytorch lightning
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -720,10 +720,11 @@ class Model(_RuntimeMixin): # pyright: ignore[reportGeneralTypeIssues]
720
720
  Defaults to False.
721
721
  progress_file (Path, optional): Path to a file to save progress information for the
722
722
  App. Defaults to None.
723
- reader (optional): which video-reading backend to use, "dali" or "pynvvc".
724
- None (default) auto-selects pynvvc if it's usable on this machine for this
725
- video, else falls back to dali. Independent of the model's runtime
726
- (eager/onnx) and torch.compile -- this only controls video ingestion.
723
+ reader (optional): which video-reading backend to use, "dali", "pynvvc", or
724
+ "opencv". None (default) auto-selects pynvvc if it's usable on this machine
725
+ for this video, else dali if it's installed, else opencv (the portable
726
+ fallback, always available). Independent of the model's runtime (eager/onnx)
727
+ and torch.compile -- this only controls video ingestion.
727
728
  bbox_file (str | Path, optional): Path to a per-frame bbox CSV (columns x, y, h, w;
728
729
  one row per frame). When provided, each frame is cropped to its bounding box
729
730
  before being passed to the model, and predictions are returned in the original
@@ -809,9 +810,10 @@ class Model(_RuntimeMixin): # pyright: ignore[reportGeneralTypeIssues]
809
810
  compute_metrics: whether to compute pixel error and loss metrics on predictions.
810
811
  generate_labeled_video: whether to save a labeled video.
811
812
  progress_file: path to a file to save progress information for the App.
812
- reader: which video-reading backend to use, "dali" or "pynvvc". None (default)
813
- auto-selects pynvvc if it's usable on this machine for this video, else falls
814
- back to dali.
813
+ reader: which video-reading backend to use, "dali", "pynvvc", or "opencv". None
814
+ (default) auto-selects pynvvc if it's usable on this machine for this video,
815
+ else dali if it's installed, else opencv (the portable fallback, always
816
+ available).
815
817
 
816
818
  Returns:
817
819
  object containing the predictions and metrics for each view.
@@ -905,7 +907,7 @@ def _build_datamodule_pred(cfg: DictConfig | ListConfig) -> BaseDataModule | Unl
905
907
  with open_dict(cfg_pred.training):
906
908
  cfg_pred.training.imgaug = "default"
907
909
  cfg_pred.training.imgaug_hflip = False
908
- imgaug_transform_pred = get_imgaug_transform(cfg=cfg_pred)
910
+ imgaug_transform_pred = get_imgaug_transform(cfg=cfg_pred, data_dir=cfg_pred.data.data_dir)
909
911
  dataset_pred = get_dataset(
910
912
  cfg=cfg_pred,
911
913
  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:
@@ -19,14 +19,18 @@ command module must define three top-level functions:
19
19
 
20
20
  **Adding a new command**: create ``<name>.py`` here with all three functions (copy an existing
21
21
  module, e.g. ``train.py``, as a template), add it to :data:`COMMANDS` below, and add a matching
22
- ``docs/source/cli_reference/<name>.rst`` page.
22
+ ``docs/source/cli_reference/<name>.rst`` page (with a toctree entry in that directory's
23
+ ``index.rst``). ``tests/cli/test_main.py::TestCliReferenceDocs`` fails if either is missing, so
24
+ a forgotten doc page is caught by CI rather than relying on this docstring being read.
23
25
  """
24
26
 
25
27
  from lightning_pose.cli.commands import (
28
+ convert,
26
29
  create_bbox,
27
30
  crop,
28
31
  export,
29
32
  predict,
33
+ recommend,
30
34
  remap,
31
35
  run_app,
32
36
  smooth_bbox,
@@ -43,4 +47,6 @@ COMMANDS = {
43
47
  'crop': crop,
44
48
  'remap': remap,
45
49
  'run_app': run_app,
50
+ 'recommend': recommend,
51
+ 'convert': convert,
46
52
  }
@@ -0,0 +1,72 @@
1
+ """Convert command for the lightning-pose CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def register_parser(subparsers: Any) -> argparse.ArgumentParser:
11
+ """Register the convert command parser."""
12
+ from textwrap import dedent
13
+
14
+ description_text = dedent(
15
+ """\
16
+ Converts a labeled dataset from another pose estimation tool into a Lightning
17
+ Pose project directory.
18
+
19
+ ``dataset_path`` is dispatched by type:
20
+
21
+ * a directory -- treated as a DeepLabCut project (``labeled-data/<video>/
22
+ CollectedData*.{csv,h5}``).
23
+ * a ``.slp`` file -- treated as a SLEAP ``.pkg.slp`` package. Export it from the
24
+ SLEAP GUI via Predict -> Export Labels Package. Only single-view, single-animal
25
+ SLEAP projects are supported.
26
+ """
27
+ )
28
+
29
+ convert_parser = subparsers.add_parser(
30
+ 'convert',
31
+ description=description_text,
32
+ usage='litpose convert <dataset_path> --lp_dir LP_DIR',
33
+ )
34
+ convert_parser.add_argument(
35
+ 'dataset_path',
36
+ type=Path,
37
+ help='a DeepLabCut project directory, or a SLEAP .pkg.slp file',
38
+ )
39
+ convert_parser.add_argument(
40
+ '--lp_dir',
41
+ type=Path,
42
+ required=True,
43
+ help='destination Lightning Pose project directory; created if it does not exist',
44
+ )
45
+ return convert_parser
46
+
47
+
48
+ def get_parser() -> argparse.ArgumentParser:
49
+ """Return an ArgumentParser for the `litpose convert` subcommand (for docs)."""
50
+ parser = argparse.ArgumentParser(prog='litpose')
51
+ subparsers = parser.add_subparsers(dest='command')
52
+ return register_parser(subparsers)
53
+
54
+
55
+ def handle(args: argparse.Namespace) -> None:
56
+ """Handle the convert command."""
57
+ dataset_path: Path = args.dataset_path
58
+ lp_dir: Path = args.lp_dir
59
+
60
+ if dataset_path.is_dir():
61
+ from lightning_pose.converters import dlc
62
+
63
+ dlc.convert(dataset_path, lp_dir)
64
+ elif dataset_path.suffix == '.slp':
65
+ from lightning_pose.converters import sleap
66
+
67
+ sleap.convert(dataset_path, lp_dir)
68
+ else:
69
+ raise ValueError(
70
+ f'could not determine dataset type for {dataset_path}: expected a DeepLabCut '
71
+ f'project directory or a SLEAP .slp file'
72
+ )
@@ -156,9 +156,10 @@ def register_parser(subparsers: Any) -> argparse.ArgumentParser:
156
156
  choices=sorted(_READER_CHOICES),
157
157
  default=None,
158
158
  help=(
159
- "video-reading backend for video inputs. 'dali' or 'pynvvc'. If omitted "
160
- "(default), auto-selects 'pynvvc' when it's usable on this machine for the "
161
- "given video, else falls back to 'dali'. Independent of --runtime/--compile "
159
+ "video-reading backend for video inputs. 'dali', 'pynvvc', or 'opencv'. If "
160
+ "omitted (default), auto-selects 'pynvvc' when it's usable on this machine "
161
+ "for the given video, else 'dali' when it's installed, else 'opencv' (the "
162
+ "portable fallback, always available). Independent of --runtime/--compile "
162
163
  "-- this only controls video ingestion, not model execution. Ignored for "
163
164
  "CSV/image inputs."
164
165
  ),
@@ -0,0 +1,120 @@
1
+ """Recommend command for the lightning-pose CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def register_parser(subparsers: Any) -> argparse.ArgumentParser:
16
+ """Register the recommend command parser."""
17
+ from textwrap import dedent
18
+
19
+ description_text = dedent(
20
+ """\
21
+ Analyzes a lightning-pose dataset and prints config recommendations.
22
+
23
+ Unlabeled videos are assumed to live in ``<dataset_path>/videos``. A complete,
24
+ ready-to-use config YAML is always written; use ``--output`` to control where, or
25
+ ``--print_only`` to skip writing a file entirely.
26
+ """
27
+ )
28
+
29
+ recommend_parser = subparsers.add_parser(
30
+ 'recommend',
31
+ description=description_text,
32
+ usage='litpose recommend <dataset_path> [--output OUTPUT | --print_only]',
33
+ )
34
+ recommend_parser.add_argument(
35
+ 'dataset_path',
36
+ type=Path,
37
+ help='directory containing one or more label CSV files (auto-discovered; multiple '
38
+ 'CSVs are treated as a multi-view dataset), or a single label CSV file',
39
+ )
40
+ output_group = recommend_parser.add_mutually_exclusive_group()
41
+ output_group.add_argument(
42
+ '--output',
43
+ type=Path,
44
+ default=None,
45
+ help='path to write the recommended config YAML. a directory writes config.yaml '
46
+ 'inside it. defaults to <dataset_path>/config.yaml, or a timestamped filename if '
47
+ 'that already exists.',
48
+ )
49
+ output_group.add_argument(
50
+ '--print_only',
51
+ action='store_true',
52
+ help='print the recommendation report only; do not write a config YAML.',
53
+ )
54
+ return recommend_parser
55
+
56
+
57
+ def get_parser() -> argparse.ArgumentParser:
58
+ """Return an ArgumentParser for the `litpose recommend` subcommand (for docs)."""
59
+ parser = argparse.ArgumentParser(prog='litpose')
60
+ subparsers = parser.add_subparsers(dest='command')
61
+ return register_parser(subparsers)
62
+
63
+
64
+ def _ask_top_down_freely_moving() -> bool:
65
+ """Interactively ask whether the dataset is a top-down view of a freely moving animal.
66
+
67
+ Returns False without prompting when stdin is not an interactive terminal.
68
+ """
69
+ if not sys.stdin.isatty():
70
+ return False
71
+ answer = input(
72
+ 'Does your dataset have a top-down view with a freely moving animal? [y/N]: '
73
+ )
74
+ return answer.strip().lower() in ('y', 'yes')
75
+
76
+
77
+ def _resolve_output_path(dataset_path: Path, output: Path | None) -> Path:
78
+ """determine where to write the recommended config yaml"""
79
+ base_dir = dataset_path if dataset_path.is_dir() else dataset_path.parent
80
+ if output is None:
81
+ candidate = base_dir / 'config.yaml'
82
+ if candidate.exists():
83
+ candidate = base_dir / f'config_{datetime.now():%Y%m%d_%H%M%S}.yaml'
84
+ return candidate
85
+ if output.is_dir():
86
+ return output / 'config.yaml'
87
+ return output
88
+
89
+
90
+ def handle(args: argparse.Namespace) -> None:
91
+ """Handle the recommend command."""
92
+ from omegaconf import OmegaConf
93
+
94
+ from lightning_pose.utils import recommender
95
+
96
+ analysis = recommender.analyze_dataset(args.dataset_path)
97
+ gpu = recommender.get_gpu_info()
98
+ top_down_freely_moving = (
99
+ _ask_top_down_freely_moving() if analysis.view_names is None else False
100
+ )
101
+ rec = recommender.recommend(analysis, gpu, top_down_freely_moving=top_down_freely_moving)
102
+
103
+ logger.info(recommender.format_report(rec, analysis, gpu))
104
+
105
+ if args.print_only:
106
+ return
107
+
108
+ output_path = _resolve_output_path(args.dataset_path, args.output)
109
+ output_path.parent.mkdir(parents=True, exist_ok=True)
110
+
111
+ header = '\n'.join(
112
+ ['# generated by `litpose recommend`; rationale for each recommended field:']
113
+ + [f'# {name}: {reason}' for name, reason in rec.rationale.items()]
114
+ )
115
+ cfg = recommender.build_config(rec, analysis)
116
+ with open(output_path, 'w') as f:
117
+ f.write(header + '\n\n')
118
+ f.write(OmegaConf.to_yaml(cfg))
119
+
120
+ logger.info(f'\nwrote config to {output_path}')
@@ -0,0 +1,18 @@
1
+ """Convert labeled datasets from other pose estimation tools into Lightning Pose projects.
2
+
3
+ Two converters, one per source format:
4
+
5
+ - :func:`lightning_pose.converters.dlc.convert` -- reads a DeepLabCut project directory
6
+ (``labeled-data/<video>/CollectedData*.{csv,h5}``) and writes a Lightning Pose project
7
+ directory (``CollectedData.csv`` plus copied ``labeled-data/`` and ``videos/``).
8
+ - :func:`lightning_pose.converters.sleap.convert` -- reads a SLEAP ``.pkg.slp`` package
9
+ (frames and labels bundled in one HDF5 file) and writes a Lightning Pose project directory.
10
+
11
+ Both converters are dispatched from ``litpose convert <dataset_path> --lp_dir <lp_dir>``
12
+ (:mod:`lightning_pose.cli.commands.convert`), which decides which one to call based on
13
+ whether ``dataset_path`` is a directory (DLC) or a ``.slp`` file (SLEAP).
14
+
15
+ **Adding a new converter**: create ``<name>.py`` here with a ``convert(<source>: Path, lp_dir:
16
+ Path) -> None`` function, then add a dispatch branch in
17
+ ``lightning_pose.cli.commands.convert.handle``.
18
+ """
@@ -0,0 +1,123 @@
1
+ """Convert a DeepLabCut project directory into a Lightning Pose project directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ import pandas as pd
11
+
12
+ from lightning_pose.utils.io import fix_empty_first_row
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _load_labels_for_video_dir(dlc_dir: Path, video_dir_name: str) -> pd.DataFrame | None:
18
+ """Load the ``CollectedData`` labels for a single ``labeled-data/<video_dir_name>`` dir.
19
+
20
+ Tries the CSV export first, falling back to the HDF5 export. Both DLC labeling schemes
21
+ are supported: the older scheme indexes rows by ``labeled-data/<video>/<image>.png``
22
+ directly, and the newer scheme splits the video and image name into separate columns
23
+ (or separate MultiIndex levels for the HDF5 export), which are recombined into that same
24
+ index format here.
25
+
26
+ Args:
27
+ dlc_dir: root of the DLC project.
28
+ video_dir_name: name of the ``labeled-data`` subdirectory to load labels for.
29
+
30
+ Returns:
31
+ the labels as a DataFrame with a 3-level ``(scorer, bodyparts, coords)`` column
32
+ MultiIndex, or None if no ``CollectedData*`` file was found for this directory.
33
+ """
34
+ video_dir = dlc_dir / 'labeled-data' / video_dir_name
35
+ csv_files = sorted(video_dir.glob('CollectedData*.csv'))
36
+ if csv_files:
37
+ df = pd.read_csv(csv_files[0], header=[0, 1, 2], index_col=0)
38
+ df = fix_empty_first_row(df)
39
+ if len(df.index.unique()) != df.shape[0]:
40
+ # new DLC labeling scheme that splits video/image in different cells
41
+ vids = df.loc[:, ('Unnamed: 1_level_0', 'Unnamed: 1_level_1', 'Unnamed: 1_level_2')]
42
+ imgs = df.loc[:, ('Unnamed: 2_level_0', 'Unnamed: 2_level_1', 'Unnamed: 2_level_2')]
43
+ new_index = [f'labeled-data/{v}/{i}' for v, i in zip(vids, imgs, strict=True)]
44
+ df = df.drop(
45
+ columns=[
46
+ ('Unnamed: 1_level_0', 'Unnamed: 1_level_1', 'Unnamed: 1_level_2'),
47
+ ('Unnamed: 2_level_0', 'Unnamed: 2_level_1', 'Unnamed: 2_level_2'),
48
+ ],
49
+ )
50
+ df.index = new_index
51
+ return df
52
+
53
+ h5_files = sorted(video_dir.glob('CollectedData*.h5'))
54
+ if h5_files:
55
+ df = cast(pd.DataFrame, pd.read_hdf(h5_files[0]))
56
+ if isinstance(df.index, pd.MultiIndex):
57
+ # new DLC labeling scheme that splits video/image in different cells
58
+ imgs = [i[2] for i in df.index]
59
+ vids = [df.index[0][1] for _ in imgs]
60
+ new_index = [f'labeled-data/{v}/{i}' for v, i in zip(vids, imgs, strict=True)]
61
+ df = df.reset_index(drop=True)
62
+ df.index = new_index
63
+ return df
64
+
65
+ logger.warning(f'could not find labels for {video_dir_name}; skipping')
66
+ return None
67
+
68
+
69
+ def convert(dlc_dir: Path, lp_dir: Path) -> None:
70
+ """Convert a DeepLabCut project directory into a Lightning Pose project directory.
71
+
72
+ Reads every ``labeled-data/<video>/CollectedData*.{csv,h5}`` file under ``dlc_dir``,
73
+ concatenates them into a single ``CollectedData.csv``, and copies the ``labeled-data/``
74
+ and ``videos/`` directories over to ``lp_dir``.
75
+
76
+ Args:
77
+ dlc_dir: root of the DLC project (must contain a ``labeled-data`` subdirectory).
78
+ lp_dir: destination Lightning Pose project directory; created if it does not exist.
79
+ Must not already contain a ``labeled-data`` or ``videos`` subdirectory.
80
+
81
+ Raises:
82
+ NotADirectoryError: if ``dlc_dir`` does not exist.
83
+ ValueError: if ``dlc_dir`` and ``lp_dir`` are the same path.
84
+ """
85
+ logger.info(f'converting DLC project at {dlc_dir} to LP project at {lp_dir}')
86
+
87
+ if not dlc_dir.is_dir():
88
+ raise NotADirectoryError(f'did not find the directory {dlc_dir}')
89
+ if dlc_dir.resolve() == lp_dir.resolve():
90
+ raise ValueError('dlc_dir and lp_dir cannot be the same')
91
+
92
+ video_dir_names = sorted(
93
+ p.name for p in (dlc_dir / 'labeled-data').iterdir()
94
+ if p.is_dir() and not p.name.startswith('.') and not p.name.endswith('_labeled')
95
+ )
96
+
97
+ dfs = []
98
+ for video_dir_name in video_dir_names:
99
+ logger.info(video_dir_name)
100
+ df = _load_labels_for_video_dir(dlc_dir, video_dir_name)
101
+ if df is not None:
102
+ dfs.append(df)
103
+ df_all = pd.concat(dfs)
104
+
105
+ lp_dir.mkdir(parents=True, exist_ok=True)
106
+ df_all.to_csv(lp_dir / 'CollectedData.csv')
107
+
108
+ shutil.copytree(dlc_dir / 'labeled-data', lp_dir / 'labeled-data')
109
+
110
+ src_videos = dlc_dir / 'videos'
111
+ dst_videos = lp_dir / 'videos'
112
+ if src_videos.exists():
113
+ logger.info('copying video files')
114
+ shutil.copytree(src_videos, dst_videos)
115
+ else:
116
+ logger.info('DLC video directory does not exist; creating empty video directory')
117
+ dst_videos.mkdir(parents=True, exist_ok=True)
118
+
119
+ for image_path in df_all.index:
120
+ if not (lp_dir / image_path).exists():
121
+ raise FileNotFoundError(
122
+ f'expected image file not found after conversion: {image_path}'
123
+ )