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 +107 -0
- poseorbit/__main__.py +60 -0
- poseorbit/api.py +132 -0
- poseorbit/detect.py +205 -0
- poseorbit/draw.py +97 -0
- poseorbit/geometry.py +113 -0
- poseorbit/server.py +53 -0
- poseorbit-0.1.1.dist-info/METADATA +351 -0
- poseorbit-0.1.1.dist-info/RECORD +13 -0
- poseorbit-0.1.1.dist-info/WHEEL +4 -0
- poseorbit-0.1.1.dist-info/entry_points.txt +2 -0
- poseorbit-0.1.1.dist-info/licenses/LICENSE +202 -0
- poseorbit-0.1.1.dist-info/licenses/NOTICE +28 -0
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
|