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.
@@ -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.2.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). `want_3d` adds each person's 133 points as `[x, y, z]` in units of
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). `want_3d` adds each person's 133 points as `[x, y, z]` in units of
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.2.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 is detected then
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
- points = np.stack([p.points_3d for p in drawn])
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" } # optional; "dwpose" (default) or "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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "poseorbit"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Find the people in a picture, turn their pose in 3D, and draw the skeleton a pose-conditioned image model follows."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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