poser-go
Reusable Go library for pose normalization, similarity scoring, and
mirror-aware ranking — the canonical Go home for
poser's BONE pose-similarity math.
Landmarks in, results out: extraction (image → 33 landmarks) is MediaPipe and
stays in Python; poser-go consumes landmarks, it does not produce them.
Ships two ways from one codebase:
- a Go library — import
pose and search, no network cost;
- a self-hosted HTTP server (
poserd) so non-Go consumers (Python, JS,
anything) use the same scoring/ranking over HTTP.
The server is a thin binary over the library — no logic lives only in the server.
pose and search are pure and deterministic: no I/O, no globals, no
network, standard library only. Parity with the Python implementation is
enforced by a golden fixture at 1e-9 (see Parity).
Status
v0.x — the exported API may change until Suratea has run it in production; then
v1.0 freezes it.
Install
go get codeberg.org/datuch/poser-go
Module path: codeberg.org/datuch/poser-go. Requires Go 1.26+.
Library usage
import (
"codeberg.org/datuch/poser-go/pose"
"codeberg.org/datuch/poser-go/search"
)
// 1. Normalize raw MediaPipe landmarks (len 33) into a 66-float feature vector.
query := pose.Normalize(queryLandmarks)
// 2. Score two poses directly (result in [0,1], 1 == identical).
s := pose.DefaultScorer()
similarity := s.Score(query, other)
// 3. Rank a candidate set, mirror-aware, top-k, self-excluded.
cands := []search.Candidate{
{ID: "img-1", Features: pose.Normalize(lms1)},
{ID: "img-2", Features: pose.Normalize(lms2)},
}
results := search.RankLandmarks(s, queryLandmarks, cands, search.Options{
Limit: 20,
Mirror: true, // also match the horizontally-flipped pose
ExcludeID: "img-self", // e.g. the query's own stored pose
})
// results is sorted by descending Similarity.
Packages:
| Package |
Purpose |
Deps |
pose |
types, Normalize, Mirror, Scorer/DefaultScorer |
stdlib only |
search |
stateless Rank / RankLandmarks over candidates |
pose |
extract |
optional HTTP client for a poser /landmarks svc |
stdlib only |
httpapi |
stateless HTTP handlers over the above |
stdlib only |
A consumer that only needs a score imports pose and pulls no ranking or HTTP
code. Extraction is a separate extract package, so pure-landmark consumers
take no net/http client dependency.
Custom scorer
DefaultScorer() is poser's golden-verified configuration. Every knob is
exported and overridable:
s := pose.DefaultScorer()
s.LandmarkWeights[pose.NumLandmarks-1] = 0 // ignore a landmark
s.BoneBlend = 0.5 // 50/50 bone vs. angle
// s.Bones, s.AngleChains are also replaceable.
Bone weights are derived as the mean of a bone's two endpoint landmark weights;
each angle chain's weight is its middle (vertex) landmark's weight — so
adjusting LandmarkWeights reweights bones and angles consistently.
Server usage
Build and run the static binary:
go build ./cmd/poserd
./poserd -listen 127.0.0.1:8080
# with extraction passthrough to a poser instance:
./poserd -listen 127.0.0.1:8080 -poser-url http://poser:8000
Flags mirror env vars: POSERD_LISTEN, POSERD_POSER_URL. A Docker image and a
docker-compose.example.yml (poserd in front of a private poser instance) are
included.
httpapi.Handler(cfg) is importable — a Go app can mount it under its own mux.
All endpoints are stateless; the caller supplies candidates per request, so
one process serves many independent corpora.
Endpoints
| Method & path |
Request |
Response |
GET /healthz |
— |
{"status":"ok"} |
POST /normalize |
{"landmarks":[33]} |
{"features":[66]} |
POST /mirror |
{"landmarks":[33]} |
{"landmarks":[33]} |
POST /score |
{"a":[66],"b":[66]} |
{"similarity":0.9} |
POST /rank |
{"query":[33],"candidates":[…],"options":{…}} |
{"results":[…]} |
POST /extract† |
multipart file |
{"landmarks":[33]} |
POST /search-image† |
multipart file + candidates,options fields |
{"results":[…]} |
† only mounted when -poser-url is set.
candidates are {"id":…,"features":[66]}; options are
{"limit":int,"mirror":bool,"exclude_id":string}. Errors use a JSON envelope
{"error":"…"} with status codes: 400 bad body, 422 no pose detected in an
image, 503 extraction service unavailable.
curl -s -X POST localhost:8080/score \
-d '{"a":[...66 floats...],"b":[...66 floats...]}'
# {"similarity":0.87}
Algorithm spec
This section defines the behavior independently of any implementation. Together
with the golden fixture it is the source of truth — neither Python nor Go is
authoritative.
Input. 33 MediaPipe Pose landmarks, each {x, y, z, visibility}. x/y are
image-normalized to [0,1]; z is unused by scoring; visibility is
occlusion confidence in [0,1].
Normalization → 66-float feature vector
For landmarks L[0..32], using only x/y:
- Center on the hip midpoint. With left hip = index 23, right hip = 24:
cx = (x[23]+x[24])/2, cy = (y[23]+y[24])/2; subtract (cx, cy) from every
landmark.
- Scale by torso size.
torso = hypot(x[11]-x[24], y[11]-y[24])
(left shoulder to right hip). If torso < 1e-6, fall back to shoulder width
hypot(x[11]-x[12], y[11]-y[12]). Then scale = torso + 1e-8.
- Weight by visibility. For each landmark
i, emit
(x[i]/scale · vis[i], y[i]/scale · vis[i]).
The result is 66 floats, interleaved x,y per landmark, in landmark index
order.
Mirror
Horizontal flip without re-running inference: set x → 1-x for every landmark,
then swap these left/right index pairs:
(1,4) (2,5) (3,6) (7,8) (9,10) (11,12) (13,14) (15,16)
(17,18) (19,20) (21,22) (23,24) (25,26) (27,28) (29,30) (31,32)
Mirror operates on raw landmarks; normalize afterward for a mirrored
feature vector.
BONE similarity score
Given two feature vectors, the score is a weighted blend of bone-direction
agreement and joint-angle agreement, in [0,1]:
score = 0.6 · boneTerm + 0.4 · angleTerm
Bone term. For each of the 14 bones (a,b) below, take the direction
vectors dir = (p[b]-p[a]), normalized to unit length when non-degenerate
(length > 1e-8; otherwise left as the raw difference). Per-bone similarity is
(dot(dir1, dir2) + 1) / 2 (cosine remapped to [0,1]). boneTerm is the
weighted mean over bones; a bone's weight is (w[a]+w[b])/2.
14 bones (landmark index pairs):
(11,13) (13,15) left arm (12,14) (14,16) right arm
(23,25) (25,27) left leg (24,26) (26,28) right leg
(11,23) (12,24) torso sides (11,12) (23,24) shoulder & hip lines
(0,11) (0,12) neck/head
Angle term. For each of the 10 joint chains (j1,j2,j3) below, measure the
angle at the vertex j2 (in radians, via acos of the clamped cosine; 0 when
either segment is degenerate, length < 1e-8). Per-chain similarity is
1 - |angle1 - angle2| / π. angleTerm is the weighted mean; a chain's weight
is w[j2].
10 angle chains:
(11,13,15) (12,14,16) elbows
(23,25,27) (24,26,28) knees
(11,23,25) (12,24,26) hip flexion
(12,11,13) (11,12,14) shoulder line vs. upper arm
(24,23,25) (23,24,26) hip line vs. thigh
Landmark weights
w[i], from poser's landmark_weights.yaml (higher = more salient for pose
matching):
| idx |
landmark |
w |
idx |
landmark |
w |
| 0 |
nose |
5 |
17 |
left_pinky |
1 |
| 1 |
left_eye_inner |
1 |
18 |
right_pinky |
1 |
| 2 |
left_eye |
1 |
19 |
left_index |
1 |
| 3 |
left_eye_outer |
1 |
20 |
right_index |
1 |
| 4 |
right_eye_inner |
1 |
21 |
left_thumb |
1 |
| 5 |
right_eye |
1 |
22 |
right_thumb |
1 |
| 6 |
right_eye_outer |
1 |
23 |
left_hip |
8 |
| 7 |
left_ear |
1 |
24 |
right_hip |
8 |
| 8 |
right_ear |
1 |
25 |
left_knee |
9 |
| 9 |
mouth_left |
1 |
26 |
right_knee |
9 |
| 10 |
mouth_right |
1 |
27 |
left_ankle |
7 |
| 11 |
left_shoulder |
10 |
28 |
right_ankle |
7 |
| 12 |
right_shoulder |
10 |
29 |
left_heel |
3 |
| 13 |
left_elbow |
8 |
30 |
right_heel |
3 |
| 14 |
right_elbow |
8 |
31 |
left_foot_index |
1 |
| 15 |
left_wrist |
11 |
32 |
right_foot_index |
1 |
| 16 |
right_wrist |
11 |
|
|
|
Parity / regeneration
Behavior parity with the Python poser is enforced by a golden fixture and the
conformance test; a diff means the two implementations have diverged. See
docs/parity.md for the schema and how to regenerate.
Design
docs/design.md — architecture, boundaries, and locked
decisions.
License
GPL-3.0-or-later. See LICENSE.