@rig-c/pose 0.18.0 → 0.20.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 +16 -16
- package/cli.ts +1 -1
- package/package.json +1 -1
- package/src/clean.ts +115 -21
- package/src/legs.ts +174 -46
- package/src/pose.ts +82 -33
- package/src/retarget.ts +199 -11
- package/src/run.ts +3 -2
- package/src/switch.ts +33 -5
- package/src/verify.ts +221 -17
package/src/verify.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { poseRawAnimation, poseRawSetup, type CompiledDocument, type RawBone } from 'rig-c/core';
|
|
12
12
|
import type { Body18 } from './joints.ts';
|
|
13
|
-
import { aimAngle, pointAt, type JointMap } from './map.ts';
|
|
14
|
-
import type { HipsInferred, Joint, Run } from './pose.ts';
|
|
13
|
+
import { aimAngle, aimEnds, pointAt, type JointMap } from './map.ts';
|
|
14
|
+
import type { HipsInferred, Joint, PelvisFit, Run } from './pose.ts';
|
|
15
15
|
import type { Retarget } from './retarget.ts';
|
|
16
16
|
import { pictureAngle, pointOn, wrap } from './retarget.ts';
|
|
17
17
|
|
|
@@ -260,13 +260,25 @@ export interface Posture {
|
|
|
260
260
|
*/
|
|
261
261
|
hipsInferred?: { frames: number; source: string };
|
|
262
262
|
/**
|
|
263
|
-
* Present only when the hips were measured (#86, `hipsInferred.by` is
|
|
263
|
+
* Present only when the hips were measured (#86, #106, `hipsInferred.by` is
|
|
264
264
|
* `measured`): the method, how many frames the tilt was read on — the
|
|
265
|
-
* record's read, interpolated and held frames with both hips good,
|
|
266
|
-
* class counted — and the record's
|
|
267
|
-
* inferred hip is left out
|
|
265
|
+
* record's read, interpolated, scaled and held frames with both hips good,
|
|
266
|
+
* each class counted — and the record's spans. Any `unmeasured` frame (corr
|
|
267
|
+
* < 0.9) or other frame with an inferred hip is left out of `tilt`.
|
|
268
268
|
*/
|
|
269
|
-
tiltMeasured?: {
|
|
269
|
+
tiltMeasured?: {
|
|
270
|
+
method: string;
|
|
271
|
+
frames: number;
|
|
272
|
+
read: number;
|
|
273
|
+
interpolated: number;
|
|
274
|
+
held: number;
|
|
275
|
+
heldSpans: Run[];
|
|
276
|
+
scaled?: number;
|
|
277
|
+
scaledSpans?: Run[];
|
|
278
|
+
unmeasured?: number;
|
|
279
|
+
unmeasuredSpans?: Run[];
|
|
280
|
+
fit?: PelvisFit;
|
|
281
|
+
};
|
|
270
282
|
/**
|
|
271
283
|
* Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
|
|
272
284
|
* picture px × the figure scale) and in each body's own shoulder widths.
|
|
@@ -277,6 +289,81 @@ export interface Posture {
|
|
|
277
289
|
ratio: number | null;
|
|
278
290
|
ratioSw: number | null;
|
|
279
291
|
} | null;
|
|
292
|
+
/**
|
|
293
|
+
* Present with `--plant-feet` when there is at least one support span (#107):
|
|
294
|
+
* per support span, the detrended (centred ±12-frame moving mean removed)
|
|
295
|
+
* vertical sd, rig/reference ratio and correlation for the hip, the neck, and
|
|
296
|
+
* eyes − neck.
|
|
297
|
+
*/
|
|
298
|
+
bounce?: BounceSpan[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface BounceMetric {
|
|
302
|
+
reference: number;
|
|
303
|
+
rig: number;
|
|
304
|
+
ratio: number | null;
|
|
305
|
+
corr: number | null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface BounceSpan {
|
|
309
|
+
side: 'r' | 'l';
|
|
310
|
+
frames: [number, number];
|
|
311
|
+
hip: BounceMetric;
|
|
312
|
+
neck: BounceMetric;
|
|
313
|
+
eyesNeck: BounceMetric;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Half-window (frames each side; 25-frame centred window at 24 fps ≈ 1 s) for detrending a support span's bounce (#107). */
|
|
317
|
+
export const POSTURE_DETREND_HALF_WINDOW = 12;
|
|
318
|
+
|
|
319
|
+
function detrend(xs: readonly number[], w = POSTURE_DETREND_HALF_WINDOW): number[] {
|
|
320
|
+
const n = xs.length;
|
|
321
|
+
return xs.map((x, i) => {
|
|
322
|
+
const lo = Math.max(0, i - w);
|
|
323
|
+
const hi = Math.min(n - 1, i + w);
|
|
324
|
+
let sum = 0;
|
|
325
|
+
for (let k = lo; k <= hi; k++) sum += xs[k]!;
|
|
326
|
+
return x - sum / (hi - lo + 1);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function sdOf(xs: readonly number[]): number {
|
|
331
|
+
if (xs.length === 0) return 0;
|
|
332
|
+
const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
333
|
+
let s2 = 0;
|
|
334
|
+
for (const x of xs) s2 += (x - mean) * (x - mean);
|
|
335
|
+
return Math.sqrt(s2 / xs.length);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function corrOf(a: readonly number[], b: readonly number[]): number | null {
|
|
339
|
+
if (a.length < 2 || a.length !== b.length) return null;
|
|
340
|
+
const ma = a.reduce((x, y) => x + y, 0) / a.length;
|
|
341
|
+
const mb = b.reduce((x, y) => x + y, 0) / b.length;
|
|
342
|
+
let num = 0;
|
|
343
|
+
let da = 0;
|
|
344
|
+
let db = 0;
|
|
345
|
+
for (let i = 0; i < a.length; i++) {
|
|
346
|
+
const xa = a[i]! - ma;
|
|
347
|
+
const xb = b[i]! - mb;
|
|
348
|
+
num += xa * xb;
|
|
349
|
+
da += xa * xa;
|
|
350
|
+
db += xb * xb;
|
|
351
|
+
}
|
|
352
|
+
const den = Math.sqrt(da * db);
|
|
353
|
+
return den > 1e-9 ? num / den : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function bounceMetric(ref: readonly number[], rig: readonly number[]): BounceMetric {
|
|
357
|
+
const dr = detrend(ref);
|
|
358
|
+
const dg = detrend(rig);
|
|
359
|
+
const sr = sdOf(dr);
|
|
360
|
+
const sg = sdOf(dg);
|
|
361
|
+
return {
|
|
362
|
+
reference: sr,
|
|
363
|
+
rig: sg,
|
|
364
|
+
ratio: sr > 1e-9 ? sg / sr : null,
|
|
365
|
+
corr: corrOf(dr, dg),
|
|
366
|
+
};
|
|
280
367
|
}
|
|
281
368
|
|
|
282
369
|
function rangeOf(xs: number[]): Range {
|
|
@@ -311,11 +398,12 @@ const medianOf = (xs: number[]): number => {
|
|
|
311
398
|
* and the line counts the frames left out. Sway is read as before, inferred
|
|
312
399
|
* hips included: it is a different measure, and the card leaves it as it is.
|
|
313
400
|
*
|
|
314
|
-
* Measured hips (#86, `measured`): the hips are inferred on every frame,
|
|
315
|
-
* turned to a measured angle: read off the picture, interpolated between two
|
|
316
|
-
* close reads,
|
|
317
|
-
*
|
|
318
|
-
*
|
|
401
|
+
* Measured hips (#86, #106, `measured`): the hips are inferred on every frame,
|
|
402
|
+
* and turned to a measured angle: read off the picture, interpolated between two
|
|
403
|
+
* close reads, scaled from the detector's hip line when `fit.corr >= 0.9`, or
|
|
404
|
+
* held from the earlier read. Those four are the measurement, so the tilt is
|
|
405
|
+
* read on them; any `unmeasured` frame (`fit.corr < 0.9`) or other frame with
|
|
406
|
+
* an inferred hip is left out and counted.
|
|
319
407
|
*/
|
|
320
408
|
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', measured: Extract<HipsInferred, { by: 'measured' }> | null = null): Posture | null {
|
|
321
409
|
const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
|
|
@@ -330,16 +418,23 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
330
418
|
const refSw: number[] = [];
|
|
331
419
|
let inferred = 0;
|
|
332
420
|
const inClass = (rs: Run[], i: number) => rs.some(([a, b]) => a <= i && i <= b);
|
|
333
|
-
const counts = { read: 0, interpolated: 0, held: 0 };
|
|
421
|
+
const counts = { read: 0, interpolated: 0, held: 0, scaled: 0, unmeasured: 0 };
|
|
334
422
|
frames.forEach((f, i) => {
|
|
335
423
|
const rh = at(i, 'r_hip');
|
|
336
424
|
const lh = at(i, 'l_hip');
|
|
337
425
|
if (rh === null || lh === null) return;
|
|
338
426
|
const isInferred = f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred';
|
|
339
427
|
const cls = measured === null ? undefined : (['read', 'interpolated', 'held'] as const).find((c) => inClass(measured[c], i));
|
|
428
|
+
const isScaled = measured !== null && measured.scaled !== undefined && inClass(measured.scaled, i);
|
|
429
|
+
const isUnmeasured = measured !== null && measured.unmeasured !== undefined && inClass(measured.unmeasured, i);
|
|
340
430
|
if (isInferred && cls !== undefined) {
|
|
341
431
|
counts[cls]++;
|
|
342
432
|
refTilt.push(pictureAngle(rh, lh));
|
|
433
|
+
} else if (isInferred && isScaled) {
|
|
434
|
+
counts.scaled++;
|
|
435
|
+
refTilt.push(pictureAngle(rh, lh));
|
|
436
|
+
} else if (isInferred && isUnmeasured) {
|
|
437
|
+
counts.unmeasured++;
|
|
343
438
|
} else if (isInferred) inferred++;
|
|
344
439
|
else refTilt.push(pictureAngle(rh, lh));
|
|
345
440
|
const rs = at(i, 'r_shoulder');
|
|
@@ -352,7 +447,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
352
447
|
const aim = pelvis.aim[0]!;
|
|
353
448
|
let tilt: Posture['tilt'] = null;
|
|
354
449
|
const why = `hips inferred (${inferredSource})`;
|
|
355
|
-
if (inferred === 0 || refTilt.length > 0) {
|
|
450
|
+
if ((inferred === 0 && counts.unmeasured === 0) || refTilt.length > 0) {
|
|
356
451
|
const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
|
|
357
452
|
tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
|
|
358
453
|
tilt.ratio = tilt.rig.range / tilt.reference.range;
|
|
@@ -387,6 +482,79 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
387
482
|
ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
|
|
388
483
|
};
|
|
389
484
|
}
|
|
485
|
+
const bounce: BounceSpan[] = [];
|
|
486
|
+
if (r.planted && r.feet !== null && r.feet.supports.length > 0) {
|
|
487
|
+
const scaleNeckIdx = map.translate.scale.pose.indexOf('neck');
|
|
488
|
+
const neckChain = map.chains.find((c) => c.joints.includes('neck'));
|
|
489
|
+
const neckJointIdx = neckChain === undefined ? -1 : neckChain.joints.indexOf('neck');
|
|
490
|
+
const rigNeck = posed[0]?.has('neck')
|
|
491
|
+
? 'neck'
|
|
492
|
+
: scaleNeckIdx >= 0
|
|
493
|
+
? map.translate.scale.rig[scaleNeckIdx]!
|
|
494
|
+
: neckChain !== undefined && neckJointIdx > 0
|
|
495
|
+
? (neckChain.aim[neckJointIdx - 1] === 'x' ? `${neckChain.bones[neckJointIdx - 1]}:tip` : neckChain.aim[neckJointIdx - 1]!)
|
|
496
|
+
: rigShoulder;
|
|
497
|
+
const headChain = map.chains.find((c) => c.joints.includes('r_eye') && c.joints.includes('l_eye'));
|
|
498
|
+
const rigNeckY = (b: Map<string, RawBone>): number | null => (rigNeck === null ? null : (pointAt(b, lengths, rigNeck)?.[1] ?? null));
|
|
499
|
+
const rigEyesY = (b: Map<string, RawBone>): number | null => {
|
|
500
|
+
if (headChain !== undefined) {
|
|
501
|
+
const k = headChain.joints.findIndex((j, idx) => idx + 1 < headChain.joints.length && ((j === 'r_eye' && headChain.joints[idx + 1] === 'l_eye') || (j === 'l_eye' && headChain.joints[idx + 1] === 'r_eye')));
|
|
502
|
+
if (k >= 0) {
|
|
503
|
+
const ha = headChain.aim[k]!;
|
|
504
|
+
const hb = headChain.bones[k]!;
|
|
505
|
+
if (ha.includes('>')) {
|
|
506
|
+
const [pa, pb] = aimEnds(hb, ha);
|
|
507
|
+
const ya = pointAt(b, lengths, pa);
|
|
508
|
+
const yb = pointAt(b, lengths, pb);
|
|
509
|
+
if (ya !== null && yb !== null) return (ya[1] + yb[1]) / 2;
|
|
510
|
+
}
|
|
511
|
+
const p = pointAt(b, lengths, ha === 'x' ? hb : ha);
|
|
512
|
+
if (p !== null) return p[1];
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (b.has('eye_r') && b.has('eye_l')) return (b.get('eye_r')!.worldY + b.get('eye_l')!.worldY) / 2;
|
|
516
|
+
if (b.has('head')) return b.get('head')!.worldY;
|
|
517
|
+
return rigNeckY(b);
|
|
518
|
+
};
|
|
519
|
+
for (const sp of r.feet.supports) {
|
|
520
|
+
const refHip: number[] = [];
|
|
521
|
+
const rigHipY: number[] = [];
|
|
522
|
+
const refNeck: number[] = [];
|
|
523
|
+
const rigNeckS: number[] = [];
|
|
524
|
+
const refEyesNeck: number[] = [];
|
|
525
|
+
const rigEyesNeck: number[] = [];
|
|
526
|
+
for (let i = sp.frames[0]; i <= sp.frames[1]; i++) {
|
|
527
|
+
const b = posed[i]!;
|
|
528
|
+
const rh = at(i, 'r_hip');
|
|
529
|
+
const lh = at(i, 'l_hip');
|
|
530
|
+
if (rh !== null && lh !== null) {
|
|
531
|
+
refHip.push(-((rh[1] + lh[1]) / 2) * s);
|
|
532
|
+
rigHipY.push(b.get(r.origin.bone)!.worldY);
|
|
533
|
+
}
|
|
534
|
+
const nk = at(i, 'neck');
|
|
535
|
+
const ny = rigNeckY(b);
|
|
536
|
+
if (nk !== null && ny !== null) {
|
|
537
|
+
refNeck.push(-nk[1] * s);
|
|
538
|
+
rigNeckS.push(ny);
|
|
539
|
+
const re = at(i, 'r_eye');
|
|
540
|
+
const le = at(i, 'l_eye');
|
|
541
|
+
const ey = rigEyesY(b);
|
|
542
|
+
if (re !== null && le !== null && ey !== null) {
|
|
543
|
+
refEyesNeck.push(-((re[1] + le[1]) / 2 - nk[1]) * s);
|
|
544
|
+
rigEyesNeck.push(ey - ny);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
bounce.push({
|
|
549
|
+
side: sp.side,
|
|
550
|
+
frames: [sp.frames[0], sp.frames[1]],
|
|
551
|
+
hip: bounceMetric(refHip, rigHipY),
|
|
552
|
+
neck: bounceMetric(refNeck, rigNeckS),
|
|
553
|
+
eyesNeck: bounceMetric(refEyesNeck, rigEyesNeck),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const has106 = measured !== null && (measured.scaled !== undefined || measured.unmeasured !== undefined || measured.fit !== undefined);
|
|
390
558
|
return {
|
|
391
559
|
pelvis: pelvis.name,
|
|
392
560
|
rigHip: r.origin.bone,
|
|
@@ -395,22 +563,52 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
395
563
|
tilt,
|
|
396
564
|
...(tilt === null ? { tiltWhy: why } : {}),
|
|
397
565
|
...(inferred > 0 ? { hipsInferred: { frames: inferred, source: inferredSource } } : {}),
|
|
398
|
-
...(measured !== null
|
|
566
|
+
...(measured !== null
|
|
567
|
+
? {
|
|
568
|
+
tiltMeasured: {
|
|
569
|
+
method: measured.method,
|
|
570
|
+
frames: counts.read + counts.interpolated + counts.scaled + counts.held,
|
|
571
|
+
read: counts.read,
|
|
572
|
+
interpolated: counts.interpolated,
|
|
573
|
+
held: counts.held,
|
|
574
|
+
heldSpans: measured.held.map(([a, b]): Run => [a, b]),
|
|
575
|
+
...(has106
|
|
576
|
+
? {
|
|
577
|
+
scaled: counts.scaled,
|
|
578
|
+
scaledSpans: (measured.scaled ?? []).map(([a, b]): Run => [a, b]),
|
|
579
|
+
unmeasured: counts.unmeasured,
|
|
580
|
+
unmeasuredSpans: (measured.unmeasured ?? []).map(([a, b]): Run => [a, b]),
|
|
581
|
+
...(measured.fit !== undefined ? { fit: { frames: measured.fit.frames, gain: measured.fit.gain, corr: measured.fit.corr } } : {}),
|
|
582
|
+
}
|
|
583
|
+
: {}),
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
: {}),
|
|
399
587
|
sway,
|
|
588
|
+
...(bounce.length > 0 ? { bounce } : {}),
|
|
400
589
|
};
|
|
401
590
|
}
|
|
402
591
|
|
|
403
592
|
const f1 = (x: number) => x.toFixed(1);
|
|
404
593
|
const f3 = (x: number) => x.toFixed(3);
|
|
405
594
|
const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
|
|
595
|
+
const spansText = (rs: Run[] | undefined) => (rs === undefined || rs.length === 0 ? 'none' : rs.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', '));
|
|
406
596
|
|
|
407
597
|
/** The POSTURE lines. */
|
|
408
598
|
export function postureLines(q: Posture): string[] {
|
|
409
599
|
const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
|
|
410
600
|
const t = q.tilt;
|
|
411
601
|
const m = q.tiltMeasured;
|
|
412
|
-
const heldSpans = m
|
|
413
|
-
const
|
|
602
|
+
const heldSpans = spansText(m?.heldSpans);
|
|
603
|
+
const scaledPart =
|
|
604
|
+
m?.scaled !== undefined && m.scaled > 0
|
|
605
|
+
? `, ${m.scaled} scaled (scaled: ${spansText(m.scaledSpans)}${m.fit?.gain !== undefined && m.fit.gain !== null && m.fit.corr !== null ? `, gain ${m.fit.gain.toFixed(2)}×, r=${m.fit.corr.toFixed(3)}` : ''})`
|
|
606
|
+
: '';
|
|
607
|
+
const unmeasuredPart =
|
|
608
|
+
m?.unmeasured !== undefined && (m.unmeasured > 0 || (m.fit !== undefined && (m.fit.corr === null || m.fit.corr < 0.9)))
|
|
609
|
+
? `; ${m.unmeasured} unmeasured (${spansText(m.unmeasuredSpans)}${m.fit !== undefined ? `, r=${m.fit.corr === null ? 'none' : m.fit.corr.toFixed(3)} < 0.9` : ''})`
|
|
610
|
+
: '';
|
|
611
|
+
const measured = m === undefined ? '' : `; tilt measured (${m.method}) on ${m.frames} frame(s): ${m.read} read, ${m.interpolated} interpolated${scaledPart}, ${m.held} held (held: ${heldSpans})${unmeasuredPart}`;
|
|
414
612
|
const left = q.hipsInferred === undefined ? '' : `; ${q.hipsInferred.frames} frame(s) with an inferred hip not read (${q.hipsInferred.source})`;
|
|
415
613
|
const out = [
|
|
416
614
|
t === null
|
|
@@ -428,5 +626,11 @@ export function postureLines(q: Posture): string[] {
|
|
|
428
626
|
out.push(`sway, x-range of mid_hip − mid_shoulder: ${ref}, rig ${f1(w.rig.range)} units (${sw}; mid_hip = "${q.rigHip}", mid_shoulder = "${q.rigShoulder}") — rig/reference ${f3(w.ratio!)} in units${ratioSw}`);
|
|
429
627
|
}
|
|
430
628
|
}
|
|
629
|
+
if (q.bounce !== undefined) {
|
|
630
|
+
const fmt = (bm: BounceMetric) => `${bm.ratio === null ? 'n/a' : f3(bm.ratio)} (${f1(bm.rig)}/${f1(bm.reference)}u, r=${bm.corr === null ? 'none' : f3(bm.corr)})`;
|
|
631
|
+
for (const b of q.bounce) {
|
|
632
|
+
out.push(`bounce ${b.side} ${b.frames[0]}–${b.frames[1]} (detrended y sd, rig/reference): hip ${fmt(b.hip)}, neck ${fmt(b.neck)}, eyes − neck ${fmt(b.eyesNeck)}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
431
635
|
return out;
|
|
432
636
|
}
|