paces 0.0.6__tar.gz → 0.0.8__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.
- {paces-0.0.6 → paces-0.0.8}/PKG-INFO +26 -3
- {paces-0.0.6 → paces-0.0.8}/README.md +19 -0
- paces-0.0.8/paces/__main__.py +39 -0
- paces-0.0.8/paces/pose.py +460 -0
- {paces-0.0.6 → paces-0.0.8}/paces/tools.py +3 -2
- {paces-0.0.6 → paces-0.0.8}/pyproject.toml +74 -4
- paces-0.0.8/tests/test_cli_surface.py +133 -0
- paces-0.0.8/tests/test_pose.py +520 -0
- paces-0.0.6/paces/__main__.py +0 -31
- {paces-0.0.6 → paces-0.0.8}/.gitignore +0 -0
- {paces-0.0.6 → paces-0.0.8}/LICENSE +0 -0
- {paces-0.0.6 → paces-0.0.8}/docs/README.md +0 -0
- {paces-0.0.6 → paces-0.0.8}/docs/alignment/README.md +0 -0
- {paces-0.0.6 → paces-0.0.8}/docs/poc-reference/README.md +0 -0
- {paces-0.0.6 → paces-0.0.8}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/__init__.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/derivation.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/edits.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/excerpts.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/measure.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/model.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/projection.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/render.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/paces/segmenters.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/audio_synth.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/data/routine.json +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/poc_fixture.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_chapters.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_derivation.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_edits.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_excerpts.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_measure.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_render_media.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_segment.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_smoke.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/test_vertical_slice.py +0 -0
- {paces-0.0.6 → paces-0.0.8}/tests/video_synth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: paces
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: Turn instructional media into structured, interactive learning material
|
|
5
5
|
Project-URL: Homepage, https://github.com/thorwhalen/paces
|
|
6
6
|
Project-URL: Repository, https://github.com/thorwhalen/paces
|
|
@@ -17,19 +17,23 @@ Requires-Dist: mixing[audio,beats]>=0.0.36; extra == 'audio'
|
|
|
17
17
|
Requires-Dist: numba>=0.59; extra == 'audio'
|
|
18
18
|
Provides-Extra: cli
|
|
19
19
|
Requires-Dist: argcomplete>=3; extra == 'cli'
|
|
20
|
-
Requires-Dist:
|
|
20
|
+
Requires-Dist: cw<0.2,>=0.1.1; extra == 'cli'
|
|
21
21
|
Provides-Extra: dev
|
|
22
|
-
Requires-Dist:
|
|
22
|
+
Requires-Dist: cw<0.2,>=0.1.1; extra == 'dev'
|
|
23
23
|
Requires-Dist: mixing[audio,beats]>=0.0.39; extra == 'dev'
|
|
24
24
|
Requires-Dist: numba>=0.59; extra == 'dev'
|
|
25
25
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
26
26
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
27
27
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'dev'
|
|
28
29
|
Provides-Extra: docs
|
|
29
30
|
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
30
31
|
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
31
32
|
Provides-Extra: media
|
|
32
33
|
Requires-Dist: mixing>=0.0.39; extra == 'media'
|
|
34
|
+
Provides-Extra: pose
|
|
35
|
+
Requires-Dist: onnxruntime; extra == 'pose'
|
|
36
|
+
Requires-Dist: rtmlib<0.1,>=0.0.16; extra == 'pose'
|
|
33
37
|
Description-Content-Type: text/markdown
|
|
34
38
|
|
|
35
39
|
# paces
|
|
@@ -93,6 +97,24 @@ recipes persist in a hand-overridable `document.recipes.json` sidecar; the
|
|
|
93
97
|
`subject_locator=` seam (default: no crop) is where pose-based auto-crop
|
|
94
98
|
plugs in. Design record: `docs/adr/0005-media-derivation.md`.
|
|
95
99
|
|
|
100
|
+
Auto-crop to the people in frame with `pip install paces[pose]`, then
|
|
101
|
+
`paces derive doc.json --media routine.mp4 --subject-locator paces.pose:rtmlib_pose`
|
|
102
|
+
(or `subject_locator=paces.pose.rtmlib_pose` from Python). It probes each
|
|
103
|
+
excerpt window at ~5 fps and reports every person it sees; the crop policy
|
|
104
|
+
stays in the core, so two people in frame get one box around both. The extra
|
|
105
|
+
itself is [rtmlib](https://github.com/Tau-J/rtmlib) (Apache-2.0, pure Python)
|
|
106
|
+
and onnxruntime (MIT), with model weights downloaded on first use. Detection is
|
|
107
|
+
YOLOX (Apache-2.0); what is barred from every extra here — rather than
|
|
108
|
+
quarantined into one — is the **ultralytics** distribution, which is AGPL-3.0.
|
|
109
|
+
|
|
110
|
+
What it pulls in is a different question, and worth stating plainly: rtmlib
|
|
111
|
+
requires opencv, and opencv's *bundled FFmpeg* is **GPL-3.0-or-later on macOS
|
|
112
|
+
wheels** (built `--enable-gpl` with libx264/libx265) though LGPL-2.1-or-later
|
|
113
|
+
on manylinux and Windows. This is measured from the shipped binaries — the
|
|
114
|
+
wheels' own `LICENSE-3RD-PARTY.txt` never mentions x264. `paces[media]`
|
|
115
|
+
already brings such a wheel, so `[pose]` adds a second copy rather than a
|
|
116
|
+
higher tier. `paces.pose.check_pose_requirements()` reports what you have.
|
|
117
|
+
|
|
96
118
|
## How it thinks
|
|
97
119
|
|
|
98
120
|
**Analysis and rendering are separate phases** with a serialisable document
|
|
@@ -123,6 +145,7 @@ content (`OpenQuestion`), and human edits are protected from regeneration
|
|
|
123
145
|
| protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
|
|
124
146
|
| the committed artifact | `to_document(seg, ...)` → `StepDocument` |
|
|
125
147
|
| real clips/gifs/posters for the page | `derive_document(doc, media=..., doc_path=...)` / `paces derive` (`pip install paces[media]`) |
|
|
148
|
+
| auto-crop those clips to the people in frame | `derive(..., subject_locator=paces.pose.rtmlib_pose)` / `--subject-locator paces.pose:rtmlib_pose` (`pip install paces[pose]`) |
|
|
126
149
|
| a practice page | `render_html(doc)` |
|
|
127
150
|
| wall-clock times from counts | `resolve(doc)` |
|
|
128
151
|
| sanity checks | `validate_document(doc)` |
|
|
@@ -59,6 +59,24 @@ recipes persist in a hand-overridable `document.recipes.json` sidecar; the
|
|
|
59
59
|
`subject_locator=` seam (default: no crop) is where pose-based auto-crop
|
|
60
60
|
plugs in. Design record: `docs/adr/0005-media-derivation.md`.
|
|
61
61
|
|
|
62
|
+
Auto-crop to the people in frame with `pip install paces[pose]`, then
|
|
63
|
+
`paces derive doc.json --media routine.mp4 --subject-locator paces.pose:rtmlib_pose`
|
|
64
|
+
(or `subject_locator=paces.pose.rtmlib_pose` from Python). It probes each
|
|
65
|
+
excerpt window at ~5 fps and reports every person it sees; the crop policy
|
|
66
|
+
stays in the core, so two people in frame get one box around both. The extra
|
|
67
|
+
itself is [rtmlib](https://github.com/Tau-J/rtmlib) (Apache-2.0, pure Python)
|
|
68
|
+
and onnxruntime (MIT), with model weights downloaded on first use. Detection is
|
|
69
|
+
YOLOX (Apache-2.0); what is barred from every extra here — rather than
|
|
70
|
+
quarantined into one — is the **ultralytics** distribution, which is AGPL-3.0.
|
|
71
|
+
|
|
72
|
+
What it pulls in is a different question, and worth stating plainly: rtmlib
|
|
73
|
+
requires opencv, and opencv's *bundled FFmpeg* is **GPL-3.0-or-later on macOS
|
|
74
|
+
wheels** (built `--enable-gpl` with libx264/libx265) though LGPL-2.1-or-later
|
|
75
|
+
on manylinux and Windows. This is measured from the shipped binaries — the
|
|
76
|
+
wheels' own `LICENSE-3RD-PARTY.txt` never mentions x264. `paces[media]`
|
|
77
|
+
already brings such a wheel, so `[pose]` adds a second copy rather than a
|
|
78
|
+
higher tier. `paces.pose.check_pose_requirements()` reports what you have.
|
|
79
|
+
|
|
62
80
|
## How it thinks
|
|
63
81
|
|
|
64
82
|
**Analysis and rendering are separate phases** with a serialisable document
|
|
@@ -89,6 +107,7 @@ content (`OpenQuestion`), and human edits are protected from regeneration
|
|
|
89
107
|
| protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
|
|
90
108
|
| the committed artifact | `to_document(seg, ...)` → `StepDocument` |
|
|
91
109
|
| real clips/gifs/posters for the page | `derive_document(doc, media=..., doc_path=...)` / `paces derive` (`pip install paces[media]`) |
|
|
110
|
+
| auto-crop those clips to the people in frame | `derive(..., subject_locator=paces.pose.rtmlib_pose)` / `--subject-locator paces.pose:rtmlib_pose` (`pip install paces[pose]`) |
|
|
92
111
|
| a practice page | `render_html(doc)` |
|
|
93
112
|
| wall-clock times from counts | `resolve(doc)` |
|
|
94
113
|
| sanity checks | `validate_document(doc)` |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
"""paces CLI entry point — ``python -m paces`` / ``paces`` (after install)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import dataclasses
|
|
7
|
+
|
|
8
|
+
import cw
|
|
9
|
+
|
|
10
|
+
from paces.tools import _dispatch_funcs
|
|
11
|
+
|
|
12
|
+
#: Positional args (with or without defaults) stay positional; the keyword-only args
|
|
13
|
+
#: every tool uses become ``--flags``.
|
|
14
|
+
#:
|
|
15
|
+
#: This is ``argh``'s post-0.30 ``BY_NAME_IF_KWONLY`` policy, which this CLI has always
|
|
16
|
+
#: run on and which cw spells the same way. It is **not** cw's default: ``cw.ARGH``
|
|
17
|
+
#: reproduces ``argh.dispatch_commands`` instead, under which a defaulted positional
|
|
18
|
+
#: becomes an option. ``segment(media=None, *, ...)`` is exactly that shape, so the
|
|
19
|
+
#: difference is the difference between ``paces segment video.mp4`` and
|
|
20
|
+
#: ``paces segment --media video.mp4``. The former is what the docs, the smoke test and
|
|
21
|
+
#: the vertical-slice test all type.
|
|
22
|
+
CONVENTION = dataclasses.replace(cw.ARGH, naming=cw.BY_NAME_IF_KWONLY)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
"""Build the parser over the paces tools and dispatch.
|
|
27
|
+
|
|
28
|
+
``cw.run`` offers the parser to ``argcomplete`` before parsing, exactly where
|
|
29
|
+
``argh``'s dispatch did, so ``PYTHON_ARGCOMPLETE_OK`` above still applies with no
|
|
30
|
+
hand-written completion block here. It *returns* the exit code rather than raising
|
|
31
|
+
it, which is why the ``SystemExit`` is written out: without it every usage error
|
|
32
|
+
would exit 0, and the end-to-end tests all assert ``returncode == 0``.
|
|
33
|
+
"""
|
|
34
|
+
parser = cw.mk_parser(_dispatch_funcs, convention=CONVENTION)
|
|
35
|
+
raise SystemExit(cw.run(parser))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""The pointable ``subject_locator``: person boxes from rtmlib pose keypoints.
|
|
2
|
+
|
|
3
|
+
ADR-0005 §3 shipped the ``subject_locator=`` seam with :func:`paces.derivation.full_frame`
|
|
4
|
+
as its default — a real implementation whose honest answer is "no crop" — and pinned
|
|
5
|
+
the contract with fake locators. This module is the pointable replacement: probe the
|
|
6
|
+
excerpt window at ~5 fps, run RTMPose over each probed frame, and hand the core one
|
|
7
|
+
region list per instant, every person seen. It **observes only**; the core still owns
|
|
8
|
+
the one policy pipeline (union → percentile envelope → pad → aspect → clamp → one
|
|
9
|
+
static box per window), which is exactly where the POC measured that crops go wrong.
|
|
10
|
+
|
|
11
|
+
**Licence perimeter.** What this extra itself adds is permissive: rtmlib is
|
|
12
|
+
Apache-2.0 (a pure-Python wheel, no bundled binaries or weights) and onnxruntime is
|
|
13
|
+
MIT. Its detector is **YOLOX** (Megvii, Apache-2.0) — measured, not assumed: every
|
|
14
|
+
``Body`` mode downloads a ``yolox_*`` checkpoint from download.openmmlab.com, and the
|
|
15
|
+
RTMDet code in rtmlib is not what ``Body`` uses. So the accurate claim is *not* "no
|
|
16
|
+
YOLO" (the issue and ADR-0005 §3 say RTMDet; that is wrong) but the one that actually
|
|
17
|
+
carries the licence: no **ultralytics**, whose YOLOv5/v8/v11 are AGPL-3.0 and whose §13
|
|
18
|
+
network clause reaches users you serve rather than only people you hand a copy to. What
|
|
19
|
+
is barred is that distribution, not the word YOLO.
|
|
20
|
+
``tests/test_pose.py`` guards that perimeter as the packaging fact it is. kodokan's
|
|
21
|
+
``pose``/``track`` split is the fleet's worked example of the same quarantine.
|
|
22
|
+
|
|
23
|
+
**The closure is NOT uniformly permissive, and the wheel's own notice file will not
|
|
24
|
+
tell you so.** opencv — which rtmlib pulls, and which ``[media]``'s mixing already
|
|
25
|
+
declares — bundles FFmpeg whose licence tier is **platform-dependent** and must be
|
|
26
|
+
read off the binary, because ``LICENSE-3RD-PARTY.txt`` never mentions x264 on any
|
|
27
|
+
platform. Measured per wheel (see the PR for #15 for the commands):
|
|
28
|
+
|
|
29
|
+
- manylinux (what CI installs) and Windows: no x264/x265, no ``--enable-gpl``;
|
|
30
|
+
``libavutil license: LGPL version 2.1 or later``.
|
|
31
|
+
- macOS, **both** arm64 and x86_64: ``--enable-gpl --enable-version3
|
|
32
|
+
--enable-libx264 --enable-libx265``, libx264/libx265 shipped;
|
|
33
|
+
``libavutil license: GPL version 3 or later``.
|
|
34
|
+
|
|
35
|
+
That tier already arrives with ``[media]``. ``[pose]`` does not raise it — but it is
|
|
36
|
+
not free of it either: rtmlib requires opencv-python *and* opencv-contrib-python,
|
|
37
|
+
both unpinned, so installing it adds a second wheel carrying its own copy of those
|
|
38
|
+
same binaries (issue #20).
|
|
39
|
+
|
|
40
|
+
**Nothing here is imported until it is used.** ``import paces`` never touches this
|
|
41
|
+
module, and importing *this* module never touches rtmlib: every rtmlib access goes
|
|
42
|
+
through :func:`_import_body` or :func:`_rtmlib_version`, both of which raise an
|
|
43
|
+
``ImportError`` naming the extra. Model weights download on first inference, so CI
|
|
44
|
+
covers the seam's contract through fake locators and never runs a model.
|
|
45
|
+
|
|
46
|
+
Usage — the shell, then the library::
|
|
47
|
+
|
|
48
|
+
paces derive doc.json --media routine.mp4 --subject-locator paces.pose:rtmlib_pose
|
|
49
|
+
|
|
50
|
+
from paces.derivation import derive_document
|
|
51
|
+
from paces.pose import RtmlibPoseLocator, rtmlib_pose
|
|
52
|
+
|
|
53
|
+
derive_document(doc, media=..., doc_path=..., subject_locator=rtmlib_pose)
|
|
54
|
+
derive_document(..., subject_locator=RtmlibPoseLocator(probe_fps=10.0))
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import math
|
|
60
|
+
from dataclasses import dataclass
|
|
61
|
+
from functools import lru_cache
|
|
62
|
+
from typing import Any, Callable, Sequence
|
|
63
|
+
|
|
64
|
+
from paces.derivation import Box, LocateQuery, SubjectObservation
|
|
65
|
+
|
|
66
|
+
#: Probe rate across an excerpt window, in samples per second — the POC's rate.
|
|
67
|
+
#: Excerpts are 2.5–6 s loops, so ~5 fps is 13–30 looks: enough for the 4/96
|
|
68
|
+
#: envelope to shrug off a stray frame, cheap enough to run on CPU.
|
|
69
|
+
DFLT_PROBE_FPS = 5.0
|
|
70
|
+
|
|
71
|
+
#: rtmlib ``Body`` accuracy/latency trade-off: "lightweight" | "balanced" | "performance".
|
|
72
|
+
DFLT_MODE = "balanced"
|
|
73
|
+
|
|
74
|
+
#: onnxruntime execution device. "cpu" is the portable answer and the only one
|
|
75
|
+
#: CI would ever have; "cuda"/"mps" are a caller's choice, not a default.
|
|
76
|
+
DFLT_DEVICE = "cpu"
|
|
77
|
+
|
|
78
|
+
#: A keypoint below this score is noise, not evidence, and never widens a box.
|
|
79
|
+
#: This threshold is load-bearing, not decorative: rtmlib's ``Body`` is a
|
|
80
|
+
#: top-down pipeline, so it returns a full 17-keypoint person for a frame with
|
|
81
|
+
#: nobody in it at all. Measured on a blank frame (rtmlib 0.0.15, balanced):
|
|
82
|
+
#: one "person", every score ~0.11-0.13. Without this filter the locator would
|
|
83
|
+
#: hand the core a confident box around nothing, every time.
|
|
84
|
+
DFLT_KEYPOINT_CONFIDENCE = 0.3
|
|
85
|
+
|
|
86
|
+
#: Fewer confident keypoints than this is not a person — it is a hallucinated
|
|
87
|
+
#: limb or two, and boxing it would hand the core a region with no subject in
|
|
88
|
+
#: it. The second half of the same defence: a real detection clears 0.3 on far
|
|
89
|
+
#: more than four joints, while the blank-frame phantom above clears it on none.
|
|
90
|
+
DFLT_MIN_KEYPOINTS = 4
|
|
91
|
+
|
|
92
|
+
#: The extra that carries this locator, and the stem of its recorded identity.
|
|
93
|
+
POSE_EXTRA = "pose"
|
|
94
|
+
LOCATOR_STEM = "rtmlib-pose"
|
|
95
|
+
|
|
96
|
+
#: Opt-in for the real-model smoke test. Unset (CI, always) = no weights, no
|
|
97
|
+
#: inference — the contract is covered by fake locators instead.
|
|
98
|
+
TEST_MODELS_ENVVAR = "PACES_TEST_MODELS"
|
|
99
|
+
|
|
100
|
+
POSE_EXTRA_MISSING = (
|
|
101
|
+
"rtmlib is required for the pose subject_locator. Install it with:\n"
|
|
102
|
+
f" pip install 'paces[{POSE_EXTRA}]'\n"
|
|
103
|
+
"That extra is rtmlib (Apache-2.0, pure Python) + onnxruntime (MIT). Its\n"
|
|
104
|
+
"detector is YOLOX (Megvii, Apache-2.0); what is barred here is the\n"
|
|
105
|
+
"ultralytics distribution — AGPL-3.0 — not the YOLO family (ADR-0005 §3).\n"
|
|
106
|
+
"Model weights download on first inference, not on install.\n"
|
|
107
|
+
"Note what it pulls in: rtmlib requires BOTH opencv-python and\n"
|
|
108
|
+
"opencv-contrib-python, and opencv's bundled FFmpeg is GPL-3.0-or-later on\n"
|
|
109
|
+
"macOS (built --enable-gpl with libx264/libx265) though LGPL-2.1-or-later\n"
|
|
110
|
+
"on manylinux and Windows. paces[media] already brings one such wheel; this\n"
|
|
111
|
+
"adds a second copy, not a higher tier (issue #20).\n"
|
|
112
|
+
"The default locator (paces.derivation.full_frame) needs none of this: it\n"
|
|
113
|
+
"answers 'no crop', which is a real answer rather than a failure."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
POSE_METADATA_MISSING = (
|
|
117
|
+
"rtmlib is importable but has no distribution metadata, so its version\n"
|
|
118
|
+
"cannot be read — which is what a vendored copy or a source tree on\n"
|
|
119
|
+
"PYTHONPATH looks like. The version is not cosmetic: the crop recipe\n"
|
|
120
|
+
"records it, so that a model upgrade re-locates instead of reusing a box a\n"
|
|
121
|
+
"different model measured (ADR-0005 §3).\n"
|
|
122
|
+
f"Install rtmlib as a distribution (pip install 'paces[{POSE_EXTRA}]'), or\n"
|
|
123
|
+
"pass your own estimator — RtmlibPoseLocator(pose_estimator=...) names\n"
|
|
124
|
+
"itself after that estimator and never reads rtmlib's version."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
MEDIA_EXTRA_MISSING = (
|
|
128
|
+
"the pose locator reads frames through mixing, the same media path derive\n"
|
|
129
|
+
"itself runs on. Install it with:\n"
|
|
130
|
+
" pip install 'paces[media]'"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── rtmlib, reached from exactly two places ─────────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _import_body():
|
|
138
|
+
"""rtmlib's ``Body`` solution (YOLOX detector + RTMPose, COCO-17).
|
|
139
|
+
|
|
140
|
+
Every rtmlib *import* goes through here so the missing-extra message is
|
|
141
|
+
written once, the way kodokan routes ultralytics through one importer.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
from rtmlib import Body
|
|
145
|
+
except ImportError as error:
|
|
146
|
+
raise ImportError(POSE_EXTRA_MISSING) from error
|
|
147
|
+
return Body
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _rtmlib_version() -> str:
|
|
151
|
+
"""rtmlib's version, from distribution metadata — no import, no model load.
|
|
152
|
+
|
|
153
|
+
``derive`` reads ``locator_name`` *before* it decides whether a recipe may
|
|
154
|
+
be reused (ADR-0005 §3's re-run semantics), so naming the locator must stay
|
|
155
|
+
this cheap — and it is reported before any encode work is spent.
|
|
156
|
+
|
|
157
|
+
Two different absences, told apart because the fixes differ: no rtmlib at
|
|
158
|
+
all (install the extra) versus an importable rtmlib carrying no metadata,
|
|
159
|
+
which is a vendored copy or a source tree on ``PYTHONPATH``.
|
|
160
|
+
"""
|
|
161
|
+
import importlib.util
|
|
162
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
return version("rtmlib")
|
|
166
|
+
except PackageNotFoundError as error:
|
|
167
|
+
importable = importlib.util.find_spec("rtmlib") is not None
|
|
168
|
+
raise ImportError(
|
|
169
|
+
POSE_METADATA_MISSING if importable else POSE_EXTRA_MISSING
|
|
170
|
+
) from error
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _estimator_identity(estimator) -> str:
|
|
174
|
+
"""A stable-enough name for an injected estimator, for the recipe identity.
|
|
175
|
+
|
|
176
|
+
Its own ``locator_name`` if it declares one (the way to make an estimator
|
|
177
|
+
version itself properly), else its qualified name, else its type's.
|
|
178
|
+
"""
|
|
179
|
+
declared = getattr(estimator, "locator_name", None)
|
|
180
|
+
if isinstance(declared, str):
|
|
181
|
+
return declared
|
|
182
|
+
module = getattr(estimator, "__module__", None)
|
|
183
|
+
name = getattr(estimator, "__qualname__", None) or type(estimator).__qualname__
|
|
184
|
+
return f"{module}.{name}" if module else name
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@lru_cache(maxsize=None)
|
|
188
|
+
def _body_estimator(mode: str, device: str):
|
|
189
|
+
"""One loaded ``Body`` per (mode, device), for the life of the process.
|
|
190
|
+
|
|
191
|
+
The first call downloads the ONNX weights; every excerpt window after it
|
|
192
|
+
reuses the loaded session rather than paying that again.
|
|
193
|
+
"""
|
|
194
|
+
Body = _import_body()
|
|
195
|
+
return Body(mode=mode, backend="onnxruntime", device=device)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _frame_reader(media_path: str) -> Callable[[float], Any]:
|
|
199
|
+
"""A ``t_seconds -> frame`` reader over one media file, through ``mixing``.
|
|
200
|
+
|
|
201
|
+
All media I/O goes through mixing, never moviepy/cv2 directly (ADR-0005).
|
|
202
|
+
``mixing.Video`` indexes by time and hands back cv2's native BGR, which is
|
|
203
|
+
the layout rtmlib expects.
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
from mixing import Video
|
|
207
|
+
except ImportError as error: # pragma: no cover - needs [media] absent
|
|
208
|
+
raise ImportError(MEDIA_EXTRA_MISSING) from error
|
|
209
|
+
video = Video(media_path)
|
|
210
|
+
return lambda t_s: video[t_s]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ── the two pure pieces (no media, no model, no numpy) ──────────────────────
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def probe_times(
|
|
217
|
+
start_s: float, end_s: float, *, probe_fps: float = DFLT_PROBE_FPS
|
|
218
|
+
) -> tuple[float, ...]:
|
|
219
|
+
"""The instants to look at: ~``probe_fps`` samples spread inside the window.
|
|
220
|
+
|
|
221
|
+
Samples sit at the midpoints of ``n`` equal slices, so each one is strictly
|
|
222
|
+
inside ``[start_s, end_s)`` — a probe on the closing edge would read the
|
|
223
|
+
next shot — and a window shorter than one probe interval still gets exactly
|
|
224
|
+
one look, at its middle.
|
|
225
|
+
|
|
226
|
+
>>> tuple(round(t, 3) for t in probe_times(0.0, 1.0, probe_fps=5.0))
|
|
227
|
+
(0.1, 0.3, 0.5, 0.7, 0.9)
|
|
228
|
+
>>> tuple(round(t, 3) for t in probe_times(10.0, 10.05))
|
|
229
|
+
(10.025,)
|
|
230
|
+
"""
|
|
231
|
+
if not end_s > start_s:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"an excerpt window must be non-empty; got [{start_s}, {end_s})"
|
|
234
|
+
)
|
|
235
|
+
if not probe_fps > 0:
|
|
236
|
+
raise ValueError(f"probe_fps must be positive; got {probe_fps}")
|
|
237
|
+
count = max(1, round((end_s - start_s) * probe_fps))
|
|
238
|
+
step = (end_s - start_s) / count
|
|
239
|
+
return tuple(start_s + (index + 0.5) * step for index in range(count))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def person_boxes(
|
|
243
|
+
keypoints: Sequence[Sequence[Sequence[float]]] | None,
|
|
244
|
+
scores: Sequence[Sequence[float]] | None,
|
|
245
|
+
*,
|
|
246
|
+
frame_size: tuple[int, int],
|
|
247
|
+
keypoint_confidence: float = DFLT_KEYPOINT_CONFIDENCE,
|
|
248
|
+
min_keypoints: int = DFLT_MIN_KEYPOINTS,
|
|
249
|
+
) -> tuple[Box, ...]:
|
|
250
|
+
"""One box per person, spanning that person's *confident* keypoints.
|
|
251
|
+
|
|
252
|
+
``keypoints`` is ``(n_persons, n_keypoints, 2)`` and ``scores`` is
|
|
253
|
+
``(n_persons, n_keypoints)`` — rtmlib's output shape, read here as plain
|
|
254
|
+
nested sequences, so this stays a pure function and numpy remains the
|
|
255
|
+
extra's business rather than this module's. A person carrying fewer than
|
|
256
|
+
``min_keypoints`` confident points is dropped rather than boxed from noise;
|
|
257
|
+
boxes are clamped into the frame, and a degenerate one is dropped too.
|
|
258
|
+
Zero detections is ``()`` — evidence that nobody was there, not an error.
|
|
259
|
+
|
|
260
|
+
Order is rtmlib's and means nothing: the seam is a *set* of regions per
|
|
261
|
+
instant (the core unions them), so no person-identity claim is made here.
|
|
262
|
+
|
|
263
|
+
>>> person_boxes(
|
|
264
|
+
... [[(10, 20), (30, 60)]], [(0.9, 0.9)],
|
|
265
|
+
... frame_size=(100, 100), min_keypoints=2,
|
|
266
|
+
... )
|
|
267
|
+
((10, 20, 20, 40),)
|
|
268
|
+
"""
|
|
269
|
+
if keypoints is None or scores is None:
|
|
270
|
+
return ()
|
|
271
|
+
frame_width, frame_height = frame_size
|
|
272
|
+
boxes: list[Box] = []
|
|
273
|
+
for person, person_scores in zip(keypoints, scores):
|
|
274
|
+
confident = [
|
|
275
|
+
(float(point[0]), float(point[1]))
|
|
276
|
+
for point, score in zip(person, person_scores)
|
|
277
|
+
if float(score) >= keypoint_confidence
|
|
278
|
+
]
|
|
279
|
+
if len(confident) < min_keypoints:
|
|
280
|
+
continue
|
|
281
|
+
xs = [x for x, _y in confident]
|
|
282
|
+
ys = [y for _x, y in confident]
|
|
283
|
+
left = max(0, int(math.floor(min(xs))))
|
|
284
|
+
top = max(0, int(math.floor(min(ys))))
|
|
285
|
+
right = min(frame_width, int(math.ceil(max(xs))))
|
|
286
|
+
bottom = min(frame_height, int(math.ceil(max(ys))))
|
|
287
|
+
if right - left < 1 or bottom - top < 1:
|
|
288
|
+
continue
|
|
289
|
+
boxes.append((left, top, right - left, bottom - top))
|
|
290
|
+
return tuple(boxes)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── the locator ─────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True, kw_only=True)
|
|
297
|
+
class RtmlibPoseLocator:
|
|
298
|
+
"""The pointable ``subject_locator``: rtmlib pose boxes over a probed window.
|
|
299
|
+
|
|
300
|
+
Callable per the ADR-0005 §3 contract — a :class:`~paces.derivation.LocateQuery`
|
|
301
|
+
in, a :class:`~paces.derivation.SubjectObservation` out, or ``None``, the
|
|
302
|
+
honest "no crop", when the window showed nobody. It never resolves a box:
|
|
303
|
+
that is the core's :func:`~paces.derivation.resolve_crop_box`.
|
|
304
|
+
|
|
305
|
+
``pose_estimator`` is the seam inside the seam: any
|
|
306
|
+
``callable(frame) -> (keypoints, scores)``. The default is rtmlib's ``Body``
|
|
307
|
+
(YOLOX + RTMPose, COCO-17); the replacements are already pointable —
|
|
308
|
+
rtmlib's ``Wholebody``, ``Hand`` and ``Animal`` solutions have exactly this
|
|
309
|
+
shape, and the ADR's "hands + workpiece for cooking" case is one of them.
|
|
310
|
+
Tests inject a fake through it, which is how the contract is exercised with
|
|
311
|
+
no weights downloaded and no network reached.
|
|
312
|
+
|
|
313
|
+
All fields are keyword-only, and the defaults are the shipped policy.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
probe_fps: float = DFLT_PROBE_FPS
|
|
317
|
+
mode: str = DFLT_MODE
|
|
318
|
+
device: str = DFLT_DEVICE
|
|
319
|
+
keypoint_confidence: float = DFLT_KEYPOINT_CONFIDENCE
|
|
320
|
+
min_keypoints: int = DFLT_MIN_KEYPOINTS
|
|
321
|
+
pose_estimator: Callable[[Any], tuple[Any, Any]] | None = None
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def locator_name(self) -> str:
|
|
325
|
+
"""The policy identity the crop recipe records — **every** input that
|
|
326
|
+
can move a box, not just the model version.
|
|
327
|
+
|
|
328
|
+
``derive`` reuses a stored box when this name and the core's params
|
|
329
|
+
still match (ADR-0005 §3), so anything left out of it silently reuses a
|
|
330
|
+
box that something else measured. That is the whole point of the
|
|
331
|
+
fingerprint, so the probe rate and both keypoint thresholds are in it
|
|
332
|
+
alongside the model::
|
|
333
|
+
|
|
334
|
+
rtmlib-pose@0.0.16;mode=balanced;fps=5;conf=0.3;minkp=4
|
|
335
|
+
|
|
336
|
+
An injected ``pose_estimator`` names *itself* — claiming rtmlib's
|
|
337
|
+
version for a box rtmlib did not measure would be exactly the false
|
|
338
|
+
identity this exists to prevent — and drops ``mode``, which is a
|
|
339
|
+
``Body`` argument and means nothing to another estimator::
|
|
340
|
+
|
|
341
|
+
rtmlib-pose@custom:mypkg.my_estimator;fps=5;conf=0.3;minkp=4
|
|
342
|
+
|
|
343
|
+
``device`` is deliberately absent: it selects an execution provider,
|
|
344
|
+
not a policy, and putting it in would re-locate every box on a machine
|
|
345
|
+
change — a false honesty flag, which this codebase treats as the
|
|
346
|
+
failure mode it is.
|
|
347
|
+
"""
|
|
348
|
+
if self.pose_estimator is None:
|
|
349
|
+
engine = _rtmlib_version()
|
|
350
|
+
terms = [f"mode={self.mode}"]
|
|
351
|
+
else:
|
|
352
|
+
engine = f"custom:{_estimator_identity(self.pose_estimator)}"
|
|
353
|
+
terms = []
|
|
354
|
+
terms += [
|
|
355
|
+
f"fps={self.probe_fps:g}",
|
|
356
|
+
f"conf={self.keypoint_confidence:g}",
|
|
357
|
+
f"minkp={self.min_keypoints:g}",
|
|
358
|
+
]
|
|
359
|
+
return ";".join([f"{LOCATOR_STEM}@{engine}", *terms])
|
|
360
|
+
|
|
361
|
+
def __call__(self, query: LocateQuery) -> SubjectObservation | None:
|
|
362
|
+
estimate = self.pose_estimator or _body_estimator(self.mode, self.device)
|
|
363
|
+
read_frame = _frame_reader(query.media_path)
|
|
364
|
+
frame_size = (query.frame_width, query.frame_height)
|
|
365
|
+
samples: list[tuple[float, tuple[Box, ...]]] = []
|
|
366
|
+
unreadable: list[float] = []
|
|
367
|
+
for t_s in probe_times(query.start_s, query.end_s, probe_fps=self.probe_fps):
|
|
368
|
+
try:
|
|
369
|
+
frame = read_frame(t_s)
|
|
370
|
+
except (ValueError, OSError):
|
|
371
|
+
# one unreadable probe is a damaged frame, not a verdict on the
|
|
372
|
+
# window; only losing every probe is a fault worth raising
|
|
373
|
+
unreadable.append(t_s)
|
|
374
|
+
continue
|
|
375
|
+
keypoints, scores = estimate(frame)
|
|
376
|
+
samples.append(
|
|
377
|
+
(
|
|
378
|
+
t_s,
|
|
379
|
+
person_boxes(
|
|
380
|
+
keypoints,
|
|
381
|
+
scores,
|
|
382
|
+
frame_size=frame_size,
|
|
383
|
+
keypoint_confidence=self.keypoint_confidence,
|
|
384
|
+
min_keypoints=self.min_keypoints,
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
if not samples:
|
|
389
|
+
raise RuntimeError(
|
|
390
|
+
f"read no frame of {query.media_path} in "
|
|
391
|
+
f"[{query.start_s}, {query.end_s}) — {len(unreadable)} probes "
|
|
392
|
+
"all failed; the media is unreadable there, which is a fault, "
|
|
393
|
+
"not a 'no crop' answer"
|
|
394
|
+
)
|
|
395
|
+
if not any(boxes for _t, boxes in samples):
|
|
396
|
+
# ADR-0005 §3: None is the zero-detections fallback, and no crop
|
|
397
|
+
# means genuinely uncropped — never letterboxed
|
|
398
|
+
return None
|
|
399
|
+
return SubjectObservation(samples=tuple(samples))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
#: The ready-made locator: no configuration, and the target a CLI
|
|
403
|
+
#: ``--subject-locator paces.pose:rtmlib_pose`` reference resolves to.
|
|
404
|
+
rtmlib_pose = RtmlibPoseLocator()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def check_pose_requirements() -> dict:
|
|
408
|
+
"""Preflight for the ``[pose]`` extra, per channel and never silently.
|
|
409
|
+
|
|
410
|
+
- ``rtmlib`` / ``onnxruntime`` — the locator and its inference backend.
|
|
411
|
+
- ``cv2_providers`` — rtmlib declares BOTH ``opencv-python`` and
|
|
412
|
+
``opencv-contrib-python``, while the fleet standardises on the contrib
|
|
413
|
+
superset (mixing's single cv2 provider). An install can therefore end up
|
|
414
|
+
with two distributions owning one ``cv2`` package. Reported, not fatal:
|
|
415
|
+
it works until one of them is uninstalled, and a reader deserves to know.
|
|
416
|
+
|
|
417
|
+
Downloads nothing: weights arrive on first inference, not on this check.
|
|
418
|
+
"""
|
|
419
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
420
|
+
|
|
421
|
+
def _version(distribution: str) -> str | None:
|
|
422
|
+
try:
|
|
423
|
+
return version(distribution)
|
|
424
|
+
except PackageNotFoundError:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
notes: list[str] = []
|
|
428
|
+
rtmlib_version = _version("rtmlib")
|
|
429
|
+
onnxruntime_version = _version("onnxruntime")
|
|
430
|
+
if rtmlib_version is None or onnxruntime_version is None:
|
|
431
|
+
missing = [
|
|
432
|
+
name
|
|
433
|
+
for name, found in (
|
|
434
|
+
("rtmlib", rtmlib_version),
|
|
435
|
+
("onnxruntime", onnxruntime_version),
|
|
436
|
+
)
|
|
437
|
+
if found is None
|
|
438
|
+
]
|
|
439
|
+
notes.append(
|
|
440
|
+
f"{', '.join(missing)} not installed — install the pose extra: "
|
|
441
|
+
f"pip install 'paces[{POSE_EXTRA}]'"
|
|
442
|
+
)
|
|
443
|
+
cv2_providers = [
|
|
444
|
+
name
|
|
445
|
+
for name in ("opencv-python", "opencv-contrib-python")
|
|
446
|
+
if _version(name) is not None
|
|
447
|
+
]
|
|
448
|
+
if len(cv2_providers) > 1:
|
|
449
|
+
notes.append(
|
|
450
|
+
"two distributions provide cv2 "
|
|
451
|
+
f"({', '.join(cv2_providers)}) — rtmlib declares both; uninstalling "
|
|
452
|
+
"either can leave the other's cv2 broken"
|
|
453
|
+
)
|
|
454
|
+
return {
|
|
455
|
+
"ok": rtmlib_version is not None and onnxruntime_version is not None,
|
|
456
|
+
"rtmlib": rtmlib_version,
|
|
457
|
+
"onnxruntime": onnxruntime_version,
|
|
458
|
+
"cv2_providers": cv2_providers,
|
|
459
|
+
"notes": notes,
|
|
460
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Agent-callable tool surface over ``paces`` — plain functions with JSON-able
|
|
2
2
|
arguments returning JSON-ready values, deliberately CLI/MCP/HTTP-agnostic.
|
|
3
3
|
|
|
4
|
-
This module is the SSOT for "what paces can do": every surface (the ``
|
|
4
|
+
This module is the SSOT for "what paces can do": every surface (the ``cw``
|
|
5
5
|
CLI in ``__main__.py`` today; ``py2mcp``/``qh`` wrappers tomorrow) dispatches
|
|
6
6
|
over ``_dispatch_funcs`` and nothing else. Arguments that carry structure
|
|
7
7
|
(steps, grids, documents) accept a Python object, a JSON string, or a path to
|
|
@@ -338,7 +338,8 @@ def derive(
|
|
|
338
338
|
explicit store. ``media`` is one local file, or ``{"<source-id>":
|
|
339
339
|
"<path>"}`` when several sources carry excerpts. ``subject_locator`` is
|
|
340
340
|
a lazy ``"module:attr"`` ref to an ADR-0005 §3 locator; the default is
|
|
341
|
-
no crop
|
|
341
|
+
no crop, and ``paces.pose:rtmlib_pose`` is the shipped pointable one
|
|
342
|
+
(rtmlib pose boxes, ``pip install paces[pose]``). The updated document is written back to ``output`` (default:
|
|
342
343
|
the document path itself — derive's media side effects and the refs
|
|
343
344
|
pointing at them must not go out of sync); the returned payload carries
|
|
344
345
|
``flags`` — read them, they are the honesty report.
|