paces 0.0.7__tar.gz → 0.0.9__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.9}/PKG-INFO +42 -1
  2. {paces-0.0.7 → paces-0.0.9}/README.md +37 -0
  3. paces-0.0.9/paces/pose.py +469 -0
  4. {paces-0.0.7 → paces-0.0.9}/paces/tools.py +2 -1
  5. {paces-0.0.7 → paces-0.0.9}/pyproject.toml +76 -1
  6. paces-0.0.9/tests/test_pose.py +550 -0
  7. {paces-0.0.7 → paces-0.0.9}/.gitignore +0 -0
  8. {paces-0.0.7 → paces-0.0.9}/LICENSE +0 -0
  9. {paces-0.0.7 → paces-0.0.9}/docs/README.md +0 -0
  10. {paces-0.0.7 → paces-0.0.9}/docs/alignment/README.md +0 -0
  11. {paces-0.0.7 → paces-0.0.9}/docs/poc-reference/README.md +0 -0
  12. {paces-0.0.7 → paces-0.0.9}/docs/poc-reference/artifacts/clips.json +0 -0
  13. {paces-0.0.7 → paces-0.0.9}/paces/__init__.py +0 -0
  14. {paces-0.0.7 → paces-0.0.9}/paces/__main__.py +0 -0
  15. {paces-0.0.7 → paces-0.0.9}/paces/derivation.py +0 -0
  16. {paces-0.0.7 → paces-0.0.9}/paces/edits.py +0 -0
  17. {paces-0.0.7 → paces-0.0.9}/paces/excerpts.py +0 -0
  18. {paces-0.0.7 → paces-0.0.9}/paces/measure.py +0 -0
  19. {paces-0.0.7 → paces-0.0.9}/paces/model.py +0 -0
  20. {paces-0.0.7 → paces-0.0.9}/paces/projection.py +0 -0
  21. {paces-0.0.7 → paces-0.0.9}/paces/render.py +0 -0
  22. {paces-0.0.7 → paces-0.0.9}/paces/segmenters.py +0 -0
  23. {paces-0.0.7 → paces-0.0.9}/tests/audio_synth.py +0 -0
  24. {paces-0.0.7 → paces-0.0.9}/tests/data/routine.json +0 -0
  25. {paces-0.0.7 → paces-0.0.9}/tests/poc_fixture.py +0 -0
  26. {paces-0.0.7 → paces-0.0.9}/tests/test_chapters.py +0 -0
  27. {paces-0.0.7 → paces-0.0.9}/tests/test_cli_surface.py +0 -0
  28. {paces-0.0.7 → paces-0.0.9}/tests/test_derivation.py +0 -0
  29. {paces-0.0.7 → paces-0.0.9}/tests/test_edits.py +0 -0
  30. {paces-0.0.7 → paces-0.0.9}/tests/test_excerpts.py +0 -0
  31. {paces-0.0.7 → paces-0.0.9}/tests/test_measure.py +0 -0
  32. {paces-0.0.7 → paces-0.0.9}/tests/test_render_media.py +0 -0
  33. {paces-0.0.7 → paces-0.0.9}/tests/test_roundtrip_poc.py +0 -0
  34. {paces-0.0.7 → paces-0.0.9}/tests/test_segment.py +0 -0
  35. {paces-0.0.7 → paces-0.0.9}/tests/test_smoke.py +0 -0
  36. {paces-0.0.7 → paces-0.0.9}/tests/test_vertical_slice.py +0 -0
  37. {paces-0.0.7 → paces-0.0.9}/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.9
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,42 @@ 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 of the versions measured (4.12.0.88 / 4.13.0.92)** (built `--enable-gpl`
113
+ with libx264/libx265) though LGPL-2.1-or-later on manylinux and Windows — a
114
+ per-version fact, not a per-platform one: the 5.0.0.93 macOS x86_64 wheel ships
115
+ no FFmpeg at all. This is measured from the shipped binaries — the wheels' own
116
+ `LICENSE-3RD-PARTY.txt` never mentions x264. `paces[media]` already brings such
117
+ a wheel, so `[pose]` adds a second copy rather than a higher tier. Worse:
118
+ rtmlib's own metadata requires *both* `opencv-python` *and*
119
+ `opencv-contrib-python`, unpinned, so a plain `pip install paces[media,pose]`
120
+ ends up with two distributions owning one `cv2` — harmless until either is
121
+ uninstalled, at which point the survivor's `cv2` can be left with files
122
+ missing (issue #20). rtmlib only calls plain `cv2` APIs (`VideoCapture`,
123
+ `dnn.readNetFromONNX`, drawing helpers — nothing contrib-only), so `[media]`'s
124
+ `opencv-contrib-python` already covers it; to keep the closure
125
+ single-provider, install in two steps instead of one:
126
+
127
+ ```bash
128
+ pip install "paces[media]" # opencv-contrib-python, the fleet's one cv2
129
+ pip install --no-deps "rtmlib>=0.0.16,<0.1" # skip rtmlib's own opencv-* re-declaration
130
+ pip install onnxruntime tqdm # rtmlib's other real deps (numpy already arrives via opencv)
131
+ ```
132
+
133
+ `paces.pose.check_pose_requirements()` reports what you have, and names the
134
+ repair command if both providers are already present.
135
+
96
136
  ## How it thinks
97
137
 
98
138
  **Analysis and rendering are separate phases** with a serialisable document
