Skip to main content

AlchemyPose

PyPI Python 3.12 | 3.13 | 3.14 Ruff License: MIT

Whole-body human pose over an image — a typed Python library and a PySide6 desktop application, built on RTMW and YOLOX through ONNX Runtime.

Drop in a photograph. Every person in it gets 133 keypoints — body, feet, face and both hands — and you choose what to look at: the whole skeleton, one hand, the left leg, the face on its own.

1.0.0 — single images. pip install alchemypose, type alchemypose, and the window opens. Every person in the photograph you drop in comes back with 133 keypoints on a zoomable canvas, driven by a layer tree, preset chips and a confidence slider. The face resolves from contours to individual points as it grows on screen, and the Export menu writes the keypoints as JSON or CSV and the overlay as a PNG. The CLI produces the same result with no window at all. Video and 3D are the next two versions — see Roadmap.


Why

Most pose demos give you 17 body keypoints and a stick figure. AlchemyPose is built around the thing that is actually hard once you have 133 of them: deciding what to show. Face landmarks alone outnumber every other part combined, so the interface is as much of the problem as the model.

It also stays light. No PyTorch, no MediaPipe, no ultralytics — the whole inference path is ONNX Runtime, OpenCV and numpy, and the model files are fetched once at runtime rather than shipped in the wheel.


Install

pip install alchemypose
alchemypose

The second line opens the window: alchemypose on its own is the application. alchemypose --version prints 1.0.0, and the subcommands further down do the same work with no window at all.

From source instead, if you have access to the repository:

git clone https://github.com/kouya-marino/AlchemyPose.git
cd AlchemyPose
python3 -m venv .venv && source .venv/bin/activate   # 3.12, 3.13 or 3.14
pip install -e ".[dev]"

Python 3.12 or newer. requires-python is >=3.12 — a floor with no ceiling. The floor is there because the development toolchain needs it: numpy ships type stubs written in 3.12 syntax, which mypy cannot parse under 3.11. That is a reason for a floor and not for a ceiling, so there is no upper bound; every dependency here already supports 3.13 and 3.14. The suite has been run in full on 3.12 and on 3.14, and CI runs it on 3.12, 3.13 and 3.14 on every push.

Install weight, stated honestly. The package itself is tiny — a 79 KB wheel of pure Python — but two things behind it are not. PySide6_Essentials is a 106 MB download on macOS, whose wheel is a universal binary, and 55 to 76 MB on Linux and Windows; installed, it takes 328 MB on disk here. And the default model preset fetches 253 MB of ONNX weights the first time you run inference — 235 MB over the wire, since upstream ships them zipped. Both are one-time. The lite preset is 63 MB if you would rather trade accuracy for footprint.

On a bare Linux box the PySide6 wheel also needs a handful of system libraries it links against but does not carry: libegl1, libgl1, libx11-6, libxkbcommon0, libfontconfig1, libdbus-1-3 and glib. The CI workflow installs exactly that set and nothing more, and a runner has since loaded Qt's offscreen plugin with it.


The keypoint schema

COCO-WholeBody 133, in contiguous index ranges that map directly onto the interface:

Part Indices Count
Body 0–16 17
Feet 17–22 6
Face 23–90 68 (dlib-68 order)
Left hand 91–111 21
Right hand 112–132 21

Those ranges are grouped again into the 14 parts the UI exposes — torso_head, left_arm, right_leg, face_eyes, left_hand and so on — joined by 127 edges.

The groups deliberately overlap on anchor joints: left_arm owns the shoulder even though torso_head does too. Without that, asking for "just the left leg" draws a shin — the thigh needs the hip, and a bone is only drawn when both of its endpoints are visible.

from alchemypose.core.presets import selection_for
from alchemypose.core.topology import COCO_WHOLEBODY_133 as topology

selection = selection_for("hands")          # or full_body, upper, lower, left_side, face …
bones = [e for e in topology.edges if selection.is_edge_visible(e)]

