@rig-c/pose 0.13.0 → 0.15.0
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/README.md +59 -15
- package/cli.ts +101 -14
- package/package.json +1 -1
- package/src/clean.ts +448 -0
- package/src/pose.ts +128 -4
- package/src/retarget.ts +7 -68
- package/src/run.ts +21 -3
- package/src/switch.ts +1 -1
- package/src/verify.ts +49 -10
package/src/retarget.ts
CHANGED
|
@@ -19,9 +19,8 @@
|
|
|
19
19
|
* pose puts the segment's end, relative to the translated joint and scaled. So
|
|
20
20
|
* the hip and its travel are computed before any bone's target.
|
|
21
21
|
*
|
|
22
|
-
* Smoothing is
|
|
23
|
-
*
|
|
24
|
-
* it moves no motion in time; a cubic keeps a peak a moving average flattens.
|
|
22
|
+
* Smoothing is not done here: the sequence comes cleaned (`clean.ts`, #67 —
|
|
23
|
+
* spikes, occlusion, a per-joint Savitzky–Golay), and its record says how.
|
|
25
24
|
*
|
|
26
25
|
* The short hold (rig-c/pose#22) comes next: a segment whose picture length on
|
|
27
26
|
* a frame is under `short` of its own median length over the sequence is seen
|
|
@@ -35,7 +34,7 @@
|
|
|
35
34
|
* The flip guard (rig-c/pose#15) then spreads a segment's turn greater than
|
|
36
35
|
* `flip` degrees in one frame over the frames around it, linearly and centred on
|
|
37
36
|
* the jump (`spreadFlips`). It reads the mapped segments' target angles after
|
|
38
|
-
* holds
|
|
37
|
+
* holds and the short hold, and before the hinge check. A reach's
|
|
39
38
|
* target is aimed later, from posed positions, and is neither held short nor
|
|
40
39
|
* guarded.
|
|
41
40
|
*
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
* against it (`artTurn`, and `artRange` where it leaves its range).
|
|
53
52
|
*
|
|
54
53
|
* The lines across the body — hips, shoulders, eyes — are read from the pose
|
|
55
|
-
* after holds
|
|
54
|
+
* after holds, whether the map uses them or not: a line under
|
|
56
55
|
* `TURN_FRACTION` of its median picture length, or reversed (pointing more
|
|
57
56
|
* than 90° from the subject's right to left), for ≥ `TURN_SECONDS`, is a `TURN`:
|
|
58
57
|
* the body or head turned, a pose a flat rig cannot draw. It changes no key.
|
|
@@ -77,8 +76,6 @@ const DEG = 180 / Math.PI;
|
|
|
77
76
|
export interface RetargetOptions extends SwitchOptions {
|
|
78
77
|
/** A joint with a score below this is not good on that frame (a joint with no score is). */
|
|
79
78
|
minScore: number;
|
|
80
|
-
/** Savitzky–Golay window over joint positions, in frames: 1 = none, else odd and ≥ 5. */
|
|
81
|
-
smooth: number;
|
|
82
79
|
/** What a hinge bent the wrong way does: refuse the run, or straighten that hinge on that frame. */
|
|
83
80
|
bends: 'refuse' | 'clamp';
|
|
84
81
|
/** Degrees past straight a hinge may bend the wrong way before it counts. */
|
|
@@ -331,64 +328,6 @@ function median(xs: number[]): number {
|
|
|
331
328
|
return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
|
|
332
329
|
}
|
|
333
330
|
|
|
334
|
-
/**
|
|
335
|
-
* Savitzky–Golay, centred: at each frame, the least-squares polynomial of
|
|
336
|
-
* `order` over the frames of the window that exist (clipped at the ends), read
|
|
337
|
-
* at that frame. With fewer points than order + 1 the order drops to fit them
|
|
338
|
-
* exactly, so a window clipped to 4 frames or fewer returns the input there.
|
|
339
|
-
* A polynomial of degree ≤ `order` passes through unchanged.
|
|
340
|
-
*/
|
|
341
|
-
export function savitzkyGolay(xs: readonly Pt[], window: number, order = 3): Pt[] {
|
|
342
|
-
if (window <= 1) return xs.map((x) => [x[0], x[1]]);
|
|
343
|
-
const h = window >> 1;
|
|
344
|
-
const n = xs.length;
|
|
345
|
-
const cache = new Map<string, number[]>();
|
|
346
|
-
return xs.map((_, i) => {
|
|
347
|
-
const lo = Math.max(0, i - h) - i;
|
|
348
|
-
const hi = Math.min(n - 1, i + h) - i;
|
|
349
|
-
const key = `${lo},${hi}`;
|
|
350
|
-
let w = cache.get(key);
|
|
351
|
-
if (w === undefined) {
|
|
352
|
-
w = sgWeights(lo, hi, Math.min(order, hi - lo));
|
|
353
|
-
cache.set(key, w);
|
|
354
|
-
}
|
|
355
|
-
let x = 0;
|
|
356
|
-
let y = 0;
|
|
357
|
-
for (let u = lo; u <= hi; u++) {
|
|
358
|
-
const c = w[u - lo]!;
|
|
359
|
-
x += c * xs[i + u]![0];
|
|
360
|
-
y += c * xs[i + u]![1];
|
|
361
|
-
}
|
|
362
|
-
return [x, y];
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** The weights that read a least-squares polynomial of degree q over u = lo..hi at u = 0. */
|
|
367
|
-
function sgWeights(lo: number, hi: number, q: number): number[] {
|
|
368
|
-
const m = q + 1;
|
|
369
|
-
// Normal equations: A[j][l] = Σ u^(j+l); solve A z = e0, then w(u) = Σ z_j u^j.
|
|
370
|
-
const A = Array.from({ length: m }, (_, j) => Array.from({ length: m + 1 }, (_, l) => {
|
|
371
|
-
if (l === m) return j === 0 ? 1 : 0;
|
|
372
|
-
let s = 0;
|
|
373
|
-
for (let u = lo; u <= hi; u++) s += u ** (j + l);
|
|
374
|
-
return s;
|
|
375
|
-
}));
|
|
376
|
-
for (let c = 0; c < m; c++) {
|
|
377
|
-
let piv = c;
|
|
378
|
-
for (let r = c + 1; r < m; r++) if (Math.abs(A[r]![c]!) > Math.abs(A[piv]![c]!)) piv = r;
|
|
379
|
-
[A[c], A[piv]] = [A[piv]!, A[c]!];
|
|
380
|
-
for (let r = 0; r < m; r++) {
|
|
381
|
-
if (r === c) continue;
|
|
382
|
-
const f = A[r]![c]! / A[c]![c]!;
|
|
383
|
-
for (let l = c; l <= m; l++) A[r]![l]! -= f * A[c]![l]!;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
const z = A.map((row, j) => row[m]! / row[j]!);
|
|
387
|
-
const w: number[] = [];
|
|
388
|
-
for (let u = lo; u <= hi; u++) w.push(z.reduce((s, zj, j) => s + zj * u ** j, 0));
|
|
389
|
-
return w;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
331
|
const r3 = (v: number) => {
|
|
393
332
|
const r = Math.round(v * 1000) / 1000;
|
|
394
333
|
return Object.is(r, -0) ? 0 : r;
|
|
@@ -439,7 +378,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
439
378
|
const series = new Map<string, Pt[]>();
|
|
440
379
|
const good = new Map<string, boolean[]>();
|
|
441
380
|
const holds: Hold[] = [];
|
|
442
|
-
/** A point held where it is not good
|
|
381
|
+
/** A point held where it is not good; or why it is good on no frame. */
|
|
443
382
|
const heldOf = (name: string): { at: Pt[]; good: boolean[]; holds: Hold[] } | { never: string } => {
|
|
444
383
|
const raw = frames.map((f) => pointOn(f, name, opts.minScore));
|
|
445
384
|
const firstGood = raw.findIndex((v) => typeof v !== 'string');
|
|
@@ -463,7 +402,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
463
402
|
spans.push(span);
|
|
464
403
|
}
|
|
465
404
|
}
|
|
466
|
-
return { at:
|
|
405
|
+
return { at: out, good: raw.map((v) => typeof v !== 'string'), holds: spans };
|
|
467
406
|
};
|
|
468
407
|
for (const name of [...needed].sort()) {
|
|
469
408
|
const h = heldOf(name);
|
|
@@ -477,7 +416,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
477
416
|
}
|
|
478
417
|
p.throwIfAny();
|
|
479
418
|
|
|
480
|
-
// TURN: the lines across the body, read from the pose (held
|
|
419
|
+
// TURN: the lines across the body, read from the pose (held as the
|
|
481
420
|
// map's points are, whether or not the map uses them). A reading; no key moves.
|
|
482
421
|
const turns: Turn[] = [];
|
|
483
422
|
const turnsUnread: Retarget['turnsUnread'] = [];
|
package/src/run.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeF
|
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { readMap, resolveMap, type JointMap, type Rest } from './map.ts';
|
|
11
|
+
import { cleanLine, inferredSource } from './clean.ts';
|
|
11
12
|
import { convertCoco17, personTrack, readPoseSequence, type CameraFrame } from './pose.ts';
|
|
12
13
|
import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
|
|
13
14
|
import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
|
|
@@ -26,6 +27,8 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
26
27
|
out: string;
|
|
27
28
|
name: string;
|
|
28
29
|
coco: boolean;
|
|
30
|
+
/** `--smooth`'s value when given: an alias since #67 — the cleaning carries the smoothing; it prints so and changes nothing. */
|
|
31
|
+
smoothAlias?: string;
|
|
29
32
|
person?: string;
|
|
30
33
|
baseMotion?: string;
|
|
31
34
|
/** Passed to rigc build as --images: the directory the rig spec's images resolve in, when rig.json's own `images` does not point there (a copied rig). */
|
|
@@ -87,6 +90,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
87
90
|
const map = readMap(readFileSync(a.map, 'utf8'), a.map);
|
|
88
91
|
const poseText = readFileSync(a.pose, 'utf8');
|
|
89
92
|
const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
|
|
93
|
+
if (seq.cleaned === undefined) refuse([{ code: 'POSE_NOT_CLEANED', text: notCleaned(a.pose, a.coco) }]);
|
|
90
94
|
const frames = personTrack(seq, a.person, a.pose);
|
|
91
95
|
const times = seq.frames.map((f) => f.t);
|
|
92
96
|
|
|
@@ -104,8 +108,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
104
108
|
resolveMap(map, rest, a.map, slots);
|
|
105
109
|
|
|
106
110
|
const r = retarget(rest, map, frames, times, a, slots);
|
|
107
|
-
say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore},
|
|
111
|
+
say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
|
|
108
112
|
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
113
|
+
say(` CLEAN ${cleanLine(seq.cleaned!)}`);
|
|
114
|
+
if (a.smoothAlias !== undefined) say(` SMOOTH --smooth is carried by clean; ignored (given ${a.smoothAlias}; this sequence was cleaned with legs ${seq.cleaned!.params.legs}, rest ${seq.cleaned!.params.rest})`);
|
|
109
115
|
if (seq.camera !== undefined) say(` CAMERA ${cameraLine(seq.camera, times, seq.cameraSource)}`);
|
|
110
116
|
else if (a.plantFeet === true) say(' CAMERA none in the sequence: --plant-feet reads the picture as a fixed camera, so a zoom or a pan in the video moves the floor with it and the floor may step with the picture (tools/scan.py reads the camera, rig-c/pose#63)');
|
|
111
117
|
if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
|
|
@@ -219,7 +225,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
219
225
|
p.throwIfAny();
|
|
220
226
|
|
|
221
227
|
const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
|
|
222
|
-
const posture = measurePosture(posed, lengths, map, r, frames, a.minScore);
|
|
228
|
+
const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned));
|
|
223
229
|
if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
|
|
224
230
|
const legBones = legBonesOf(map);
|
|
225
231
|
const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
|
|
@@ -266,7 +272,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
266
272
|
animation: a.name,
|
|
267
273
|
frames: seq.frames.length,
|
|
268
274
|
duration: r.animation.duration,
|
|
269
|
-
|
|
275
|
+
cleaned: seq.cleaned,
|
|
276
|
+
options: { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
|
|
270
277
|
scale: r.scale,
|
|
271
278
|
holds: r.holds,
|
|
272
279
|
short: r.short,
|
|
@@ -436,3 +443,14 @@ function legScaleLine(l: LegScale, times: readonly number[]): string {
|
|
|
436
443
|
const clamp = (rs: Array<[number, number]>, at: number, how: string) => (rs.length === 0 ? `none ${how} ${at}` : `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})`);
|
|
437
444
|
return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (95th percentile of ${times.length} frames); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under')}; ${clamp(l.high, l.limits[1], 'over')}`;
|
|
438
445
|
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* POSE_NOT_CLEANED (#67): retarget reads only a sequence `rig-pose clean` wrote.
|
|
449
|
+
* The message is the command to run on this file; the output name is a
|
|
450
|
+
* suggestion beside it (`<name>.clean.json`).
|
|
451
|
+
*/
|
|
452
|
+
export function notCleaned(pose: string, coco: boolean): string {
|
|
453
|
+
const out = pose.endsWith('.json') ? `${pose.slice(0, -'.json'.length)}.clean.json` : `${pose}.clean.json`;
|
|
454
|
+
const what = coco ? `${pose} is COCO-17 (--coco), which carries no "cleaned" record` : `${pose} has no "cleaned" record`;
|
|
455
|
+
return `${what}; retarget reads a sequence rig-pose clean wrote (spikes, occlusion, smoothing; there is no flag to skip it). Run \`rig-pose clean ${coco ? '--coco ' : ''}--pose ${pose} --out ${out}\` (add --loop for one cycle of a gait) and retarget --pose ${out}`;
|
|
456
|
+
}
|
package/src/switch.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The metric, per frame and per set: the largest |wrap(pose − key)| over every
|
|
6
6
|
* segment of the set's chains, where "pose" is the segment's angle exactly as
|
|
7
|
-
* the retarget asked the bone for it (after holds,
|
|
7
|
+
* the retarget asked the bone for it (after holds, the short hold,
|
|
8
8
|
* the flip guard and the hinge check; before any range clamp) and "key" is the
|
|
9
9
|
* same segment's angle in the set's key skeleton, both by `pictureAngle`. Per
|
|
10
10
|
* frame the candidate is the set with the smallest distance within the
|
package/src/verify.ts
CHANGED
|
@@ -246,8 +246,19 @@ export interface Posture {
|
|
|
246
246
|
rigShoulder: string | null;
|
|
247
247
|
/** Reference frames on which the hips (tilt) and the hips and shoulders (sway) are all good. */
|
|
248
248
|
frames: { tilt: number; sway: number; of: number };
|
|
249
|
-
/**
|
|
250
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Hip-line tilt, Spine degrees (r_hip → l_hip; the rig: the pelvis chain's
|
|
251
|
+
* aim). Null when every frame with both hips good has an `inferred` hip
|
|
252
|
+
* (#63): the tilt is not read, and `tiltWhy` says why.
|
|
253
|
+
*/
|
|
254
|
+
tilt: { reference: Range; rig: Range; ratio: number } | null;
|
|
255
|
+
/** Present only when `tilt` is null: why it was not read. */
|
|
256
|
+
tiltWhy?: string;
|
|
257
|
+
/**
|
|
258
|
+
* Present only when some frame with both hips good has an `inferred` hip:
|
|
259
|
+
* how many such frames the tilt left out, and where the state came from.
|
|
260
|
+
*/
|
|
261
|
+
hipsInferred?: { frames: number; source: string };
|
|
251
262
|
/**
|
|
252
263
|
* Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
|
|
253
264
|
* picture px × the figure scale) and in each body's own shoulder widths.
|
|
@@ -282,8 +293,17 @@ const medianOf = (xs: number[]): number => {
|
|
|
282
293
|
* the joints it needs are good), the rig is the gated build posed by rig-c.
|
|
283
294
|
* Null when the map has no pelvis chain (joints exactly r_hip, l_hip) or no
|
|
284
295
|
* translate.
|
|
296
|
+
*
|
|
297
|
+
* Inferred hips (#63): a frame on which either hip is `inferred` is left out of
|
|
298
|
+
* the tilt — its hip line is a position the detector supplied but did not see,
|
|
299
|
+
* and a tilt read off it would be the detector's guess reported as the dancer's.
|
|
300
|
+
* When that is every frame (`clean --hips-inferred` marks them all), the tilt
|
|
301
|
+
* is null and `tiltWhy` names the reason and its source (`inferredSource`, read
|
|
302
|
+
* from the clean record). On some frames only, the tilt is read over the rest
|
|
303
|
+
* and the line counts the frames left out. Sway is read as before, inferred
|
|
304
|
+
* hips included: it is a different measure, and the card leaves it as it is.
|
|
285
305
|
*/
|
|
286
|
-
export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number): Posture | null {
|
|
306
|
+
export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number, inferredSource = 'source not stated'): Posture | null {
|
|
287
307
|
const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
|
|
288
308
|
if (pelvis === undefined || map.translate === null || r.origin === null || r.scale === null) return null;
|
|
289
309
|
const s = r.scale.factor;
|
|
@@ -294,11 +314,13 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
294
314
|
const refTilt: number[] = [];
|
|
295
315
|
const refSway: number[] = [];
|
|
296
316
|
const refSw: number[] = [];
|
|
297
|
-
|
|
317
|
+
let inferred = 0;
|
|
318
|
+
frames.forEach((f, i) => {
|
|
298
319
|
const rh = at(i, 'r_hip');
|
|
299
320
|
const lh = at(i, 'l_hip');
|
|
300
321
|
if (rh === null || lh === null) return;
|
|
301
|
-
|
|
322
|
+
if (f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred') inferred++;
|
|
323
|
+
else refTilt.push(pictureAngle(rh, lh));
|
|
302
324
|
const rs = at(i, 'r_shoulder');
|
|
303
325
|
const ls = at(i, 'l_shoulder');
|
|
304
326
|
if (rs === null || ls === null) return;
|
|
@@ -307,9 +329,13 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
307
329
|
});
|
|
308
330
|
const bone = pelvis.bones[0]!;
|
|
309
331
|
const aim = pelvis.aim[0]!;
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
332
|
+
let tilt: Posture['tilt'] = null;
|
|
333
|
+
const why = `hips inferred (${inferredSource})`;
|
|
334
|
+
if (inferred === 0 || refTilt.length > 0) {
|
|
335
|
+
const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
|
|
336
|
+
tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
|
|
337
|
+
tilt.ratio = tilt.rig.range / tilt.reference.range;
|
|
338
|
+
}
|
|
313
339
|
|
|
314
340
|
// The rig's mid-shoulder: the point a reach aims at, else the midpoint of the arms' first bones.
|
|
315
341
|
const reach = map.chains.find((c) => c.reach);
|
|
@@ -340,7 +366,16 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
340
366
|
ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
|
|
341
367
|
};
|
|
342
368
|
}
|
|
343
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
pelvis: pelvis.name,
|
|
371
|
+
rigHip: r.origin.bone,
|
|
372
|
+
rigShoulder,
|
|
373
|
+
frames: { tilt: refTilt.length, sway: refSway.length, of: frames.length },
|
|
374
|
+
tilt,
|
|
375
|
+
...(tilt === null ? { tiltWhy: why } : {}),
|
|
376
|
+
...(inferred > 0 ? { hipsInferred: { frames: inferred, source: inferredSource } } : {}),
|
|
377
|
+
sway,
|
|
378
|
+
};
|
|
344
379
|
}
|
|
345
380
|
|
|
346
381
|
const f1 = (x: number) => x.toFixed(1);
|
|
@@ -350,8 +385,12 @@ const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
|
|
|
350
385
|
/** The POSTURE lines. */
|
|
351
386
|
export function postureLines(q: Posture): string[] {
|
|
352
387
|
const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
|
|
388
|
+
const t = q.tilt;
|
|
389
|
+
const left = q.hipsInferred === undefined ? '' : `; ${q.hipsInferred.frames} frame(s) with an inferred hip not read (${q.hipsInferred.source})`;
|
|
353
390
|
const out = [
|
|
354
|
-
|
|
391
|
+
t === null
|
|
392
|
+
? `hip tilt not read: ${q.tiltWhy}`
|
|
393
|
+
: `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(t.reference.range)}° (${sg(t.reference.min)}° … ${sg(t.reference.max)}°${over(q.frames.tilt)}), rig ${f1(t.rig.range)}° (${sg(t.rig.min)}° … ${sg(t.rig.max)}°) — rig/reference ${f3(t.ratio)}${left}`,
|
|
355
394
|
];
|
|
356
395
|
const w = q.sway;
|
|
357
396
|
if (w === null) out.push('sway: the reference has no frame with both hips and both shoulders good; not measured');
|