marm-behavior 0.1.0__py3-none-any.whl

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 (39) hide show
  1. marm_behavior/__init__.py +25 -0
  2. marm_behavior/__main__.py +435 -0
  3. marm_behavior/_data_files.py +458 -0
  4. marm_behavior/_tf_quiet.py +71 -0
  5. marm_behavior/data/README.txt +60 -0
  6. marm_behavior/data/__init__.py +1 -0
  7. marm_behavior/data/nn_reference/README.txt +57 -0
  8. marm_behavior/depths/__init__.py +0 -0
  9. marm_behavior/depths/depths_1.py +291 -0
  10. marm_behavior/dlc_inference.py +369 -0
  11. marm_behavior/el_to_csv.py +131 -0
  12. marm_behavior/extract/__init__.py +0 -0
  13. marm_behavior/extract/extract_1.py +331 -0
  14. marm_behavior/extract/extract_2.py +666 -0
  15. marm_behavior/extract/extract_3.py +285 -0
  16. marm_behavior/features/__init__.py +0 -0
  17. marm_behavior/features/labels.py +1300 -0
  18. marm_behavior/io/__init__.py +0 -0
  19. marm_behavior/io/csv_io.py +43 -0
  20. marm_behavior/io/mat_io.py +249 -0
  21. marm_behavior/nn_postprocess.py +945 -0
  22. marm_behavior/numerics/__init__.py +0 -0
  23. marm_behavior/numerics/helpers.py +284 -0
  24. marm_behavior/numerics/hull.py +252 -0
  25. marm_behavior/pipeline/__init__.py +0 -0
  26. marm_behavior/pipeline/orchestrators.py +508 -0
  27. marm_behavior/process/__init__.py +0 -0
  28. marm_behavior/process/postures.py +153 -0
  29. marm_behavior/process/process_1.py +99 -0
  30. marm_behavior/process/process_2.py +292 -0
  31. marm_behavior/process/process_3.py +323 -0
  32. marm_behavior/process/process_4.py +502 -0
  33. marm_behavior/run.py +525 -0
  34. marm_behavior-0.1.0.dist-info/LICENSE +21 -0
  35. marm_behavior-0.1.0.dist-info/METADATA +378 -0
  36. marm_behavior-0.1.0.dist-info/RECORD +39 -0
  37. marm_behavior-0.1.0.dist-info/WHEEL +5 -0
  38. marm_behavior-0.1.0.dist-info/entry_points.txt +2 -0
  39. marm_behavior-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,25 @@