Using it

The window

alchemypose

That is the whole of it: no subcommand, no arguments. (alchemypose gui does the same thing explicitly, and python -m alchemypose gui works too.)

Cmd+O opens an image — Ctrl+O on Windows and Linux, since the action takes Qt's standard Open sequence. The photograph appears immediately and inference runs on a worker thread, so the window never freezes; the skeleton lands when the model is done. Ctrl+wheel zooms under the cursor (a bare wheel scrolls the view), and the middle button drags to pan.

Everything on the right decides what is drawn. The layer tree is the single source of truth — a two-level, tri-state list of render groups — and the preset chips above it (Full, Upper, Lower, Left, Right, Hands, Face, All, None) simply set its check states, so a chip lights up whenever the tree happens to equal it, including when you got there by hand. With more than one person, the numbered chips in the toolbar narrow everything to one subject.

On the canvas, clicking a joint emphasises its group and fades the rest; double-click (or Shift+H) solos it and hides everything else; Esc or Alt+H puts it back. Hovering reads the keypoint's name, confidence and group into the inspector.

Zooming into a face changes what is drawn on it and not merely how large it is. Below about 80 pixels of face on screen it is drawn as contours in a single grey; above that the brows, eyes, nose and lips take their own colours; and only above about 200 pixels — where adjacent points are finally far enough apart to aim at — are the 68 individual keypoints drawn at all. Sixty-eight dots on a thumbnail are a smudge, whereas the contours through them read as a face at any size. The Face chip zooms to the face as well as selecting it, which is what spares the window a separate face mode.

Getting it back out

The Export menu writes the keypoints as JSON or CSV, and the overlay as a PNG. The PNG renders the scene rather than the widget, so it arrives at the photograph's own resolution and is the same file whatever the zoom, the scroll position or the shape of the window. A PNG needs only an open photograph; the two data formats need a result. When there is nothing to write yet it says so in the status bar rather than opening a dialog on an empty canvas.

One image, from the shell

$ alchemypose image tests/integration/fixtures/two_people.jpg --preset lite --json /tmp/out.json
Wrote /tmp/out.json

That path is the test fixture in the source tree — a public-domain 1935 photograph of two people — so substitute any photograph of your own and the numbers below become yours. ONNX Runtime also writes a few [W:onnxruntime:…] lines to stderr while it builds a session, about unused initializers and how much of the graph CoreML took; they are notices, not errors, and stdout carries only the line above.

Without --json the result goes to stdout; --csv also writes the flat frame,person,keypoint_index,keypoint_name,x,y,score form. The JSON carries the topology inline, so a result stays readable years later without this package:

$ python - <<'PY'
import json
import numpy as np
r = json.load(open("/tmp/out.json"))
frame = r["frames"][0]
print(r["model"], frame["image_size"], len(frame["people"]), "people")
for i, p in enumerate(frame["people"]):
    s = np.array(p["scores"])
    print(f"person {i}: {len(p['keypoints'])} keypoints, confidence {s.min():.2f}-{s.max():.2f} (median {np.median(s):.2f})")
PY
rtmw_m_256x192 [982, 670] 2 people
person 0: 133 keypoints, confidence 0.56-1.00 (median 0.94)
person 1: 133 keypoints, confidence 0.61-1.00 (median 0.90)

Weights are fetched on first use. To pay for that up front:

$ alchemypose models fetch --preset lite
Fetching yolox_nano ...
Fetching rtmw_m_256x192 ...
Done.

$ alchemypose models list
Cache: /Users/you/Library/Caches/alchemypose

lite:
  yolox_nano             cached
  rtmw_m_256x192         cached

balanced:
  yolox_tiny             cached
  dwpose_l_384x288       cached

performance:
  yolox_s                cached
  rtmw_l_384x288         cached

What a score means

