poseorbit 0.1.1__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,107 @@
1
+ """poseorbit: find the people in a picture, pick one (or everyone), turn the
2
+ pose in 3D, and draw the skeleton a pose-conditioned image model follows.
3
+
4
+ from poseorbit import Detector, Camera, pose
5
+
6
+ detector = Detector()
7
+ result = pose(detector, picture, size=(832, 1216), person=0,
8
+ camera=Camera(yaw=45), style="openpose")
9
+ result.skeleton.save("skeleton.png")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+ from .detect import KEYPOINT_THRESHOLD, Detector, NoPersonError, Person, most_confident
20
+ from .draw import STYLES, Style, letterbox, render
21
+ from .geometry import MAX_PITCH, MAX_YAW, MAX_ZOOM, MIN_ZOOM, Camera, Framing, fit, frame, scene_centre, view
22
+
23
+ __version__ = "0.1.1"
24
+
25
+ # Person index meaning "everyone detected", for a group pose.
26
+ ALL_PEOPLE = -1
27
+
28
+ __all__ = [
29
+ "ALL_PEOPLE",
30
+ "Camera",
31
+ "Detector",
32
+ "Framing",
33
+ "KEYPOINT_THRESHOLD",
34
+ "MAX_PITCH",
35
+ "MAX_YAW",
36
+ "MAX_ZOOM",
37
+ "MIN_ZOOM",
38
+ "NoPersonError",
39
+ "Person",
40
+ "PoseResult",
41
+ "STYLES",
42
+ "Style",
43
+ "letterbox",
44
+ "pose",
45
+ "render",
46
+ ]
47
+
48
+
49
+ @dataclass
50
+ class PoseResult:
51
+ skeleton: Image.Image
52
+ # Everyone detected, left to right, and which of them was drawn (ALL_PEOPLE for all).
53
+ people: list[Person]
54
+ person: int
55
+ # The camera and framing actually used, after clamping.
56
+ camera: Camera
57
+ framing: Framing
58
+ # The drawn people's body joints (of 17 each) that are seen and inside the
59
+ # canvas, for the one with the most: few left (a close-up) means a
60
+ # body-only skeleton has little to follow.
61
+ joints_in_frame: int
62
+
63
+
64
+ def chosen(people: list[Person], person: int | None) -> tuple[int, list[Person]]:
65
+ """`person` resolved (None = the most confident) and the people it means."""
66
+ if person is None:
67
+ person = most_confident(people)
68
+ if person == ALL_PEOPLE:
69
+ return person, people
70
+ if 0 <= person < len(people):
71
+ return person, [people[person]]
72
+ raise ValueError(f"No person {person}: {len(people)} found.")
73
+
74
+
75
+ def pose(
76
+ detector: Detector,
77
+ image: Image.Image,
78
+ size: tuple[int, int],
79
+ person: int | None = None,
80
+ camera: Camera | None = None,
81
+ style: Style = "dwpose",
82
+ depth: bool = False,
83
+ framing: Framing | None = None,
84
+ ) -> PoseResult:
85
+ """Detect, choose, turn and draw in one call.
86
+
87
+ `person` is an index into the left-to-right order, ALL_PEOPLE for
88
+ everyone, None for the most confident. A `camera` other than the front
89
+ view needs depth, so it is detected then whatever `depth` says. The
90
+ skeleton is drawn at `size` (the output's width, height), letterboxed,
91
+ then `framing` zooms and moves it on that canvas.
92
+ """
93
+ camera = (camera or Camera()).clamped()
94
+ framing = (framing or Framing()).clamped()
95
+ people = detector.detect(image, depth=depth or not camera.is_front)
96
+ person, drawn = chosen(people, person)
97
+ points = np.stack([p.points_3d for p in drawn])
98
+ if camera.is_front:
99
+ flat = points[..., :2]
100
+ else:
101
+ # Everyone turns around one shared centre, so a group keeps its layout.
102
+ flat = view(points, scene_centre(points), camera)
103
+ keypoints = frame(fit(flat, (image.width, image.height), size), size, framing)
104
+ scores = np.stack([p.scores for p in drawn])
105
+ inside = (keypoints >= 0).all(axis=-1) & (keypoints < np.array(size)).all(axis=-1)
106
+ joints = int(((scores[:, :17] >= KEYPOINT_THRESHOLD) & inside[:, :17]).sum(axis=1).max())
107
+ return PoseResult(render(keypoints, scores, size, style), people, person, camera, framing, joints)
poseorbit/__main__.py ADDED
@@ -0,0 +1,60 @@
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]
3
+ [--zoom Z --centre X,Y] [--style S]"""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(prog="poseorbit")
13
+ commands = parser.add_subparsers(dest="command", required=True)
14
+
15
+ serve = commands.add_parser("serve", help="answer /api/pose over HTTP")
16
+ serve.add_argument("--host", default="127.0.0.1")
17
+ serve.add_argument("--port", type=int, default=7870)
18
+ serve.add_argument("--weights-dir", type=Path, default=None)
19
+
20
+ draw = commands.add_parser("draw", help="draw one picture's skeleton to a PNG")
21
+ draw.add_argument("picture", type=Path)
22
+ draw.add_argument("out", type=Path)
23
+ draw.add_argument("--size", default=None, help="WIDTHxHEIGHT (default: the picture's)")
24
+ draw.add_argument("--person", type=int, default=None, help="index, or -1 for everyone")
25
+ draw.add_argument("--yaw", type=float, default=0.0)
26
+ draw.add_argument("--pitch", type=float, default=0.0)
27
+ draw.add_argument("--zoom", type=float, default=1.0)
28
+ draw.add_argument("--centre", default="0.5,0.5", help="the canvas point to centre, as fractions X,Y")
29
+ draw.add_argument("--style", default="dwpose", choices=["dwpose", "openpose"])
30
+ draw.add_argument("--weights-dir", type=Path, default=None)
31
+
32
+ args = parser.parse_args()
33
+ if args.command == "serve":
34
+ import uvicorn
35
+
36
+ from .server import create_app
37
+
38
+ uvicorn.run(create_app(args.weights_dir), host=args.host, port=args.port, log_level="warning")
39
+ return
40
+
41
+ from PIL import Image
42
+
43
+ from . import Camera, Detector, Framing, pose
44
+
45
+ picture = Image.open(args.picture)
46
+ size = tuple(int(part) for part in args.size.lower().split("x")) if args.size else picture.size
47
+ x, y = (float(part) for part in args.centre.split(","))
48
+ result = pose(
49
+ Detector(args.weights_dir), picture, size, args.person, Camera(args.yaw, args.pitch), args.style,
50
+ framing=Framing(args.zoom, x, y),
51
+ )
52
+ result.skeleton.save(args.out)
53
+ print(
54
+ f"{len(result.people)} found; drew person {result.person} at yaw {result.camera.yaw}, pitch {result.camera.pitch}, "
55
+ f"zoom {result.framing.zoom}; {result.joints_in_frame} body joints in frame"
56
+ )
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
poseorbit/api.py ADDED
@@ -0,0 +1,132 @@
1
+ """The `/api/pose` request and answer, independent of any web framework, so
2
+ poseorbit's own server and a generation server that embeds poseorbit speak
3
+ exactly the same JSON.
4
+
5
+ Request:
6
+ { "image_base64": "...", "width": 832, "height": 1216,
7
+ "person": 0, # optional; -1 = everyone, absent = most confident
8
+ "want_3d": true, # optional; answer with each person's 3D points
9
+ "camera": { "yaw": 30, "pitch": 10, # optional; degrees, see geometry.py
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"
12
+
13
+ Answer:
14
+ { "skeleton_base64": "...png...", "width": 832, "height": 1216, "person": 0,
15
+ "camera": { "yaw": 30, "pitch": 10, "framing": { "zoom": 3, "x": 0.5, "y": 0.2 } },
16
+ "style": "openpose",
17
+ "limits": { "yaw": 90, "pitch": 45, "zoom": [0.5, 6] },
18
+ "joints_in_frame": 9, # body joints (of 17) seen inside the canvas, best drawn person
19
+ "people": [ { "bbox": [x0, y0, x1, y1], # fractions of the picture
20
+ "points_3d": [[x, y, z], ...], # with want_3d: 133 points, fractions of the picture's width
21
+ "scores": [...] } ] } # with want_3d: 133 scores
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import io
28
+ import math
29
+ from typing import Any
30
+
31
+ from PIL import Image
32
+
33
+ from . import ALL_PEOPLE, MAX_PITCH, MAX_YAW, MAX_ZOOM, MIN_ZOOM, STYLES, Camera, Detector, Framing, pose
34
+ from .draw import Style
35
+
36
+
37
+ class BadRequest(ValueError):
38
+ """The request is wrong; answer 400 with the message."""
39
+
40
+
41
+ def decode_image(data: str) -> Image.Image:
42
+ if data.startswith("data:"):
43
+ data = data.split(",", 1)[1]
44
+ try:
45
+ image = Image.open(io.BytesIO(base64.b64decode(data)))
46
+ image.load()
47
+ except Exception as error: # noqa: BLE001 - any decode failure is the caller's input
48
+ raise BadRequest("The picture could not be read.") from error
49
+ return image
50
+
51
+
52
+ def encode_png(image: Image.Image) -> str:
53
+ buffer = io.BytesIO()
54
+ image.save(buffer, format="PNG")
55
+ return base64.b64encode(buffer.getvalue()).decode("ascii")
56
+
57
+
58
+ def _number(value: Any, name: str) -> float:
59
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
60
+ raise BadRequest(f"{name} must be a number")
61
+ return float(value)
62
+
63
+
64
+ def parse_camera(value: Any) -> tuple[Camera | None, Framing | None]:
65
+ if value is None:
66
+ return None, None
67
+ if not isinstance(value, dict):
68
+ raise BadRequest("camera must be an object { yaw, pitch, framing }")
69
+ camera = Camera(
70
+ yaw=_number(value.get("yaw", 0), "camera.yaw"),
71
+ pitch=_number(value.get("pitch", 0), "camera.pitch"),
72
+ ).clamped()
73
+ framing = value.get("framing")
74
+ if framing is None:
75
+ return camera, None
76
+ if not isinstance(framing, dict):
77
+ raise BadRequest("camera.framing must be an object { zoom, x, y }")
78
+ return camera, Framing(
79
+ zoom=_number(framing.get("zoom", 1), "camera.framing.zoom"),
80
+ x=_number(framing.get("x", 0.5), "camera.framing.x"),
81
+ y=_number(framing.get("y", 0.5), "camera.framing.y"),
82
+ ).clamped()
83
+
84
+
85
+ def handle(detector: Detector, body: dict[str, Any], default_style: Style = "dwpose") -> dict[str, Any]:
86
+ """Answers one request. Raises BadRequest (and NoPersonError, a ValueError) for a 400."""
87
+ data = body.get("image_base64")
88
+ if not isinstance(data, str) or not data:
89
+ raise BadRequest("image_base64 is required")
90
+ image = decode_image(data)
91
+ width = int(_number(body.get("width", image.width), "width"))
92
+ height = int(_number(body.get("height", image.height), "height"))
93
+ if not (64 <= width <= 4096 and 64 <= height <= 4096):
94
+ raise BadRequest("width and height must be 64-4096")
95
+ person = body.get("person")
96
+ if person is not None and (isinstance(person, bool) or not isinstance(person, int) or person < ALL_PEOPLE):
97
+ raise BadRequest("person must be an index, or -1 for everyone")
98
+ style = body.get("style") or default_style
99
+ if style not in STYLES:
100
+ raise BadRequest(f"style must be one of {', '.join(STYLES)}")
101
+ want_3d = body.get("want_3d") is True
102
+
103
+ camera, framing = parse_camera(body.get("camera"))
104
+ result = pose(detector, image, (width, height), person, camera, style, depth=want_3d, framing=framing)
105
+ people = []
106
+ for found in result.people:
107
+ x0, y0, x1, y1 = found.bbox
108
+ # Keypoints can be guessed past the edge (feet below the frame); the
109
+ # box is for drawing over the picture, so it stays inside it.
110
+ box = [x0 / image.width, y0 / image.height, x1 / image.width, y1 / image.height]
111
+ entry: dict[str, Any] = {"bbox": [round(min(1.0, max(0.0, edge)), 5) for edge in box]}
112
+ if want_3d:
113
+ # One unit for all three axes (the picture's width), so a viewer
114
+ # can draw them without knowing the picture's size or aspect.
115
+ entry["points_3d"] = (found.points_3d / image.width).round(5).tolist()
116
+ entry["scores"] = found.scores.round(3).tolist()
117
+ people.append(entry)
118
+ return {
119
+ "skeleton_base64": encode_png(result.skeleton),
120
+ "width": width,
121
+ "height": height,
122
+ "person": result.person,
123
+ "camera": {
124
+ "yaw": result.camera.yaw,
125
+ "pitch": result.camera.pitch,
126
+ "framing": {"zoom": result.framing.zoom, "x": result.framing.x, "y": result.framing.y},
127
+ },
128
+ "style": style,
129
+ "limits": {"yaw": MAX_YAW, "pitch": MAX_PITCH, "zoom": [MIN_ZOOM, MAX_ZOOM]},
130
+ "joints_in_frame": result.joints_in_frame,
131
+ "people": people,
132
+ }
poseorbit/detect.py ADDED
@@ -0,0 +1,205 @@
1
+ """Finding people in a picture: their 133 whole-body keypoints and, on request,
2
+ how far each keypoint sits in front of or behind the figure.
3
+
4
+ One YOLOX person detector feeds two keypoint models on the same boxes:
5
+
6
+ - RTMW-DW-x-l (DWPose, COCO-WholeBody 133 points) for x and y. Skeleton-
7
+ conditioned models are commonly trained on skeletons from this detector,
8
+ so one drawn from it looks like their training data.
9
+ - RTMW3D-x for depth only, when asked. Its own x and y are close to DWPose's
10
+ (median 17 px apart on an 832x1216 picture) but not equal; taking x and y
11
+ from DWPose keeps a front view of the 3D pose identical to the 2D one.
12
+
13
+ Both run on the CPU with onnxruntime through rtmlib: about 0.7 s for one
14
+ person at 832x1216, all three models together.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ from PIL import Image
25
+
26
+ # A keypoint counts as seen at this score: rtmlib's default drawing cut-off.
27
+ KEYPOINT_THRESHOLD = 0.3
28
+ # Of the 17 body keypoints, how many must be seen for a detection to be a person.
29
+ MIN_BODY_KEYPOINTS = 8
30
+
31
+ # The ONNX files: (Hugging Face repository, path in it, local name). All are
32
+ # OpenMMLab models under Apache-2.0. The first two come zipped from rtmlib's
33
+ # own mirror of OpenMMLab's downloads (download.openmmlab.com has been
34
+ # unreliable); the onnx inside is kept, renamed.
35
+ DETECTOR_FILE = ("Tau-J/RTMPose", "rtmposev1/onnx_sdk/yolox_m_8xb8-300e_humanart-c2c7a14a.zip", "yolox_m_8xb8-300e_humanart-c2c7a14a.onnx")
36
+ POSE_2D_FILE = ("Tau-J/RTMPose", "rtmw/onnx_sdk/rtmw-dw-x-l_simcc-cocktail14_270e-256x192_20231122.zip", "rtmw-dw-x-l_simcc-cocktail14_270e-256x192_20231122.onnx")
37
+ POSE_3D_FILE = ("Soykaf/RTMW3D-x", "onnx/rtmw3d-x_8xb64_cocktail14-384x288-b0a0eab7_20240626.onnx", "rtmw3d-x_8xb64_cocktail14-384x288-b0a0eab7_20240626.onnx")
38
+ # Where the files go when the caller names no folder.
39
+ DEFAULT_WEIGHTS_DIR = Path.home() / ".cache" / "poseorbit"
40
+
41
+ # RTMW3D's depth: 288 bins (its input width) spanning +-Z_RANGE metres around
42
+ # the hips, as its SimCC3D codec was trained. rtmlib's own conversion divides
43
+ # by the input height instead, so the bins are read here directly.
44
+ DEPTH_BINS = 288
45
+ Z_RANGE = 2.1744869
46
+ # Shoulders-to-hips length used to turn metres into the picture's pixels.
47
+ # Adult figures measure about this; the result only sets how deep a turned
48
+ # pose looks, so being off by a little shows as a slightly flatter or deeper
49
+ # figure, not a broken one.
50
+ TORSO_METRES = 0.5
51
+
52
+
53
+ class NoPersonError(ValueError):
54
+ """The picture has nobody in it that is seen well enough to follow."""
55
+
56
+
57
+ @dataclass
58
+ class Person:
59
+ """One detected figure, in the picture's pixels."""
60
+
61
+ keypoints: np.ndarray # (133, 2) x, y
62
+ scores: np.ndarray # (133,)
63
+ # Each keypoint's distance behind the hips' plane, in the same pixels as x
64
+ # and y (positive = away from the viewer); None unless depth was asked for.
65
+ depth: np.ndarray | None = None
66
+
67
+ @property
68
+ def visible(self) -> int:
69
+ """How many of the 17 body keypoints are seen: how sure this is a person."""
70
+ return int((self.scores[:17] >= KEYPOINT_THRESHOLD).sum())
71
+
72
+ @property
73
+ def bbox(self) -> tuple[float, float, float, float]:
74
+ """(x0, y0, x1, y1) around the keypoints that would be drawn."""
75
+ shown = self.keypoints[self.scores >= KEYPOINT_THRESHOLD]
76
+ x0, y0 = shown.min(axis=0)
77
+ x1, y1 = shown.max(axis=0)
78
+ return float(x0), float(y0), float(x1), float(y1)
79
+
80
+ @property
81
+ def points_3d(self) -> np.ndarray:
82
+ """(133, 3): x, y and depth, all in pixels. Flat (depth 0) without depth."""
83
+ depth = self.depth if self.depth is not None else np.zeros(len(self.keypoints))
84
+ return np.column_stack([self.keypoints, depth])
85
+
86
+
87
+ def model_file(spec: tuple[str, str, str], weights_dir: Path | None) -> str:
88
+ """A local path to one of the ONNX files, downloading it the first time.
89
+
90
+ Kept as `<weights_dir>/<local name>` (default ~/.cache/poseorbit).
91
+ """
92
+ import shutil
93
+ import zipfile
94
+
95
+ from huggingface_hub import hf_hub_download
96
+
97
+ repo, filename, name = spec
98
+ folder = weights_dir or DEFAULT_WEIGHTS_DIR
99
+ local = folder / name
100
+ if local.is_file() and local.stat().st_size > 1_000_000:
101
+ return str(local)
102
+ folder.mkdir(parents=True, exist_ok=True)
103
+ downloaded = hf_hub_download(repo, filename)
104
+ partial = local.with_suffix(".part")
105
+ if filename.endswith(".zip"):
106
+ with zipfile.ZipFile(downloaded) as archive:
107
+ member = next(item for item in archive.namelist() if item.endswith(".onnx"))
108
+ with archive.open(member) as source, open(partial, "wb") as target:
109
+ shutil.copyfileobj(source, target)
110
+ else:
111
+ shutil.copyfile(downloaded, partial)
112
+ partial.replace(local)
113
+ return str(local)
114
+
115
+
116
+ def depth_in_pixels(depth_bins: np.ndarray, keypoints: np.ndarray, scores: np.ndarray) -> np.ndarray:
117
+ """RTMW3D's depth bins for one person as pixels of the picture, relative to the hips.
118
+
119
+ The model gives depth in metres; the picture is in pixels. The scale comes
120
+ from the torso: its length on screen is the true length foreshortened by
121
+ how far the shoulders lean toward or away from the viewer, which the depth
122
+ itself says, so a figure bending over is not mistaken for a small one.
123
+ """
124
+ metres = (depth_bins / (DEPTH_BINS / 2) - 1) * Z_RANGE
125
+ shoulders = keypoints[[5, 6]].mean(axis=0)
126
+ hips = keypoints[[11, 12]].mean(axis=0)
127
+ seen = scores[[5, 6, 11, 12]].min() >= KEYPOINT_THRESHOLD
128
+ hips_depth = metres[[11, 12]].mean()
129
+ if not seen:
130
+ # No torso to measure: fall back to the figure's height as ~1.6 m.
131
+ shown = keypoints[scores >= KEYPOINT_THRESHOLD]
132
+ height = float(np.ptp(shown[:, 1])) if len(shown) else 1.0
133
+ return (metres - hips_depth) * max(height, 1.0) / 1.6
134
+ lean = metres[[5, 6]].mean() - hips_depth
135
+ on_screen = float(np.hypot(*(shoulders - hips)))
136
+ # Never let the lean eat more than 70% of the torso: past that the
137
+ # estimate is noise, and dividing by almost nothing would explode.
138
+ along = max(TORSO_METRES**2 - lean**2, (0.3 * TORSO_METRES) ** 2) ** 0.5
139
+ pixels_per_metre = max(on_screen, 1.0) / along
140
+ return (metres - hips_depth) * pixels_per_metre
141
+
142
+
143
+ class Detector:
144
+ """Loads its models on first use and is safe to share between threads."""
145
+
146
+ def __init__(self, weights_dir: Path | None = None, device: str = "cpu"):
147
+ self.weights_dir = weights_dir
148
+ self.device = device
149
+ self._lock = threading.Lock()
150
+ self._boxes = None
151
+ self._pose_2d = None
152
+ self._pose_3d = None
153
+
154
+ def _models(self, depth: bool):
155
+ import onnxruntime
156
+ from rtmlib import RTMPose, RTMPose3d, YOLOX
157
+
158
+ # RTMW3D's file carries unused initialisers, and onnxruntime warns
159
+ # about each one on every load; errors still show.
160
+ onnxruntime.set_default_logger_severity(3)
161
+ options = {"backend": "onnxruntime", "device": self.device}
162
+ if self._boxes is None:
163
+ self._boxes = YOLOX(model_file(DETECTOR_FILE, self.weights_dir), model_input_size=(640, 640), **options)
164
+ self._pose_2d = RTMPose(model_file(POSE_2D_FILE, self.weights_dir), model_input_size=(192, 256), **options)
165
+ if depth and self._pose_3d is None:
166
+ self._pose_3d = RTMPose3d(model_file(POSE_3D_FILE, self.weights_dir), model_input_size=(288, 384), **options)
167
+ return self._boxes, self._pose_2d, self._pose_3d
168
+
169
+ def detect(self, image: Image.Image, depth: bool = False) -> list[Person]:
170
+ """Everyone in `image`, ordered left to right by the centre of the figure,
171
+ so the same picture always numbers its people the same way.
172
+
173
+ Raises NoPersonError when nobody is seen well enough.
174
+ """
175
+ import cv2
176
+
177
+ bgr = cv2.cvtColor(np.asarray(image.convert("RGB")), cv2.COLOR_RGB2BGR)
178
+ with self._lock:
179
+ boxes_model, pose_2d, pose_3d = self._models(depth)
180
+ boxes = boxes_model(bgr)
181
+ if len(boxes) == 0:
182
+ raise NoPersonError("Nobody was found in the picture.")
183
+ keypoints, scores = pose_2d(bgr, bboxes=boxes)
184
+ depth_bins = pose_3d(bgr, bboxes=boxes)[2][..., 2] if depth else None
185
+ # rtmlib returns a "person" even for a blank picture, just with every
186
+ # score low: measured, a white canvas gave 2 of 17 body keypoints over
187
+ # the threshold and noise 0, against 17 for real figures. Half the body
188
+ # is the bar, so a skeleton made of guesses is refused, not followed --
189
+ # per person, so a crowd's half-hidden extras don't become pickable ghosts.
190
+ people = []
191
+ for index in range(len(keypoints)):
192
+ person = Person(np.asarray(keypoints[index], float), np.asarray(scores[index], float))
193
+ if person.visible < MIN_BODY_KEYPOINTS:
194
+ continue
195
+ if depth_bins is not None:
196
+ person.depth = depth_in_pixels(np.asarray(depth_bins[index], float), person.keypoints, person.scores)
197
+ people.append(person)
198
+ if not people:
199
+ raise NoPersonError("Nobody was found in the picture.")
200
+ people.sort(key=lambda person: (person.bbox[0] + person.bbox[2]) / 2)
201
+ return people
202
+
203
+
204
+ def most_confident(people: list[Person]) -> int:
205
+ return max(range(len(people)), key=lambda index: people[index].visible)
poseorbit/draw.py ADDED
@@ -0,0 +1,97 @@
1
+ """Drawing skeletons the way pose-conditioned models were trained to read them.
2
+
3
+ - "dwpose": rtmlib's COCO-WholeBody drawing (thin coloured lines and dots),
4
+ the usual input of DWPose-trained models such as Anima-Control-Pose.
5
+ - "openpose": the 18 OpenPose body points with thick limbs that grow with
6
+ the canvas, as xinsir/controlnet-openpose-sdxl-1.0 was trained. Body only:
7
+ hands and face were not in its training skeletons.
8
+ `_draw_openpose_body` follows the draw_bodypose function on that model's
9
+ card (Apache-2.0), itself a modification of controlnet_aux's (Apache-2.0);
10
+ see NOTICE.
11
+
12
+ Both on black, at the output's own size.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from typing import Literal
19
+
20
+ import numpy as np
21
+ from PIL import Image
22
+
23
+ from .detect import KEYPOINT_THRESHOLD
24
+
25
+ Style = Literal["dwpose", "openpose"]
26
+ STYLES: tuple[Style, ...] = ("dwpose", "openpose")
27
+
28
+ # OpenPose's 18-point limbs (1-based, as published) and its colours (RGB).
29
+ _OPENPOSE_LIMBS = [
30
+ (2, 3), (2, 6), (3, 4), (4, 5), (6, 7), (7, 8), (2, 9), (9, 10), (10, 11),
31
+ (2, 12), (12, 13), (13, 14), (2, 1), (1, 15), (15, 17), (1, 16), (16, 18),
32
+ ]
33
+ _OPENPOSE_COLOURS = [
34
+ (255, 0, 0), (255, 85, 0), (255, 170, 0), (255, 255, 0), (170, 255, 0), (85, 255, 0),
35
+ (0, 255, 0), (0, 255, 85), (0, 255, 170), (0, 255, 255), (0, 170, 255), (0, 85, 255),
36
+ (0, 0, 255), (85, 0, 255), (170, 0, 255), (255, 0, 255), (255, 0, 170), (255, 0, 85),
37
+ ]
38
+
39
+
40
+ def render(keypoints: np.ndarray, scores: np.ndarray, size: tuple[int, int], style: Style = "dwpose") -> Image.Image:
41
+ """`keypoints` (N, 133, 2) in the canvas's pixels with `scores` (N, 133), on a `size` canvas."""
42
+ if style not in STYLES:
43
+ raise ValueError(f"Unknown skeleton style {style!r}; one of {', '.join(STYLES)}")
44
+ width, height = size
45
+ canvas = np.zeros((height, width, 3), np.uint8)
46
+ if style == "openpose":
47
+ from rtmlib.tools.pose_estimation.post_processings import convert_coco_to_openpose
48
+
49
+ body, body_scores = convert_coco_to_openpose(keypoints, scores)
50
+ for person, person_scores in zip(body[:, :18], body_scores[:, :18]):
51
+ _draw_openpose_body(canvas, person, person_scores)
52
+ return Image.fromarray(canvas)
53
+
54
+ import cv2
55
+ from rtmlib import draw_skeleton
56
+
57
+ canvas = draw_skeleton(canvas, keypoints, scores, openpose_skeleton=False, kpt_thr=KEYPOINT_THRESHOLD)
58
+ return Image.fromarray(cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB))
59
+
60
+
61
+ def _draw_openpose_body(canvas: np.ndarray, points: np.ndarray, scores: np.ndarray) -> None:
62
+ """One person's 18 points onto an RGB canvas, in place."""
63
+ import cv2
64
+
65
+ height, width = canvas.shape[:2]
66
+ longest = max(width, height)
67
+ # The card's steps: 1x under 500 px, then one more per 1000 px.
68
+ ratio = 1.0 if longest < 500 else 2.0 if longest < 1000 else min(7.0, 3.0 + (longest - 1000) // 1000)
69
+ seen = scores >= KEYPOINT_THRESHOLD
70
+ for (first, second), colour in zip(_OPENPOSE_LIMBS, _OPENPOSE_COLOURS):
71
+ a, b = first - 1, second - 1
72
+ if not (seen[a] and seen[b]):
73
+ continue
74
+ (x0, y0), (x1, y1) = points[a], points[b]
75
+ length = math.hypot(x1 - x0, y1 - y0)
76
+ angle = math.degrees(math.atan2(y0 - y1, x0 - x1))
77
+ polygon = cv2.ellipse2Poly(
78
+ (int((x0 + x1) / 2), int((y0 + y1) / 2)), (int(length / 2), int(4 * ratio)), int(angle), 0, 360, 1
79
+ )
80
+ cv2.fillConvexPoly(canvas, polygon, [int(c * 0.6) for c in colour])
81
+ for point, colour, shown in zip(points, _OPENPOSE_COLOURS, seen):
82
+ if shown:
83
+ cv2.circle(canvas, (int(point[0]), int(point[1])), int(4 * ratio), colour, thickness=-1)
84
+
85
+
86
+ def letterbox(skeleton: Image.Image, size: tuple[int, int]) -> Image.Image:
87
+ """A skeleton drawn for another size, scaled uniformly and centred on a black
88
+ `size` canvas, so it lines up with the output however it was drawn."""
89
+ if skeleton.size == size:
90
+ return skeleton.convert("RGB")
91
+ scale = min(size[0] / skeleton.width, size[1] / skeleton.height)
92
+ resized = skeleton.convert("RGB").resize(
93
+ (max(1, round(skeleton.width * scale)), max(1, round(skeleton.height * scale))), Image.Resampling.BILINEAR
94
+ )
95
+ canvas = Image.new("RGB", size, (0, 0, 0))
96
+ canvas.paste(resized, ((size[0] - resized.width) // 2, (size[1] - resized.height) // 2))
97
+ return canvas