@rig-c/pose 0.16.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/README.md +52 -8
- package/cli.ts +56 -6
- package/package.json +1 -1
- package/src/clean.ts +339 -7
- package/src/legs.ts +211 -15
- package/src/pose.ts +307 -21
- package/src/retarget.ts +49 -19
- package/src/run.ts +57 -8
- package/src/verify.ts +29 -4
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
|
-
*
|
|
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
|
|
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: {
|
|
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; "
|
|
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.
|
|
779
|
+
return h.by === 'declared' ? 'declared at clean' : `measured by ${h.method}`;
|
|
448
780
|
}
|