Every keypoint carries a confidence in [0, 1], and it means the same thing whichever preset produced it. That took work: the RTMPose-family graphs end in a bare MatMul — no softmax, no sigmoid — so the raw SimCC peak is a logit whose height depends on the model's training temperature. Measured on real output, a confident peak reaches about 1.0 on RTMW-m and DWPose-l but an order of magnitude more on RTMW-l — 8.576 was the highest of the fixture's 399 — which made a fixed 0..1 threshold meaningless across presets and inert on the default one. rtmw.PEAK_SCALE records the divisor per model, 1.0 for the first two and 10.0 for RTMW-l, and decoding divides it out and clips to [0, 1]. It is a monotone confidence, not a calibrated probability — nothing in the training objective makes it one.

The window's slider filters on exactly that number and starts at 0.30. Below it a keypoint is not drawn at all; from there the drawing fades in, reaching full opacity at 0.55, and a bone whose weaker end is under 0.25 is drawn dashed. Those three numbers were calibrated against measured score distributions, not chosen by eye.


Models

Weights are downloaded on first use into the platform cache directory (~/Library/Caches/alchemypose on macOS, ~/.cache/alchemypose on Linux; override with ALCHEMYPOSE_MODEL_DIR) and verified against a recorded SHA-256. Nothing is vendored in the repository or the wheel.

Preset Detector + pose model On disk Detection Pose model, per person
lite YOLOX-nano + RTMW-m 256×192 63 MB 8 ms 4 ms
balanced YOLOX-tiny + DWPose-l 384×288 148 MB 15 ms 14 ms
performance (default) YOLOX-s + RTMW-l 384×288 253 MB 42 ms 25 ms

Sizes are the extracted .onnx files as they sit in the cache, in binary megabytes — the figures ls -lh and du -h print — so the three rows add up to the 464 MB all six files occupy together. Upstream ships them inside zips, so the transfer itself is slightly smaller (59 / 138 / 235 MB).

The timings are medians of fifteen warm runs on one 982×670 photograph on an M5, with the split described below — the detector on the CPU provider, the pose model on CoreML. Detection is per frame whatever the head count; the pose column is per detected person. End to end that photograph takes 17 ms on lite (2 people found), 58 ms on balanced and 114 ms on performance (3 people found). Opening the models costs another 1–3 seconds once per process, before the first inference.

The person detector runs on the CPU execution provider and the pose model on CoreML. That split is not a tuning choice, though the reason is narrower than "CoreML cannot load that graph". It loads it — the session is created and reports both providers — and then raises a rank mismatch on the first run(), inside the partition holding YOLOX's baked-in NMS. Sessions are therefore proved with one throwaway inference on zeros and demoted to CPU if it fails.

All model weights are Apache-2.0, from the MMPose project.


Architecture

Layers, with the Qt boundary enforced by a test:

core/        topology · schema · presets · serialization · H36M mapping
             no Qt, no onnxruntime — pure data, testable anywhere

media/       reading an image as upright BGR, EXIF applied

inference/   ONNX sessions · YOLOX detection · SimCC decoding · weight cache ·
             the worker thread — no Qt, so it is testable headless

gui/         PySide6: canvas · layer tree · preset and person chips ·
             confidence slider · inspector

import alchemypose pulls in no Qt at all — the application class resolves lazily — so the library half runs headless, in CI, and on a machine with no display. The inference worker is plain threading; gui/bridge.py is the only place it meets Qt, and it does so by emitting signals, so nothing on a worker thread ever touches a widget.

One result structure serves every phase. PersonPose carries track_id and keypoints_3d fields that stay empty in 1.0.0, so video and 3D are additive rather than a schema migration.


Roadmap

v1 — single image. Shipped in 1.0.0.

  • COCO-WholeBody 133 topology, render groups, presets
  • Result schema with reserved video/3D slots
  • JSON and CSV serialization
  • COCO → H36M-17 mapping (the seam 3D lifting plugs into)
  • Model registry with cached, checksum-verified downloads
  • ONNX sessions with the CPU/CoreML provider split
  • SimCC decoding and top-down affine cropping
  • YOLOX detector and the assembled pose engine
  • Inference worker and CLI
  • Canvas, layer tree, preset chips, selection
  • Confidence: honest 0..1 scores, threshold slider, fade and dashed bones
  • Face level of detail
  • Export from the window

