paces 0.0.7__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.
Files changed (37) hide show
  1. {paces-0.0.7 → paces-0.0.8}/PKG-INFO +24 -1
  2. {paces-0.0.7 → paces-0.0.8}/README.md +19 -0
  3. paces-0.0.8/paces/pose.py +460 -0
  4. {paces-0.0.7 → paces-0.0.8}/paces/tools.py +2 -1
  5. {paces-0.0.7 → paces-0.0.8}/pyproject.toml +71 -1
  6. paces-0.0.8/tests/test_pose.py +520 -0
  7. {paces-0.0.7 → paces-0.0.8}/.gitignore +0 -0
  8. {paces-0.0.7 → paces-0.0.8}/LICENSE +0 -0
  9. {paces-0.0.7 → paces-0.0.8}/docs/README.md +0 -0
  10. {paces-0.0.7 → paces-0.0.8}/docs/alignment/README.md +0 -0
  11. {paces-0.0.7 → paces-0.0.8}/docs/poc-reference/README.md +0 -0
  12. {paces-0.0.7 → paces-0.0.8}/docs/poc-reference/artifacts/clips.json +0 -0
  13. {paces-0.0.7 → paces-0.0.8}/paces/__init__.py +0 -0
  14. {paces-0.0.7 → paces-0.0.8}/paces/__main__.py +0 -0
  15. {paces-0.0.7 → paces-0.0.8}/paces/derivation.py +0 -0
  16. {paces-0.0.7 → paces-0.0.8}/paces/edits.py +0 -0
  17. {paces-0.0.7 → paces-0.0.8}/paces/excerpts.py +0 -0
  18. {paces-0.0.7 → paces-0.0.8}/paces/measure.py +0 -0
  19. {paces-0.0.7 → paces-0.0.8}/paces/model.py +0 -0
  20. {paces-0.0.7 → paces-0.0.8}/paces/projection.py +0 -0
  21. {paces-0.0.7 → paces-0.0.8}/paces/render.py +0 -0
  22. {paces-0.0.7 → paces-0.0.8}/paces/segmenters.py +0 -0
  23. {paces-0.0.7 → paces-0.0.8}/tests/audio_synth.py +0 -0
  24. {paces-0.0.7 → paces-0.0.8}/tests/data/routine.json +0 -0
  25. {paces-0.0.7 → paces-0.0.8}/tests/poc_fixture.py +0 -0
  26. {paces-0.0.7 → paces-0.0.8}/tests/test_chapters.py +0 -0
  27. {paces-0.0.7 → paces-0.0.8}/tests/test_cli_surface.py +0 -0
  28. {paces-0.0.7 → paces-0.0.8}/tests/test_derivation.py +0 -0
  29. {paces-0.0.7 → paces-0.0.8}/tests/test_edits.py +0 -0
  30. {paces-0.0.7 → paces-0.0.8}/tests/test_excerpts.py +0 -0
  31. {paces-0.0.7 → paces-0.0.8}/tests/test_measure.py +0 -0
  32. {paces-0.0.7 → paces-0.0.8}/tests/test_render_media.py +0 -0
  33. {paces-0.0.7 → paces-0.0.8}/tests/test_roundtrip_poc.py +0 -0
  34. {paces-0.0.7 → paces-0.0.8}/tests/test_segment.py +0 -0
  35. {paces-0.0.7 → paces-0.0.8}/tests/test_smoke.py +0 -0
  36. {paces-0.0.7 → paces-0.0.8}/tests/test_vertical_slice.py +0 -0
  37. {paces-0.0.7 → 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.7
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
@@ -25,11 +25,15 @@ 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,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
+ }
@@ -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. The updated document is written back to ``output`` (default:
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.
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.7"
9
+ version = "0.0.8"
10
10
  description = "Turn instructional media into structured, interactive learning material"
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -59,6 +59,41 @@ media = [
59
59
  # get_video_dimensions ship there.
60
60
  "mixing>=0.0.39",
61
61
  ]
62
+ # The pointable subject_locator (issue #15, ADR-0005 §3): rtmlib pose boxes,
63
+ # reached through `paces.pose:rtmlib_pose`. rtmlib is Apache-2.0 (a pure-Python
64
+ # wheel: no bundled binaries, no vendored weights) and onnxruntime is MIT.
65
+ # Its detector is YOLOX (Megvii, Apache-2.0) — checked by running it, not taken
66
+ # from the issue, which says RTMDet: every `Body` mode fetches a `yolox_*`
67
+ # checkpoint. So the barred thing is the `ultralytics` DISTRIBUTION (AGPL-3.0,
68
+ # §13's network clause reaching served users, not only people handed a copy),
69
+ # not the YOLO family. That bar covers EVERY extra here, and
70
+ # tests/test_pose.py is what notices if it ever stops being true.
71
+ #
72
+ # What this extra pulls in is NOT uniformly permissive, and opencv's own
73
+ # LICENSE-3RD-PARTY.txt does not say so (it never mentions x264, on any
74
+ # platform) — so it was measured off the shipped binaries instead:
75
+ # manylinux (CI) and Windows: no x264/x265, no --enable-gpl;
76
+ # "libavutil license: LGPL version 2.1 or later"
77
+ # macOS arm64 AND x86_64: --enable-gpl --enable-version3 with
78
+ # libx264/libx265 shipped;
79
+ # "libavutil license: GPL version 3 or later"
80
+ # That tier already arrives with [media] (mixing declares opencv-contrib-
81
+ # python). [pose] does not raise it, but it does add a SECOND wheel with its
82
+ # own copy of those binaries, because rtmlib requires opencv-python and
83
+ # opencv-contrib-python both, unpinned — see issue #20.
84
+ #
85
+ # onnxruntime is named rather than left to rtmlib's transitive closure: it is
86
+ # the backend= this locator asks for by name, not an incidental fact.
87
+ # rtmlib is bounded: the licence audit above was done against 0.0.16, and a
88
+ # 0.0.x line promises no stability — a floor without a ceiling would let an
89
+ # unaudited wheel in silently.
90
+ # Not mirrored into [dev] on purpose: the tests are offline and stay on fake
91
+ # locators (no weights, no network), and CI has no reason to take on the second
92
+ # cv2 provider. `check_pose_requirements()` reports that pair when it happens.
93
+ pose = [
94
+ "rtmlib>=0.0.16,<0.1",
95
+ "onnxruntime",
96
+ ]
62
97
  dev = [
63
98
  "pytest>=7.0",
64
99
  "pytest-cov>=4.0",
@@ -72,6 +107,9 @@ dev = [
72
107
  # [media] (the >=0.0.39 floor wins over [audio]'s >=0.0.36).
73
108
  "mixing[beats,audio]>=0.0.39",
74
109
  "numba>=0.59",
110
+ # tests/test_pose.py reads pyproject.toml to guard the licence perimeter;
111
+ # stdlib tomllib is 3.11+, and a skipped guard guards nothing.
112
+ "tomli>=2.0; python_version < '3.11'",
75
113
  ]
76
114
  docs = [
77
115
  "sphinx>=6.0",
@@ -93,6 +131,38 @@ install.linux = ["sudo apt-get update", "sudo apt-get install -y ffmpeg"]
93
131
  install.macos = "brew install ffmpeg"
94
132
  install.windows = "choco install ffmpeg -y --no-progress"
95
133
 
134
+ # --------------------------------------------------------------------------
135
+ # Licence adjudication
136
+ #
137
+ # paces is MIT and its core dependency is pydantic alone, so `pip install
138
+ # paces` pulls nothing copyleft. No extra reaches AGPL either: `pose` is rtmlib
139
+ # (Apache-2.0) + onnxruntime (MIT), and the AGPL alternative for that job
140
+ # (ultralytics) is barred outright rather than quarantined — kodokan needs a
141
+ # `track` extra because Ultralytics' tracker is genuinely load-bearing there;
142
+ # paces needs boxes, and rtmlib's Apache-2.0 YOLOX gives them. There is nothing
143
+ # to adjudicate at the AGPL tier, hence no [tool.wads.licence.exceptions]
144
+ # table: an entry appearing here later would mean the perimeter moved.
145
+ #
146
+ # What IS copyleft, and stated because a checker reading declared metadata will
147
+ # not find it: opencv's bundled FFmpeg binaries, pulled by both [media] and
148
+ # [pose], are GPL-3.0-or-later on macOS wheels and LGPL-2.1-or-later on
149
+ # manylinux/Windows — measured off the shipped libraries, since the wheels'
150
+ # LICENSE-3RD-PARTY.txt never mentions x264. See the [pose] block above.
151
+ #
152
+ # `include-extras` makes the check look where the exposure is — a check that
153
+ # reads only hard dependencies would report paces clean, which is correct and
154
+ # incomplete. `allowed`/`forbidden` are deliberately unset: they REPLACE wads'
155
+ # defaults rather than extend them, so re-stating the permissive families here
156
+ # would silently narrow the policy every time wads widens it.
157
+ #
158
+ # `enabled` stays false until the gate ships in a released wads and the extras'
159
+ # closures have been walked with it once; flipping it is a one-line change.
160
+ # Until then the perimeter is enforced by tests/test_pose.py, which runs in CI.
161
+ # --------------------------------------------------------------------------
162
+ [tool.wads.licence]
163
+ enabled = false
164
+ include-extras = ["cli", "audio", "media", "pose"]
165
+
96
166
  [tool.hatch.build.targets.wheel]
97
167
  packages = ["paces"]
98
168
 
@@ -0,0 +1,520 @@
1
+ """The rtmlib pose subject_locator (issue #15), and the licence perimeter it sits in.
2
+
3
+ Offline by construction. Nothing here installs rtmlib, downloads a weight, or
4
+ reaches the network: the locator's contract is exercised with a fake pose
5
+ estimator injected through ``pose_estimator=``, reading real frames out of the
6
+ same synthetic video the derivation tests use. The one test that would run a
7
+ real model is opt-in behind ``PACES_TEST_MODELS`` and skipped everywhere else,
8
+ CI included — which is why ``[pose]`` is deliberately NOT mirrored into ``[dev]``.
9
+
10
+ The rest is packaging. ``[pose]`` is the only extra that reaches a model
11
+ runtime, and no behaviour in this suite would notice AGPL being slipped into it
12
+ — these tests are what notice.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import mixing # noqa: F401 — the frame reader is real; fail loudly, never skip
23
+ import pytest
24
+
25
+ from paces import pose
26
+ from paces.derivation import (
27
+ LocateQuery,
28
+ SubjectObservation,
29
+ derive_document,
30
+ load_recipes,
31
+ resolve_crop_box,
32
+ )
33
+ from paces.model import Measure, Source, SourceSpan, Step, StepDocument
34
+ from paces.pose import (
35
+ DFLT_MIN_KEYPOINTS,
36
+ POSE_EXTRA_MISSING,
37
+ TEST_MODELS_ENVVAR,
38
+ RtmlibPoseLocator,
39
+ check_pose_requirements,
40
+ person_boxes,
41
+ probe_times,
42
+ rtmlib_pose,
43
+ )
44
+ from video_synth import FRAME_SIZE, practice_video
45
+
46
+ #: A version to stand in for an installed rtmlib, so the naming contract can be
47
+ #: asserted on a machine that has no rtmlib at all (every CI machine).
48
+ FAKE_RTMLIB_VERSION = "0.0.16"
49
+
50
+ #: Distributions carrying AGPL terms that must never reach ANY paces extra.
51
+ #: ultralytics pulls the other two itself, and a licence check matches per
52
+ #: distribution name — so all three are named (ADR-0005 §3).
53
+ FORBIDDEN_DISTRIBUTIONS = frozenset(
54
+ {"ultralytics", "ultralytics-thop", "ultralytics-platform"}
55
+ )
56
+
57
+ _PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml"
58
+
59
+ try: # Python 3.11+
60
+ import tomllib
61
+ except ModuleNotFoundError: # Python 3.10
62
+ import tomli as tomllib
63
+
64
+
65
+ @pytest.fixture(scope="module")
66
+ def pyproject():
67
+ return tomllib.loads(_PYPROJECT.read_text(encoding="utf-8"))
68
+
69
+
70
+ @pytest.fixture(scope="module")
71
+ def video(tmp_path_factory):
72
+ """The derivation suite's fixture clip: a green rect on blue, 2 s at 24 fps."""
73
+ return practice_video(tmp_path_factory.mktemp("pose") / "practice.mp4")
74
+
75
+
76
+ def _people(*boxes, score=0.9):
77
+ """rtmlib-shaped ``(keypoints, scores)`` for people occupying ``boxes``.
78
+
79
+ Four corner keypoints each, which is exactly ``DFLT_MIN_KEYPOINTS``.
80
+ """
81
+ keypoints = [
82
+ [(x, y), (x + w, y), (x, y + h), (x + w, y + h)] for x, y, w, h in boxes
83
+ ]
84
+ return keypoints, [(score,) * 4 for _ in boxes]
85
+
86
+
87
+ def _query(video, *, start_s=0.0, end_s=2.0):
88
+ return LocateQuery(
89
+ media_path=str(video),
90
+ start_s=start_s,
91
+ end_s=end_s,
92
+ frame_width=FRAME_SIZE[0],
93
+ frame_height=FRAME_SIZE[1],
94
+ step_id="b4",
95
+ tags=("performance",),
96
+ )
97
+
98
+
99
+ # ── probe_times (pure) ──────────────────────────────────────────────────────
100
+
101
+
102
+ def test_probe_times_spreads_the_asked_rate_inside_the_window():
103
+ times = probe_times(10.0, 13.0, probe_fps=5.0)
104
+ assert len(times) == 15 # 3 s at 5 fps
105
+ assert all(10.0 < t < 13.0 for t in times)
106
+ assert times == tuple(sorted(times))
107
+
108
+
109
+ def test_probe_times_gives_a_short_window_its_middle():
110
+ # shorter than one probe interval: one look, and not on either edge (a probe
111
+ # on the closing edge reads the next shot)
112
+ assert probe_times(4.0, 4.1, probe_fps=5.0) == (4.05,)
113
+
114
+
115
+ def test_probe_times_refuses_an_empty_window_and_a_dead_rate():
116
+ with pytest.raises(ValueError, match="non-empty"):
117
+ probe_times(2.0, 2.0)
118
+ with pytest.raises(ValueError, match="probe_fps"):
119
+ probe_times(0.0, 1.0, probe_fps=0.0)
120
+
121
+
122
+ # ── person_boxes (pure) ─────────────────────────────────────────────────────
123
+
124
+
125
+ def test_person_boxes_spans_the_confident_keypoints():
126
+ keypoints, scores = _people((60, 40, 100, 80))
127
+ assert person_boxes(keypoints, scores, frame_size=FRAME_SIZE) == (
128
+ (60, 40, 100, 80),
129
+ )
130
+
131
+
132
+ def test_person_boxes_keeps_every_person():
133
+ # the judo case: two people, two regions, no identity claim and no choosing
134
+ keypoints, scores = _people((10, 10, 40, 90), (200, 20, 50, 80))
135
+ assert person_boxes(keypoints, scores, frame_size=FRAME_SIZE) == (
136
+ (10, 10, 40, 90),
137
+ (200, 20, 50, 80),
138
+ )
139
+
140
+
141
+ def test_person_boxes_ignores_keypoints_below_the_confidence():
142
+ keypoints = [[(60, 40), (160, 40), (60, 120), (160, 120), (5, 5)]]
143
+ scores = [(0.9, 0.9, 0.9, 0.9, 0.05)] # the stray point is noise
144
+ assert person_boxes(keypoints, scores, frame_size=FRAME_SIZE) == (
145
+ (60, 40, 100, 80),
146
+ )
147
+
148
+
149
+ def test_person_boxes_drops_a_person_with_too_little_evidence():
150
+ keypoints, scores = _people((60, 40, 100, 80))
151
+ assert (
152
+ person_boxes(
153
+ keypoints,
154
+ scores,
155
+ frame_size=FRAME_SIZE,
156
+ min_keypoints=DFLT_MIN_KEYPOINTS + 1,
157
+ )
158
+ == ()
159
+ )
160
+
161
+
162
+ def test_person_boxes_clamps_into_the_frame():
163
+ keypoints, scores = _people((-20, -30, 400, 400))
164
+ assert person_boxes(keypoints, scores, frame_size=FRAME_SIZE) == (
165
+ (0, 0, FRAME_SIZE[0], FRAME_SIZE[1]),
166
+ )
167
+
168
+
169
+ def test_person_boxes_reports_zero_detections_as_evidence_not_error():
170
+ assert person_boxes([], [], frame_size=FRAME_SIZE) == ()
171
+ assert person_boxes(None, None, frame_size=FRAME_SIZE) == ()
172
+
173
+
174
+ # ── the locator, against real frames and a fake model ───────────────────────
175
+
176
+
177
+ def test_locator_observes_every_probe_and_every_person(video):
178
+ seen_shapes = []
179
+
180
+ def estimator(frame):
181
+ seen_shapes.append(frame.shape)
182
+ return _people((60, 40, 100, 80), (200, 40, 60, 120))
183
+
184
+ locator = RtmlibPoseLocator(probe_fps=5.0, pose_estimator=estimator)
185
+ query = _query(video)
186
+ observation = locator(query)
187
+
188
+ assert isinstance(observation, SubjectObservation)
189
+ assert len(observation.samples) == 10 # 2 s at 5 fps
190
+ assert len(seen_shapes) == 10, "every probe must reach the estimator"
191
+ # real frames, read through mixing: cv2's (height, width, BGR) layout
192
+ assert set(seen_shapes) == {(FRAME_SIZE[1], FRAME_SIZE[0], 3)}
193
+ assert all(query.start_s < t < query.end_s for t, _boxes in observation.samples)
194
+ assert all(
195
+ boxes == ((60, 40, 100, 80), (200, 40, 60, 120))
196
+ for _t, boxes in observation.samples
197
+ )
198
+
199
+
200
+ def test_locator_hands_the_core_something_the_policy_can_resolve(video):
201
+ locator = RtmlibPoseLocator(
202
+ probe_fps=5.0,
203
+ pose_estimator=lambda frame: _people((10, 10, 40, 90), (200, 20, 50, 80)),
204
+ )
205
+ observation = locator(_query(video))
206
+ box = resolve_crop_box(observation, frame_size=FRAME_SIZE, pad=0.0, aspect=1.0)
207
+ assert box is not None
208
+ x, y, w, h = box
209
+ # the core unioned both people and kept them inside the frame
210
+ assert x <= 10 and y <= 10 and x + w >= 250 and x + w <= FRAME_SIZE[0]
211
+ assert y + h <= FRAME_SIZE[1]
212
+
213
+
214
+ def test_locator_answers_none_when_it_sees_nobody(video):
215
+ locator = RtmlibPoseLocator(probe_fps=5.0, pose_estimator=lambda frame: _people())
216
+ # ADR-0005 §3: None is the zero-detections fallback, and no crop means
217
+ # genuinely uncropped rather than letterboxed
218
+ assert locator(_query(video)) is None
219
+
220
+
221
+ def test_locator_raises_rather_than_calling_unreadable_media_uncropped():
222
+ locator = RtmlibPoseLocator(pose_estimator=lambda frame: _people())
223
+ query = _query("no-such-file.mp4")
224
+ with pytest.raises((RuntimeError, ValueError)):
225
+ locator(query)
226
+
227
+
228
+ # ── the recorded identity ───────────────────────────────────────────────────
229
+
230
+
231
+ def test_locator_name_carries_the_rtmlib_version(monkeypatch):
232
+ # the recipe fingerprint reads this: an rtmlib upgrade must re-locate rather
233
+ # than reuse a box a different model measured (ADR-0005 §3)
234
+ monkeypatch.setattr(pose, "_rtmlib_version", lambda: FAKE_RTMLIB_VERSION)
235
+ assert rtmlib_pose.locator_name == (
236
+ f"rtmlib-pose@{FAKE_RTMLIB_VERSION};mode=balanced;fps=5;conf=0.3;minkp=4"
237
+ )
238
+
239
+
240
+ @pytest.mark.parametrize(
241
+ "changed",
242
+ [
243
+ {"mode": "performance"},
244
+ {"probe_fps": 10.0},
245
+ {"keypoint_confidence": 0.5},
246
+ {"min_keypoints": 6},
247
+ ],
248
+ )
249
+ def test_every_input_that_moves_a_box_moves_the_locator_name(monkeypatch, changed):
250
+ # derive gates recipe reuse on this name (derivation.py's _reconcile_recipe),
251
+ # so anything left out of it silently reuses a box measured under different
252
+ # settings — precisely what ADR-0005 §3's fingerprint exists to prevent
253
+ monkeypatch.setattr(pose, "_rtmlib_version", lambda: FAKE_RTMLIB_VERSION)
254
+ assert RtmlibPoseLocator(**changed).locator_name != rtmlib_pose.locator_name
255
+
256
+
257
+ def test_an_injected_estimator_names_itself_instead_of_claiming_rtmlib(monkeypatch):
258
+ # claiming rtmlib's version for a box rtmlib did not measure would be the
259
+ # same false identity in the other direction
260
+ def boom():
261
+ raise AssertionError("an injected estimator must not read rtmlib's version")
262
+
263
+ monkeypatch.setattr(pose, "_rtmlib_version", boom)
264
+
265
+ def my_estimator(frame):
266
+ return _people()
267
+
268
+ name = RtmlibPoseLocator(pose_estimator=my_estimator).locator_name
269
+ assert name.startswith("rtmlib-pose@custom:")
270
+ assert "my_estimator" in name
271
+ assert "mode=" not in name # a Body argument; meaningless to another estimator
272
+ assert ";fps=5;conf=0.3;minkp=4" in name
273
+
274
+
275
+ def test_an_estimator_may_version_itself(monkeypatch):
276
+ monkeypatch.setattr(pose, "_rtmlib_version", lambda: FAKE_RTMLIB_VERSION)
277
+
278
+ def versioned(frame):
279
+ return _people()
280
+
281
+ versioned.locator_name = "my-model@2.1"
282
+ name = RtmlibPoseLocator(pose_estimator=versioned).locator_name
283
+ assert name.startswith("rtmlib-pose@custom:my-model@2.1;")
284
+
285
+
286
+ def test_naming_the_locator_without_the_extra_names_the_extra(monkeypatch):
287
+ import importlib.metadata
288
+ import importlib.util
289
+
290
+ def not_installed(distribution, *args, **kwargs):
291
+ if distribution == "rtmlib":
292
+ raise importlib.metadata.PackageNotFoundError(distribution)
293
+ return FAKE_RTMLIB_VERSION
294
+
295
+ monkeypatch.setattr(importlib.metadata, "version", not_installed)
296
+ # patched too, so this describes an absent rtmlib rather than whatever the
297
+ # developer's machine happens to have installed
298
+ monkeypatch.setattr(importlib.util, "find_spec", lambda name: None)
299
+ with pytest.raises(ImportError) as excinfo:
300
+ rtmlib_pose.locator_name
301
+ message = str(excinfo.value)
302
+ assert "paces[pose]" in message
303
+ assert "rtmlib" in message
304
+ assert "full_frame" in message, (
305
+ "the message must name the answer that needs nothing"
306
+ )
307
+
308
+
309
+ def test_running_the_locator_without_the_extra_names_the_extra(monkeypatch):
310
+ # the calling path, not just the naming one: every rtmlib import goes
311
+ # through _import_body precisely so this message is written once
312
+ import builtins
313
+
314
+ real_import = builtins.__import__
315
+
316
+ def no_rtmlib(name, *args, **kwargs):
317
+ if name == "rtmlib" or name.startswith("rtmlib."):
318
+ raise ImportError("No module named 'rtmlib'")
319
+ return real_import(name, *args, **kwargs)
320
+
321
+ monkeypatch.setattr(builtins, "__import__", no_rtmlib)
322
+ pose._body_estimator.cache_clear()
323
+ with pytest.raises(ImportError) as excinfo:
324
+ pose._import_body()
325
+ assert "paces[pose]" in str(excinfo.value)
326
+
327
+
328
+ def test_a_vendored_rtmlib_is_told_apart_from_a_missing_one(monkeypatch):
329
+ # importable but no dist-info (a vendored copy, or a source tree on
330
+ # PYTHONPATH): "pip install paces[pose]" is the wrong advice there
331
+ import importlib.metadata
332
+ import importlib.util
333
+
334
+ def no_metadata(distribution, *args, **kwargs):
335
+ raise importlib.metadata.PackageNotFoundError(distribution)
336
+
337
+ monkeypatch.setattr(importlib.metadata, "version", no_metadata)
338
+ monkeypatch.setattr(importlib.util, "find_spec", lambda name: object())
339
+ with pytest.raises(ImportError) as excinfo:
340
+ rtmlib_pose.locator_name
341
+ message = str(excinfo.value)
342
+ assert "no distribution metadata" in message
343
+ assert "pose_estimator" in message, "the message must name the way out"
344
+
345
+
346
+ def test_the_missing_extra_message_states_the_licence_boundary():
347
+ assert "Apache-2.0" in POSE_EXTRA_MISSING
348
+ assert "AGPL" in POSE_EXTRA_MISSING
349
+
350
+
351
+ # ── the seam, end to end through derive ─────────────────────────────────────
352
+
353
+
354
+ def test_derive_records_the_pose_locator_in_the_recipe(tmp_path, video, monkeypatch):
355
+ monkeypatch.setattr(pose, "_rtmlib_version", lambda: FAKE_RTMLIB_VERSION)
356
+ document = StepDocument(
357
+ id="routine",
358
+ title="Routine",
359
+ sources=[Source(id="perf", kind="video", uri="https://example.com/v")],
360
+ steps=[
361
+ Step(
362
+ id="b4",
363
+ name="Step",
364
+ duration=Measure(value="4", unit="eight"),
365
+ spans=[
366
+ SourceSpan(
367
+ source="perf",
368
+ role="performance",
369
+ start="0.4",
370
+ excerpt=("0.4", "1.2"),
371
+ )
372
+ ],
373
+ )
374
+ ],
375
+ )
376
+ doc_path = tmp_path / "routine.json"
377
+ doc_path.write_text("{}", encoding="utf-8")
378
+
379
+ def one_dancer(frame):
380
+ return _people((60, 40, 100, 80))
381
+
382
+ locator = RtmlibPoseLocator(probe_fps=5.0, pose_estimator=one_dancer)
383
+ result = derive_document(
384
+ document,
385
+ media={"perf": str(video)},
386
+ doc_path=doc_path,
387
+ subject_locator=locator,
388
+ pad=0.0,
389
+ aspect=1.0,
390
+ roles=("poster",),
391
+ )
392
+ recipes = load_recipes(tmp_path / "routine.recipes.json")
393
+ entry = recipes.entries["b4/perf/performance/0.4"]
394
+ # the whole policy identity lands in the sidecar, not just a model version
395
+ assert entry.locator.startswith("rtmlib-pose@custom:")
396
+ assert "one_dancer" in entry.locator
397
+ assert entry.locator.endswith(";fps=5;conf=0.3;minkp=4")
398
+ assert entry.box is not None
399
+ x, y, w, h = entry.box
400
+ # the rect (60, 40, 100, 80) squared to aspect 1.0 about its own centre
401
+ assert (x, y, w, h) == (60, 30, 100, 100)
402
+ assert result.document.steps[0].artifacts
403
+
404
+
405
+ # ── the extra is genuinely lazy ─────────────────────────────────────────────
406
+
407
+
408
+ def test_importing_paces_never_pulls_the_pose_stack():
409
+ # the guarantee is structural, so assert it structurally: rtmlib and
410
+ # onnxruntime are heavy, and `import paces` is not where anyone opted in
411
+ repo_root = Path(pose.__file__).resolve().parents[1] # holds the `paces` package
412
+ env = dict(os.environ, PYTHONPATH=str(repo_root))
413
+ script = (
414
+ "import sys\n"
415
+ "import paces\n"
416
+ "import paces.pose\n"
417
+ "leaked = {'rtmlib', 'onnxruntime'} & set(sys.modules)\n"
418
+ "assert not leaked, leaked\n"
419
+ "print('clean')\n"
420
+ )
421
+ completed = subprocess.run(
422
+ [sys.executable, "-c", script],
423
+ capture_output=True,
424
+ text=True,
425
+ env=env,
426
+ cwd=str(repo_root),
427
+ )
428
+ assert completed.returncode == 0, completed.stderr
429
+ assert "clean" in completed.stdout
430
+
431
+
432
+ def test_check_pose_requirements_reports_channels_without_downloading():
433
+ report = check_pose_requirements()
434
+ assert set(report) == {"ok", "rtmlib", "onnxruntime", "cv2_providers", "notes"}
435
+ assert report["ok"] is (
436
+ report["rtmlib"] is not None and report["onnxruntime"] is not None
437
+ )
438
+ if not report["ok"]:
439
+ assert any("paces[pose]" in note for note in report["notes"])
440
+
441
+
442
+ # ── the licence perimeter (packaging facts, guarded because nothing else is) ─
443
+
444
+
445
+ def _distribution_names(requirements):
446
+ """Bare distribution names out of PEP 508 requirement strings."""
447
+ for requirement in requirements:
448
+ head = requirement.split(";")[0].split("[")[0]
449
+ for separator in ("==", ">=", "<=", "~=", "!=", ">", "<", " ", "@"):
450
+ head = head.split(separator)[0]
451
+ yield head.strip().lower().replace("_", "-")
452
+
453
+
454
+ def test_core_dependencies_stay_copyleft_free(pyproject):
455
+ core = set(_distribution_names(pyproject["project"]["dependencies"]))
456
+ assert core == {"pydantic"}
457
+
458
+
459
+ def test_no_extra_reaches_agpl(pyproject):
460
+ # putting ultralytics into `pose` is the regression this exists to catch:
461
+ # it is how a user who asked for pose boxes would silently inherit the AGPL.
462
+ # Scope, stated so nobody reads more into a green run than is there: this
463
+ # reads paces' OWN pyproject, i.e. what paces declares. It says nothing
464
+ # about transitive closures, and nothing about what a dependency's wheels
465
+ # actually ship — opencv's GPL-on-macOS FFmpeg is exactly that gap, which
466
+ # is why it was measured off the binaries and written down instead.
467
+ for extra, requirements in pyproject["project"]["optional-dependencies"].items():
468
+ found = set(_distribution_names(requirements)) & FORBIDDEN_DISTRIBUTIONS
469
+ assert not found, f"AGPL {sorted(found)} reached the `{extra}` extra"
470
+
471
+
472
+ def test_pose_is_the_extra_that_declares_rtmlib(pyproject):
473
+ requirements = pyproject["project"]["optional-dependencies"]["pose"]
474
+ assert set(_distribution_names(requirements)) == {"rtmlib", "onnxruntime"}
475
+ # the licence audit was done against one rtmlib version, and 0.0.x promises
476
+ # no stability: a floor with no ceiling would let an unaudited wheel in
477
+ (rtmlib_requirement,) = [r for r in requirements if r.startswith("rtmlib")]
478
+ assert "<" in rtmlib_requirement, (
479
+ f"rtmlib must carry an upper bound, got {rtmlib_requirement!r}"
480
+ )
481
+
482
+
483
+ def test_the_licence_table_looks_where_the_exposure_is(pyproject):
484
+ # wads' LicencePolicy rejects unknown keys deliberately, so a typo here is a
485
+ # hard error rather than a silent fallback to defaults — keep it parseable.
486
+ known = {
487
+ "enabled",
488
+ "allowed",
489
+ "forbidden",
490
+ "exceptions",
491
+ "include-extras",
492
+ "unknown-is-failure",
493
+ "unclassified-is-failure",
494
+ }
495
+ table = pyproject["tool"]["wads"]["licence"]
496
+ assert set(table) <= known, f"unknown keys: {sorted(set(table) - known)}"
497
+ # every extra is inside the perimeter — a new one must be adjudicated, not
498
+ # quietly excluded from the check
499
+ declared = set(pyproject["project"]["optional-dependencies"]) - {"dev", "docs"}
500
+ assert declared <= set(table["include-extras"])
501
+ # paces adjudicates nothing: an exceptions table appearing here would mean
502
+ # the perimeter moved, and that is a decision, not a diff
503
+ assert "exceptions" not in table
504
+
505
+
506
+ # ── the real model: opt-in, never CI ────────────────────────────────────────
507
+
508
+
509
+ @pytest.mark.skipif(
510
+ os.environ.get(TEST_MODELS_ENVVAR) is None,
511
+ reason=f"set {TEST_MODELS_ENVVAR}=1 to download rtmlib weights and run a real pass",
512
+ )
513
+ def test_real_rtmlib_pass_smoke(video):
514
+ # not an accuracy claim: the fixture is a green rectangle and RTMPose is
515
+ # entitled to see nobody in it. What this proves is the wiring — Body
516
+ # constructs, weights resolve, inference runs, and its output is the shape
517
+ # person_boxes reads.
518
+ observation = rtmlib_pose(_query(video))
519
+ assert observation is None or isinstance(observation, SubjectObservation)
520
+ assert rtmlib_pose.locator_name.startswith("rtmlib-pose@")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes