@rig-c/pose 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/bin/rig-pose.cjs +33 -0
- package/cli.ts +171 -0
- package/package.json +56 -4
- package/src/joints.ts +70 -0
- package/src/map.ts +325 -0
- package/src/pose.ts +303 -0
- package/src/problems.ts +69 -0
- package/src/retarget.ts +461 -0
- package/src/rigc.ts +154 -0
- package/src/run.ts +164 -0
- package/src/verify.ts +216 -0
package/src/map.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The joint map: `rig-pose-map/1` — which bones of the rig carry which joints.
|
|
3
|
+
*
|
|
4
|
+
* { "spec": "rig-pose-map/1",
|
|
5
|
+
* "chains": [
|
|
6
|
+
* { "name": "arm_r", "bones": ["sleeve_r0", "sleeve_r1"], "joints": ["r_shoulder", "r_elbow", "r_wrist"] },
|
|
7
|
+
* { "name": "trunk", "bones": ["chest"], "joints": ["mid_hip", "neck"], "aim": ["neck"] } ],
|
|
8
|
+
* "translate": { "bone": "hip", "joint": "mid_hip",
|
|
9
|
+
* "scale": { "pose": ["mid_hip", "neck"], "rig": ["hip", "neck"] } } }
|
|
10
|
+
*
|
|
11
|
+
* A chain's bone k carries the segment from joint k to joint k+1, so a chain
|
|
12
|
+
* has one joint more than it has bones; each bone after the first must descend
|
|
13
|
+
* from the one before it. Each joint strictly inside a chain is a hinge, and
|
|
14
|
+
* `bend` says which way it may bend (see `checkBends`).
|
|
15
|
+
*
|
|
16
|
+
* `aim` says which direction of the rig's bone the segment is, one entry per
|
|
17
|
+
* bone: `"x"` (the default) is the bone's own x axis — right for a chain link
|
|
18
|
+
* @rig-c/parts turned along its limb; a bone name is the direction from this
|
|
19
|
+
* bone's origin to that descendant's origin — right for a trunk bone, whose
|
|
20
|
+
* own axis @rig-c/parts leaves at world 0°; and `"a>b"` is the direction from
|
|
21
|
+
* point a to point b, each the bone itself or a descendant (`name:tip` is the
|
|
22
|
+
* end of a bone's x axis) — right for a head, whose eye line
|
|
23
|
+
* (`"eye_r>eye_l"` against the joints `r_eye`, `l_eye`) turns with it.
|
|
24
|
+
*
|
|
25
|
+
* Wherever the map names a point of the rig — an aim's end, a scale's end — it
|
|
26
|
+
* may also be `"a+b"`, the midpoint of two points (each a bone or `name:tip`):
|
|
27
|
+
* `"sleeve_r0+sleeve_l0"` is the rig's mid-shoulder.
|
|
28
|
+
*
|
|
29
|
+
* `reach: true` on a one-bone chain whose first joint is the translated joint
|
|
30
|
+
* (a trunk from mid_hip) aims the bone not along the pose's segment but from
|
|
31
|
+
* where its origin is posed — the hip moved and turned — at the point where the
|
|
32
|
+
* pose puts the segment's end, relative to the translated joint and scaled: so a
|
|
33
|
+
* tilted pelvis does not carry the chest off the shoulders' line. Its aim is one
|
|
34
|
+
* point (a bone, `name:tip` or `a+b`), measured from the bone's own origin.
|
|
35
|
+
*
|
|
36
|
+
* `translate` moves one bone by the travel of one point, scaled by the ratio of
|
|
37
|
+
* a length measured on the rig (between two points: a bone's origin, `name:tip`
|
|
38
|
+
* the end of a bone's x axis, or `a+b`) to the same length measured in the pose
|
|
39
|
+
* (its median over the frames where both points are good).
|
|
40
|
+
*/
|
|
41
|
+
import type { RawBone } from 'rig-c/core';
|
|
42
|
+
import { POINT_NAMES } from './joints.ts';
|
|
43
|
+
import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './problems.ts';
|
|
44
|
+
|
|
45
|
+
export const MAP_SPEC = 'rig-pose-map/1';
|
|
46
|
+
export const BENDS = ['rest', 'ccw', 'cw', 'free'] as const;
|
|
47
|
+
export type Bend = (typeof BENDS)[number];
|
|
48
|
+
|
|
49
|
+
export interface Chain {
|
|
50
|
+
name: string;
|
|
51
|
+
bones: string[];
|
|
52
|
+
joints: string[];
|
|
53
|
+
aim: string[];
|
|
54
|
+
bend: Bend;
|
|
55
|
+
/** Aim from the bone's posed origin at the pose's end point, anchored at the translated bone. */
|
|
56
|
+
reach: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface Translate {
|
|
59
|
+
bone: string;
|
|
60
|
+
joint: string;
|
|
61
|
+
scale: { pose: [string, string]; rig: [string, string] };
|
|
62
|
+
}
|
|
63
|
+
export interface JointMap {
|
|
64
|
+
chains: Chain[];
|
|
65
|
+
translate: Translate | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The rig as the map is resolved against it: each bone's setup pose, by rig-c's poser. */
|
|
69
|
+
export interface RestBone extends RawBone {
|
|
70
|
+
/** The bone's `length` in the compiled model (0 when it has none). */
|
|
71
|
+
length: number;
|
|
72
|
+
}
|
|
73
|
+
export type Rest = ReadonlyMap<string, RestBone>;
|
|
74
|
+
|
|
75
|
+
export function readMap(text: string, where: string): JointMap {
|
|
76
|
+
const doc = parseJson(text, where, 'MAP_JSON');
|
|
77
|
+
const p = new Problems();
|
|
78
|
+
if (!isObject(doc)) {
|
|
79
|
+
p.add('MAP_SHAPE', `${where} is ${typeName(doc)}; a ${MAP_SPEC} document is an object`);
|
|
80
|
+
p.throwIfAny();
|
|
81
|
+
throw new Error('unreachable');
|
|
82
|
+
}
|
|
83
|
+
refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate']);
|
|
84
|
+
if (doc.spec !== MAP_SPEC) p.add('MAP_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${MAP_SPEC}"`);
|
|
85
|
+
const chains: Chain[] = [];
|
|
86
|
+
if (!Array.isArray(doc.chains) || doc.chains.length === 0) {
|
|
87
|
+
p.add('MAP_CHAINS', `${where} "chains" is ${typeName(doc.chains)}; it must be a non-empty array of chains`);
|
|
88
|
+
} else {
|
|
89
|
+
const names = new Set<string>();
|
|
90
|
+
doc.chains.forEach((c: unknown, i: number) => {
|
|
91
|
+
const cw = `${where} chains[${i}]`;
|
|
92
|
+
if (!isObject(c)) {
|
|
93
|
+
p.add('MAP_CHAIN_SHAPE', `${cw} is ${typeName(c)}; a chain is { name, bones, joints, aim?, bend?, reach? }`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
refuseUnknownKeys(p, 'MAP_KEYS', cw, c, ['name', 'bones', 'joints', 'aim', 'bend', 'reach']);
|
|
97
|
+
const name = typeof c.name === 'string' && c.name !== '' ? c.name : null;
|
|
98
|
+
if (name === null) p.add('MAP_CHAIN_NAME', `${cw} "name" is ${typeName(c.name)}; it must be a non-empty string`);
|
|
99
|
+
else if (names.has(name)) p.add('MAP_CHAIN_NAME', `${cw} "name" "${name}" names two chains`);
|
|
100
|
+
if (name !== null) names.add(name);
|
|
101
|
+
const label = name === null ? cw : `chain "${name}"`;
|
|
102
|
+
const bones = strings(c.bones);
|
|
103
|
+
const joints = strings(c.joints);
|
|
104
|
+
if (bones === null || bones.length === 0) p.add('MAP_CHAIN_BONES', `${label} "bones" is ${typeName(c.bones)}; it must be a non-empty array of bone names`);
|
|
105
|
+
if (joints === null || joints.length < 2) p.add('MAP_CHAIN_JOINTS', `${label} "joints" is ${typeName(c.joints)}; it must be an array of at least two joint names`);
|
|
106
|
+
if (bones !== null && joints !== null && bones.length > 0 && joints.length !== bones.length + 1) {
|
|
107
|
+
p.add('MAP_CHAIN_JOINTS', `${label} has ${bones.length} bone(s) and ${joints.length} joint(s); bone k carries the segment from joint k to joint k+1, so it needs ${bones.length + 1}`);
|
|
108
|
+
}
|
|
109
|
+
for (const j of joints ?? []) {
|
|
110
|
+
if (!POINT_NAMES.includes(j)) p.add('MAP_JOINT_NAME', `${label} names joint "${j}", which is not a point this package reads; they are ${POINT_NAMES.join(', ')}`);
|
|
111
|
+
}
|
|
112
|
+
let aim = bones === null ? [] : bones.map(() => 'x');
|
|
113
|
+
if ('aim' in c) {
|
|
114
|
+
const a = strings(c.aim);
|
|
115
|
+
if (a === null || bones === null || a.length !== bones.length) p.add('MAP_CHAIN_AIM', `${label} "aim" is ${typeName(c.aim)}; it must be one entry per bone ("x", a descendant point, or "a>b")`);
|
|
116
|
+
else {
|
|
117
|
+
aim = a;
|
|
118
|
+
for (const x of a) if (x !== 'x') for (const end of x.split('>')) pointProblem(p, label, end);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
let bend: Bend = 'rest';
|
|
122
|
+
if ('bend' in c) {
|
|
123
|
+
if (typeof c.bend !== 'string' || !(BENDS as readonly string[]).includes(c.bend)) p.add('MAP_CHAIN_BEND', `${label} "bend" is ${typeName(c.bend)}; it must be one of ${BENDS.join(', ')}`);
|
|
124
|
+
else bend = c.bend as Bend;
|
|
125
|
+
}
|
|
126
|
+
let reach = false;
|
|
127
|
+
if ('reach' in c) {
|
|
128
|
+
if (typeof c.reach !== 'boolean') p.add('MAP_REACH', `${label} "reach" is ${typeName(c.reach)}; it must be true or false`);
|
|
129
|
+
else reach = c.reach;
|
|
130
|
+
}
|
|
131
|
+
chains.push({ name: name ?? cw, bones: bones ?? [], joints: joints ?? [], aim, bend, reach });
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
let translate: Translate | null = null;
|
|
135
|
+
if ('translate' in doc) {
|
|
136
|
+
const t = doc.translate;
|
|
137
|
+
const tw = `${where} "translate"`;
|
|
138
|
+
if (!isObject(t)) {
|
|
139
|
+
p.add('MAP_TRANSLATE', `${tw} is ${typeName(t)}; it is { bone, joint, scale: { pose: [a, b], rig: [a, b] } }`);
|
|
140
|
+
} else {
|
|
141
|
+
refuseUnknownKeys(p, 'MAP_KEYS', tw, t, ['bone', 'joint', 'scale']);
|
|
142
|
+
if (typeof t.bone !== 'string' || t.bone === '') p.add('MAP_TRANSLATE', `${tw} "bone" is ${typeName(t.bone)}; it must name a bone`);
|
|
143
|
+
if (typeof t.joint !== 'string' || !POINT_NAMES.includes(t.joint)) p.add('MAP_JOINT_NAME', `${tw} "joint" is ${typeName(t.joint)}; it must be one of ${POINT_NAMES.join(', ')}`);
|
|
144
|
+
const s = t.scale;
|
|
145
|
+
const pose = isObject(s) ? strings(s.pose) : null;
|
|
146
|
+
const rig = isObject(s) ? strings(s.rig) : null;
|
|
147
|
+
if (isObject(s)) refuseUnknownKeys(p, 'MAP_KEYS', `${tw} "scale"`, s, ['pose', 'rig']);
|
|
148
|
+
if (pose === null || pose.length !== 2 || rig === null || rig.length !== 2) {
|
|
149
|
+
p.add('MAP_TRANSLATE_SCALE', `${tw} "scale" is ${JSON.stringify(s)}; it must be { pose: [joint, joint], rig: [bone, bone] }, the same length measured in each`);
|
|
150
|
+
} else {
|
|
151
|
+
for (const j of pose) if (!POINT_NAMES.includes(j)) p.add('MAP_JOINT_NAME', `${tw} "scale.pose" names "${j}"; the points are ${POINT_NAMES.join(', ')}`);
|
|
152
|
+
for (const r of rig) pointProblem(p, `${tw} "scale.rig"`, r);
|
|
153
|
+
if (typeof t.bone === 'string' && typeof t.joint === 'string') translate = { bone: t.bone, joint: t.joint, scale: { pose: [pose[0]!, pose[1]!], rig: [rig[0]!, rig[1]!] } };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const c of chains) {
|
|
158
|
+
if (!c.reach) continue;
|
|
159
|
+
const label = `chain "${c.name}"`;
|
|
160
|
+
if (c.bones.length !== 1) p.add('MAP_REACH', `${label} has "reach" and ${c.bones.length} bones; a reach turns one bone from its posed origin, so the chain must have exactly one`);
|
|
161
|
+
const aim = c.aim[0];
|
|
162
|
+
if (aim !== undefined && (aim === 'x' || aim.includes('>'))) {
|
|
163
|
+
p.add('MAP_REACH', `${label} has "reach" and aims ${JSON.stringify(aim)}; a reach aims from the bone's own origin at one point, so "aim" must name it (a bone, name:tip or a+b)`);
|
|
164
|
+
}
|
|
165
|
+
if (!('translate' in doc)) p.add('MAP_REACH', `${label} has "reach" and the map has no "translate"; a reach is anchored at the translated bone's posed origin`);
|
|
166
|
+
else if (translate !== null && c.joints[0] !== translate.joint) {
|
|
167
|
+
p.add('MAP_REACH', `${label} has "reach" and starts at joint "${c.joints[0]}"; the reach is measured from the translated joint, so its first joint must be "${translate.joint}"`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
p.throwIfAny();
|
|
171
|
+
return { chains, translate };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A rig point as the map spells it: `name`, `name:tip`, or `a+b`, the midpoint of two such. */
|
|
175
|
+
function pointProblem(p: Problems, label: string, spec: string): void {
|
|
176
|
+
const parts = spec.split('+');
|
|
177
|
+
if (parts.length > 2 || parts.some((x) => x === '' || x === ':tip')) {
|
|
178
|
+
p.add('MAP_POINT', `${label} names the point ${JSON.stringify(spec)}; a point is a bone name, name:tip (the end of its x axis), or a+b (the midpoint of two of those)`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The points a point spec averages: one (`name`, `name:tip`) or two (`a+b`). */
|
|
183
|
+
export function pointParts(spec: string): string[] {
|
|
184
|
+
return spec.split('+');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The bone a point part is read off (`name:tip` → `name`). */
|
|
188
|
+
export function partBone(part: string): string {
|
|
189
|
+
return part.endsWith(':tip') ? part.slice(0, -4) : part;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function strings(v: unknown): string[] | null {
|
|
193
|
+
return Array.isArray(v) && v.every((s) => typeof s === 'string') ? (v as string[]) : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether `bone` descends from `ancestor` (strictly). */
|
|
197
|
+
export function descends(rest: Rest, bone: string, ancestor: string): boolean {
|
|
198
|
+
for (let b = rest.get(bone)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) if (b === ancestor) return true;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The two points a non-`x` aim runs between: `"a>b"`, or the bone's origin to a descendant point. */
|
|
203
|
+
export function aimEnds(bone: string, aim: string): [string, string] {
|
|
204
|
+
const i = aim.indexOf('>');
|
|
205
|
+
return i < 0 ? [bone, aim] : [aim.slice(0, i), aim.slice(i + 1)];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What an aim reads off a bone: its world x axis column and origin. */
|
|
209
|
+
export interface AimBone {
|
|
210
|
+
a: number;
|
|
211
|
+
c: number;
|
|
212
|
+
worldX: number;
|
|
213
|
+
worldY: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A mapped bone's aim, Spine world degrees, on any pose of the rig: the setup
|
|
218
|
+
* pose (the rest angle) or a posed frame (where it landed). `lengths` are the
|
|
219
|
+
* model's bone lengths, for `name:tip`.
|
|
220
|
+
*/
|
|
221
|
+
export function aimAngle(bones: ReadonlyMap<string, AimBone>, lengths: ReadonlyMap<string, number>, bone: string, aim: string): number {
|
|
222
|
+
const DEG = 180 / Math.PI;
|
|
223
|
+
if (aim === 'x') {
|
|
224
|
+
const b = bones.get(bone)!;
|
|
225
|
+
return Math.atan2(b.c, b.a) * DEG;
|
|
226
|
+
}
|
|
227
|
+
const [f, t] = aimEnds(bone, aim).map((e) => pointAt(bones, lengths, e)!) as [[number, number], [number, number]];
|
|
228
|
+
return Math.atan2(t[1] - f[1], t[0] - f[0]) * DEG;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A rig point on any pose of the rig: a bone's origin, `name:tip` (the end of
|
|
233
|
+
* its x axis), or `a+b` (their midpoint); null when a bone is not there.
|
|
234
|
+
*/
|
|
235
|
+
export function pointAt(bones: ReadonlyMap<string, AimBone>, lengths: ReadonlyMap<string, number>, spec: string): [number, number] | null {
|
|
236
|
+
let x = 0;
|
|
237
|
+
let y = 0;
|
|
238
|
+
const parts = pointParts(spec);
|
|
239
|
+
for (const part of parts) {
|
|
240
|
+
const name = partBone(part);
|
|
241
|
+
const b = bones.get(name);
|
|
242
|
+
if (b === undefined) return null;
|
|
243
|
+
const l = part.endsWith(':tip') ? lengths.get(name) ?? 0 : 0;
|
|
244
|
+
x += b.worldX + b.a * l;
|
|
245
|
+
y += b.worldY + b.c * l;
|
|
246
|
+
}
|
|
247
|
+
return [x / parts.length, y / parts.length];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A rig point at the setup pose: a bone's origin, `name:tip`, or `a+b`. */
|
|
251
|
+
export function rigPoint(rest: Rest, spec: string): [number, number] | null {
|
|
252
|
+
return pointAt(rest, new Map([...rest].map(([n, b]) => [n, b.length])), spec);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Check every name the map gives against the rig; every miss is named. */
|
|
256
|
+
export function resolveMap(map: JointMap, rest: Rest, where: string): void {
|
|
257
|
+
const p = new Problems();
|
|
258
|
+
const all = [...rest.keys()];
|
|
259
|
+
const listed = all.length <= 40 ? all.join(', ') : `${all.slice(0, 40).join(', ')} … (${all.length} in all)`;
|
|
260
|
+
const owner = new Map<string, string>();
|
|
261
|
+
for (const c of map.chains) {
|
|
262
|
+
c.bones.forEach((b, k) => {
|
|
263
|
+
if (!rest.has(b)) {
|
|
264
|
+
p.add('MAP_BONE_EXISTS', `chain "${c.name}" — bone "${b}" is not in the rig; its bones are ${listed}`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const prev = owner.get(b);
|
|
268
|
+
if (prev !== undefined) p.add('MAP_BONE_ONCE', `bone "${b}" is in chain "${prev}" and in chain "${c.name}"; a bone carries one segment`);
|
|
269
|
+
owner.set(b, c.name);
|
|
270
|
+
if (k > 0 && rest.has(c.bones[k - 1]!) && !descends(rest, b, c.bones[k - 1]!)) {
|
|
271
|
+
p.add('MAP_CHAIN_ORDER', `chain "${c.name}" — bone "${b}" does not descend from "${c.bones[k - 1]}"; its parent is "${rest.get(b)!.parent}"`);
|
|
272
|
+
}
|
|
273
|
+
const aim = c.aim[k]!;
|
|
274
|
+
if (aim === 'x') {
|
|
275
|
+
const o = rest.get(b)!;
|
|
276
|
+
if (Math.hypot(o.a, o.c) < 1e-9) p.add('MAP_AIM_LENGTH', `chain "${c.name}" — bone "${b}" has a collapsed x axis at setup (scale 0); aim it at a descendant`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const [from, to] = aimEnds(b, aim);
|
|
280
|
+
const verb = aim.includes('>') ? 'aims through' : 'aims at';
|
|
281
|
+
let ok = true;
|
|
282
|
+
for (const end of [from, to]) {
|
|
283
|
+
for (const part of pointParts(end)) {
|
|
284
|
+
const name = partBone(part);
|
|
285
|
+
const which = part === end ? `"${end}"` : `"${part}" (of "${end}")`;
|
|
286
|
+
if (!rest.has(name)) {
|
|
287
|
+
p.add('MAP_AIM_EXISTS', `chain "${c.name}" — bone "${b}" ${verb} ${which}, which is not in the rig`);
|
|
288
|
+
ok = false;
|
|
289
|
+
} else if (name !== b && !descends(rest, name, b)) {
|
|
290
|
+
p.add('MAP_AIM_DESCENDS', `chain "${c.name}" — bone "${b}" ${verb} "${name}", which does not descend from it; turning "${b}" would not turn that direction`);
|
|
291
|
+
ok = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (ok) {
|
|
296
|
+
const pf = rigPoint(rest, from)!;
|
|
297
|
+
const pt = rigPoint(rest, to)!;
|
|
298
|
+
if (Math.hypot(pt[0] - pf[0], pt[1] - pf[1]) < 1e-6) p.add('MAP_AIM_LENGTH', `chain "${c.name}" — bone "${b}" aims from "${from}" to "${to}", which are at the same point; the direction is undefined`);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const t = map.translate;
|
|
303
|
+
if (t !== null) {
|
|
304
|
+
if (!rest.has(t.bone)) p.add('MAP_BONE_EXISTS', `"translate" — bone "${t.bone}" is not in the rig; its bones are ${listed}`);
|
|
305
|
+
else {
|
|
306
|
+
for (const b of owner.keys()) {
|
|
307
|
+
if (descends(rest, t.bone, b)) p.add('MAP_TRANSLATE_PARENT', `"translate" — bone "${t.bone}" descends from "${b}", which chain "${owner.get(b)}" turns; a translated bone's parent frame must not move`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const r of t.scale.rig) {
|
|
311
|
+
for (const part of pointParts(r)) if (!rest.has(partBone(part))) p.add('MAP_BONE_EXISTS', `"translate" "scale.rig" — "${r}"${part === r ? '' : ` reads "${partBone(part)}", which`} is not a bone in the rig (a point is a bone name, name:tip, or a+b)`);
|
|
312
|
+
}
|
|
313
|
+
const [r0, r1] = t.scale.rig.map((r) => rigPoint(rest, r));
|
|
314
|
+
if (r0 && r1 && Math.hypot(r1[0] - r0[0], r1[1] - r0[1]) < 1e-6) p.add('MAP_TRANSLATE_SCALE', `"translate" "scale.rig" — "${t.scale.rig[0]}" and "${t.scale.rig[1]}" are at the same point; the length is 0`);
|
|
315
|
+
}
|
|
316
|
+
for (const c of map.chains) {
|
|
317
|
+
const b = c.bones[0];
|
|
318
|
+
if (!c.reach || t === null || b === undefined || !rest.has(b) || !rest.has(t.bone)) continue;
|
|
319
|
+
if (b !== t.bone && !descends(rest, b, t.bone)) {
|
|
320
|
+
p.add('MAP_REACH', `chain "${c.name}" — bone "${b}" reaches from its posed origin, but it does not descend from the translated bone "${t.bone}"; its origin would not travel with the anchor`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (p.any) p.list.unshift({ code: 'MAP_RESOLVES', text: `${where} does not resolve against the rig` });
|
|
324
|
+
p.throwIfAny();
|
|
325
|
+
}
|
package/src/pose.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pose sequence: `rig-pose-sequence/1`.
|
|
3
|
+
*
|
|
4
|
+
* It is @rig-c/parts' `spine-parts-keypoints/1` (one picture's joints) extended
|
|
5
|
+
* to frames, with the same vocabulary — `space`, `width`/`height`, `source`,
|
|
6
|
+
* `people[].id`, `people[].joints.<body-18 name>.{state, at, score}` — and one
|
|
7
|
+
* new level, `frames[]`, each carrying its time `t` in seconds:
|
|
8
|
+
*
|
|
9
|
+
* { "spec": "rig-pose-sequence/1",
|
|
10
|
+
* "space": { "units": "picture-px", "origin": "top-left", "y": "down" },
|
|
11
|
+
* "width": 1080, "height": 1920,
|
|
12
|
+
* "source": "what produced the file",
|
|
13
|
+
* "frames": [ { "t": 0, "people": [ { "id": "a", "joints": {
|
|
14
|
+
* "neck": { "state": "observed", "at": [540, 400], "score": 0.93 } } } ] } ] }
|
|
15
|
+
*
|
|
16
|
+
* Differences from the keypoints file, each a consequence of what a sequence
|
|
17
|
+
* is: the unit is `picture-px` (a video frame is not a painting), and `at` may
|
|
18
|
+
* lie outside the picture (a dancer's hand leaves the frame; a pose model
|
|
19
|
+
* places it anyway, and its score says how much to believe it).
|
|
20
|
+
*/
|
|
21
|
+
import { BODY18, COCO17, COCO17_TO_BODY18, isBody18, type Body18 } from './joints.ts';
|
|
22
|
+
import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './problems.ts';
|
|
23
|
+
|
|
24
|
+
export const POSE_SPEC = 'rig-pose-sequence/1';
|
|
25
|
+
export const POSE_SPACE = { units: 'picture-px', origin: 'top-left', y: 'down' } as const;
|
|
26
|
+
const STATES = ['observed', 'occluded', 'missing'] as const;
|
|
27
|
+
export type JointState = (typeof STATES)[number];
|
|
28
|
+
|
|
29
|
+
export interface Joint {
|
|
30
|
+
state: JointState;
|
|
31
|
+
at?: [number, number];
|
|
32
|
+
score?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface Person {
|
|
35
|
+
id: string;
|
|
36
|
+
joints: Partial<Record<Body18, Joint>>;
|
|
37
|
+
}
|
|
38
|
+
export interface PoseFrame {
|
|
39
|
+
t: number;
|
|
40
|
+
people: Person[];
|
|
41
|
+
}
|
|
42
|
+
export interface PoseSequence {
|
|
43
|
+
spec: typeof POSE_SPEC;
|
|
44
|
+
space: typeof POSE_SPACE;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
source: string;
|
|
48
|
+
frames: PoseFrame[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
|
|
52
|
+
|
|
53
|
+
/** Read and check a pose sequence; every problem is collected and thrown once. */
|
|
54
|
+
export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
55
|
+
const doc = parseJson(text, where, 'POSE_JSON');
|
|
56
|
+
const p = new Problems();
|
|
57
|
+
if (!isObject(doc)) {
|
|
58
|
+
p.add('POSE_SHAPE', `${where} is ${typeName(doc)}; a ${POSE_SPEC} document is an object`);
|
|
59
|
+
p.throwIfAny();
|
|
60
|
+
throw new Error('unreachable');
|
|
61
|
+
}
|
|
62
|
+
refuseUnknownKeys(p, 'POSE_KEYS', where, doc, TOP_KEYS);
|
|
63
|
+
for (const k of TOP_KEYS) if (!(k in doc)) p.add('POSE_KEYS', `${where} has no "${k}"; a ${POSE_SPEC} document carries ${TOP_KEYS.join(', ')}`);
|
|
64
|
+
if ('spec' in doc && doc.spec !== POSE_SPEC) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}"`);
|
|
65
|
+
if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(POSE_SPACE)) {
|
|
66
|
+
p.add('POSE_SPACE', `${where} "space" is ${JSON.stringify(doc.space)}; it must be exactly ${JSON.stringify(POSE_SPACE)}`);
|
|
67
|
+
}
|
|
68
|
+
for (const k of ['width', 'height'] as const) {
|
|
69
|
+
if (k in doc && !(Number.isInteger(doc[k]) && (doc[k] as number) > 0)) p.add('POSE_SIZE', `${where} "${k}" is ${typeName(doc[k])}; it must be a positive integer (picture pixels)`);
|
|
70
|
+
}
|
|
71
|
+
if ('source' in doc && (typeof doc.source !== 'string' || doc.source.trim() === '')) p.add('POSE_SOURCE', `${where} "source" is ${typeName(doc.source)}; it must say what produced the file`);
|
|
72
|
+
const frames: PoseFrame[] = [];
|
|
73
|
+
if ('frames' in doc) {
|
|
74
|
+
if (!Array.isArray(doc.frames) || doc.frames.length === 0) {
|
|
75
|
+
p.add('POSE_FRAMES', `${where} "frames" is ${typeName(doc.frames)}; it must be a non-empty array`);
|
|
76
|
+
} else {
|
|
77
|
+
let lastT = -Infinity;
|
|
78
|
+
doc.frames.forEach((f: unknown, i: number) => {
|
|
79
|
+
const fw = `${where} frames[${i}]`;
|
|
80
|
+
if (!isObject(f)) {
|
|
81
|
+
p.add('POSE_FRAME_SHAPE', `${fw} is ${typeName(f)}; a frame is { t, people }`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
refuseUnknownKeys(p, 'POSE_KEYS', fw, f, ['t', 'people']);
|
|
85
|
+
const t = f.t;
|
|
86
|
+
if (typeof t !== 'number' || !Number.isFinite(t) || t < 0) {
|
|
87
|
+
p.add('POSE_TIME', `${fw} "t" is ${typeName(t)}; it must be a finite number of seconds ≥ 0`);
|
|
88
|
+
} else if (t <= lastT) {
|
|
89
|
+
p.add('POSE_TIME', `${fw} "t" is ${t}s, not after frame ${i - 1}'s ${lastT}s; times must strictly increase`);
|
|
90
|
+
}
|
|
91
|
+
if (typeof t === 'number' && Number.isFinite(t)) lastT = Math.max(lastT, t);
|
|
92
|
+
const people: Person[] = [];
|
|
93
|
+
if (!Array.isArray(f.people)) {
|
|
94
|
+
p.add('POSE_PEOPLE', `${fw} "people" is ${typeName(f.people)}; it must be an array (empty when nobody was found)`);
|
|
95
|
+
} else {
|
|
96
|
+
const ids = new Set<string>();
|
|
97
|
+
f.people.forEach((person: unknown, j: number) => {
|
|
98
|
+
const pw = `${fw} people[${j}]`;
|
|
99
|
+
if (!isObject(person)) {
|
|
100
|
+
p.add('POSE_PERSON_SHAPE', `${pw} is ${typeName(person)}; a person is { id, joints }`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
refuseUnknownKeys(p, 'POSE_KEYS', pw, person, ['id', 'joints']);
|
|
104
|
+
if (typeof person.id !== 'string' || person.id === '') {
|
|
105
|
+
p.add('POSE_PERSON_ID', `${pw} "id" is ${typeName(person.id)}; it must be a non-empty string`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (ids.has(person.id)) p.add('POSE_PERSON_ID', `${pw} "id" "${person.id}" appears twice in frame ${i}`);
|
|
109
|
+
ids.add(person.id);
|
|
110
|
+
people.push({ id: person.id, joints: readJoints(p, pw, person.joints) });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
frames.push({ t: typeof t === 'number' ? t : NaN, people });
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
p.throwIfAny();
|
|
118
|
+
return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, frames };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
|
|
122
|
+
const out: Partial<Record<Body18, Joint>> = {};
|
|
123
|
+
if (!isObject(joints)) {
|
|
124
|
+
p.add('POSE_JOINTS', `${pw} "joints" is ${typeName(joints)}; it must be an object keyed by body-18 joint names`);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
for (const [name, j] of Object.entries(joints)) {
|
|
128
|
+
const jw = `${pw} joint "${name}"`;
|
|
129
|
+
if (!isBody18(name)) {
|
|
130
|
+
p.add('POSE_JOINT_NAME', `${pw} names joint "${name}", which body-18 does not have; the joints are ${BODY18.join(', ')}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!isObject(j)) {
|
|
134
|
+
p.add('POSE_JOINT_SHAPE', `${jw} is ${typeName(j)}; a joint is { state, at?, score? }`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
refuseUnknownKeys(p, 'POSE_KEYS', jw, j, ['state', 'at', 'score']);
|
|
138
|
+
const state = j.state;
|
|
139
|
+
if (typeof state !== 'string' || !(STATES as readonly string[]).includes(state)) {
|
|
140
|
+
p.add('POSE_JOINT_STATE', `${jw} "state" is ${typeName(state)}; it must be one of ${STATES.join(', ')}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const joint: Joint = { state: state as JointState };
|
|
144
|
+
if ('at' in j) {
|
|
145
|
+
const at = j.at;
|
|
146
|
+
if (state === 'missing') {
|
|
147
|
+
p.add('POSE_JOINT_AT', `${jw} is "missing" and carries "at"; a missing joint has no position`);
|
|
148
|
+
} else if (!Array.isArray(at) || at.length !== 2 || !at.every((v) => typeof v === 'number' && Number.isFinite(v))) {
|
|
149
|
+
p.add('POSE_JOINT_AT', `${jw} "at" is ${typeName(at)}; it must be [x, y] in picture pixels`);
|
|
150
|
+
} else {
|
|
151
|
+
joint.at = [at[0] as number, at[1] as number];
|
|
152
|
+
}
|
|
153
|
+
} else if (state === 'observed') {
|
|
154
|
+
p.add('POSE_JOINT_AT', `${jw} is "observed" and has no "at"; an observed joint has a position`);
|
|
155
|
+
}
|
|
156
|
+
if ('score' in j) {
|
|
157
|
+
if (typeof j.score !== 'number' || !Number.isFinite(j.score)) p.add('POSE_JOINT_SCORE', `${jw} "score" is ${typeName(j.score)}; it must be a finite number`);
|
|
158
|
+
else joint.score = j.score;
|
|
159
|
+
}
|
|
160
|
+
out[name] = joint;
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The one person the retarget follows: `wanted` when given, else the only id
|
|
167
|
+
* the sequence contains. Frames where that person is absent carry no joints.
|
|
168
|
+
*/
|
|
169
|
+
export function personTrack(seq: PoseSequence, wanted: string | undefined, where: string): Array<Partial<Record<Body18, Joint>>> {
|
|
170
|
+
const ids = [...new Set(seq.frames.flatMap((f) => f.people.map((q) => q.id)))].sort();
|
|
171
|
+
const p = new Problems();
|
|
172
|
+
let id = wanted;
|
|
173
|
+
if (id === undefined) {
|
|
174
|
+
if (ids.length === 1) id = ids[0];
|
|
175
|
+
else if (ids.length === 0) p.add('POSE_PERSON', `${where} has no person on any of its ${seq.frames.length} frames`);
|
|
176
|
+
else p.add('POSE_PERSON', `${where} holds ${ids.length} people (${ids.join(', ')}); name the one to follow with --person`);
|
|
177
|
+
} else if (!ids.includes(id)) {
|
|
178
|
+
p.add('POSE_PERSON', `--person "${id}" is not in ${where}; its people are ${ids.join(', ') || '(none)'}`);
|
|
179
|
+
}
|
|
180
|
+
p.throwIfAny();
|
|
181
|
+
return seq.frames.map((f) => f.people.find((q) => q.id === id)?.joints ?? {});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- COCO-17 -------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export const COCO_SPEC = 'coco17-sequence/1';
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Convert a COCO-17 sequence to a pose sequence. The input is the common shape
|
|
190
|
+
* of a pose model's per-frame output:
|
|
191
|
+
*
|
|
192
|
+
* { "format": "coco17-sequence/1", "width": W, "height": H, "source": "…",
|
|
193
|
+
* "frames": [ { "t": 0, "keypoints": [[x, y, score] × 17] | [x, y, s × 17 flat] | null } ] }
|
|
194
|
+
*
|
|
195
|
+
* in COCO's standard joint order (`COCO17`). A keypoint whose score is 0 is
|
|
196
|
+
* missing (COCO's `v = 0`); every other keypoint is observed with its score —
|
|
197
|
+
* the retarget, not the conversion, decides what score is good enough. `neck`
|
|
198
|
+
* is the shoulders' midpoint (`COCO17_TO_BODY18`). A `null` frame is a frame
|
|
199
|
+
* with nobody in it. The one person is written with id "a".
|
|
200
|
+
*/
|
|
201
|
+
export function convertCoco17(text: string, where: string): PoseSequence {
|
|
202
|
+
const doc = parseJson(text, where, 'COCO_JSON');
|
|
203
|
+
const p = new Problems();
|
|
204
|
+
if (!isObject(doc)) {
|
|
205
|
+
p.add('COCO_SHAPE', `${where} is ${typeName(doc)}; a ${COCO_SPEC} document is an object`);
|
|
206
|
+
p.throwIfAny();
|
|
207
|
+
throw new Error('unreachable');
|
|
208
|
+
}
|
|
209
|
+
refuseUnknownKeys(p, 'COCO_KEYS', where, doc, ['format', 'width', 'height', 'source', 'frames', 'joints', 'fps', 'model']);
|
|
210
|
+
if (doc.format !== COCO_SPEC) p.add('COCO_SPEC', `${where} "format" is ${typeName(doc.format)}; this reader reads "${COCO_SPEC}"`);
|
|
211
|
+
if ('joints' in doc && JSON.stringify(doc.joints) !== JSON.stringify(COCO17)) {
|
|
212
|
+
p.add('COCO_ORDER', `${where} "joints" is ${JSON.stringify(doc.joints)}; this reader reads COCO's standard order ${JSON.stringify(COCO17)}`);
|
|
213
|
+
}
|
|
214
|
+
for (const k of ['width', 'height'] as const) {
|
|
215
|
+
if (!(Number.isInteger(doc[k]) && (doc[k] as number) > 0)) p.add('COCO_SIZE', `${where} "${k}" is ${typeName(doc[k])}; it must be a positive integer (picture pixels)`);
|
|
216
|
+
}
|
|
217
|
+
const source = typeof doc.source === 'string' ? doc.source : isObject(doc.source) ? JSON.stringify(doc.source) : '';
|
|
218
|
+
if (source === '') p.add('COCO_SOURCE', `${where} "source" is ${typeName(doc.source)}; it must say what produced the file`);
|
|
219
|
+
const frames: PoseFrame[] = [];
|
|
220
|
+
if (!Array.isArray(doc.frames) || doc.frames.length === 0) {
|
|
221
|
+
p.add('COCO_FRAMES', `${where} "frames" is ${typeName(doc.frames)}; it must be a non-empty array`);
|
|
222
|
+
} else {
|
|
223
|
+
doc.frames.forEach((f: unknown, i: number) => {
|
|
224
|
+
const fw = `${where} frames[${i}]`;
|
|
225
|
+
if (!isObject(f)) {
|
|
226
|
+
p.add('COCO_FRAME_SHAPE', `${fw} is ${typeName(f)}; a frame is { t, keypoints }`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (typeof f.t !== 'number' || !Number.isFinite(f.t)) {
|
|
230
|
+
p.add('COCO_TIME', `${fw} "t" is ${typeName(f.t)}; it must be seconds`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (f.keypoints === null) {
|
|
234
|
+
frames.push({ t: f.t, people: [] });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const triples = cocoTriples(f.keypoints);
|
|
238
|
+
if (triples === null) {
|
|
239
|
+
p.add('COCO_KEYPOINTS', `${fw} "keypoints" is ${typeName(f.keypoints)}; it must be 17 [x, y, score] triples, 51 numbers, or null`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const joints: Partial<Record<Body18, Joint>> = {};
|
|
243
|
+
COCO17.forEach((name, k) => {
|
|
244
|
+
const [x, y, s] = triples[k] as [number, number, number];
|
|
245
|
+
joints[COCO17_TO_BODY18[name]] = s === 0 ? { state: 'missing' } : { state: 'observed', at: [x, y], score: s };
|
|
246
|
+
});
|
|
247
|
+
const ls = joints.l_shoulder;
|
|
248
|
+
const rs = joints.r_shoulder;
|
|
249
|
+
if (ls?.at !== undefined && rs?.at !== undefined) {
|
|
250
|
+
joints.neck = {
|
|
251
|
+
state: 'observed',
|
|
252
|
+
at: [(ls.at[0] + rs.at[0]) / 2, (ls.at[1] + rs.at[1]) / 2],
|
|
253
|
+
score: Math.min(ls.score ?? 1, rs.score ?? 1),
|
|
254
|
+
};
|
|
255
|
+
} else {
|
|
256
|
+
joints.neck = { state: 'missing' };
|
|
257
|
+
}
|
|
258
|
+
frames.push({ t: f.t, people: [{ id: 'a', joints: sortedJoints(joints) }] });
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
p.throwIfAny();
|
|
262
|
+
return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: `converted from COCO-17 (${COCO_SPEC}): ${source}`, frames };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function cocoTriples(k: unknown): Array<[number, number, number]> | null {
|
|
266
|
+
if (!Array.isArray(k)) return null;
|
|
267
|
+
const nums = (a: unknown[]): a is number[] => a.every((v) => typeof v === 'number' && Number.isFinite(v));
|
|
268
|
+
if (k.length === 51 && nums(k)) return Array.from({ length: 17 }, (_, i) => [k[3 * i], k[3 * i + 1], k[3 * i + 2]] as [number, number, number]);
|
|
269
|
+
if (k.length === 17 && k.every((t) => Array.isArray(t) && t.length === 3 && nums(t))) return k as Array<[number, number, number]>;
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Joints in body-18 order, so a written file's key order is fixed. */
|
|
274
|
+
export function sortedJoints(j: Partial<Record<Body18, Joint>>): Partial<Record<Body18, Joint>> {
|
|
275
|
+
const out: Partial<Record<Body18, Joint>> = {};
|
|
276
|
+
for (const n of BODY18) {
|
|
277
|
+
const v = j[n];
|
|
278
|
+
if (v !== undefined) out[n] = v;
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A pose sequence as JSON text, in a fixed key order. */
|
|
284
|
+
export function writePoseSequence(seq: PoseSequence): string {
|
|
285
|
+
const round = (v: number, d: number) => Number(v.toFixed(d));
|
|
286
|
+
const frames = seq.frames.map((f) => ({
|
|
287
|
+
t: f.t,
|
|
288
|
+
people: f.people.map((q) => ({
|
|
289
|
+
id: q.id,
|
|
290
|
+
joints: Object.fromEntries(
|
|
291
|
+
Object.entries(sortedJoints(q.joints)).map(([n, j]) => [
|
|
292
|
+
n,
|
|
293
|
+
{
|
|
294
|
+
state: j.state,
|
|
295
|
+
...(j.at !== undefined ? { at: [round(j.at[0], 2), round(j.at[1], 2)] } : {}),
|
|
296
|
+
...(j.score !== undefined ? { score: round(j.score, 4) } : {}),
|
|
297
|
+
},
|
|
298
|
+
]),
|
|
299
|
+
),
|
|
300
|
+
})),
|
|
301
|
+
}));
|
|
302
|
+
return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, frames }, null, 2)}\n`;
|
|
303
|
+
}
|