poseorbit 0.1.2__tar.gz → 0.2.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-10-11)
4
+
5
+ - **Choose some of the people**: `pose(..., person=[0, 2])` draws just those
6
+ (turned together around their shared centre), and `/api/pose` takes
7
+ `"person": [0, 2]` (up to 64 indices). The list comes back sorted and
8
+ without repeats; an empty list or an index past the end is refused.
9
+ `python -m poseorbit draw --person 0,2` does the same.
10
+ - `/api/pose` answers `"person_subsets": true`, so a client knows it may
11
+ send a list.
12
+ - **Changed:** `PoseResult.person` (and the answer's `person`) is a list when
13
+ a list was asked for; code that only passes an int or `ALL_PEOPLE` still
14
+ gets an int.
15
+
3
16
  ## 0.1.2 (2026-10-11)
4
17
 
5
18
  - **Pinned model downloads**: the three ONNX files come from a fixed commit
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: poseorbit
3
- Version: 0.1.2
3
+ Version: 0.2.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/
@@ -122,9 +122,9 @@ Turning a pose in 3D for ControlNet is not new (see
122
122
  person boxes. Hands and face turn with the body instead of staying flat or
123
123
  being dropped, and the front view is pixel-identical to plain 2D detection.
124
124
  - **Choose a person in a group.** Everyone found comes back left to right
125
- with a box, so index 1 is the same person on every call. Draw one of them
126
- or all of them, turned together around a shared centre so the group keeps
127
- its layout.
125
+ with a box, so index 1 is the same person on every call. Draw one of them,
126
+ any few of them (`person=[0, 2]`) or all of them, turned together around a
127
+ shared centre so the group keeps its layout.
128
128
  - **Drawn for the model that will read it.** `dwpose` is rtmlib's
129
129
  COCO-WholeBody drawing (thin lines, hands and face), the usual input of
130
130
  DWPose-trained adapters. `openpose` is the thick, body-only drawing
@@ -251,7 +251,7 @@ curl -s localhost:7870/api/pose -H 'Content-Type: application/json' -d '{
251
251
  ```
252
252
 
253
253
  ```json
254
- { "skeleton_base64": "…png…", "width": 832, "height": 1216, "person": 0,
254
+ { "skeleton_base64": "…png…", "width": 832, "height": 1216, "person": 0, "person_subsets": true,
255
255
  "camera": { "yaw": 30, "pitch": 10, "framing": { "zoom": 2, "x": 0.5, "y": 0.35 } },
256
256
  "style": "openpose", "limits": { "yaw": 90, "pitch": 45, "zoom": [0.5, 6] },
257
257
  "joints_in_frame": 13,
@@ -260,8 +260,10 @@ curl -s localhost:7870/api/pose -H 'Content-Type: application/json' -d '{
260
260
  ```
261
261
 
262
262
  Everything but `image_base64` is optional. `person` is an index into
263
- `people` (left to right), `-1` for everyone, or absent for the most
264
- confident. `want_3d` adds each person's 133 points as `[x, y, z]` in units of
263
+ `people` (left to right), a list of indices for some of them (`[0, 2]`, up to
264
+ 64; answered sorted), `-1` for everyone, or absent for the most confident.
265
+ `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
265
267
  the picture's width, for a 3D viewer. Out-of-range angles and zoom are
266
268
  clamped; a picture with nobody in it, or a person index past the end, gets a
267
269
  400 with a `detail` message. Server and API are 127.0.0.1-only by default.
@@ -85,9 +85,9 @@ Turning a pose in 3D for ControlNet is not new (see
85
85
  person boxes. Hands and face turn with the body instead of staying flat or
86
86
  being dropped, and the front view is pixel-identical to plain 2D detection.
87
87
  - **Choose a person in a group.** Everyone found comes back left to right
88
- with a box, so index 1 is the same person on every call. Draw one of them
89
- or all of them, turned together around a shared centre so the group keeps
90
- its layout.
88
+ with a box, so index 1 is the same person on every call. Draw one of them,
89
+ any few of them (`person=[0, 2]`) or all of them, turned together around a
90
+ shared centre so the group keeps its layout.
91
91
  - **Drawn for the model that will read it.** `dwpose` is rtmlib's
92
92
  COCO-WholeBody drawing (thin lines, hands and face), the usual input of
93
93
  DWPose-trained adapters. `openpose` is the thick, body-only drawing
@@ -214,7 +214,7 @@ curl -s localhost:7870/api/pose -H 'Content-Type: application/json' -d '{
214
214
  ```
215
215
 
216
216
  ```json
217
- { "skeleton_base64": "…png…", "width": 832, "height": 1216, "person": 0,
217
+ { "skeleton_base64": "…png…", "width": 832, "height": 1216, "person": 0, "person_subsets": true,
218
218
  "camera": { "yaw": 30, "pitch": 10, "framing": { "zoom": 2, "x": 0.5, "y": 0.35 } },
219
219
  "style": "openpose", "limits": { "yaw": 90, "pitch": 45, "zoom": [0.5, 6] },
220
220
  "joints_in_frame": 13,
@@ -223,8 +223,10 @@ curl -s localhost:7870/api/pose -H 'Content-Type: application/json' -d '{
223
223
  ```
224
224
 
225
225
  Everything but `image_base64` is optional. `person` is an index into
226
- `people` (left to right), `-1` for everyone, or absent for the most
227
- confident. `want_3d` adds each person's 133 points as `[x, y, z]` in units of
226
+ `people` (left to right), a list of indices for some of them (`[0, 2]`, up to
227
+ 64; answered sorted), `-1` for everyone, or absent for the most confident.
228
+ `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
228
230
  the picture's width, for a 3D viewer. Out-of-range angles and zoom are
229
231
  clamped; a picture with nobody in it, or a person index past the end, gets a
230
232
  400 with a `detail` message. Server and API are 127.0.0.1-only by default.
@@ -11,6 +11,7 @@ pose in 3D, and draw the skeleton a pose-conditioned image model follows.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ from collections.abc import Sequence
14
15
  from dataclasses import dataclass
15
16
 
16
17
  import numpy as np
@@ -20,7 +21,7 @@ from .detect import KEYPOINT_THRESHOLD, Detector, NoPersonError, Person, most_co
20
21
  from .draw import STYLES, Style, letterbox, render
21
22
  from .geometry import MAX_PITCH, MAX_YAW, MAX_ZOOM, MIN_ZOOM, Camera, Framing, fit, frame, scene_centre, view
22
23
 
23
- __version__ = "0.1.2"
24
+ __version__ = "0.2.0"
24
25
 
25
26
  # Person index meaning "everyone detected", for a group pose.
26
27
  ALL_PEOPLE = -1
@@ -53,7 +54,8 @@ class PoseResult:
53
54
  Attributes:
54
55
  skeleton: The skeleton on black, at the requested size.
55
56
  people: Everyone detected, left to right.
56
- person: Who was drawn: an index into `people`, or `ALL_PEOPLE`.
57
+ person: Who was drawn: an index into `people`, `ALL_PEOPLE`, or the
58
+ sorted indices when several were chosen by a list.
57
59
  camera: The camera actually used, after clamping.
58
60
  framing: The framing actually used, after clamping.
59
61
  joints_in_frame: Body joints (of 17) seen and inside the canvas, for
@@ -62,9 +64,10 @@ class PoseResult:
62
64
  """
63
65
 
64
66
  skeleton: Image.Image
65
- # Everyone detected, left to right, and which of them was drawn (ALL_PEOPLE for all).
67
+ # Everyone detected, left to right, and which of them was drawn: an index,
68
+ # ALL_PEOPLE for all, or the sorted indices of the ones chosen.
66
69
  people: list[Person]
67
- person: int
70
+ person: int | list[int]
68
71
  # The camera and framing actually used, after clamping.
69
72
  camera: Camera
70
73
  framing: Framing
@@ -74,26 +77,38 @@ class PoseResult:
74
77
  joints_in_frame: int
75
78
 
76
79
 
77
- def chosen(people: list[Person], person: int | None) -> tuple[int, list[Person]]:
80
+ def chosen(people: list[Person], person: int | Sequence[int] | None) -> tuple[int | list[int], list[Person]]:
78
81
  """`person` resolved (None = the most confident) and the people it means.
79
82
 
83
+ A sequence chooses any of them: the indices come back sorted, without
84
+ repeats, and the people in that (left-to-right) order.
85
+
80
86
  Raises:
81
- ValueError: `person` is past the end of `people`.
87
+ ValueError: `person` (or one of a sequence) is past the end of
88
+ `people`, or the sequence is empty.
82
89
  """
83
90
  if person is None:
84
91
  person = most_confident(people)
85
- if person == ALL_PEOPLE:
86
- return person, people
87
- if 0 <= person < len(people):
88
- return person, [people[person]]
89
- raise ValueError(f"No person {person}: {len(people)} found.")
92
+ if isinstance(person, int):
93
+ if person == ALL_PEOPLE:
94
+ return person, people
95
+ if 0 <= person < len(people):
96
+ return person, [people[person]]
97
+ raise ValueError(f"No person {person}: {len(people)} found.")
98
+ indices = sorted(set(person))
99
+ if not indices:
100
+ raise ValueError("Choose at least one person.")
101
+ for index in indices:
102
+ if not 0 <= index < len(people):
103
+ raise ValueError(f"No person {index}: {len(people)} found.")
104
+ return indices, [people[index] for index in indices]
90
105
 
91
106
 
92
107
  def pose(
93
108
  detector: Detector,
94
109
  image: Image.Image,
95
110
  size: tuple[int, int],
96
- person: int | None = None,
111
+ person: int | Sequence[int] | None = None,
97
112
  camera: Camera | None = None,
98
113
  style: Style = "dwpose",
99
114
  depth: bool = False,
@@ -110,7 +125,9 @@ def pose(
110
125
  size: The output's (width, height). The skeleton is drawn at it, the
111
126
  picture letterboxed onto it.
112
127
  person: An index into the left-to-right order, `ALL_PEOPLE` (-1) for
113
- everyone, or None for the most confident.
128
+ everyone, a sequence of indices for some of them (drawn together
129
+ and turned around their shared centre), or None for the most
130
+ confident.
114
131
  camera: Where the camera stands; None for the front view.
115
132
  style: `"dwpose"` or `"openpose"`; see [render][poseorbit.render].
116
133
  depth: Detect depth even for the front view (to read
@@ -122,7 +139,8 @@ def pose(
122
139
 
123
140
  Raises:
124
141
  NoPersonError: Nobody in the picture is seen well enough.
125
- ValueError: `person` is past the end of the people found.
142
+ ValueError: `person` (or one of a sequence) is past the end of the
143
+ people found, or the sequence is empty.
126
144
  """
127
145
  camera = (camera or Camera()).clamped()
128
146
  framing = (framing or Framing()).clamped()
@@ -1,5 +1,5 @@
1
1
  """python -m poseorbit serve [--host 127.0.0.1] [--port 7870] [--weights-dir DIR]
2
- python -m poseorbit draw PICTURE OUT.png [--size 832x1216] [--person N] [--yaw D] [--pitch D]
2
+ python -m poseorbit draw PICTURE OUT.png [--size 832x1216] [--person N|N,M,...] [--yaw D] [--pitch D]
3
3
  [--zoom Z --centre X,Y] [--style S]"""
4
4
 
5
5
  from __future__ import annotations
@@ -8,6 +8,13 @@ import argparse
8
8
  from pathlib import Path
9
9
 
10
10
 
11
+ def _person(text: str) -> int | list[int]:
12
+ """`--person`: one index (-1 for everyone), or several as "0,2"."""
13
+ if "," in text:
14
+ return [int(part) for part in text.split(",") if part.strip()]
15
+ return int(text)
16
+
17
+
11
18
  def main() -> None:
12
19
  """The `poseorbit` command (`python -m poseorbit`)."""
13
20
  parser = argparse.ArgumentParser(prog="poseorbit")
@@ -22,7 +29,7 @@ def main() -> None:
22
29
  draw.add_argument("picture", type=Path)
23
30
  draw.add_argument("out", type=Path)
24
31
  draw.add_argument("--size", default=None, help="WIDTHxHEIGHT (default: the picture's)")
25
- draw.add_argument("--person", type=int, default=None, help="index, or -1 for everyone")
32
+ draw.add_argument("--person", type=_person, default=None, help="index, several as 0,2, or -1 for everyone")
26
33
  draw.add_argument("--yaw", type=float, default=0.0)
27
34
  draw.add_argument("--pitch", type=float, default=0.0)
28
35
  draw.add_argument("--zoom", type=float, default=1.0)
@@ -4,14 +4,15 @@ exactly the same JSON.
4
4
 
5
5
  Request:
6
6
  { "image_base64": "...", "width": 832, "height": 1216,
7
- "person": 0, # optional; -1 = everyone, absent = most confident
7
+ "person": 0, # optional; -1 = everyone, [0, 2] = those, absent = most confident
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
11
  "style": "openpose" } # optional; "dwpose" (default) or "openpose"
12
12
 
13
13
  Answer:
14
- { "skeleton_base64": "...png...", "width": 832, "height": 1216, "person": 0,
14
+ { "skeleton_base64": "...png...", "width": 832, "height": 1216, "person": 0, # or the sorted list asked for
15
+ "person_subsets": true, # "person" may be a list: a client may offer picking any of them
15
16
  "camera": { "yaw": 30, "pitch": 10, "framing": { "zoom": 3, "x": 0.5, "y": 0.2 } },
16
17
  "style": "openpose",
17
18
  "limits": { "yaw": 90, "pitch": 45, "zoom": [0.5, 6] },
@@ -33,6 +34,10 @@ from PIL import Image
33
34
  from . import ALL_PEOPLE, MAX_PITCH, MAX_YAW, MAX_ZOOM, MIN_ZOOM, STYLES, Camera, Detector, Framing, pose
34
35
  from .draw import Style
35
36
 
37
+ # The most people one request may choose by a list: far more than a picture
38
+ # usefully holds, and it bounds the list before anything is detected.
39
+ MAX_CHOSEN = 64
40
+
36
41
 
37
42
  class BadRequest(ValueError):
38
43
  """The request is wrong; answer 400 with the message."""
@@ -119,8 +124,12 @@ def handle(detector: Detector, body: dict[str, Any], default_style: Style = "dwp
119
124
  if not (64 <= width <= 4096 and 64 <= height <= 4096):
120
125
  raise BadRequest("width and height must be 64-4096")
121
126
  person = body.get("person")
122
- if person is not None and (isinstance(person, bool) or not isinstance(person, int) or person < ALL_PEOPLE):
123
- raise BadRequest("person must be an index, or -1 for everyone")
127
+ if isinstance(person, list):
128
+ # Bounded before anything is detected; past the people found is a ValueError later.
129
+ if not 1 <= len(person) <= MAX_CHOSEN or any(isinstance(index, bool) or not isinstance(index, int) or index < 0 for index in person):
130
+ raise BadRequest(f"person as a list must hold 1-{MAX_CHOSEN} indices")
131
+ elif person is not None and (isinstance(person, bool) or not isinstance(person, int) or person < ALL_PEOPLE):
132
+ raise BadRequest("person must be an index, a list of indices, or -1 for everyone")
124
133
  style = body.get("style") or default_style
125
134
  if style not in STYLES:
126
135
  raise BadRequest(f"style must be one of {', '.join(STYLES)}")
@@ -146,6 +155,7 @@ def handle(detector: Detector, body: dict[str, Any], default_style: Style = "dwp
146
155
  "width": width,
147
156
  "height": height,
148
157
  "person": result.person,
158
+ "person_subsets": True,
149
159
  "camera": {
150
160
  "yaw": result.camera.yaw,
151
161
  "pitch": result.camera.pitch,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "poseorbit"
3
- version = "0.1.2"
3
+ version = "0.2.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"
@@ -44,6 +44,7 @@ def test_an_answer_has_everything_a_client_needs():
44
44
  answer = handle(StubDetector([_person(100), _person(300)]), {"image_base64": _picture(), "width": 832, "height": 1216})
45
45
  assert answer["width"] == 832 and answer["height"] == 1216
46
46
  assert answer["person"] in (0, 1)
47
+ assert answer["person_subsets"] is True
47
48
  assert answer["camera"] == {"yaw": 0.0, "pitch": 0.0, "framing": {"zoom": 1.0, "x": 0.5, "y": 0.5}}
48
49
  assert answer["limits"] == {"yaw": 90.0, "pitch": 45.0, "zoom": [0.5, 6.0]}
49
50
  assert answer["style"] == "dwpose"
@@ -93,6 +94,11 @@ def test_zooming_in_leaves_fewer_joints_in_frame():
93
94
  {"image_base64": "PIC", "width": 10},
94
95
  {"image_base64": "PIC", "person": -2},
95
96
  {"image_base64": "PIC", "person": True},
97
+ {"image_base64": "PIC", "person": []},
98
+ {"image_base64": "PIC", "person": [0, -1]},
99
+ {"image_base64": "PIC", "person": [0, True]},
100
+ {"image_base64": "PIC", "person": ["0"]},
101
+ {"image_base64": "PIC", "person": list(range(65))},
96
102
  {"image_base64": "PIC", "style": "sketch"},
97
103
  {"image_base64": "PIC", "camera": 5},
98
104
  {"image_base64": "PIC", "camera": {"yaw": "left"}},
@@ -111,3 +117,20 @@ def test_nobody_and_a_person_past_the_end_are_refused():
111
117
  handle(StubDetector([]), {"image_base64": _picture()})
112
118
  with pytest.raises(ValueError):
113
119
  handle(StubDetector([_person(200)]), {"image_base64": _picture(), "person": 3})
120
+ with pytest.raises(ValueError):
121
+ handle(StubDetector([_person(200)]), {"image_base64": _picture(), "person": [0, 3]})
122
+
123
+
124
+ def test_some_people_are_drawn_together():
125
+ people = [_person(80), _person(200), _person(320)]
126
+ body = {"image_base64": _picture(), "width": 400, "height": 600}
127
+ some = handle(StubDetector(people), {**body, "person": [2, 0]})
128
+ assert some["person"] == [0, 2]
129
+ assert len(some["people"]) == 3
130
+ # Two figures draw differently from one, and from all three.
131
+ one = handle(StubDetector(people), {**body, "person": 0})
132
+ everyone = handle(StubDetector(people), {**body, "person": -1})
133
+ assert len({some["skeleton_base64"], one["skeleton_base64"], everyone["skeleton_base64"]}) == 3
134
+ # Turned, the chosen ones still turn together.
135
+ turned = handle(StubDetector(people), {**body, "person": [0, 2], "camera": {"yaw": 30}})
136
+ assert turned["person"] == [0, 2] and turned["camera"]["yaw"] == 30
@@ -82,6 +82,17 @@ def test_choosing_people():
82
82
  chosen(people, 2)
83
83
 
84
84
 
85
+ def test_choosing_some_people_by_a_list():
86
+ people = [_person(10), _person(20), _person(30)]
87
+ # Sorted, without repeats, in left-to-right order.
88
+ assert chosen(people, [2, 0, 2]) == ([0, 2], [people[0], people[2]])
89
+ assert chosen(people, (1,)) == ([1], [people[1]])
90
+ with pytest.raises(ValueError):
91
+ chosen(people, [])
92
+ with pytest.raises(ValueError):
93
+ chosen(people, [0, 3])
94
+
95
+
85
96
  def test_whole_framing_changes_nothing():
86
97
  points = np.array([[10.0, 20.0], [300.0, 400.0]])
87
98
  assert np.allclose(frame(points, (832, 1216), Framing()), points)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes