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.
- {paces-0.0.7 → paces-0.0.8}/PKG-INFO +24 -1
- {paces-0.0.7 → paces-0.0.8}/README.md +19 -0
- paces-0.0.8/paces/pose.py +460 -0
- {paces-0.0.7 → paces-0.0.8}/paces/tools.py +2 -1
- {paces-0.0.7 → paces-0.0.8}/pyproject.toml +71 -1
- paces-0.0.8/tests/test_pose.py +520 -0
- {paces-0.0.7 → paces-0.0.8}/.gitignore +0 -0
- {paces-0.0.7 → paces-0.0.8}/LICENSE +0 -0
- {paces-0.0.7 → paces-0.0.8}/docs/README.md +0 -0
- {paces-0.0.7 → paces-0.0.8}/docs/alignment/README.md +0 -0
- {paces-0.0.7 → paces-0.0.8}/docs/poc-reference/README.md +0 -0
- {paces-0.0.7 → paces-0.0.8}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/__init__.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/__main__.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/derivation.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/edits.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/excerpts.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/measure.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/model.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/projection.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/render.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/paces/segmenters.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/audio_synth.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/data/routine.json +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/poc_fixture.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_chapters.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_cli_surface.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_derivation.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_edits.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_excerpts.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_measure.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_render_media.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_segment.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_smoke.py +0 -0
- {paces-0.0.7 → paces-0.0.8}/tests/test_vertical_slice.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|