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.
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/PKG-INFO +1 -1
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/api/model.py +10 -8
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/api/model_config.py +51 -12
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/api/model_runtime.py +1 -1
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/__init__.py +7 -1
- lightning_pose-2.4.2/lightning_pose/cli/commands/convert.py +72 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/predict.py +4 -3
- lightning_pose-2.4.2/lightning_pose/cli/commands/recommend.py +120 -0
- lightning_pose-2.4.2/lightning_pose/converters/__init__.py +18 -0
- lightning_pose-2.4.2/lightning_pose/converters/dlc.py +123 -0
- lightning_pose-2.4.2/lightning_pose/converters/sleap.py +193 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/__init__.py +4 -4
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/bboxes.py +4 -4
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/datamodules.py +1 -1
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/datasets.py +38 -3
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/factory.py +24 -7
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/utils.py +0 -1
- lightning_pose-2.4.2/lightning_pose/data/video/__init__.py +34 -0
- {lightning_pose-2.4.0/lightning_pose/data → lightning_pose-2.4.2/lightning_pose/data/video}/dali.py +1 -1
- lightning_pose-2.4.2/lightning_pose/data/video/factory.py +203 -0
- lightning_pose-2.4.2/lightning_pose/data/video/opencv.py +422 -0
- {lightning_pose-2.4.0/lightning_pose/data → lightning_pose-2.4.2/lightning_pose/data/video}/pynvvc.py +62 -25
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/losses/factory.py +5 -1
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/losses/losses.py +11 -6
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/factory.py +5 -1
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heatmap_tracker_multiview.py +5 -2
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/train.py +3 -2
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/__init__.py +2 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/cropzoom.py +5 -3
- lightning_pose-2.4.2/lightning_pose/utils/device.py +82 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/inference_types.py +1 -1
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/io.py +22 -9
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/predictions.py +27 -52
- lightning_pose-2.4.2/lightning_pose/utils/recommender.py +839 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/pyproject.toml +1 -1
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/LICENSE +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/README.md +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/__init__.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/api/__init__.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/callbacks.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/__init__.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/create_bbox.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/crop.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/export.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/remap.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/run_app.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/smooth_bbox.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/commands/train.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/friendly.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/main.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/cli/types.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/augmentations.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/cameras.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/datatypes.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/extractor.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/data/heatmaps.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/losses/__init__.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/metrics.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/migrations/__init__.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/migrations/migrations.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/migrations/rename_time_directories.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/__init__.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/__init__.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/factory.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/vit.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/vit_dino.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/vit_sam.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/backbones/vit_sam2.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/base.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/datatypes.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heads/__init__.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heads/heatmap.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heads/heatmap_mhcrnn.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heads/regression.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heatmap_tracker.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/heatmap_tracker_mhcrnn.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/models/regression_tracker.py +0 -0
- {lightning_pose-2.4.0 → lightning_pose-2.4.2}/lightning_pose/utils/pca.py +0 -0
|
@@ -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"
|
|
724
|
-
None (default) auto-selects pynvvc if it's usable on this machine
|
|
725
|
-
video, else
|
|
726
|
-
|
|
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 "
|
|
813
|
-
auto-selects pynvvc if it's usable on this machine for this video,
|
|
814
|
-
|
|
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``
|
|
230
|
-
|
|
275
|
+
- When ``losses.supervised_reprojection_heatmap_mse`` or
|
|
276
|
+
``losses.supervised_pairwise_projections`` is active, see
|
|
277
|
+
:meth:`_validate_camera_dependent_loss`.
|
|
231
278
|
|
|
232
279
|
Raises:
|
|
233
280
|
AssertionError: if any check fails.
|
|
@@ -251,16 +298,8 @@ class ModelConfig:
|
|
|
251
298
|
stacklevel=2,
|
|
252
299
|
)
|
|
253
300
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
assert self.cfg.training.imgaug == 'dlc', (
|
|
257
|
-
"training.imgaug must be 'dlc' when "
|
|
258
|
-
"losses.supervised_reprojection_heatmap_mse is active"
|
|
259
|
-
)
|
|
260
|
-
assert self.cfg.training.get('imgaug_3d') is True, (
|
|
261
|
-
"training.imgaug_3d must be true when "
|
|
262
|
-
"losses.supervised_reprojection_heatmap_mse is active"
|
|
263
|
-
)
|
|
301
|
+
self._validate_camera_dependent_loss('supervised_reprojection_heatmap_mse')
|
|
302
|
+
self._validate_camera_dependent_loss('supervised_pairwise_projections')
|
|
264
303
|
|
|
265
304
|
def _validate_losses(self) -> None:
|
|
266
305
|
"""Validate the ``losses`` config section.
|
|
@@ -118,7 +118,7 @@ def load_model_from_checkpoint(
|
|
|
118
118
|
if not data_module and not skip_data_module:
|
|
119
119
|
delete_extras = True
|
|
120
120
|
data_dir, video_dir = return_absolute_data_paths(data_cfg=cfg.data)
|
|
121
|
-
imgaug_transform = get_imgaug_transform(cfg=cfg)
|
|
121
|
+
imgaug_transform = get_imgaug_transform(cfg=cfg, data_dir=data_dir)
|
|
122
122
|
dataset = get_dataset(cfg=cfg, data_dir=data_dir, imgaug_transform=imgaug_transform)
|
|
123
123
|
data_module = get_data_module(cfg=cfg, dataset=dataset, video_dir=video_dir)
|
|
124
124
|
if not data_module:
|
|
@@ -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 '
|
|
160
|
-
"(default), auto-selects 'pynvvc' when it's usable on this machine
|
|
161
|
-
"given video, else
|
|
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
|
+
)
|