@rig-c/pose 0.15.0 → 0.17.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/clean.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * hanging wrist moves ±20 px a frame and a cloak-hidden leg jumps 100+ px; this
5
5
  * removes that once, at the sequence, where others can inspect the result.
6
6
  *
7
- * Four steps, in this order, each on one person's joints:
7
+ * Five steps, in this order, each on one person's joints:
8
8
  *
9
9
  * 1. **Spikes.** A joint more than `SPIKE_TRUNKS` (0.2) trunk lengths from its
10
10
  * `SPIKE_WINDOW`-frame (5) median takes the median. The trunk is neck →
@@ -24,6 +24,9 @@
24
24
  * 4. **Floor.** With `loop` only: the whole figure is moved in y by one
25
25
  * constant, so the lowest ankle over the cycle (the largest y of either
26
26
  * ankle on any frame) is at its height before smoothing.
27
+ * 5. **Hold** (#91, unless `hold` is "off"). A joint that has stopped is
28
+ * written at one position: detector noise on a still joint is not motion.
29
+ * `holdStill` below states the test and the write.
27
30
  *
28
31
  * What a median is here: per coordinate (x and y apart), over the frames of the
29
32
  * window on which the joint has a position — its own frame included, any state.
@@ -39,7 +42,7 @@
39
42
  * score are the detector's and are written as they were read: this step moves
40
43
  * positions only — with one stated exception, `hipsInferred` below.
41
44
  *
42
- * `hipsInferred` (rig-c/pose#63, `--hips-inferred`): after the four steps, r_hip
45
+ * `hipsInferred` (rig-c/pose#63, `--hips-inferred`): after the steps, r_hip
43
46
  * and l_hip are marked `inferred` on every frame where they have a position,
44
47
  * and the record says who said so (`{ by: 'declared' }`, the person running
45
48
  * clean). Positions and scores are not touched by it, so retarget's keys are
@@ -59,7 +62,12 @@
59
62
  * Pure: no clock, no randomness, no disk.
60
63
  */
61
64
  import { BODY18, type Body18 } from './joints.ts';
62
- import type { CleanRecord, CleanSpike, Joint, PoseSequence } from './pose.ts';
65
+ import type { CleanRecord, CleanSeam, CleanSpike, CleanUnheld, Joint, PoseSequence } from './pose.ts';
66
+
67
+ /** A joint not held inside a region where others are (#91): the test it fails there and its figure. */
68
+ export type Unheld = CleanUnheld;
69
+ /** Two holds of one joint back to back (#91): the second's first frame, and the step between their medians. */
70
+ export type Seam = CleanSeam;
63
71
  import { CLEAN_SPEC, personTrack, sortedJoints } from './pose.ts';
64
72
  import { Problems } from './problems.ts';
65
73
 
@@ -75,6 +83,16 @@ export const OCCLUSION_SCORE = 0.3;
75
83
  export const LEGS: readonly Body18[] = ['r_hip', 'r_knee', 'r_ankle', 'l_hip', 'l_knee', 'l_ankle'];
76
84
  /** The polynomial's degree. */
77
85
  const ORDER = 2;
86
+ /** The stillness hold (#91): a joint's speed, trunk lengths per frame, under which a frame can be still (`--hold-speed`). */
87
+ export const HOLD_SPEED = 0.015;
88
+ /** The stillness hold: how far, in trunk lengths, a joint may lie from its span median (`--hold-reach`). */
89
+ export const HOLD_REACH = 0.03;
90
+ /** The shortest hold, seconds: the first frame's time to the end of the last. */
91
+ export const HOLD_MIN_SECONDS = 0.5;
92
+ /** The frames before a hold over which the positions blend into it. */
93
+ export const HOLD_BLEND = 6;
94
+ /** The speed's median window, frames. */
95
+ export const HOLD_SPEED_WINDOW = 5;
78
96
 
79
97
  export interface CleanParams {
80
98
  /** The sequence is one cycle: every window wraps, and the floor is re-pinned. */
@@ -85,6 +103,12 @@ export interface CleanParams {
85
103
  rest: number;
86
104
  /** Mark r_hip and l_hip `inferred` wherever they have a position (#63): the person running clean declares it. */
87
105
  hipsInferred?: boolean;
106
+ /**
107
+ * The stillness hold (#91): its thresholds, or "off". Absent, the step is not
108
+ * run and the record does not name it (a caller written before #91); the CLI
109
+ * always passes one.
110
+ */
111
+ hold?: { speed: number; reach: number } | 'off';
88
112
  }
89
113
 
90
114
  /** The joints `--hips-inferred` marks. */
@@ -113,6 +137,23 @@ export interface Floor {
113
137
  shift: number;
114
138
  }
115
139
 
140
+ export interface HoldFound {
141
+ joint: Body18;
142
+ /** The span, inclusive. */
143
+ frames: [number, number];
144
+ /** First frame's time to the end of the last, seconds. */
145
+ seconds: number;
146
+ /** The joint's largest 5-frame median speed on the span, trunk lengths per frame. */
147
+ speed: number;
148
+ /** Its largest distance from its span median, trunk lengths. */
149
+ reach: number;
150
+ /** The frames blended in before it and out after it, inclusive; null where there are none. */
151
+ blendIn: [number, number] | null;
152
+ blendOut: [number, number] | null;
153
+ /** The joint's jitter over the triples inside the span, px/f²: cleaned, and held. */
154
+ jitter: { before: number | null; after: number | null };
155
+ }
156
+
116
157
  export interface Cleaned {
117
158
  seq: PoseSequence;
118
159
  record: CleanRecord;
@@ -131,6 +172,12 @@ export interface Cleaned {
131
172
  triples: number;
132
173
  /** With `hipsInferred`: how many joint-frames were marked `inferred`, per hip. */
133
174
  inferred: { r_hip: number; l_hip: number } | null;
175
+ /** The stillness holds (#91), one per joint and span, in frame then body-18 order; empty when none was found or the step is off or not run. */
176
+ holds: HoldFound[];
177
+ /** The joints not held inside a region where others are, with the test each fails there. */
178
+ unheld: Unheld[];
179
+ /** Back-to-back holds of one joint, cross-faded, in frame then body-18 order. */
180
+ seams: Seam[];
134
181
  }
135
182
 
136
183
  /**
@@ -278,6 +325,225 @@ export function jitterOf(all: readonly Series[], loop: boolean): { value: number
278
325
  return { value: count === 0 ? null : sum / count, triples: count };
279
326
  }
280
327
 
328
+ /**
329
+ * The stillness hold (#91), on the cleaned positions (after the smoothing and
330
+ * the floor), joint by joint: stillness is a property of a joint, so a joint
331
+ * that has stopped is held while one beside it still moves.
332
+ *
333
+ * A joint's speed on frame i is its movement from frame i − 1 in trunk lengths
334
+ * (wrapping with `loop`; none where either frame has no position), taken as a
335
+ * `HOLD_SPEED_WINDOW`-frame (5) median. The joint is still on a frame where it
336
+ * has a position and a median speed under `speed` (no speed in its window is
337
+ * not still: its speed is not known).
338
+ *
339
+ * A hold of a joint is a span of its still frames lasting at least
340
+ * `HOLD_MIN_SECONDS` (first frame's time to the end of the last) over which it
341
+ * stays within `reach` trunk lengths of its own per-coordinate span median. The
342
+ * span is chosen greedily, left to right, inside each run of still frames:
343
+ * from a start it grows one frame at a time while the reach holds on the grown
344
+ * span (its median re-read each time) and stops at the first frame that breaks
345
+ * it; if what it reached lasts long enough it is a hold and the next start is
346
+ * the frame after it, else the start moves one frame on. A span never crosses
347
+ * the loop's seam.
348
+ *
349
+ * On every frame of a hold the joint takes its span median. It blends in over
350
+ * the `HOLD_BLEND` (6) frames before (p + w (held − p), w = (6 − k)/6 on the
351
+ * k-th frame before: the 6th stays as cleaned, the frame before is 5/6 of the
352
+ * way) and out over the 6 frames after, mirrored (the frame after is 5/6 of the
353
+ * way, the 6th after stays as cleaned). A blend frame outside the clip, or
354
+ * inside another hold of the same joint, is not touched; where two blends of
355
+ * the same joint meet, the frame takes the one with the larger weight (the
356
+ * earlier hold on a tie). No position is invented: a joint without one on a
357
+ * frame stays without one.
358
+ *
359
+ * A seam is two holds of a joint meeting: the first ending on frame k and the
360
+ * next starting on k + 1 + g, with a gap of g < `HOLD_BLEND` frames between
361
+ * them (0 when they are back to back). Their medians differ, and stepping from
362
+ * one to the other across a frame or a short gap would be a jump. The seam
363
+ * cross-fades over `HOLD_BLEND` + g frames, symmetric about the gap's centre:
364
+ * frames k − 2 … k + 3 + g take m₁ + w (m₂ − m₁) with w = (2j + 1)/(2(6 + g))
365
+ * on the j-th of them (for g = 0: 1/12, 3/12, … 11/12, the blend's 1/6 steps
366
+ * placed symmetric about the seam), so no frame moves more than 1/(6 + g) of the
367
+ * step. The cross-fade is written after the blends, so it replaces them on the
368
+ * gap's frames; a frame outside the two holds and the gap is not touched, nor a
369
+ * frame without a position; where two seams' frames meet (a hold of under 6
370
+ * frames between them), the later seam is written last. Each seam is recorded
371
+ * with the joint, the second hold's first frame, the step |m₂ − m₁| in trunk
372
+ * lengths and the gap.
373
+ *
374
+ * A hold that carries a cross-fade's frames has a non-zero jitter after: the
375
+ * ramp's own curvature, not noise. The lines say "ramp" beside it.
376
+ *
377
+ * Where joints are held, the others are read too, so the lines can say why
378
+ * they were not: the frames any held span covers are joined into regions
379
+ * (overlapping or touching spans), and every run of a region's frames on which
380
+ * a joint has a position and no hold of its own is named with the test it
381
+ * fails over that run — `speed` (its largest median speed there, null where
382
+ * its speed is not known on some frame), else `reach` (its largest distance
383
+ * from its median over the run), else `short` (it passes both, and the run
384
+ * lasts under `HOLD_MIN_SECONDS`; the figure is its seconds), else `span` (it
385
+ * passes all three over the run, and its greedy spans broke elsewhere; the
386
+ * figure is its reach).
387
+ */
388
+ export function holdStill(series: ReadonlyMap<Body18, Series>, times: readonly number[], trunk: number, speed: number, reach: number, loop: boolean, minSeconds = HOLD_MIN_SECONDS): { held: Map<Body18, Series>; holds: HoldFound[]; unheld: Unheld[]; seams: Seam[] } {
389
+ const n = times.length;
390
+ const names = BODY18.filter((name) => series.has(name));
391
+ const endOf = (i: number): number => (i + 1 < n ? times[i + 1]! : n > 1 ? 2 * times[i]! - times[i - 1]! : times[i]!);
392
+ const secondsOf = (a: number, b: number): number => Math.round((endOf(b) - times[a]!) * 1e6) / 1e6;
393
+ const med = new Map<Body18, Array<number | null>>();
394
+ for (const name of names) {
395
+ const xs = series.get(name)!;
396
+ const v: Array<number | null> = xs.map((p, i) => {
397
+ if (p === null || n < 2) return null;
398
+ if (!loop && i === 0) return null;
399
+ const q = xs[(i - 1 + n) % n];
400
+ if (q === null || q === undefined) return null;
401
+ return Math.hypot(p[0] - q[0], p[1] - q[1]) / trunk;
402
+ });
403
+ med.set(name, v.map((_, i) => {
404
+ const w = windowAt(i, n, HOLD_SPEED_WINDOW, loop).map((j) => v[j]).filter((x): x is number => x !== null && x !== undefined);
405
+ return w.length === 0 ? null : median1(w);
406
+ }));
407
+ }
408
+ /** One joint's distance (largest) from its per-coordinate median over a..b, trunk lengths, and that median; null where it has no position there. */
409
+ const spanOf = (name: Body18, a: number, b: number): { reach: number; at: Pt } | null => {
410
+ const pts = series.get(name)!.slice(a, b + 1).filter((p): p is Pt => p !== null);
411
+ if (pts.length === 0) return null;
412
+ const m: Pt = [median1(pts.map((p) => p[0])), median1(pts.map((p) => p[1]))];
413
+ let worst = 0;
414
+ for (const p of pts) worst = Math.max(worst, Math.hypot(p[0] - m[0], p[1] - m[1]) / trunk);
415
+ return { reach: worst, at: m };
416
+ };
417
+ const held = new Map<Body18, Series>();
418
+ const holds: HoldFound[] = [];
419
+ const seams: Seam[] = [];
420
+ for (const name of names) {
421
+ const xs = series.get(name)!;
422
+ const sp = med.get(name)!;
423
+ const still = xs.map((p, i) => p !== null && sp[i] !== null && sp[i] !== undefined && sp[i]! < speed);
424
+ const spans: Array<[number, number]> = [];
425
+ let s = 0;
426
+ while (s < n) {
427
+ if (!still[s]) {
428
+ s++;
429
+ continue;
430
+ }
431
+ let e = s;
432
+ while (e + 1 < n && still[e + 1] && spanOf(name, s, e + 1)!.reach <= reach) e++;
433
+ if (secondsOf(s, e) >= minSeconds - 1e-9) {
434
+ spans.push([s, e]);
435
+ s = e + 1;
436
+ } else s++;
437
+ }
438
+ const out: Series = xs.map((p) => (p === null ? null : [p[0], p[1]] as Pt));
439
+ const inHold = (i: number) => spans.some(([a, b]) => i >= a && i <= b);
440
+ // The blends: on each frame outside a hold, the largest weight any hold of this joint gives it (the earlier hold on a tie).
441
+ const blend = new Map<number, { w: number; at: Pt }>();
442
+ const found: Array<{ a: number; b: number; at: Pt; in: number[]; out: number[] }> = [];
443
+ for (const [a, b] of spans) {
444
+ const { at } = spanOf(name, a, b)!;
445
+ const ins: number[] = [];
446
+ const outs: number[] = [];
447
+ for (let k = 1; k < HOLD_BLEND; k++) {
448
+ const w = (HOLD_BLEND - k) / HOLD_BLEND;
449
+ for (const [i, list] of [[a - k, ins], [b + k, outs]] as const) {
450
+ if (i < 0 || i >= n || inHold(i)) continue;
451
+ list.push(i);
452
+ const was = blend.get(i);
453
+ if (was === undefined || w > was.w) blend.set(i, { w, at });
454
+ }
455
+ }
456
+ found.push({ a, b, at, in: ins, out: outs });
457
+ }
458
+ for (const { a, b, at } of found) for (let i = a; i <= b; i++) if (out[i] !== null) out[i] = [at[0], at[1]];
459
+ for (const [i, { w, at }] of blend) {
460
+ const p = xs[i];
461
+ if (p === null || p === undefined) continue;
462
+ out[i] = [p[0] + w * (at[0] - p[0]), p[1] + w * (at[1] - p[1])];
463
+ }
464
+ // The seams: two holds back to back, or with a gap of under HOLD_BLEND frames, cross-fade over the
465
+ // HOLD_BLEND + gap frames centred on the gap's centre (one.b − 2 … two.a + 2), after the blends.
466
+ for (let q = 0; q + 1 < found.length; q++) {
467
+ const one = found[q]!;
468
+ const two = found[q + 1]!;
469
+ const gap = two.a - one.b - 1;
470
+ if (gap < 0 || gap >= HOLD_BLEND) continue;
471
+ seams.push({ joint: name, frame: two.a, step: Math.hypot(two.at[0] - one.at[0], two.at[1] - one.at[1]) / trunk, gap });
472
+ const width = HOLD_BLEND + gap;
473
+ for (let j = 0; j < width; j++) {
474
+ const i = one.b - (HOLD_BLEND / 2 - 1) + j;
475
+ if (i < one.a || i > two.b || out[i] === null) continue;
476
+ const w = (2 * j + 1) / (2 * width);
477
+ out[i] = [one.at[0] + w * (two.at[0] - one.at[0]), one.at[1] + w * (two.at[1] - one.at[1])];
478
+ }
479
+ }
480
+ held.set(name, out);
481
+ for (const f of found) {
482
+ let fastest = 0;
483
+ for (let i = f.a; i <= f.b; i++) if (xs[i] !== null && sp[i] !== null && sp[i] !== undefined) fastest = Math.max(fastest, sp[i]!);
484
+ const range = (xs2: number[]): [number, number] | null => (xs2.length === 0 ? null : [Math.min(...xs2), Math.max(...xs2)]);
485
+ holds.push({
486
+ joint: name,
487
+ frames: [f.a, f.b],
488
+ seconds: secondsOf(f.a, f.b),
489
+ speed: fastest,
490
+ reach: spanOf(name, f.a, f.b)!.reach,
491
+ blendIn: range(f.in),
492
+ blendOut: range(f.out),
493
+ jitter: { before: jitterOf([xs.slice(f.a, f.b + 1)], false).value, after: jitterOf([out.slice(f.a, f.b + 1)], false).value },
494
+ });
495
+ }
496
+ }
497
+ holds.sort((x, y) => x.frames[0] - y.frames[0] || BODY18.indexOf(x.joint) - BODY18.indexOf(y.joint));
498
+ const unheld: Unheld[] = [];
499
+ for (const [a, b] of holdRegions(holds)) {
500
+ for (const name of names) {
501
+ const xs = series.get(name)!;
502
+ const sp = med.get(name)!;
503
+ const covered = (i: number) => holds.some((h) => h.joint === name && i >= h.frames[0] && i <= h.frames[1]);
504
+ const runs: Array<[number, number]> = [];
505
+ for (let i = a; i <= b; i++) {
506
+ if (xs[i] === null || covered(i)) continue;
507
+ const last = runs[runs.length - 1];
508
+ if (last !== undefined && last[1] === i - 1) last[1] = i;
509
+ else runs.push([i, i]);
510
+ }
511
+ for (const [s0, s1] of runs) {
512
+ let fastest: number | null = 0;
513
+ for (let i = s0; i <= s1; i++) {
514
+ const v = sp[i];
515
+ if (v === null || v === undefined) {
516
+ fastest = null;
517
+ break;
518
+ }
519
+ fastest = Math.max(fastest, v);
520
+ }
521
+ const r = spanOf(name, s0, s1)!;
522
+ const seconds = secondsOf(s0, s1);
523
+ if (fastest === null || fastest >= speed) unheld.push({ joint: name, frames: [s0, s1], test: 'speed', value: fastest });
524
+ else if (r.reach > reach) unheld.push({ joint: name, frames: [s0, s1], test: 'reach', value: r.reach });
525
+ else if (seconds < minSeconds - 1e-9) unheld.push({ joint: name, frames: [s0, s1], test: 'short', value: seconds });
526
+ else unheld.push({ joint: name, frames: [s0, s1], test: 'span', value: r.reach });
527
+ }
528
+ }
529
+ }
530
+ unheld.sort((x, y) => x.frames[0] - y.frames[0] || BODY18.indexOf(x.joint) - BODY18.indexOf(y.joint));
531
+ seams.sort((x, y) => x.frame - y.frame || BODY18.indexOf(x.joint) - BODY18.indexOf(y.joint));
532
+ return { held, holds, unheld, seams };
533
+ }
534
+
535
+ /** The regions the holds cover: their spans joined where they overlap or touch, in frame order. */
536
+ export function holdRegions(holds: ReadonlyArray<{ frames: readonly [number, number] }>): Array<[number, number]> {
537
+ const spans = holds.map((h) => [h.frames[0], h.frames[1]] as [number, number]).sort((x, y) => x[0] - y[0] || x[1] - y[1]);
538
+ const out: Array<[number, number]> = [];
539
+ for (const [a, b] of spans) {
540
+ const last = out[out.length - 1];
541
+ if (last !== undefined && a <= last[1] + 1) last[1] = Math.max(last[1], b);
542
+ else out.push([a, b]);
543
+ }
544
+ return out;
545
+ }
546
+
281
547
  /**
282
548
  * Clean one person's track. Refuses (every reason at once) a window that is
283
549
  * not 1 or odd ≥ 5, a loop shorter than the widest window it reads, and a
@@ -376,14 +642,38 @@ export function clean(seq: PoseSequence, params: CleanParams, wanted: string | u
376
642
  });
377
643
  }
378
644
 
645
+ // 5. The stillness hold (#91).
646
+ const hold = params.hold;
647
+ let holds: HoldFound[] = [];
648
+ let unheld: Unheld[] = [];
649
+ let seams: Seam[] = [];
650
+ if (hold !== undefined && hold !== 'off') {
651
+ const r = holdStill(smoothed, seq.frames.map((f) => f.t), trunk, hold.speed, hold.reach, params.loop);
652
+ holds = r.holds;
653
+ unheld = r.unheld;
654
+ seams = r.seams;
655
+ for (const name of names) smoothed.set(name, r.held.get(name)!);
656
+ }
657
+
379
658
  const untested = Array.from({ length: n }, (_, i) => i).filter((i) => !testedAt(i, n, params.loop));
380
659
  const before = jitterOf([...raw.values()], params.loop);
381
660
  const after = jitterOf([...smoothed.values()], params.loop);
382
661
  const record: CleanRecord = {
383
662
  spec: CLEAN_SPEC,
384
- params: { loop: params.loop, legs: params.legs, rest: params.rest, ...(params.hipsInferred === true ? { hipsInferred: { by: 'declared' as const } } : {}) },
663
+ params: {
664
+ loop: params.loop,
665
+ legs: params.legs,
666
+ rest: params.rest,
667
+ ...(params.hipsInferred === true ? { hipsInferred: { by: 'declared' as const } } : {}),
668
+ ...(hold === undefined ? {} : { hold: hold === 'off' ? ('off' as const) : { speed: hold.speed, reach: hold.reach, minSeconds: HOLD_MIN_SECONDS, blend: HOLD_BLEND } }),
669
+ },
385
670
  spikes: { found: spikes.map((s) => ({ frame: s.frame, joint: s.joint })), untested },
386
671
  jitter: { before: before.value, after: after.value },
672
+ ...(hold === undefined || hold === 'off' ? {} : {
673
+ holds: holds.map((h) => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]] as [number, number], jitter: { before: h.jitter.before, after: h.jitter.after } })),
674
+ unheld: unheld.map((u) => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]] as [number, number], test: u.test, value: u.value })),
675
+ seams: seams.map((x) => ({ joint: x.joint, frame: x.frame, step: x.step, gap: x.gap })),
676
+ }),
387
677
  };
388
678
  const inferred = params.hipsInferred === true ? { r_hip: 0, l_hip: 0 } : null;
389
679
  const frames = seq.frames.map((f, i) => {
@@ -414,6 +704,9 @@ export function clean(seq: PoseSequence, params: CleanParams, wanted: string | u
414
704
  floor,
415
705
  triples: before.triples,
416
706
  inferred,
707
+ holds,
708
+ unheld,
709
+ seams,
417
710
  };
418
711
  }
419
712
 
@@ -432,17 +725,56 @@ export function cleanLine(r: CleanRecord): string {
432
725
  const spikes = `${found.length === 0 ? 'no spike' : `${found.length} spike(s) replaced (${shown}${found.length > 8 ? `, … ${found.length - 8} more` : ''})`}, ${untestedText(r.spikes.untested)}`;
433
726
  const jit = (v: number | null) => (v === null ? 'not measured' : v.toFixed(2));
434
727
  const hips = r.params.hipsInferred === undefined ? '' : `; hips inferred (${inferredSource(r)})`;
435
- return `${r.spec}: ${windows}, ${loop ? 'loop' : 'no loop'}; ${spikes}; jitter (mean |second difference| of y) ${jit(r.jitter.before)} → ${jit(r.jitter.after)} px/f²${hips}`;
728
+ return `${r.spec}: ${windows}, ${loop ? 'loop' : 'no loop'}; ${spikes}; jitter (mean |second difference| of y) ${jit(r.jitter.before)} → ${jit(r.jitter.after)} px/f²${holdText(r)}${hips}`;
729
+ }
730
+
731
+ /**
732
+ * The stillness hold on the CLEAN line (#91): "; hold off"; "; no HOLD (…)";
733
+ * or, per region the holds cover, the joints held over which frames and the
734
+ * joints not held with the test each fails and its figure. Nothing for a
735
+ * record written before #91.
736
+ */
737
+ function holdText(r: CleanRecord): string {
738
+ const h = r.params.hold;
739
+ if (h === undefined) return '';
740
+ if (h === 'off') return '; hold off';
741
+ const holds = r.holds ?? [];
742
+ if (holds.length === 0) return `; no HOLD (speed < ${h.speed} trunk/frame, within ${h.reach} trunk, ≥ ${h.minSeconds} s)`;
743
+ return holdRegions(holds).map(([a, b]) => {
744
+ const groups = new Map<string, string[]>();
745
+ for (const x of holds) {
746
+ if (x.frames[0] > b || x.frames[1] < a) continue;
747
+ const k = `${x.frames[0]}–${x.frames[1]}`;
748
+ groups.set(k, [...(groups.get(k) ?? []), x.joint]);
749
+ }
750
+ const held = [...groups].map(([k, js]) => `${js.join(', ')} ${k}`).join('; ');
751
+ const not = (r.unheld ?? []).filter((u) => u.frames[0] >= a && u.frames[1] <= b).map(unheldText(h));
752
+ const sm = (r.seams ?? []).filter((x) => x.frame >= a && x.frame <= b).map((x) => `${x.joint} ${x.frame} (step ${x.step.toFixed(4)}${x.gap > 0 ? `, gap ${x.gap}` : ''})`);
753
+ const ramps = holds.filter((x) => x.frames[0] <= b && x.frames[1] >= a && x.jitter.after !== null && x.jitter.after > 0).map((x) => `${x.joint} ${x.frames[0]}–${x.frames[1]} ${x.jitter.after!.toFixed(4)} px/f² (ramp)`);
754
+ return `; HOLD frames ${a}–${b}: held ${held}${sm.length === 0 ? '' : `; seams cross-faded: ${sm.join(', ')}`}${ramps.length === 0 ? '' : `; jitter after ${ramps.join(', ')}`}${not.length === 0 ? '; every joint held' : `; not held: ${not.join(', ')}`}`;
755
+ }).join('');
756
+ }
757
+
758
+ /** One joint's frames not held, as the lines say it: `r_wrist 21–59 (reach 0.1375 > 0.03)` (the drifting wrist of the tests). */
759
+ export function unheldText(h: { speed: number; reach: number; minSeconds?: number }): (u: CleanUnheld) => string {
760
+ return (u) => {
761
+ const at = `${u.joint} ${u.frames[0] === u.frames[1] ? `${u.frames[0]}` : `${u.frames[0]}–${u.frames[1]}`}`;
762
+ if (u.test === 'speed') return `${at} (speed ${u.value === null ? 'not known on some frame' : `${u.value.toFixed(4)} ≥ ${h.speed}`})`;
763
+ if (u.test === 'reach') return `${at} (reach ${u.value!.toFixed(4)} > ${h.reach})`;
764
+ if (u.test === 'short') return `${at} (still, ${u.value!.toFixed(3)} s < ${h.minSeconds ?? HOLD_MIN_SECONDS} s)`;
765
+ return `${at} (passes all three, reach ${u.value!.toFixed(4)}; its spans broke elsewhere)`;
766
+ };
436
767
  }
437
768
 
438
769
  /**
439
770
  * Where a sequence's `inferred` hips come from, as the lines say it: "declared
440
- * at clean" when the record says `--hips-inferred` marked them; "source not
771
+ * at clean" when the record says `--hips-inferred` marked them; "measured by
772
+ * <method>" when a tool read the pelvis line off the picture (#86); "source not
441
773
  * stated" when the joints carry the state and the record does not say why
442
774
  * (a file whose states were written by hand or by another tool).
443
775
  */
444
776
  export function inferredSource(r: CleanRecord | undefined): string {
445
777
  const h = r?.params.hipsInferred;
446
778
  if (h === undefined) return 'source not stated';
447
- return h.by === 'declared' ? 'declared at clean' : h.by;
779
+ return h.by === 'declared' ? 'declared at clean' : `measured by ${h.method}`;
448
780
  }