@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/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, each
266
- * class counted — and the record's held spans. Any other frame with an
267
- * inferred hip is left out and counted in `hipsInferred`.
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?: { method: string; frames: number; read: number; interpolated: number; held: number; heldSpans: Run[] };
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, and
315
- * turned to a measured angle: read off the picture, interpolated between two
316
- * close reads, or held from the earlier read. Those three are the measurement,
317
- * so the tilt is read on them; any other frame with an inferred hip is left
318
- * out and counted, as above. The line names the held spans.
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 ? { tiltMeasured: { method: measured.method, frames: counts.read + counts.interpolated + counts.held, ...counts, heldSpans: measured.held.map(([a, b]): Run => [a, b]) } } : {}),
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 === undefined || m.heldSpans.length === 0 ? 'none' : m.heldSpans.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
413
- const measured = m === undefined ? '' : `; tilt measured (${m.method}) on ${m.frames} frame(s): ${m.read} read, ${m.interpolated} interpolated, ${m.held} held (held: ${heldSpans})`;
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
  }