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