poseorbit 0.2.0__tar.gz → 0.3.0__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.
- {poseorbit-0.2.0 → poseorbit-0.3.0}/CHANGELOG.md +16 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/PKG-INFO +12 -4
- {poseorbit-0.2.0 → poseorbit-0.3.0}/README.md +11 -3
- {poseorbit-0.2.0 → poseorbit-0.3.0}/poseorbit/__init__.py +19 -6
- {poseorbit-0.2.0 → poseorbit-0.3.0}/poseorbit/api.py +51 -5
- poseorbit-0.3.0/poseorbit/body.py +170 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/pyproject.toml +1 -1
- {poseorbit-0.2.0 → poseorbit-0.3.0}/tests/test_api.py +39 -1
- poseorbit-0.3.0/tests/test_body.py +95 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/.gitattributes +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/.gitignore +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/LICENSE +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/NOTICE +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/SECURITY.md +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/poseorbit/__main__.py +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/poseorbit/detect.py +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/poseorbit/draw.py +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/poseorbit/geometry.py +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/poseorbit/server.py +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/tests/test_downloads.py +0 -0
- {poseorbit-0.2.0 → poseorbit-0.3.0}/tests/test_geometry.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-10-25)
|
|
4
|
+
|
|
5
|
+
- **Move a joint**: `pose(..., edits=[Edit(person, joint, to)])` turns a
|
|
6
|
+
joint about its parent to point at `to`, carrying everything hanging from
|
|
7
|
+
it, so no bone changes length. The head, arms, legs and feet move
|
|
8
|
+
(`MOVABLE`); a hand moves with its wrist and the face with the head; the
|
|
9
|
+
hips stay put. Edits apply in order. `bend` and `apply_edits` do the same
|
|
10
|
+
for points of your own, and `poseorbit.body` holds the keypoint names and
|
|
11
|
+
their tree.
|
|
12
|
+
- `/api/pose` takes `"edits": [{ "person", "joint", "to": [x, y, z] }]`
|
|
13
|
+
(up to 256, `to` in picture widths like `points_3d`) and answers
|
|
14
|
+
`"movable"`, the joints it may move, so a client knows it may send them.
|
|
15
|
+
`points_3d` stays as detected.
|
|
16
|
+
- A request with edits detects depth even for the front view.
|
|
17
|
+
- The command line does not take edits yet.
|
|
18
|
+
|
|
3
19
|
## 0.2.0 (2026-10-11)
|
|
4
20
|
|
|
5
21
|
- **Choose some of the people**: `pose(..., person=[0, 2])` draws just those
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: poseorbit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Find the people in a picture, turn their pose in 3D, and draw the skeleton a pose-conditioned image model follows.
|
|
5
5
|
Project-URL: Homepage, https://github.com/33rd-kk/poseorbit
|
|
6
6
|
Project-URL: Documentation, https://33rd-kk.github.io/poseorbit/
|
|
@@ -200,7 +200,7 @@ is used; nothing is left behind in Hugging Face's own cache.
|
|
|
200
200
|
```python
|
|
201
201
|
from pathlib import Path
|
|
202
202
|
from PIL import Image
|
|
203
|
-
from poseorbit import ALL_PEOPLE, Camera, Detector, Framing, pose
|
|
203
|
+
from poseorbit import ALL_PEOPLE, Camera, Detector, Edit, Framing, pose
|
|
204
204
|
|
|
205
205
|
detector = Detector(weights_dir=Path("weights")) # load once, reuse; thread-safe
|
|
206
206
|
picture = Image.open("reference.png")
|
|
@@ -212,6 +212,10 @@ result = pose(detector, picture, size=(832, 1216), person=ALL_PEOPLE,
|
|
|
212
212
|
camera=Camera(yaw=-30), style="dwpose")
|
|
213
213
|
result.skeleton # PIL image, 832x1216, on black
|
|
214
214
|
result.joints_in_frame # body joints left inside the canvas
|
|
215
|
+
|
|
216
|
+
# Raise the first person's left wrist; the hand comes with it, the arm keeps its length.
|
|
217
|
+
x, y, z = detector.detect(picture, depth=True)[0].points_3d[9]
|
|
218
|
+
raised = pose(detector, picture, size=(832, 1216), person=0, edits=[Edit(0, 9, (x, y - 200, z))])
|
|
215
219
|
```
|
|
216
220
|
|
|
217
221
|
### With diffusers
|
|
@@ -263,7 +267,10 @@ Everything but `image_base64` is optional. `person` is an index into
|
|
|
263
267
|
`people` (left to right), a list of indices for some of them (`[0, 2]`, up to
|
|
264
268
|
64; answered sorted), `-1` for everyone, or absent for the most confident.
|
|
265
269
|
`person_subsets: true` in the answer tells a client it may send a list (0.2.0
|
|
266
|
-
and later). `
|
|
270
|
+
and later). `edits` (0.3.0 and later) moves joints, in order:
|
|
271
|
+
`[{ "person": 0, "joint": 9, "to": [x, y, z] }]`, up to 256, `to` in units of
|
|
272
|
+
the picture's width; `movable` in the answer lists the joints it may move.
|
|
273
|
+
`want_3d` adds each person's 133 points as `[x, y, z]` in units of
|
|
267
274
|
the picture's width, for a 3D viewer. Out-of-range angles and zoom are
|
|
268
275
|
clamped; a picture with nobody in it, or a person index past the end, gets a
|
|
269
276
|
400 with a `detail` message. Server and API are 127.0.0.1-only by default.
|
|
@@ -273,11 +280,12 @@ clamped; a picture with nobody in it, or a person index past the end, gets a
|
|
|
273
280
|
| Name | What it is |
|
|
274
281
|
|---|---|
|
|
275
282
|
| `Detector(weights_dir=None, device="cpu")` | Loads the models on first use. `detect(image, depth=False) -> list[Person]`, left to right; raises `NoPersonError`. |
|
|
276
|
-
| `pose(detector, image, size, person=None, camera=None, style="dwpose", depth=False, framing=None) -> PoseResult` | Detect, choose, turn, frame and draw in one call. |
|
|
283
|
+
| `pose(detector, image, size, person=None, camera=None, style="dwpose", depth=False, framing=None, edits=()) -> PoseResult` | Detect, choose, turn, frame and draw in one call. |
|
|
277
284
|
| `Camera(yaw=0, pitch=0)` | Degrees. `+yaw` swings the camera to the viewer's right, `+pitch` raises it. Clamped to `MAX_YAW` (90) / `MAX_PITCH` (45). |
|
|
278
285
|
| `Framing(zoom=1, x=0.5, y=0.5)` | The canvas point `(x, y)` (fractions) goes to the middle, scaled by `zoom` (`MIN_ZOOM` 0.5 to `MAX_ZOOM` 6). |
|
|
279
286
|
| `PoseResult` | `skeleton` (PIL image), `people`, `person`, `camera` and `framing` as used, `joints_in_frame`. |
|
|
280
287
|
| `Person` | `keypoints` (133×2), `scores` (133), `depth` (133 or None), `bbox`, `visible` (body joints seen), `points_3d`. |
|
|
288
|
+
| `Edit(person, joint, to)` | Turn `joint` (one of `MOVABLE`) about its parent to point at `to` (pixels), carrying what hangs from it; bones keep their length. `apply_edits(points, edits)` and `bend(points, joint, to)` do it on arrays. |
|
|
281
289
|
| `render(keypoints, scores, size, style)` | Draw `(N, 133, 2)` keypoints on a black `size` canvas. |
|
|
282
290
|
| `letterbox(skeleton, size)` | Fit a skeleton drawn at another size onto `size`, uniformly scaled and centred. |
|
|
283
291
|
| `poseorbit.api.handle(detector, body, default_style)` | The `/api/pose` handler without a web framework, to embed in another server. |
|
|
@@ -163,7 +163,7 @@ is used; nothing is left behind in Hugging Face's own cache.
|
|
|
163
163
|
```python
|
|
164
164
|
from pathlib import Path
|
|
165
165
|
from PIL import Image
|
|
166
|
-
from poseorbit import ALL_PEOPLE, Camera, Detector, Framing, pose
|
|
166
|
+
from poseorbit import ALL_PEOPLE, Camera, Detector, Edit, Framing, pose
|
|
167
167
|
|
|
168
168
|
detector = Detector(weights_dir=Path("weights")) # load once, reuse; thread-safe
|
|
169
169
|
picture = Image.open("reference.png")
|
|
@@ -175,6 +175,10 @@ result = pose(detector, picture, size=(832, 1216), person=ALL_PEOPLE,
|
|
|
175
175
|
camera=Camera(yaw=-30), style="dwpose")
|
|
176
176
|
result.skeleton # PIL image, 832x1216, on black
|
|
177
177
|
result.joints_in_frame # body joints left inside the canvas
|
|
178
|
+
|
|
179
|
+
# Raise the first person's left wrist; the hand comes with it, the arm keeps its length.
|
|
180
|
+
x, y, z = detector.detect(picture, depth=True)[0].points_3d[9]
|
|
181
|
+
raised = pose(detector, picture, size=(832, 1216), person=0, edits=[Edit(0, 9, (x, y - 200, z))])
|
|
178
182
|
```
|
|
179
183
|
|
|
180
184
|
### With diffusers
|
|
@@ -226,7 +230,10 @@ Everything but `image_base64` is optional. `person` is an index into
|
|
|
226
230
|
`people` (left to right), a list of indices for some of them (`[0, 2]`, up to
|
|
227
231
|
64; answered sorted), `-1` for everyone, or absent for the most confident.
|
|
228
232
|
`person_subsets: true` in the answer tells a client it may send a list (0.2.0
|
|
229
|
-
and later). `
|
|
233
|
+
and later). `edits` (0.3.0 and later) moves joints, in order:
|
|
234
|
+
`[{ "person": 0, "joint": 9, "to": [x, y, z] }]`, up to 256, `to` in units of
|
|
235
|
+
the picture's width; `movable` in the answer lists the joints it may move.
|
|
236
|
+
`want_3d` adds each person's 133 points as `[x, y, z]` in units of
|
|
230
237
|
the picture's width, for a 3D viewer. Out-of-range angles and zoom are
|
|
231
238
|
clamped; a picture with nobody in it, or a person index past the end, gets a
|
|
232
239
|
400 with a `detail` message. Server and API are 127.0.0.1-only by default.
|
|
@@ -236,11 +243,12 @@ clamped; a picture with nobody in it, or a person index past the end, gets a
|
|
|
236
243
|
| Name | What it is |
|
|
237
244
|
|---|---|
|
|
238
245
|
| `Detector(weights_dir=None, device="cpu")` | Loads the models on first use. `detect(image, depth=False) -> list[Person]`, left to right; raises `NoPersonError`. |
|
|
239
|
-
| `pose(detector, image, size, person=None, camera=None, style="dwpose", depth=False, framing=None) -> PoseResult` | Detect, choose, turn, frame and draw in one call. |
|
|
246
|
+
| `pose(detector, image, size, person=None, camera=None, style="dwpose", depth=False, framing=None, edits=()) -> PoseResult` | Detect, choose, turn, frame and draw in one call. |
|
|
240
247
|
| `Camera(yaw=0, pitch=0)` | Degrees. `+yaw` swings the camera to the viewer's right, `+pitch` raises it. Clamped to `MAX_YAW` (90) / `MAX_PITCH` (45). |
|
|
241
248
|
| `Framing(zoom=1, x=0.5, y=0.5)` | The canvas point `(x, y)` (fractions) goes to the middle, scaled by `zoom` (`MIN_ZOOM` 0.5 to `MAX_ZOOM` 6). |
|
|
242
249
|
| `PoseResult` | `skeleton` (PIL image), `people`, `person`, `camera` and `framing` as used, `joints_in_frame`. |
|
|
243
250
|
| `Person` | `keypoints` (133×2), `scores` (133), `depth` (133 or None), `bbox`, `visible` (body joints seen), `points_3d`. |
|
|
251
|
+
| `Edit(person, joint, to)` | Turn `joint` (one of `MOVABLE`) about its parent to point at `to` (pixels), carrying what hangs from it; bones keep their length. `apply_edits(points, edits)` and `bend(points, joint, to)` do it on arrays. |
|
|
244
252
|
| `render(keypoints, scores, size, style)` | Draw `(N, 133, 2)` keypoints on a black `size` canvas. |
|
|
245
253
|
| `letterbox(skeleton, size)` | Fit a skeleton drawn at another size onto `size`, uniformly scaled and centred. |
|
|
246
254
|
| `poseorbit.api.handle(detector, body, default_style)` | The `/api/pose` handler without a web framework, to embed in another server. |
|
|
@@ -17,11 +17,12 @@ from dataclasses import dataclass
|
|
|
17
17
|
import numpy as np
|
|
18
18
|
from PIL import Image
|
|
19
19
|
|
|
20
|
+
from .body import MOVABLE, NAMES, Edit, apply_edits, bend
|
|
20
21
|
from .detect import KEYPOINT_THRESHOLD, Detector, NoPersonError, Person, most_confident
|
|
21
22
|
from .draw import STYLES, Style, letterbox, render
|
|
22
23
|
from .geometry import MAX_PITCH, MAX_YAW, MAX_ZOOM, MIN_ZOOM, Camera, Framing, fit, frame, scene_centre, view
|
|
23
24
|
|
|
24
|
-
__version__ = "0.
|
|
25
|
+
__version__ = "0.3.0"
|
|
25
26
|
|
|
26
27
|
# Person index meaning "everyone detected", for a group pose.
|
|
27
28
|
ALL_PEOPLE = -1
|
|
@@ -30,17 +31,22 @@ __all__ = [
|
|
|
30
31
|
"ALL_PEOPLE",
|
|
31
32
|
"Camera",
|
|
32
33
|
"Detector",
|
|
34
|
+
"Edit",
|
|
33
35
|
"Framing",
|
|
34
36
|
"KEYPOINT_THRESHOLD",
|
|
35
37
|
"MAX_PITCH",
|
|
36
38
|
"MAX_YAW",
|
|
37
39
|
"MAX_ZOOM",
|
|
38
40
|
"MIN_ZOOM",
|
|
41
|
+
"MOVABLE",
|
|
42
|
+
"NAMES",
|
|
39
43
|
"NoPersonError",
|
|
40
44
|
"Person",
|
|
41
45
|
"PoseResult",
|
|
42
46
|
"STYLES",
|
|
43
47
|
"Style",
|
|
48
|
+
"apply_edits",
|
|
49
|
+
"bend",
|
|
44
50
|
"letterbox",
|
|
45
51
|
"pose",
|
|
46
52
|
"render",
|
|
@@ -113,11 +119,12 @@ def pose(
|
|
|
113
119
|
style: Style = "dwpose",
|
|
114
120
|
depth: bool = False,
|
|
115
121
|
framing: Framing | None = None,
|
|
122
|
+
edits: Sequence[Edit] = (),
|
|
116
123
|
) -> PoseResult:
|
|
117
124
|
"""Detect, choose, turn, frame and draw in one call.
|
|
118
125
|
|
|
119
|
-
A `camera` other than the front view needs depth, so it
|
|
120
|
-
whatever `depth` says.
|
|
126
|
+
A `camera` other than the front view, or any `edits`, needs depth, so it
|
|
127
|
+
is detected then whatever `depth` says.
|
|
121
128
|
|
|
122
129
|
Args:
|
|
123
130
|
detector: A [Detector][poseorbit.Detector]; load it once and reuse it.
|
|
@@ -133,6 +140,9 @@ def pose(
|
|
|
133
140
|
depth: Detect depth even for the front view (to read
|
|
134
141
|
`Person.depth` from the result).
|
|
135
142
|
framing: Zoom and centre on the canvas; None for the whole picture.
|
|
143
|
+
edits: Joints to move before turning, in order; see
|
|
144
|
+
[apply_edits][poseorbit.apply_edits]. `PoseResult.people` stays as
|
|
145
|
+
detected.
|
|
136
146
|
|
|
137
147
|
Returns:
|
|
138
148
|
The skeleton, everyone found and how it was drawn.
|
|
@@ -140,13 +150,16 @@ def pose(
|
|
|
140
150
|
Raises:
|
|
141
151
|
NoPersonError: Nobody in the picture is seen well enough.
|
|
142
152
|
ValueError: `person` (or one of a sequence) is past the end of the
|
|
143
|
-
people found, or the sequence is empty
|
|
153
|
+
people found, or the sequence is empty, or an edit names a
|
|
154
|
+
person past the end or a joint that is not movable.
|
|
144
155
|
"""
|
|
145
156
|
camera = (camera or Camera()).clamped()
|
|
146
157
|
framing = (framing or Framing()).clamped()
|
|
147
|
-
people = detector.detect(image, depth=depth or not camera.is_front)
|
|
158
|
+
people = detector.detect(image, depth=depth or not camera.is_front or bool(edits))
|
|
159
|
+
bent = apply_edits([p.points_3d for p in people], edits)
|
|
148
160
|
person, drawn = chosen(people, person)
|
|
149
|
-
|
|
161
|
+
indices = range(len(people)) if person == ALL_PEOPLE else [person] if isinstance(person, int) else person
|
|
162
|
+
points = np.stack([bent[index] for index in indices])
|
|
150
163
|
if camera.is_front:
|
|
151
164
|
flat = points[..., :2]
|
|
152
165
|
else:
|
|
@@ -8,17 +8,20 @@ Request:
|
|
|
8
8
|
"want_3d": true, # optional; answer with each person's 3D points
|
|
9
9
|
"camera": { "yaw": 30, "pitch": 10, # optional; degrees, see geometry.py
|
|
10
10
|
"framing": { "zoom": 3, "x": 0.5, "y": 0.2 } }, # optional; the canvas point to centre, and zoom
|
|
11
|
-
"style": "openpose"
|
|
11
|
+
"style": "openpose", # optional; "dwpose" (default) or "openpose"
|
|
12
|
+
"edits": [ { "person": 0, "joint": 9, # optional; joints to move, in order (see body.py)
|
|
13
|
+
"to": [x, y, z] } ] } # where it points, fractions of the picture's width
|
|
12
14
|
|
|
13
15
|
Answer:
|
|
14
16
|
{ "skeleton_base64": "...png...", "width": 832, "height": 1216, "person": 0, # or the sorted list asked for
|
|
15
17
|
"person_subsets": true, # "person" may be a list: a client may offer picking any of them
|
|
18
|
+
"movable": [0, 5, ...], # the joints "edits" may move
|
|
16
19
|
"camera": { "yaw": 30, "pitch": 10, "framing": { "zoom": 3, "x": 0.5, "y": 0.2 } },
|
|
17
20
|
"style": "openpose",
|
|
18
21
|
"limits": { "yaw": 90, "pitch": 45, "zoom": [0.5, 6] },
|
|
19
22
|
"joints_in_frame": 9, # body joints (of 17) seen inside the canvas, best drawn person
|
|
20
23
|
"people": [ { "bbox": [x0, y0, x1, y1], # fractions of the picture
|
|
21
|
-
"points_3d": [[x, y, z], ...], # with want_3d: 133 points, fractions of the picture's width
|
|
24
|
+
"points_3d": [[x, y, z], ...], # with want_3d: 133 points as detected (before edits), fractions of the picture's width
|
|
22
25
|
"scores": [...] } ] } # with want_3d: 133 scores
|
|
23
26
|
"""
|
|
24
27
|
|
|
@@ -31,13 +34,20 @@ from typing import Any
|
|
|
31
34
|
|
|
32
35
|
from PIL import Image
|
|
33
36
|
|
|
34
|
-
from . import ALL_PEOPLE, MAX_PITCH, MAX_YAW, MAX_ZOOM, MIN_ZOOM, STYLES, Camera, Detector, Framing, pose
|
|
37
|
+
from . import ALL_PEOPLE, MAX_PITCH, MAX_YAW, MAX_ZOOM, MIN_ZOOM, MOVABLE, STYLES, Camera, Detector, Edit, Framing, pose
|
|
35
38
|
from .draw import Style
|
|
36
39
|
|
|
37
40
|
# The most people one request may choose by a list: far more than a picture
|
|
38
41
|
# usefully holds, and it bounds the list before anything is detected.
|
|
39
42
|
MAX_CHOSEN = 64
|
|
40
43
|
|
|
44
|
+
# The most edits one request may carry: hundreds of drags, and it bounds the
|
|
45
|
+
# work before anything is detected.
|
|
46
|
+
MAX_EDITS = 256
|
|
47
|
+
|
|
48
|
+
# How far an edit may point, in picture widths: well past any figure.
|
|
49
|
+
_MAX_REACH = 16
|
|
50
|
+
|
|
41
51
|
|
|
42
52
|
class BadRequest(ValueError):
|
|
43
53
|
"""The request is wrong; answer 400 with the message."""
|
|
@@ -98,6 +108,40 @@ def parse_camera(value: Any) -> tuple[Camera | None, Framing | None]:
|
|
|
98
108
|
).clamped()
|
|
99
109
|
|
|
100
110
|
|
|
111
|
+
def parse_edits(value: Any, width: int) -> list[Edit]:
|
|
112
|
+
"""The request's `edits` as [Edit][poseorbit.Edit]s in pixels (empty when absent).
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
value: The `edits` list: `{ person, joint, to: [x, y, z] }` each, `to`
|
|
116
|
+
in fractions of the picture's width.
|
|
117
|
+
width: The picture's width in pixels.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
BadRequest: It is not such a list, too long, names a joint that is
|
|
121
|
+
not movable, or points too far.
|
|
122
|
+
"""
|
|
123
|
+
if value is None:
|
|
124
|
+
return []
|
|
125
|
+
if not isinstance(value, list) or len(value) > MAX_EDITS:
|
|
126
|
+
raise BadRequest(f"edits must be a list of at most {MAX_EDITS}")
|
|
127
|
+
edits = []
|
|
128
|
+
for edit in value:
|
|
129
|
+
if not isinstance(edit, dict):
|
|
130
|
+
raise BadRequest("each edit must be an object { person, joint, to }")
|
|
131
|
+
person, joint, to = edit.get("person"), edit.get("joint"), edit.get("to")
|
|
132
|
+
if isinstance(person, bool) or not isinstance(person, int) or person < 0:
|
|
133
|
+
raise BadRequest("edit.person must be an index")
|
|
134
|
+
if isinstance(joint, bool) or not isinstance(joint, int) or joint not in MOVABLE:
|
|
135
|
+
raise BadRequest(f"edit.joint must be one of {sorted(MOVABLE)}")
|
|
136
|
+
if not isinstance(to, list) or len(to) != 3:
|
|
137
|
+
raise BadRequest("edit.to must be [x, y, z]")
|
|
138
|
+
point = [_number(v, "edit.to") for v in to]
|
|
139
|
+
if any(abs(v) > _MAX_REACH for v in point):
|
|
140
|
+
raise BadRequest(f"edit.to must be within {_MAX_REACH} picture widths")
|
|
141
|
+
edits.append(Edit(person, joint, (point[0] * width, point[1] * width, point[2] * width)))
|
|
142
|
+
return edits
|
|
143
|
+
|
|
144
|
+
|
|
101
145
|
def handle(detector: Detector, body: dict[str, Any], default_style: Style = "dwpose") -> dict[str, Any]:
|
|
102
146
|
"""Answers one `/api/pose` request (the JSON above), without a web framework.
|
|
103
147
|
|
|
@@ -113,12 +157,13 @@ def handle(detector: Detector, body: dict[str, Any], default_style: Style = "dwp
|
|
|
113
157
|
Raises:
|
|
114
158
|
BadRequest: The request is wrong; answer 400 with its message.
|
|
115
159
|
NoPersonError: Nobody in the picture; also a 400.
|
|
116
|
-
ValueError: A person index past the end; also a 400.
|
|
160
|
+
ValueError: A person index past the end, in `person` or an edit; also a 400.
|
|
117
161
|
"""
|
|
118
162
|
data = body.get("image_base64")
|
|
119
163
|
if not isinstance(data, str) or not data:
|
|
120
164
|
raise BadRequest("image_base64 is required")
|
|
121
165
|
image = decode_image(data)
|
|
166
|
+
edits = parse_edits(body.get("edits"), image.width)
|
|
122
167
|
width = int(_number(body.get("width", image.width), "width"))
|
|
123
168
|
height = int(_number(body.get("height", image.height), "height"))
|
|
124
169
|
if not (64 <= width <= 4096 and 64 <= height <= 4096):
|
|
@@ -136,7 +181,7 @@ def handle(detector: Detector, body: dict[str, Any], default_style: Style = "dwp
|
|
|
136
181
|
want_3d = body.get("want_3d") is True
|
|
137
182
|
|
|
138
183
|
camera, framing = parse_camera(body.get("camera"))
|
|
139
|
-
result = pose(detector, image, (width, height), person, camera, style, depth=want_3d, framing=framing)
|
|
184
|
+
result = pose(detector, image, (width, height), person, camera, style, depth=want_3d, framing=framing, edits=edits)
|
|
140
185
|
people = []
|
|
141
186
|
for found in result.people:
|
|
142
187
|
x0, y0, x1, y1 = found.bbox
|
|
@@ -156,6 +201,7 @@ def handle(detector: Detector, body: dict[str, Any], default_style: Style = "dwp
|
|
|
156
201
|
"height": height,
|
|
157
202
|
"person": result.person,
|
|
158
203
|
"person_subsets": True,
|
|
204
|
+
"movable": sorted(MOVABLE),
|
|
159
205
|
"camera": {
|
|
160
206
|
"yaw": result.camera.yaw,
|
|
161
207
|
"pitch": result.camera.pitch,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Moving a joint: the 133 COCO-WholeBody points as a tree, and bending it.
|
|
2
|
+
|
|
3
|
+
A joint turns about its parent, and everything hanging from it turns with
|
|
4
|
+
it: bending an elbow swings the forearm and the whole hand, and no bone
|
|
5
|
+
changes length. The hips are the root and stay where they are; turning the
|
|
6
|
+
whole figure is the camera's job.
|
|
7
|
+
|
|
8
|
+
from poseorbit import Edit, apply_edits
|
|
9
|
+
|
|
10
|
+
left_wrist = 9
|
|
11
|
+
moved = apply_edits([person.points_3d], [Edit(0, left_wrist, (420, 180, -40))])
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
_BODY = (
|
|
22
|
+
"nose", "left_eye", "right_eye", "left_ear", "right_ear",
|
|
23
|
+
"left_shoulder", "right_shoulder", "left_elbow", "right_elbow",
|
|
24
|
+
"left_wrist", "right_wrist", "left_hip", "right_hip",
|
|
25
|
+
"left_knee", "right_knee", "left_ankle", "right_ankle",
|
|
26
|
+
)
|
|
27
|
+
_FEET = ("left_big_toe", "left_small_toe", "left_heel", "right_big_toe", "right_small_toe", "right_heel")
|
|
28
|
+
|
|
29
|
+
NAMES: tuple[str, ...] = (
|
|
30
|
+
_BODY
|
|
31
|
+
+ _FEET
|
|
32
|
+
+ tuple(f"face_{i}" for i in range(68))
|
|
33
|
+
+ tuple(f"left_hand_{i}" for i in range(21))
|
|
34
|
+
+ tuple(f"right_hand_{i}" for i in range(21))
|
|
35
|
+
)
|
|
36
|
+
"""The 133 keypoints' names, in COCO-WholeBody order."""
|
|
37
|
+
|
|
38
|
+
NECK = -1
|
|
39
|
+
"""`PARENT` of the nose: the middle of the shoulders, found when bending."""
|
|
40
|
+
|
|
41
|
+
# Each point's parent: hips are roots, shoulders hang from the hips, the
|
|
42
|
+
# head from the neck, hands from the wrists and the face from the nose.
|
|
43
|
+
PARENT: tuple[int | None, ...] = (
|
|
44
|
+
(NECK, 0, 0, 1, 2, 11, 12, 5, 6, 7, 8, None, None, 11, 12, 13, 14)
|
|
45
|
+
+ (15, 15, 15, 16, 16, 16)
|
|
46
|
+
+ (0,) * 68
|
|
47
|
+
+ (9,) * 21
|
|
48
|
+
+ (10,) * 21
|
|
49
|
+
)
|
|
50
|
+
"""Each keypoint's parent index; None for the hips, `NECK` for the nose."""
|
|
51
|
+
|
|
52
|
+
MOVABLE: frozenset[int] = frozenset({0, *range(5, 11), *range(13, 23)})
|
|
53
|
+
"""The joints [bend][poseorbit.bend] moves: the head, arms, legs and feet."""
|
|
54
|
+
|
|
55
|
+
_CHILDREN: dict[int, list[int]] = {}
|
|
56
|
+
for _point, _parent in enumerate(PARENT):
|
|
57
|
+
if _parent is not None and _parent != NECK:
|
|
58
|
+
_CHILDREN.setdefault(_parent, []).append(_point)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def subtree(joint: int) -> tuple[int, ...]:
|
|
62
|
+
"""`joint` and every point hanging from it, in index order."""
|
|
63
|
+
found = [joint]
|
|
64
|
+
for point in found:
|
|
65
|
+
found.extend(_CHILDREN.get(point, ()))
|
|
66
|
+
return tuple(sorted(found))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pivot(points: np.ndarray, joint: int) -> np.ndarray:
|
|
70
|
+
"""The point `joint` turns about: its parent, or the neck for the nose.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: `joint` is a hip, which has no parent.
|
|
74
|
+
"""
|
|
75
|
+
parent = PARENT[joint]
|
|
76
|
+
if parent is None:
|
|
77
|
+
raise ValueError(f"{NAMES[joint]} is a root and does not turn.")
|
|
78
|
+
if parent == NECK:
|
|
79
|
+
return (points[5] + points[6]) / 2
|
|
80
|
+
return points[parent]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _rotation(u: np.ndarray, v: np.ndarray) -> np.ndarray:
|
|
84
|
+
"""The smallest rotation taking direction `u` onto `v` (both unit length)."""
|
|
85
|
+
axis = np.cross(u, v)
|
|
86
|
+
sine = float(np.linalg.norm(axis))
|
|
87
|
+
cosine = float(np.dot(u, v))
|
|
88
|
+
if sine < 1e-12:
|
|
89
|
+
if cosine > 0:
|
|
90
|
+
return np.eye(3)
|
|
91
|
+
# Straight back: half a turn about any axis square to u.
|
|
92
|
+
axis = np.cross(u, (1.0, 0.0, 0.0))
|
|
93
|
+
if np.linalg.norm(axis) < 1e-6:
|
|
94
|
+
axis = np.cross(u, (0.0, 1.0, 0.0))
|
|
95
|
+
axis = axis / np.linalg.norm(axis)
|
|
96
|
+
return 2 * np.outer(axis, axis) - np.eye(3)
|
|
97
|
+
axis = axis / sine
|
|
98
|
+
cross = np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
|
|
99
|
+
return np.eye(3) + sine * cross + (1 - cosine) * (cross @ cross)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def bend(points: np.ndarray, joint: int, target: Sequence[float] | np.ndarray) -> np.ndarray:
|
|
103
|
+
"""Turn `joint` about its parent to point at `target`, carrying its subtree.
|
|
104
|
+
|
|
105
|
+
The joint keeps its distance from the parent, so it ends on the line from
|
|
106
|
+
the parent through `target`, not on `target` itself.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
points: (133, 3) x, y and depth, as [Person.points_3d][poseorbit.Person].
|
|
110
|
+
joint: One of `MOVABLE`.
|
|
111
|
+
target: Where the joint should point, in the same units as `points`.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
A new (133, 3) array; `points` is left as it was.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: `joint` is not movable.
|
|
118
|
+
"""
|
|
119
|
+
if joint not in MOVABLE:
|
|
120
|
+
raise ValueError(f"Joint {joint} cannot be moved.")
|
|
121
|
+
points = np.array(points, dtype=float)
|
|
122
|
+
centre = pivot(points, joint)
|
|
123
|
+
u = points[joint] - centre
|
|
124
|
+
v = np.asarray(target, dtype=float) - centre
|
|
125
|
+
length, reach = float(np.linalg.norm(u)), float(np.linalg.norm(v))
|
|
126
|
+
if length < 1e-9 or reach < 1e-9:
|
|
127
|
+
return points
|
|
128
|
+
rotation = _rotation(u / length, v / reach)
|
|
129
|
+
moved = list(subtree(joint))
|
|
130
|
+
points[moved] = (points[moved] - centre) @ rotation.T + centre
|
|
131
|
+
return points
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Edit:
|
|
136
|
+
"""One joint moved on one person.
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
person: An index into the people detected, left to right.
|
|
140
|
+
joint: One of `MOVABLE`.
|
|
141
|
+
to: Where the joint should point: x, y and depth in the picture's pixels.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
person: int
|
|
145
|
+
joint: int
|
|
146
|
+
to: tuple[float, float, float]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def apply_edits(points: Sequence[np.ndarray], edits: Sequence[Edit]) -> list[np.ndarray]:
|
|
150
|
+
"""Everyone's (133, 3) points with `edits` bent in, one after another.
|
|
151
|
+
|
|
152
|
+
Order matters: each edit bends the pose the ones before it left.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
points: Each person's (133, 3) points, left to right.
|
|
156
|
+
edits: The joints to move.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
New arrays, one per person; `points` is left as it was.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
ValueError: An edit names a person past the end or a joint that is
|
|
163
|
+
not movable.
|
|
164
|
+
"""
|
|
165
|
+
bent = [np.array(p, dtype=float) for p in points]
|
|
166
|
+
for edit in edits:
|
|
167
|
+
if not 0 <= edit.person < len(bent):
|
|
168
|
+
raise ValueError(f"No person {edit.person}: {len(bent)} found.")
|
|
169
|
+
bent[edit.person] = bend(bent[edit.person], edit.joint, edit.to)
|
|
170
|
+
return bent
|
|
@@ -7,7 +7,7 @@ import numpy as np
|
|
|
7
7
|
import pytest
|
|
8
8
|
from PIL import Image
|
|
9
9
|
|
|
10
|
-
from poseorbit import MAX_YAW, MAX_ZOOM, NoPersonError, Person
|
|
10
|
+
from poseorbit import MAX_YAW, MAX_ZOOM, MOVABLE, NoPersonError, Person
|
|
11
11
|
from poseorbit.api import BadRequest, handle
|
|
12
12
|
|
|
13
13
|
|
|
@@ -134,3 +134,41 @@ def test_some_people_are_drawn_together():
|
|
|
134
134
|
# Turned, the chosen ones still turn together.
|
|
135
135
|
turned = handle(StubDetector(people), {**body, "person": [0, 2], "camera": {"yaw": 30}})
|
|
136
136
|
assert turned["person"] == [0, 2] and turned["camera"]["yaw"] == 30
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_an_edit_moves_the_drawn_skeleton_but_not_the_points():
|
|
140
|
+
body = {"image_base64": _picture(), "width": 400, "height": 600, "want_3d": True}
|
|
141
|
+
plain = handle(StubDetector([_person(200)]), body)
|
|
142
|
+
detector = StubDetector([_person(200)])
|
|
143
|
+
# The left wrist straight out to the side, front view: depth is still asked for.
|
|
144
|
+
edited = handle(detector, {**body, "want_3d": False, "edits": [{"person": 0, "joint": 9, "to": [0.9, 0.5, 0]}]})
|
|
145
|
+
assert detector.asked_depth is True
|
|
146
|
+
assert edited["skeleton_base64"] != plain["skeleton_base64"]
|
|
147
|
+
assert edited["movable"] == sorted(MOVABLE)
|
|
148
|
+
again = handle(StubDetector([_person(200)]), {**body, "edits": [{"person": 0, "joint": 9, "to": [0.9, 0.5, 0]}]})
|
|
149
|
+
assert again["people"][0]["points_3d"] == plain["people"][0]["points_3d"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.parametrize(
|
|
153
|
+
"edits",
|
|
154
|
+
[
|
|
155
|
+
5,
|
|
156
|
+
[5],
|
|
157
|
+
[{"person": True, "joint": 9, "to": [0, 0, 0]}],
|
|
158
|
+
[{"person": -1, "joint": 9, "to": [0, 0, 0]}],
|
|
159
|
+
[{"person": 0, "joint": 11, "to": [0, 0, 0]}],
|
|
160
|
+
[{"person": 0, "joint": 133, "to": [0, 0, 0]}],
|
|
161
|
+
[{"person": 0, "joint": 9, "to": [0, 0]}],
|
|
162
|
+
[{"person": 0, "joint": 9, "to": [0, 0, float("nan")]}],
|
|
163
|
+
[{"person": 0, "joint": 9, "to": [0, 0, 17]}],
|
|
164
|
+
[{"person": 0, "joint": 9, "to": [0, 0, 0]}] * 257,
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
def test_bad_edits_are_refused(edits):
|
|
168
|
+
with pytest.raises(BadRequest):
|
|
169
|
+
handle(StubDetector([_person(200)]), {"image_base64": _picture(), "edits": edits})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_an_edit_for_a_person_past_the_end_is_refused():
|
|
173
|
+
with pytest.raises(ValueError):
|
|
174
|
+
handle(StubDetector([_person(200)]), {"image_base64": _picture(), "edits": [{"person": 1, "joint": 9, "to": [0, 0, 0]}]})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Bending joints: bones keep their length and what hangs from a joint goes with it."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from poseorbit import MOVABLE, NAMES, Edit, apply_edits, bend
|
|
7
|
+
from poseorbit.body import PARENT, pivot, subtree
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _figure() -> np.ndarray:
|
|
11
|
+
"""A figure with every point somewhere different and some depth."""
|
|
12
|
+
rng = np.random.default_rng(7)
|
|
13
|
+
points = rng.uniform(-50, 50, (133, 3))
|
|
14
|
+
points[:17] = [
|
|
15
|
+
[0, -160, -5], [-8, -168, -3], [8, -168, -3], [-15, -164, 5], [15, -164, 5],
|
|
16
|
+
[-40, -120, 0], [40, -120, 0], [-70, -70, -10], [70, -70, 10],
|
|
17
|
+
[-90, -20, -20], [90, -20, 20], [-25, 0, 0], [25, 0, 0],
|
|
18
|
+
[-28, 80, 5], [28, 80, -5], [-30, 160, 0], [30, 160, 0],
|
|
19
|
+
]
|
|
20
|
+
points[91:112] += points[9]
|
|
21
|
+
points[112:133] += points[10]
|
|
22
|
+
points[23:91] = points[0] + points[23:91] / 4
|
|
23
|
+
return points
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bones(points: np.ndarray) -> np.ndarray:
|
|
27
|
+
return np.array([
|
|
28
|
+
np.linalg.norm(points[i] - pivot(points, i)) for i, parent in enumerate(PARENT) if parent is not None
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _distances(points: np.ndarray) -> np.ndarray:
|
|
33
|
+
return np.linalg.norm(points[:, None] - points[None], axis=-1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_the_tree_names_every_point():
|
|
37
|
+
assert len(NAMES) == len(PARENT) == 133
|
|
38
|
+
assert NAMES[9] == "left_wrist" and NAMES[132] == "right_hand_20"
|
|
39
|
+
assert 11 not in MOVABLE and 12 not in MOVABLE
|
|
40
|
+
assert subtree(7) == (7, 9, *range(91, 112))
|
|
41
|
+
assert set(subtree(0)) == {0, 1, 2, 3, 4, *range(23, 91)}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_bones_keep_their_length_and_the_joint_points_at_the_target():
|
|
45
|
+
points = _figure()
|
|
46
|
+
target = np.array([-150.0, -150.0, 40.0])
|
|
47
|
+
bent = bend(points, 7, target)
|
|
48
|
+
np.testing.assert_allclose(_bones(bent), _bones(points), atol=1e-9)
|
|
49
|
+
direction = (bent[7] - bent[5]) / np.linalg.norm(bent[7] - bent[5])
|
|
50
|
+
np.testing.assert_allclose(direction, (target - points[5]) / np.linalg.norm(target - points[5]), atol=1e-12)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_the_hand_goes_with_the_wrist_and_the_face_with_the_head():
|
|
54
|
+
points = _figure()
|
|
55
|
+
hand = list(subtree(9))
|
|
56
|
+
bent = bend(points, 9, [-60, -150, 0])
|
|
57
|
+
np.testing.assert_allclose(_distances(bent[hand]), _distances(points[hand]), atol=1e-9)
|
|
58
|
+
head = list(subtree(0))
|
|
59
|
+
bent = bend(points, 0, [30, -150, -60])
|
|
60
|
+
np.testing.assert_allclose(_distances(bent[head]), _distances(points[head]), atol=1e-9)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_only_the_subtree_moves():
|
|
64
|
+
points = _figure()
|
|
65
|
+
bent = bend(points, 13, [-80, 60, 30])
|
|
66
|
+
still = [i for i in range(133) if i not in subtree(13)]
|
|
67
|
+
np.testing.assert_array_equal(bent[still], points[still])
|
|
68
|
+
np.testing.assert_array_equal(points, _figure()) # the input is left alone
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_degenerate_targets_change_nothing_and_straight_back_turns_half_way():
|
|
72
|
+
points = _figure()
|
|
73
|
+
np.testing.assert_array_equal(bend(points, 7, points[5]), points)
|
|
74
|
+
flat = points.copy()
|
|
75
|
+
flat[7] = flat[5]
|
|
76
|
+
np.testing.assert_array_equal(bend(flat, 7, [0, 0, 0]), flat)
|
|
77
|
+
back = bend(points, 7, 2 * points[5] - points[7])
|
|
78
|
+
np.testing.assert_allclose(back[7], 2 * points[5] - points[7], atol=1e-9)
|
|
79
|
+
np.testing.assert_allclose(_bones(back), _bones(points), atol=1e-9)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_edits_apply_in_order_and_the_same_edits_give_the_same_pose():
|
|
83
|
+
points = [_figure(), _figure() + [200, 0, 0]]
|
|
84
|
+
edits = [Edit(0, 7, (-150, -100, 0)), Edit(0, 5, (-60, -110, 30)), Edit(1, 16, (260, 150, 40))]
|
|
85
|
+
once = apply_edits(points, edits)
|
|
86
|
+
np.testing.assert_array_equal(once[0], apply_edits(points, edits)[0])
|
|
87
|
+
swapped = apply_edits(points, [edits[1], edits[0], edits[2]])
|
|
88
|
+
assert not np.allclose(once[0], swapped[0])
|
|
89
|
+
assert not np.allclose(once[1], points[1])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize("edit", [Edit(0, 11, (0, 0, 0)), Edit(0, 133, (0, 0, 0)), Edit(1, 9, (0, 0, 0))])
|
|
93
|
+
def test_hips_unknown_joints_and_people_past_the_end_are_refused(edit):
|
|
94
|
+
with pytest.raises(ValueError):
|
|
95
|
+
apply_edits([_figure()], [edit])
|
|
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
|