v2 — video. Upload and scrub, per-frame overlay, tracking, temporal smoothing. PersonPose.track_id and PoseFrame.frame_index have been in the schema since its first commit, and the worker is already latest-request-wins, which is what scrubbing needs.

v3 — 3D. RTMW3D emits the same 133-keypoint topology with a third coordinate, so the layer panel and presets carry over unchanged; the new work is the viewport.


Development

pytest                        # the whole suite; the GUI half runs headless by itself
pytest tests/unit -q          # core and inference; no display needed
ruff check src tests main.py
mypy
mypy main.py
QT_QPA_PLATFORM=offscreen pytest tests/gui -m gui
pytest tests/integration -m models    # skipped unless weights are cached

At 1.0.0 that is 511 tests, all passing, on a machine with the weights cached. Twelve of them are marked models — two in tests/unit, ten in tests/integration — and check the cache rather than downloading anything, so without weights the same run reports 499 passed and 12 skipped. main.py sits outside src/, so neither ruff check src tests nor a bare mypy reaches it; it needs naming, which is why it appears twice above.

The GUI tests need a Qt platform plugin, not a display: offscreen renders into a buffer, so there is no xvfb anywhere in this project. They do not need that variable on the command line, though — tests/gui/conftest.py sets it itself, at import, before any widget can be constructed, so a bare pytest runs them headless too. Setting it explicitly is belt and braces, and it is what the CI step does.

CI (.github/workflows/ci.yml) runs the test suite on Python 3.12, 3.13 and 3.14 — the whole of what requires-python = ">=3.12" now claims — and runs ruff and mypy exactly once, in a job of their own. Both are static analysers configured against fixed targets (target-version = "py312", python_version = "3.12"), so they return the same answer whichever interpreter invokes them; three copies of one answer is not a broader gate, only a longer log. The tests are the part that actually varies by interpreter, so the tests are the part that gets the matrix.

Nothing on a runner ever downloads weights, so the models-marked tests never actually execute there. They skip, which is a green tick meaning not exercised rather than passed.

It has run, and it passed — though on the single-interpreter shape this workflow had until 1.0.0, so read the numbers below as 3.12's. Six runs so far, every one a push to main and every one successful, each finishing in about a minute on ubuntu-latest. The most recent reported ruff clean, mypy with no issues across 38 source files and then across main.py, 289 passed and 2 skipped in tests/unit, 179 passed in tests/gui, and 10 skipped in the weights-only step. It also printed Qt 6.11.2 on platform offscreen, which is the line worth having: the list of Linux system libraries further up was derived by reading the PySide6 wheel's ELF headers rather than by watching a runner install them, and a runner has now loaded the offscreen plugin with exactly that list present and nothing more.

The 3.13 and 3.14 legs are new, and have not had a push to run on yet. What has been measured for the widened range is local: the full suite under Python 3.14.7, in a clean virtualenv built from the released dependency set, with the package installed from a source tree of its own so that nothing resolved back to the development checkout — 511 passed, 0 skipped, pytest exit status 0. Weights were present, so the twelve models tests ran against the real ONNX graphs rather than skipping. ruff and mypy were run under 3.14 as well and returned exactly what they return under 3.12, which is the measurement behind giving them one job instead of three. The same tree's 1.0.0 wheel installs and runs on 3.14; built with the old <3.13 ceiling it does not, and pip says so — requires a different Python: 3.14.7 not in '<3.13,>=3.12' — which is what the ceiling was doing to everyone on a current interpreter. 3.13 is the gap: no 3.13 interpreter exists on the machine these numbers were taken on, so that leg is claimed by the matrix and not yet by a run.