@@ -123,6 +163,7 @@ content (`OpenQuestion`), and human edits are protected from regeneration
123
163
  | protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
124
164
  | the committed artifact | `to_document(seg, ...)` → `StepDocument` |
125
165
  | real clips/gifs/posters for the page | `derive_document(doc, media=..., doc_path=...)` / `paces derive` (`pip install paces[media]`) |
166
+ | 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
167
  | a practice page | `render_html(doc)` |
127
168
  | wall-clock times from counts | `resolve(doc)` |
128
169
  | sanity checks | `validate_document(doc)` |
@@ -59,6 +59,42 @@ 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 of the versions measured (4.12.0.88 / 4.13.0.92)** (built `--enable-gpl`
75
+ with libx264/libx265) though LGPL-2.1-or-later on manylinux and Windows — a
76
+ per-version fact, not a per-platform one: the 5.0.0.93 macOS x86_64 wheel ships
77
+ no FFmpeg at all. This is measured from the shipped binaries — the wheels' own
78
+ `LICENSE-3RD-PARTY.txt` never mentions x264. `paces[media]` already brings such
79
+ a wheel, so `[pose]` adds a second copy rather than a higher tier. Worse:
80
+ rtmlib's own metadata requires *both* `opencv-python` *and*
81
+ `opencv-contrib-python`, unpinned, so a plain `pip install paces[media,pose]`
82
+ ends up with two distributions owning one `cv2` — harmless until either is
83
+ uninstalled, at which point the survivor's `cv2` can be left with files
84
+ missing (issue #20). rtmlib only calls plain `cv2` APIs (`VideoCapture`,
85
+ `dnn.readNetFromONNX`, drawing helpers — nothing contrib-only), so `[media]`'s
86
+ `opencv-contrib-python` already covers it; to keep the closure
87
+ single-provider, install in two steps instead of one:
88
+
89
+ ```bash
90
+ pip install "paces[media]" # opencv-contrib-python, the fleet's one cv2
91
+ pip install --no-deps "rtmlib>=0.0.16,<0.1" # skip rtmlib's own opencv-* re-declaration
92
+ pip install onnxruntime tqdm # rtmlib's other real deps (numpy already arrives via opencv)
93
+ ```
94
+
95
+ `paces.pose.check_pose_requirements()` reports what you have, and names the
96
+ repair command if both providers are already present.
97
+
62
98
  ## How it thinks
63
99
 
64
100
  **Analysis and rendering are separate phases** with a serialisable document
@@ -89,6 +125,7 @@ content (`OpenQuestion`), and human edits are protected from regeneration
89
125
  | protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
90
126
  | the committed artifact | `to_document(seg, ...)` → `StepDocument` |
91
127
  | real clips/gifs/posters for the page | `derive_document(doc, media=..., doc_path=...)` / `paces derive` (`pip install paces[media]`) |
128
+ | 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
129
  | a practice page | `render_html(doc)` |
93
130
  | wall-clock times from counts | `resolve(doc)` |
94
131
  | sanity checks | `validate_document(doc)` |
@@ -0,0 +1,469 @@
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
+ — and, since neither package's uninstall is safe once both are present
417
+ (each claims files the other also claims), the note names the repair
418
+ command rather than only the collision. See README's "Auto-crop" section
419
+ for the install recipe that avoids the pair in the first place
420
+ (issue #20).
421
+
422
+ Downloads nothing: weights arrive on first inference, not on this check.
423
+ """
424
+ from importlib.metadata import PackageNotFoundError, version
425
+
426
+ def _version(distribution: str) -> str | None:
427
+ try:
428
+ return version(distribution)
429
+ except PackageNotFoundError:
430
+ return None
431
+
432
+ notes: list[str] = []
433
+ rtmlib_version = _version("rtmlib")
434
+ onnxruntime_version = _version("onnxruntime")
435
+ if rtmlib_version is None or onnxruntime_version is None:
436
+ missing = [
437
+ name
438
+ for name, found in (
439
+ ("rtmlib", rtmlib_version),
440
+ ("onnxruntime", onnxruntime_version),
441
+ )
442
+ if found is None
443
+ ]
444
+ notes.append(
445
+ f"{', '.join(missing)} not installed — install the pose extra: "
446
+ f"pip install 'paces[{POSE_EXTRA}]'"
447
+ )
448
+ cv2_providers = [
449
+ name
450
+ for name in ("opencv-python", "opencv-contrib-python")
451
+ if _version(name) is not None
452
+ ]
453
+ if len(cv2_providers) > 1:
454
+ notes.append(
455
+ "two distributions provide cv2 "
456
+ f"({', '.join(cv2_providers)}) — rtmlib declares both; uninstalling "
457
+ "either can leave the other's cv2 broken. Repair: "
458
+ "pip uninstall -y opencv-python opencv-contrib-python && "
459
+ "pip install --force-reinstall opencv-contrib-python — not sticky: "
460
+ "the next plain `pip install paces[pose]` re-adds opencv-python, "
461
+ "so use README's Auto-crop install recipe to avoid it recurring"
462
+ )
463
+ return {
464
+ "ok": rtmlib_version is not None and onnxruntime_version is not None,
465
+ "rtmlib": rtmlib_version,
466
+ "onnxruntime": onnxruntime_version,
467
+ "cv2_providers": cv2_providers,
468
+ "notes": notes,
469
+ }
@@ -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.