1
+ """
2
+ marm_behavior
3
+ =============
4
+
5
+ Multi-animal marmoset behavioral analysis pipeline. Takes a video,
6
+ runs DeepLabCut pose estimation, extracts per-animal body-part tracks,
7
+ computes per-frame behavioral features, and projects the features into
8
+ a learned cluster space.
9
+
10
+ Quickstart
11
+ ----------
12
+
13
+ >>> from marm_behavior import run
14
+ >>> result = run("my_video.avi")
15
+ >>> result["descriptions"]
16
+ {'w': Path('w_description_my_video.csv'), ...}
17
+
18
+ See :func:`marm_behavior.run.run` for all options.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .run import run
24
+
25
+ __all__ = ["run"]
@@ -0,0 +1,435 @@
1
+ """
2
+ Command-line entry point for marm_behavior.
3
+
4
+ Invoke as::
5
+
6
+ python -m marm_behavior # process all .avi in CWD
7
+ python -m marm_behavior path/to/video.avi # process one video
8
+ python -m marm_behavior path/to/video_dir/ # process all .avi in dir
9
+
10
+ See ``python -m marm_behavior --help`` for all options.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # Silence TensorFlow's chatty C++ logger BEFORE any import path
16
+ # that could pull in tensorflow (.run -> .nn_postprocess -> tf, or
17
+ # .run -> .dlc_inference -> tf via deeplabcut). Must run before
18
+ # `import tensorflow` happens anywhere in the process.
19
+ from ._tf_quiet import silence_tensorflow_logging
20
+ silence_tensorflow_logging()
21
+
22
+ import argparse
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from .run import run
27
+
28
+
29
+ #: Short colour keys and their full names, used for the per-animal
30
+ #: buddy-override CLI flags.
31
+ _COLOR_FLAGS = (
32
+ ("red", "r"),
33
+ ("white", "w"),
34
+ ("blue", "b"),
35
+ ("yellow", "y"),
36
+ )
37
+
38
+
39
+ def _build_parser() -> argparse.ArgumentParser:
40
+ p = argparse.ArgumentParser(
41
+ prog="python -m marm_behavior",
42
+ description=(
43
+ "Run the marmoset behavioral analysis pipeline on one or "
44
+ "more videos. With no positional argument, processes every "
45
+ "``.avi`` in the current working directory. Outputs land "
46
+ "next to each input video (or in --output-dir)."
47
+ ),
48
+ )
49
+ p.add_argument(
50
+ "video",
51
+ type=Path,
52
+ nargs="?",
53
+ default=None,
54
+ help=(
55
+ "Path to an input ``.avi`` file, or a directory containing "
56
+ "``.avi`` files. If omitted, uses the current working "
57
+ "directory."
58
+ ),
59
+ )
60
+ p.add_argument(
61
+ "--single-csv",
62
+ type=Path,
63
+ default=None,
64
+ help=(
65
+ "Path to the DLC *single.csv file. Default: auto-discovered "
66
+ "next to the video via glob '<video_stem>*single.csv'. "
67
+ "Ignored in multi-video (batch) mode."
68
+ ),
69
+ )
70
+ p.add_argument(
71
+ "--multi-csv",
72
+ type=Path,
73
+ default=None,
74
+ help=(
75
+ "Path to the DLC *multi.csv file. Default: auto-discovered "
76
+ "next to the video via glob '<video_stem>*multi.csv'. "
77
+ "Ignored in multi-video (batch) mode."
78
+ ),
79
+ )
80
+ p.add_argument(
81
+ "-o",
82
+ "--output-dir",
83
+ type=Path,
84
+ default=None,
85
+ help="Directory to write artifacts into. Default: each video's parent folder.",
86
+ )
87
+ p.add_argument(
88
+ "--ground-normalized",
89
+ type=Path,
90
+ default=None,
91
+ help=(
92
+ "Path to a ground_normalized.mat or .npz file. Default: the copy "
93
+ "bundled inside the package — no external file needed."
94
+ ),
95
+ )
96
+ p.add_argument(
97
+ "--dlc-config",
98
+ type=Path,
99
+ default=None,
100
+ help=(
101
+ "Path to a user-supplied DLC config.yaml. Default: use the "
102
+ "trained model bundled inside the package. Only matters if "
103
+ "the 'dlc' stage runs."
104
+ ),
105
+ )
106
+ p.add_argument(
107
+ "--nn-reference-dir",
108
+ type=Path,
109
+ default=None,
110
+ help=(
111
+ "Override the folder containing the NN reference files "
112
+ "(out_inner_mean1.csv, out_inner_std1.csv, "
113
+ "tsne_temp1_1.csv, dbscan_temp1_1.csv, plus the native "
114
+ "t-SNE cache files). Default: the canonical reference "
115
+ "data bundled inside the package at "
116
+ "marm_behavior/data/nn_reference/. You normally don't "
117
+ "need this flag — only override if you have your own "
118
+ "reference set (e.g. for a different cohort). Only "
119
+ "matters if the 'nn' stage runs."
120
+ ),
121
+ )
122
+ p.add_argument(
123
+ "--prefetch-data",
124
+ action="store_true",
125
+ help=(
126
+ "Download all bundled data (DLC model, NN encoder, NN "
127
+ "reference set, ground_normalized) from the Hugging Face "
128
+ "Hub and exit, without running any pipeline stages. "
129
+ "Mostly redundant — every pipeline invocation already "
130
+ "auto-downloads any missing files before starting work — "
131
+ "but useful if you want to warm the cache up-front or "
132
+ "verify the download succeeds without running on a real "
133
+ "video. Files are cached under ~/.cache/huggingface/hub/ "
134
+ "(override with MARM_BEHAVIOR_DATA_DIR=/path/for/shared/install)."
135
+ ),
136
+ )
137
+
138
+ # Per-colour buddy overrides for the NN stage.
139
+ for long_name, short_key in _COLOR_FLAGS:
140
+ p.add_argument(
141
+ f"--{long_name}-buddy",
142
+ nargs="+",
143
+ choices=("r", "w", "b", "y"),
144
+ metavar="COLOR",
145
+ default=None,
146
+ help=(
147
+ f"Override which other animal's description CSV is "
148
+ f"paired with {long_name.capitalize()}'s during NN "
149
+ f"encoding. Takes one or more short colour keys "
150
+ f"(r/w/b/y) in preference order. Default chains: "
151
+ f"Red→Yellow/Blue, White→Blue/Red, Blue→White/Red, "
152
+ f"Yellow→Red/Blue."
153
+ ),
154
+ )
155
+
156
+ # Per-colour "present" flags: default True, --no-<color> disables.
157
+ for long_name, _ in _COLOR_FLAGS:
158
+ p.add_argument(
159
+ f"--no-{long_name}",
160
+ dest=f"{long_name}_present",
161
+ action="store_false",
162
+ help=f"Mark {long_name} as not present in the video.",
163
+ )
164
+ p.set_defaults(**{f"{long_name}_present": True})
165
+
166
+ p.add_argument(
167
+ "--c-thresh",
168
+ type=float,
169
+ default=0.1,
170
+ help="DLC confidence threshold for the extract stage. Default: 0.1.",
171
+ )
172
+ p.add_argument(
173
+ "--target-coverage",
174
+ type=float,
175
+ default=1.0,
176
+ help="Alpha-shape hull coverage target for the extract stage. Default: 1.0.",
177
+ )
178
+ p.add_argument(
179
+ "--stages",
180
+ nargs="+",
181
+ choices=("dlc", "extract", "process", "depths", "labels", "nn"),
182
+ default=None,
183
+ metavar="STAGE",
184
+ help=(
185
+ "Which pipeline stages to run. Default: all six "
186
+ "(dlc, extract, process, depths, labels, nn). Use e.g. "
187
+ "'--stages labels' to re-run only the labels stage, or "
188
+ "'--stages extract process depths labels' to skip both "
189
+ "DLC inference and the NN postprocessing stage."
190
+ ),
191
+ )
192
+ p.add_argument(
193
+ "-q",
194
+ "--quiet",
195
+ action="store_true",
196
+ help="Suppress the per-stage progress messages.",
197
+ )
198
+ return p
199
+
200
+
201
+ def _discover_videos(target: "Path | None") -> "list[Path]":
202
+ """Resolve the positional argument into a list of video files.
203
+
204
+ * ``None`` → glob ``*.avi`` (and ``*.AVI``) in the current working
205
+ directory.
206
+ * a file path → ``[that file]``.
207
+ * a directory path → glob ``*.avi`` / ``*.AVI`` inside it.
208
+
209
+ Returns a sorted list. Raises :class:`FileNotFoundError` if no
210
+ videos are found.
211
+ """
212
+ if target is None:
213
+ search = Path.cwd()
214
+ else:
215
+ target = target.expanduser().resolve()
216
+ if target.is_file():
217
+ return [target]
218
+ if not target.exists():
219
+ raise FileNotFoundError(f"path not found: {target}")
220
+ search = target
221
+
222
+ matches = sorted(
223
+ set(search.glob("*.avi")) | set(search.glob("*.AVI"))
224
+ )
225
+ if not matches:
226
+ raise FileNotFoundError(
227
+ f"no .avi files found in {search}. Pass an explicit path "
228
+ f"or cd to a folder containing videos."
229
+ )
230
+ return matches
231
+
232
+
233
+ def _build_nn_buddies(args: argparse.Namespace) -> "dict[str, list[str]] | None":
234
+ """Collect the four per-colour ``--<colour>-buddy`` flags into a
235
+ dict suitable for :func:`marm_behavior.run.run`'s ``nn_buddies``
236
+ argument.
237
+
238
+ Returns ``None`` if no overrides were passed, so the downstream
239
+ code path uses the built-in default chains.
240
+ """
241
+ overrides: dict[str, list[str]] = {}
242
+ for long_name, short_key in _COLOR_FLAGS:
243
+ flag_value = getattr(args, f"{long_name}_buddy")
244
+ if flag_value:
245
+ overrides[short_key] = list(flag_value)
246
+ return overrides or None
247
+
248
+
249
+ def _print_video_summary(result: dict, quiet: bool) -> None:
250
+ if quiet:
251
+ return
252
+ print()
253
+ print(f"done. stages run: {', '.join(result['stages_run'])}")
254
+ if result.get("tracks") is not None:
255
+ print(f" tracks: {result['tracks']}")
256
+ if result.get("edges") is not None:
257
+ print(f" edges: {result['edges']}")
258
+ if result.get("depths") is not None:
259
+ print(f" depths: {result['depths']}")
260
+ for key, path in result.get("descriptions", {}).items():
261
+ print(f" description {key}: {path}")
262
+ for color, (hcoord, hlabel) in result.get("nn", {}).items():
263
+ print(f" nn {color}: {hcoord.name} + {hlabel.name}")
264
+
265
+
266
+ def main(argv: "list[str] | None" = None) -> int:
267
+ parser = _build_parser()
268
+ args = parser.parse_args(argv)
269
+
270
+ # Auto-download any missing bundled data files before doing
271
+ # anything else. On a fresh install this triggers a one-time
272
+ # ~310 MB download from the Hugging Face Hub; on every
273
+ # subsequent run this returns in milliseconds because every
274
+ # file is already cached. We do this up front (instead of
275
+ # inside each stage) so the user sees the "downloading..."
276
+ # notice before any stage logs scroll past, and so a network
277
+ # failure aborts cleanly instead of mid-pipeline.
278
+ from ._data_files import ensure_all, _hf_repo_id
279
+
280
+ if args.prefetch_data:
281
+ # --prefetch-data is now mostly redundant since ensure_all
282
+ # runs unconditionally, but we keep the flag as an explicit
283
+ # "download and exit" mode for users who want to warm the
284
+ # cache without running a pipeline.
285
+ print(
286
+ f"[marm_behavior] prefetching data files from "
287
+ f"https://huggingface.co/datasets/{_hf_repo_id()} ..."
288
+ )
289
+ try:
290
+ ensure_all(verbose=False)
291
+ except Exception as err:
292
+ print(
293
+ f"error: prefetch failed: "
294
+ f"{type(err).__name__}: {err}",
295
+ file=sys.stderr,
296
+ )
297
+ return 2
298
+ print("[marm_behavior] prefetch complete.")
299
+ return 0
300
+
301
+ try:
302
+ ensure_all(verbose=not args.quiet)
303
+ except Exception as err:
304
+ print(
305
+ f"error: failed to fetch bundled data: "
306
+ f"{type(err).__name__}: {err}\n"
307
+ f"Pipeline cannot run without the data files. Check your "
308
+ f"network connection, or set MARM_BEHAVIOR_DATA_DIR to a "
309
+ f"folder containing a manual copy.",
310
+ file=sys.stderr,
311
+ )
312
+ return 2
313
+
314
+ try:
315
+ videos = _discover_videos(args.video)
316
+ except FileNotFoundError as err:
317
+ print(f"error: {err}", file=sys.stderr)
318
+ return 2
319
+
320
+ nn_buddies = _build_nn_buddies(args)
321
+ batch_mode = len(videos) > 1
322
+
323
+ if batch_mode and not args.quiet:
324
+ print(f"[marm_behavior] batch mode: {len(videos)} videos found")
325
+ for v in videos:
326
+ print(f" - {v.name}")
327
+ print()
328
+
329
+ # In batch mode, --single-csv and --multi-csv don't make sense
330
+ # because they'd point at a single video's files. Warn and ignore.
331
+ if batch_mode and (args.single_csv or args.multi_csv):
332
+ print(
333
+ "warning: --single-csv / --multi-csv are ignored in batch "
334
+ "mode; each video uses its own auto-discovered CSVs.",
335
+ file=sys.stderr,
336
+ )
337
+
338
+ failures: list[tuple[Path, str]] = []
339
+ successes: list[Path] = []
340
+
341
+ # In batch mode, prebuild the nn-stage artifacts once so the ~5
342
+ # min openTSNE fit doesn't get repeated per video. Skip the
343
+ # prebuild if the user explicitly asked to omit the nn stage, or
344
+ # if it's a single-video run (no batching benefit).
345
+ nn_artifacts = None
346
+ nn_will_run = (
347
+ args.stages is None or "nn" in args.stages
348
+ )
349
+ if batch_mode and nn_will_run:
350
+ try:
351
+ from .nn_postprocess import prepare_nn_artifacts
352
+ if not args.quiet:
353
+ print(
354
+ "[marm_behavior] batch mode: building shared "
355
+ "nn artifacts (~5 min openTSNE fit, done once "
356
+ "for all videos)"
357
+ )
358
+ nn_artifacts = prepare_nn_artifacts(
359
+ reference_dir=args.nn_reference_dir,
360
+ verbose=not args.quiet,
361
+ )
362
+ except Exception as err:
363
+ # If artifact prep fails we don't want to take the whole
364
+ # batch down — fall back to per-video prep, which will
365
+ # surface the same error per video and let the per-video
366
+ # try/except below handle it.
367
+ print(
368
+ f"warning: failed to prebuild nn artifacts "
369
+ f"({type(err).__name__}: {err}); falling back to "
370
+ f"per-video setup",
371
+ file=sys.stderr,
372
+ )
373
+ nn_artifacts = None
374
+
375
+ for i, video in enumerate(videos, start=1):
376
+ if batch_mode and not args.quiet:
377
+ print(
378
+ f"[marm_behavior] === [{i}/{len(videos)}] "
379
+ f"{video.name} ==="
380
+ )
381
+ try:
382
+ result = run(
383
+ video_path=video,
384
+ single_csv_path=args.single_csv if not batch_mode else None,
385
+ multi_csv_path=args.multi_csv if not batch_mode else None,
386
+ output_dir=args.output_dir,
387
+ ground_normalized_path=args.ground_normalized,
388
+ dlc_config=args.dlc_config,
389
+ nn_reference_dir=args.nn_reference_dir,
390
+ nn_artifacts=nn_artifacts,
391
+ nn_buddies=nn_buddies,
392
+ red_present=args.red_present,
393
+ white_present=args.white_present,
394
+ blue_present=args.blue_present,
395
+ yellow_present=args.yellow_present,
396
+ c_thresh=args.c_thresh,
397
+ target_coverage=args.target_coverage,
398
+ stages=args.stages,
399
+ verbose=not args.quiet,
400
+ )
401
+ except FileNotFoundError as err:
402
+ print(f"error: {video.name}: {err}", file=sys.stderr)
403
+ failures.append((video, f"FileNotFoundError: {err}"))
404
+ if not batch_mode:
405
+ return 2
406
+ continue
407
+ except Exception as err: # pragma: no cover
408
+ print(
409
+ f"error: {video.name}: {type(err).__name__}: {err}",
410
+ file=sys.stderr,
411
+ )
412
+ failures.append((video, f"{type(err).__name__}: {err}"))
413
+ if not batch_mode:
414
+ return 1
415
+ continue
416
+
417
+ _print_video_summary(result, args.quiet)
418
+ successes.append(video)
419
+
420
+ if batch_mode and not args.quiet:
421
+ print()
422
+ print(
423
+ f"[marm_behavior] batch done: "
424
+ f"{len(successes)}/{len(videos)} succeeded"
425
+ )
426
+ if failures:
427
+ print("failures:")
428
+ for v, reason in failures:
429
+ print(f" - {v.name}: {reason}")
430
+
431
+ return 0 if not failures else 1
432
+
433
+
434
+ if __name__ == "__main__":
435
+ raise SystemExit(main())