What that tick does not cover is the half of the project that needs weights. CoreML, the timings and the score distributions on this page have only ever been measured on one Apple-silicon machine.

The suite used to pass and then abort. It no longer does. It would die during interpreter shutdown — libc++abi: terminating due to uncaught exception of type std::__1::system_error: recursive_mutex lock failed: Invalid argument, exit status 134, printed after the summary line had gone by. The cause was onnxruntime's telemetry client, which uploads on a thread of its own; at exit the teardown of its log manager frees a mutex an in-flight upload then tries to lock. tests/conftest.py now sets ORT_DISABLE_TELEMETRY=1 before anything imports onnxruntime, which is the only moment the setting is read. Measured here over 40 runs per arm, interleaved: 0 aborts with it, 4 without.

Run the suite as pytest -q > out 2>&1; rc=$? and read rc anyway. A pipeline such as pytest -q | tail -1 reports the exit status of tail, so it would have hidden that abort entirely for as long as it lasted, and it will hide the next one.

Releasing

A release is a tag. Pushing v1.0.0 runs .github/workflows/publish.yml, which re-runs every gate above on the tagged commit and then uploads to PyPI through trusted publishing, so no API token exists anywhere in this repository. It is the one irreversible thing here: PyPI never allows re-uploading a version, and yanking a release does not give its number back. So the tag is spent only after the artefacts themselves have been read — the tag is matched against the version in src/alchemypose/__init__.py, then against the metadata inside the built wheel and sdist; the wheel's Requires-Python is compared against the one in pyproject.toml, because the string pip enforces is the one baked into the artefact and a wrong range published there cannot be edited afterwards, only yanked; both archives are searched for weights, test-data/, docs/ and the test suite; the wheel is checked for a top-level main, because main.py is a checkout convenience and not ours to install into anyone's site-packages; and the wheel is installed into a throwaway virtualenv where each entry point is run and asked its version. That first check reads the attribute with ast, the way setuptools' attr: does, rather than reading project.version — with the version declared once and exposed through dynamic = ["version"], that key does not exist here.

The PyPI and Python badges at the top of this file are static and version-pinned, not the dynamic shields.io/pypi/… kind. Those proved unreliable: PyPI's JSON API serves a stale info.version for some time after an upload, shields.io caches whatever it read for three hours, and GitHub's image proxy then caches shields. The three compound, and the badge showed a version four releases behind. A pinned badge cannot be stale — and the release fails if either badge stops agreeing with what the package declares, so it cannot be forgotten either. The Python badge is held to the whole Programming Language :: Python :: classifier list rather than just to the requires-python floor: now that it names a range, checking only its first number would wave the rest of it through.


Licence

MIT — see the LICENSE file in the source distribution.

Model weights are Apache-2.0 from MMPose and are downloaded at runtime, not redistributed here.

Metadata

Release files for alchemypose 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for alchemypose 1.0.0
File Size Uploaded
alchemypose-1.0.0.tar.gz 83.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for alchemypose 1.0.0
File Interpreter ABI Platform
alchemypose-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 170.8 kB

Release files / alchemypose-1.0.0.tar.gz

Download URL alchemypose-1.0.0.tar.gz
Size 83.7 kB
Tags Source
SHA-256 checksum
How to use checksums
ca385b3d4e4337c530535de66f9fe33a8ea0c2cf2a45a11ce4d6d45de4a3e688
BLAKE2b-256 checksum
How to use checksums
1f299b51922cf20bb751f17fe8717af4203f52d12afc6cbf38f830d87a7a1c17
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / alchemypose-1.0.0-py3-none-any.whl

Download URL alchemypose-1.0.0-py3-none-any.whl
Size 87.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
56a7bcc5c5bd51436c027bfe06834d86c3ac3f5e22ba0dc191f9c649bd284924
BLAKE2b-256 checksum
How to use checksums
659b8a743b62b841849bc38d0cd076701256b5b7d6bde219c904cc0ef9e6b979
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page