@rig-c/pose 0.13.0 → 0.15.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 +59 -15
- package/cli.ts +101 -14
- package/package.json +1 -1
- package/src/clean.ts +448 -0
- package/src/pose.ts +128 -4
- package/src/retarget.ts +7 -68
- package/src/run.ts +21 -3
- package/src/switch.ts +1 -1
- package/src/verify.ts +49 -10
package/src/clean.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rig-pose clean` (rig-c/pose#67): the sequence-cleaning step every sequence
|
|
3
|
+
* passes through before `retarget`. A pose model reads each frame alone, so a
|
|
4
|
+
* hanging wrist moves ±20 px a frame and a cloak-hidden leg jumps 100+ px; this
|
|
5
|
+
* removes that once, at the sequence, where others can inspect the result.
|
|
6
|
+
*
|
|
7
|
+
* Four steps, in this order, each on one person's joints:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Spikes.** A joint more than `SPIKE_TRUNKS` (0.2) trunk lengths from its
|
|
10
|
+
* `SPIKE_WINDOW`-frame (5) median takes the median. The trunk is neck →
|
|
11
|
+
* mid_hip (the hips' midpoint), the median of its picture length over the
|
|
12
|
+
* frames where all three have a position. Every replacement is listed by
|
|
13
|
+
* frame and joint. Without `loop`, a frame whose window a clip end cuts
|
|
14
|
+
* (the first and last two) is not tested, and is listed as untested: on a
|
|
15
|
+
* cut window the median is a neighbour's value, so a joint moving steadily
|
|
16
|
+
* would read as a spike there. Reflecting the window at the end does not
|
|
17
|
+
* help — the median of [x2, x1, x0, x1, x2] is still x1.
|
|
18
|
+
* 2. **Occlusion.** A joint whose state is not `observed`, or whose score is
|
|
19
|
+
* under `OCCLUSION_SCORE` (0.3), takes its `OCCLUSION_WINDOW`-frame (3)
|
|
20
|
+
* median.
|
|
21
|
+
* 3. **Smoothing.** A quadratic Savitzky–Golay per joint: the legs (`LEGS`:
|
|
22
|
+
* both hips, knees and ankles) over `legs` frames, every other joint over
|
|
23
|
+
* `rest`. A window of 1 is none.
|
|
24
|
+
* 4. **Floor.** With `loop` only: the whole figure is moved in y by one
|
|
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.
|
|
27
|
+
*
|
|
28
|
+
* What a median is here: per coordinate (x and y apart), over the frames of the
|
|
29
|
+
* window on which the joint has a position — its own frame included, any state.
|
|
30
|
+
* An even count takes the mean of the middle two. The windows are centred; with
|
|
31
|
+
* `loop` they wrap (the sequence is one cycle, its last frame followed by its
|
|
32
|
+
* first), without it they are cut at the ends. The medians read the step's
|
|
33
|
+
* input, never a value the same step already replaced.
|
|
34
|
+
*
|
|
35
|
+
* What is never invented: a joint with no position on a frame (`missing`, or a
|
|
36
|
+
* frame the person is absent from) gets none here. It has nothing to take a
|
|
37
|
+
* median of on its own frame, and a value made from its neighbours would be a
|
|
38
|
+
* guess; `retarget` holds it, and says so with a HOLD line. A joint's state and
|
|
39
|
+
* score are the detector's and are written as they were read: this step moves
|
|
40
|
+
* positions only — with one stated exception, `hipsInferred` below.
|
|
41
|
+
*
|
|
42
|
+
* `hipsInferred` (rig-c/pose#63, `--hips-inferred`): after the four steps, r_hip
|
|
43
|
+
* and l_hip are marked `inferred` on every frame where they have a position,
|
|
44
|
+
* and the record says who said so (`{ by: 'declared' }`, the person running
|
|
45
|
+
* clean). Positions and scores are not touched by it, so retarget's keys are
|
|
46
|
+
* the same with and without it; what changes is POSTURE, which does not read a
|
|
47
|
+
* hip tilt off inferred hips. The occlusion step reads `inferred` as it reads
|
|
48
|
+
* `observed`: a cleaned file cleaned again moves its hips the same way.
|
|
49
|
+
*
|
|
50
|
+
* The smoothing fits the frames of the window where the joint has a position;
|
|
51
|
+
* where fewer than three are there, the polynomial's degree drops to fit the
|
|
52
|
+
* ones that are (two give a line, one returns the frame's own value).
|
|
53
|
+
*
|
|
54
|
+
* Jitter, the step's figure, is the mean |second difference| of y, in px/f²,
|
|
55
|
+
* over every joint and every three consecutive frames on which it has a
|
|
56
|
+
* position (wrapping with `loop`), before (the sequence as read) and after
|
|
57
|
+
* (the cleaned positions, before the writer rounds them to 0.01 px).
|
|
58
|
+
*
|
|
59
|
+
* Pure: no clock, no randomness, no disk.
|
|
60
|
+
*/
|
|
61
|
+
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';
|
|
64
|
+
import { Problems } from './problems.ts';
|
|
65
|
+
|
|
66
|
+
/** A spike is a joint this many trunk lengths or more from its median. */
|
|
67
|
+
export const SPIKE_TRUNKS = 0.2;
|
|
68
|
+
/** The spike median's window, in frames. */
|
|
69
|
+
export const SPIKE_WINDOW = 5;
|
|
70
|
+
/** The occlusion median's window, in frames. */
|
|
71
|
+
export const OCCLUSION_WINDOW = 3;
|
|
72
|
+
/** A joint scored under this is treated as occluded. */
|
|
73
|
+
export const OCCLUSION_SCORE = 0.3;
|
|
74
|
+
/** The joints smoothed over `legs`; every other joint is smoothed over `rest`. */
|
|
75
|
+
export const LEGS: readonly Body18[] = ['r_hip', 'r_knee', 'r_ankle', 'l_hip', 'l_knee', 'l_ankle'];
|
|
76
|
+
/** The polynomial's degree. */
|
|
77
|
+
const ORDER = 2;
|
|
78
|
+
|
|
79
|
+
export interface CleanParams {
|
|
80
|
+
/** The sequence is one cycle: every window wraps, and the floor is re-pinned. */
|
|
81
|
+
loop: boolean;
|
|
82
|
+
/** Savitzky–Golay window over the legs, frames: 1 (none), or odd and ≥ 5. */
|
|
83
|
+
legs: number;
|
|
84
|
+
/** Savitzky–Golay window over every other joint, frames: 1 (none), or odd and ≥ 5. */
|
|
85
|
+
rest: number;
|
|
86
|
+
/** Mark r_hip and l_hip `inferred` wherever they have a position (#63): the person running clean declares it. */
|
|
87
|
+
hipsInferred?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The joints `--hips-inferred` marks. */
|
|
91
|
+
export const HIPS: readonly Body18[] = ['r_hip', 'l_hip'];
|
|
92
|
+
|
|
93
|
+
export type Pt = [number, number];
|
|
94
|
+
/** One joint's positions over the frames; null where it has none. */
|
|
95
|
+
export type Series = Array<Pt | null>;
|
|
96
|
+
|
|
97
|
+
export interface SpikeFound extends CleanSpike {
|
|
98
|
+
/** Its distance from the median, px. */
|
|
99
|
+
off: number;
|
|
100
|
+
}
|
|
101
|
+
export interface Occluded {
|
|
102
|
+
joint: Body18;
|
|
103
|
+
frame: number;
|
|
104
|
+
/** How far the median moved it, px. */
|
|
105
|
+
moved: number;
|
|
106
|
+
why: string;
|
|
107
|
+
}
|
|
108
|
+
export interface Floor {
|
|
109
|
+
/** The lowest ankle's y before smoothing, and after, px. */
|
|
110
|
+
before: number;
|
|
111
|
+
after: number;
|
|
112
|
+
/** The y every joint was moved by, px. */
|
|
113
|
+
shift: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface Cleaned {
|
|
117
|
+
seq: PoseSequence;
|
|
118
|
+
record: CleanRecord;
|
|
119
|
+
person: string;
|
|
120
|
+
/** Neck → mid_hip, the median picture length, px, and over how many frames. */
|
|
121
|
+
trunk: { length: number; frames: number };
|
|
122
|
+
spikes: SpikeFound[];
|
|
123
|
+
occluded: Occluded[];
|
|
124
|
+
/** Joints without a position on some frames, as runs: left without one. */
|
|
125
|
+
missing: Array<{ joint: Body18; from: number; to: number }>;
|
|
126
|
+
/** The frames the spike step did not test (a clip end cuts their window). */
|
|
127
|
+
untested: number[];
|
|
128
|
+
/** With loop, where an ankle exists; else null. */
|
|
129
|
+
floor: Floor | null;
|
|
130
|
+
/** How many (joint, three-frame) runs the jitter was read over: before, after. */
|
|
131
|
+
triples: number;
|
|
132
|
+
/** With `hipsInferred`: how many joint-frames were marked `inferred`, per hip. */
|
|
133
|
+
inferred: { r_hip: number; l_hip: number } | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The windows `clean` accepts: 1, or odd and ≥ 5. A window of 3 is refused:
|
|
138
|
+
* a quadratic fitted through 3 points passes through all three, so it would
|
|
139
|
+
* change nothing (and at the ends the window cut to 2 frames is a line through
|
|
140
|
+
* both). An even window has no centre frame.
|
|
141
|
+
*/
|
|
142
|
+
export function windowProblem(flag: string, n: number): string | null {
|
|
143
|
+
if (!Number.isInteger(n) || n < 1) return `${flag} is "${n}"; it must be 1 (none) or an odd whole number ≥ 5`;
|
|
144
|
+
if (n === 3) return `${flag} is "3"; a quadratic fitted through 3 points passes through all three, so a window of 3 changes nothing — it must be 1 (none) or an odd whole number ≥ 5`;
|
|
145
|
+
if (n % 2 === 0) return `${flag} is "${n}"; an even window has no centre frame — it must be 1 (none) or an odd whole number ≥ 5`;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The frame offsets of a centred window at frame i over n frames: wrapped with loop, cut at the ends without. */
|
|
150
|
+
function windowAt(i: number, n: number, size: number, loop: boolean): number[] {
|
|
151
|
+
const h = size >> 1;
|
|
152
|
+
const out: number[] = [];
|
|
153
|
+
for (let u = -h; u <= h; u++) {
|
|
154
|
+
const j = i + u;
|
|
155
|
+
if (loop) out.push(((j % n) + n) % n);
|
|
156
|
+
else if (j >= 0 && j < n) out.push(j);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function median1(xs: number[]): number {
|
|
162
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
163
|
+
const m = s.length >> 1;
|
|
164
|
+
return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The per-coordinate median of a joint's positions over the window around i; null when it has none there. */
|
|
168
|
+
export function medianAt(xs: Series, i: number, size: number, loop: boolean): Pt | null {
|
|
169
|
+
const pts = windowAt(i, xs.length, size, loop).map((j) => xs[j]).filter((p): p is Pt => p !== null && p !== undefined);
|
|
170
|
+
if (pts.length === 0) return null;
|
|
171
|
+
return [median1(pts.map((p) => p[0])), median1(pts.map((p) => p[1]))];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether the spike step tests frame i: under loop always; without it, only where no clip end cuts the window. */
|
|
175
|
+
export function testedAt(i: number, n: number, loop: boolean, size = SPIKE_WINDOW): boolean {
|
|
176
|
+
const h = size >> 1;
|
|
177
|
+
return loop ? n >= size : i - h >= 0 && i + h < n;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Spikes on one joint: every frame where it lies more than `limit` px from its
|
|
182
|
+
* `size`-frame median (read from `xs`, not from a replaced value). Returns the
|
|
183
|
+
* frames, each with its median and distance; `xs` is not changed.
|
|
184
|
+
*/
|
|
185
|
+
export function spikesOf(xs: Series, limit: number, loop: boolean, size = SPIKE_WINDOW): Array<{ frame: number; at: Pt; off: number }> {
|
|
186
|
+
const out: Array<{ frame: number; at: Pt; off: number }> = [];
|
|
187
|
+
xs.forEach((p, i) => {
|
|
188
|
+
if (p === null || !testedAt(i, xs.length, loop, size)) return;
|
|
189
|
+
const m = medianAt(xs, i, size, loop)!;
|
|
190
|
+
const off = Math.hypot(p[0] - m[0], p[1] - m[1]);
|
|
191
|
+
if (off > limit) out.push({ frame: i, at: m, off });
|
|
192
|
+
});
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Savitzky–Golay, centred: at each frame with a position, the least-squares
|
|
198
|
+
* polynomial of degree `ORDER` (2) through the window's frames that have one,
|
|
199
|
+
* read at that frame. With `loop` the window wraps; without it, it is cut at
|
|
200
|
+
* the ends. Fewer than three points drop the degree to fit them exactly. A
|
|
201
|
+
* polynomial of degree ≤ 2 passes through unchanged; a window of 1 is the input.
|
|
202
|
+
*/
|
|
203
|
+
export function savitzkyGolay(xs: Series, window: number, loop: boolean): Series {
|
|
204
|
+
if (window <= 1) return xs.map((p) => (p === null ? null : [p[0], p[1]]));
|
|
205
|
+
const n = xs.length;
|
|
206
|
+
const h = window >> 1;
|
|
207
|
+
const cache = new Map<string, number[]>();
|
|
208
|
+
return xs.map((p, i) => {
|
|
209
|
+
if (p === null) return null;
|
|
210
|
+
const us: number[] = [];
|
|
211
|
+
const at: Pt[] = [];
|
|
212
|
+
for (let u = -h; u <= h; u++) {
|
|
213
|
+
let j = i + u;
|
|
214
|
+
if (loop) j = ((j % n) + n) % n;
|
|
215
|
+
else if (j < 0 || j >= n) continue;
|
|
216
|
+
const q = xs[j];
|
|
217
|
+
if (q === null || q === undefined) continue;
|
|
218
|
+
us.push(u);
|
|
219
|
+
at.push(q);
|
|
220
|
+
}
|
|
221
|
+
const key = us.join(',');
|
|
222
|
+
let w = cache.get(key);
|
|
223
|
+
if (w === undefined) {
|
|
224
|
+
w = sgWeights(us, Math.min(ORDER, us.length - 1));
|
|
225
|
+
cache.set(key, w);
|
|
226
|
+
}
|
|
227
|
+
let x = 0;
|
|
228
|
+
let y = 0;
|
|
229
|
+
w.forEach((c, k) => {
|
|
230
|
+
x += c * at[k]![0];
|
|
231
|
+
y += c * at[k]![1];
|
|
232
|
+
});
|
|
233
|
+
return [x, y];
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The weights that read a least-squares polynomial of degree q over the offsets `us` at offset 0. */
|
|
238
|
+
function sgWeights(us: readonly number[], q: number): number[] {
|
|
239
|
+
const m = q + 1;
|
|
240
|
+
// Normal equations: A[j][l] = Σ u^(j+l); solve A z = e0, then w(u) = Σ z_j u^j.
|
|
241
|
+
const A = Array.from({ length: m }, (_, j) => Array.from({ length: m + 1 }, (_, l) => {
|
|
242
|
+
if (l === m) return j === 0 ? 1 : 0;
|
|
243
|
+
let s = 0;
|
|
244
|
+
for (const u of us) s += u ** (j + l);
|
|
245
|
+
return s;
|
|
246
|
+
}));
|
|
247
|
+
for (let c = 0; c < m; c++) {
|
|
248
|
+
let piv = c;
|
|
249
|
+
for (let r = c + 1; r < m; r++) if (Math.abs(A[r]![c]!) > Math.abs(A[piv]![c]!)) piv = r;
|
|
250
|
+
[A[c], A[piv]] = [A[piv]!, A[c]!];
|
|
251
|
+
for (let r = 0; r < m; r++) {
|
|
252
|
+
if (r === c) continue;
|
|
253
|
+
const f = A[r]![c]! / A[c]![c]!;
|
|
254
|
+
for (let l = c; l <= m; l++) A[r]![l]! -= f * A[c]![l]!;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const z = A.map((row, j) => row[m]! / row[j]!);
|
|
258
|
+
return us.map((u) => z.reduce((s, zj, j) => s + zj * u ** j, 0));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Mean |second difference| of y, px/f², over every series and every three consecutive frames with a position (wrapping with loop). */
|
|
262
|
+
export function jitterOf(all: readonly Series[], loop: boolean): { value: number | null; triples: number } {
|
|
263
|
+
let sum = 0;
|
|
264
|
+
let count = 0;
|
|
265
|
+
for (const xs of all) {
|
|
266
|
+
const n = xs.length;
|
|
267
|
+
for (let i = 0; i < n; i++) {
|
|
268
|
+
if (!loop && (i === 0 || i === n - 1)) continue;
|
|
269
|
+
if (loop && n < 3) continue;
|
|
270
|
+
const a = xs[(i - 1 + n) % n];
|
|
271
|
+
const b = xs[i];
|
|
272
|
+
const c = xs[(i + 1) % n];
|
|
273
|
+
if (a === null || b === null || c === null || a === undefined || b === undefined || c === undefined) continue;
|
|
274
|
+
sum += Math.abs(c[1] - 2 * b[1] + a[1]);
|
|
275
|
+
count++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { value: count === 0 ? null : sum / count, triples: count };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Clean one person's track. Refuses (every reason at once) a window that is
|
|
283
|
+
* not 1 or odd ≥ 5, a loop shorter than the widest window it reads, and a
|
|
284
|
+
* sequence whose trunk is measured on no frame.
|
|
285
|
+
*/
|
|
286
|
+
export function clean(seq: PoseSequence, params: CleanParams, wanted: string | undefined, where: string): Cleaned {
|
|
287
|
+
const p = new Problems();
|
|
288
|
+
for (const [flag, v] of [['--legs', params.legs], ['--rest', params.rest]] as const) {
|
|
289
|
+
const why = windowProblem(flag, v);
|
|
290
|
+
if (why !== null) p.add('CLEAN_WINDOW', why);
|
|
291
|
+
}
|
|
292
|
+
const track = personTrack(seq, wanted, where);
|
|
293
|
+
const n = track.length;
|
|
294
|
+
const person = wanted ?? [...new Set(seq.frames.flatMap((f) => f.people.map((q) => q.id)))].sort()[0]!;
|
|
295
|
+
if (params.loop) {
|
|
296
|
+
const widest = Math.max(SPIKE_WINDOW, params.legs, params.rest);
|
|
297
|
+
if (n < widest) p.add('CLEAN_LOOP', `--loop over ${n} frame(s) of ${where}: a circular window of ${widest} frames would read a frame twice; a cycle needs ≥ ${widest} frames (the spike median reads ${SPIKE_WINDOW}, --legs ${params.legs}, --rest ${params.rest})`);
|
|
298
|
+
}
|
|
299
|
+
const at = (j: Joint | undefined): Pt | null => (j === undefined || j.state === 'missing' || j.at === undefined ? null : [j.at[0], j.at[1]]);
|
|
300
|
+
const names = BODY18.filter((name) => track.some((f) => f[name] !== undefined));
|
|
301
|
+
const raw = new Map<Body18, Series>(names.map((name) => [name, track.map((f) => at(f[name]))]));
|
|
302
|
+
|
|
303
|
+
// The trunk: neck → mid_hip, its median picture length.
|
|
304
|
+
const lengths: number[] = [];
|
|
305
|
+
for (let i = 0; i < n; i++) {
|
|
306
|
+
const neck = raw.get('neck')?.[i] ?? null;
|
|
307
|
+
const r = raw.get('r_hip')?.[i] ?? null;
|
|
308
|
+
const l = raw.get('l_hip')?.[i] ?? null;
|
|
309
|
+
if (neck === null || r === null || l === null) continue;
|
|
310
|
+
lengths.push(Math.hypot(neck[0] - (r[0] + l[0]) / 2, neck[1] - (r[1] + l[1]) / 2));
|
|
311
|
+
}
|
|
312
|
+
if (lengths.length === 0) p.add('CLEAN_TRUNK', `${where} person "${person}": neck, r_hip and l_hip have a position together on none of its ${n} frame(s); the spike limit is ${SPIKE_TRUNKS} trunk lengths (neck → mid_hip), so there is no limit to read`);
|
|
313
|
+
p.throwIfAny();
|
|
314
|
+
const trunk = median1(lengths);
|
|
315
|
+
const limit = SPIKE_TRUNKS * trunk;
|
|
316
|
+
|
|
317
|
+
// 1. Spikes.
|
|
318
|
+
const spikes: SpikeFound[] = [];
|
|
319
|
+
const despiked = new Map<Body18, Series>();
|
|
320
|
+
for (const name of names) {
|
|
321
|
+
const xs = raw.get(name)!;
|
|
322
|
+
const out = [...xs];
|
|
323
|
+
for (const s of spikesOf(xs, limit, params.loop)) {
|
|
324
|
+
out[s.frame] = s.at;
|
|
325
|
+
spikes.push({ frame: s.frame, joint: name, off: s.off });
|
|
326
|
+
}
|
|
327
|
+
despiked.set(name, out);
|
|
328
|
+
}
|
|
329
|
+
spikes.sort((a, b) => a.frame - b.frame || BODY18.indexOf(a.joint) - BODY18.indexOf(b.joint));
|
|
330
|
+
|
|
331
|
+
// 2. Occlusion.
|
|
332
|
+
const occluded: Occluded[] = [];
|
|
333
|
+
const unhidden = new Map<Body18, Series>();
|
|
334
|
+
for (const name of names) {
|
|
335
|
+
const xs = despiked.get(name)!;
|
|
336
|
+
const out = [...xs];
|
|
337
|
+
xs.forEach((q, i) => {
|
|
338
|
+
if (q === null) return;
|
|
339
|
+
const j = track[i]![name]!;
|
|
340
|
+
const low = j.score !== undefined && j.score < OCCLUSION_SCORE;
|
|
341
|
+
if ((j.state === 'observed' || j.state === 'inferred') && !low) return;
|
|
342
|
+
const m = medianAt(xs, i, OCCLUSION_WINDOW, params.loop)!;
|
|
343
|
+
out[i] = m;
|
|
344
|
+
occluded.push({ joint: name, frame: i, moved: Math.hypot(m[0] - q[0], m[1] - q[1]), why: j.state !== 'observed' && j.state !== 'inferred' ? j.state : `score ${j.score!.toFixed(3)} < ${OCCLUSION_SCORE}` });
|
|
345
|
+
});
|
|
346
|
+
unhidden.set(name, out);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 3. Smoothing.
|
|
350
|
+
const smoothed = new Map<Body18, Series>();
|
|
351
|
+
for (const name of names) smoothed.set(name, savitzkyGolay(unhidden.get(name)!, LEGS.includes(name) ? params.legs : params.rest, params.loop));
|
|
352
|
+
|
|
353
|
+
// 4. Floor (loop only).
|
|
354
|
+
let floor: Floor | null = null;
|
|
355
|
+
if (params.loop) {
|
|
356
|
+
const lowest = (m: Map<Body18, Series>): number | null => {
|
|
357
|
+
const ys = (['r_ankle', 'l_ankle'] as const).flatMap((a) => (m.get(a) ?? []).filter((q): q is Pt => q !== null).map((q) => q[1]));
|
|
358
|
+
return ys.length === 0 ? null : Math.max(...ys);
|
|
359
|
+
};
|
|
360
|
+
const before = lowest(unhidden);
|
|
361
|
+
const after = lowest(smoothed);
|
|
362
|
+
if (before !== null && after !== null) {
|
|
363
|
+
const shift = before - after;
|
|
364
|
+
floor = { before, after, shift };
|
|
365
|
+
for (const name of names) smoothed.set(name, smoothed.get(name)!.map((q) => (q === null ? null : [q[0], q[1] + shift])));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const missing: Cleaned['missing'] = [];
|
|
370
|
+
for (const name of names) {
|
|
371
|
+
raw.get(name)!.forEach((q, i) => {
|
|
372
|
+
if (q !== null) return;
|
|
373
|
+
const last = missing[missing.length - 1];
|
|
374
|
+
if (last !== undefined && last.joint === name && last.to === i - 1) last.to = i;
|
|
375
|
+
else missing.push({ joint: name, from: i, to: i });
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const untested = Array.from({ length: n }, (_, i) => i).filter((i) => !testedAt(i, n, params.loop));
|
|
380
|
+
const before = jitterOf([...raw.values()], params.loop);
|
|
381
|
+
const after = jitterOf([...smoothed.values()], params.loop);
|
|
382
|
+
const record: CleanRecord = {
|
|
383
|
+
spec: CLEAN_SPEC,
|
|
384
|
+
params: { loop: params.loop, legs: params.legs, rest: params.rest, ...(params.hipsInferred === true ? { hipsInferred: { by: 'declared' as const } } : {}) },
|
|
385
|
+
spikes: { found: spikes.map((s) => ({ frame: s.frame, joint: s.joint })), untested },
|
|
386
|
+
jitter: { before: before.value, after: after.value },
|
|
387
|
+
};
|
|
388
|
+
const inferred = params.hipsInferred === true ? { r_hip: 0, l_hip: 0 } : null;
|
|
389
|
+
const frames = seq.frames.map((f, i) => {
|
|
390
|
+
const q = f.people.find((x) => x.id === person);
|
|
391
|
+
if (q === undefined) return { t: f.t, people: [] };
|
|
392
|
+
const joints: Partial<Record<Body18, Joint>> = {};
|
|
393
|
+
for (const [name, j] of Object.entries(q.joints) as Array<[Body18, Joint]>) {
|
|
394
|
+
const moved = smoothed.get(name)?.[i] ?? null;
|
|
395
|
+
const out: Joint = moved === null ? { ...j } : { ...j, at: [moved[0], moved[1]] };
|
|
396
|
+
if (inferred !== null && (name === 'r_hip' || name === 'l_hip') && out.state !== 'missing' && out.at !== undefined) {
|
|
397
|
+
out.state = 'inferred';
|
|
398
|
+
inferred[name]++;
|
|
399
|
+
}
|
|
400
|
+
joints[name] = out;
|
|
401
|
+
}
|
|
402
|
+
return { t: f.t, people: [{ id: person, joints: sortedJoints(joints) }] };
|
|
403
|
+
});
|
|
404
|
+
const { cleaned: _was, ...rest } = seq;
|
|
405
|
+
return {
|
|
406
|
+
seq: { ...rest, cleaned: record, frames },
|
|
407
|
+
record,
|
|
408
|
+
person,
|
|
409
|
+
trunk: { length: trunk, frames: lengths.length },
|
|
410
|
+
spikes,
|
|
411
|
+
occluded,
|
|
412
|
+
missing,
|
|
413
|
+
untested,
|
|
414
|
+
floor,
|
|
415
|
+
triples: before.triples,
|
|
416
|
+
inferred,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The untested frames as text: "frames 0, 1, 30, 31 untested (a clip end cuts their window)", or "every frame tested". */
|
|
421
|
+
export function untestedText(frames: readonly number[]): string {
|
|
422
|
+
if (frames.length === 0) return 'every frame tested';
|
|
423
|
+
return `frame${frames.length === 1 ? '' : 's'} ${frames.join(', ')} untested (a clip end cuts the ${SPIKE_WINDOW}-frame window)`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The record as one line: retarget's CLEAN line, and clean's own summary. */
|
|
427
|
+
export function cleanLine(r: CleanRecord): string {
|
|
428
|
+
const { loop, legs, rest } = r.params;
|
|
429
|
+
const windows = legs === 1 && rest === 1 ? 'legs 1, rest 1 — no smoothing' : `legs ${legs}, rest ${rest}`;
|
|
430
|
+
const found = r.spikes.found;
|
|
431
|
+
const shown = found.slice(0, 8).map((s) => `frame ${s.frame} ${s.joint}`).join(', ');
|
|
432
|
+
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
|
+
const jit = (v: number | null) => (v === null ? 'not measured' : v.toFixed(2));
|
|
434
|
+
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}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 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
|
|
441
|
+
* stated" when the joints carry the state and the record does not say why
|
|
442
|
+
* (a file whose states were written by hand or by another tool).
|
|
443
|
+
*/
|
|
444
|
+
export function inferredSource(r: CleanRecord | undefined): string {
|
|
445
|
+
const h = r?.params.hipsInferred;
|
|
446
|
+
if (h === undefined) return 'source not stated';
|
|
447
|
+
return h.by === 'declared' ? 'declared at clean' : h.by;
|
|
448
|
+
}
|
package/src/pose.ts
CHANGED
|
@@ -23,7 +23,13 @@ import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './pr
|
|
|
23
23
|
|
|
24
24
|
export const POSE_SPEC = 'rig-pose-sequence/1';
|
|
25
25
|
export const POSE_SPACE = { units: 'picture-px', origin: 'top-left', y: 'down' } as const;
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* `inferred` (rig-c/pose#63): a position the detector supplied but did not see
|
|
28
|
+
* — it carries `at` like `observed`, and retarget keys it the same; what it
|
|
29
|
+
* changes is what a reading may claim from it (POSTURE does not read a hip tilt
|
|
30
|
+
* off inferred hips).
|
|
31
|
+
*/
|
|
32
|
+
const STATES = ['observed', 'occluded', 'missing', 'inferred'] as const;
|
|
27
33
|
export type JointState = (typeof STATES)[number];
|
|
28
34
|
|
|
29
35
|
export interface Joint {
|
|
@@ -52,6 +58,37 @@ export interface CameraFrame {
|
|
|
52
58
|
ty: number;
|
|
53
59
|
inliers: number;
|
|
54
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The record `rig-pose clean` writes into the sequence it cleaned (#67), and
|
|
63
|
+
* the one thing `retarget` requires of a sequence: the step is the record, not
|
|
64
|
+
* a fixed amount of smoothing (windows of 1 are a cleaned sequence too).
|
|
65
|
+
*/
|
|
66
|
+
export const CLEAN_SPEC = 'rig-pose-clean/1';
|
|
67
|
+
export interface CleanSpike {
|
|
68
|
+
frame: number;
|
|
69
|
+
joint: Body18;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Who said the hips are `inferred` (#63): `declared` — the person running
|
|
73
|
+
* `rig-pose clean --hips-inferred` said so. A measured producer would record
|
|
74
|
+
* itself under another `by`; this reader reads only `declared` today.
|
|
75
|
+
*/
|
|
76
|
+
export interface HipsInferred {
|
|
77
|
+
by: 'declared';
|
|
78
|
+
}
|
|
79
|
+
export interface CleanRecord {
|
|
80
|
+
spec: typeof CLEAN_SPEC;
|
|
81
|
+
/** `hipsInferred` is present only when clean marked the hips (`--hips-inferred`). */
|
|
82
|
+
params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred };
|
|
83
|
+
/**
|
|
84
|
+
* `found`: every joint the spike step replaced, by frame (index into `frames`)
|
|
85
|
+
* and joint. `untested`: the frames the spike step did not test, because a
|
|
86
|
+
* clip end cuts their window (none under `loop`).
|
|
87
|
+
*/
|
|
88
|
+
spikes: { found: CleanSpike[]; untested: number[] };
|
|
89
|
+
/** Mean |second difference| of y, px/f², before and after; null where no joint has three consecutive positions. */
|
|
90
|
+
jitter: { before: number | null; after: number | null };
|
|
91
|
+
}
|
|
55
92
|
export interface PoseSequence {
|
|
56
93
|
spec: typeof POSE_SPEC;
|
|
57
94
|
space: typeof POSE_SPACE;
|
|
@@ -62,11 +99,13 @@ export interface PoseSequence {
|
|
|
62
99
|
cameraSource?: string;
|
|
63
100
|
/** Optional: one per frame, when a scan read the camera (#63). */
|
|
64
101
|
camera?: CameraFrame[];
|
|
102
|
+
/** Present once `rig-pose clean` has run on the sequence (#67). */
|
|
103
|
+
cleaned?: CleanRecord;
|
|
65
104
|
frames: PoseFrame[];
|
|
66
105
|
}
|
|
67
106
|
|
|
68
107
|
const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
|
|
69
|
-
const OPTIONAL_KEYS = ['cameraSource', 'camera'];
|
|
108
|
+
const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned'];
|
|
70
109
|
const CAMERA_KEYS = ['s', 'tx', 'ty', 'inliers'];
|
|
71
110
|
|
|
72
111
|
/**
|
|
@@ -151,9 +190,10 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
|
151
190
|
if (typeof doc.cameraSource !== 'string' || doc.cameraSource.trim() === '') p.add('POSE_CAMERA_SOURCE', `${where} "cameraSource" is ${typeName(doc.cameraSource)}; it must be a non-empty string saying where the camera came from ("tracked", "hand-labelled, N keys")`);
|
|
152
191
|
else if (!('camera' in doc)) p.add('POSE_CAMERA_SOURCE', `${where} has "cameraSource" ${JSON.stringify(doc.cameraSource)} and no "camera"; it states where a camera came from, so it needs one`);
|
|
153
192
|
}
|
|
193
|
+
const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
|
|
154
194
|
p.throwIfAny();
|
|
155
195
|
const cameraSource = typeof doc.cameraSource === 'string' ? doc.cameraSource : undefined;
|
|
156
|
-
return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), frames };
|
|
196
|
+
return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), frames };
|
|
157
197
|
}
|
|
158
198
|
|
|
159
199
|
/** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
|
|
@@ -194,6 +234,85 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
|
|
|
194
234
|
return out;
|
|
195
235
|
}
|
|
196
236
|
|
|
237
|
+
/** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }. */
|
|
238
|
+
function readCleaned(p: Problems, where: string, c: unknown, frames: number | null): CleanRecord | undefined {
|
|
239
|
+
const cw = `${where} "cleaned"`;
|
|
240
|
+
const shape = '{ spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }';
|
|
241
|
+
if (!isObject(c)) {
|
|
242
|
+
p.add('POSE_CLEANED', `${cw} is ${typeName(c)}; it is the record rig-pose clean writes, ${shape}`);
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
const before = p.list.length;
|
|
246
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', cw, c, ['spec', 'params', 'spikes', 'jitter']);
|
|
247
|
+
for (const k of ['spec', 'params', 'spikes', 'jitter']) if (!(k in c)) p.add('POSE_CLEANED', `${cw} has no "${k}"; the record is ${shape}`);
|
|
248
|
+
if ('spec' in c && c.spec !== CLEAN_SPEC) p.add('POSE_CLEANED', `${cw} "spec" is ${typeName(c.spec)}; this reader reads "${CLEAN_SPEC}"`);
|
|
249
|
+
const window = (v: unknown) => Number.isInteger(v) && (v === 1 || ((v as number) >= 5 && (v as number) % 2 === 1));
|
|
250
|
+
if ('params' in c) {
|
|
251
|
+
const q = c.params;
|
|
252
|
+
if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred? }`);
|
|
253
|
+
else {
|
|
254
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred']);
|
|
255
|
+
if ('hipsInferred' in q) {
|
|
256
|
+
const h = q.hipsInferred;
|
|
257
|
+
if (!isObject(h)) p.add('POSE_CLEANED', `${cw} "params" "hipsInferred" is ${typeName(h)}; it must be { by: "declared" }`);
|
|
258
|
+
else {
|
|
259
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params" "hipsInferred"`, h, ['by']);
|
|
260
|
+
if (h.by !== 'declared') p.add('POSE_CLEANED', `${cw} "params" "hipsInferred" "by" is ${typeName(h.by)}; this reader reads "declared" (the person running rig-pose clean --hips-inferred said so)`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (typeof q.loop !== 'boolean') p.add('POSE_CLEANED', `${cw} "params" "loop" is ${typeName(q.loop)}; it must be true or false`);
|
|
264
|
+
for (const k of ['legs', 'rest'] as const) if (!window(q[k])) p.add('POSE_CLEANED', `${cw} "params" "${k}" is ${typeName(q[k])}; it must be 1 (none) or an odd whole number ≥ 5`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const frameOk = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (frames === null || (v as number) < frames);
|
|
268
|
+
const frameRange = `[0, ${frames === null ? 'the frame count' : frames - 1}]`;
|
|
269
|
+
if ('spikes' in c) {
|
|
270
|
+
const k = c.spikes;
|
|
271
|
+
if (!isObject(k)) p.add('POSE_CLEANED', `${cw} "spikes" is ${typeName(k)}; it must be { found: [{ frame, joint }], untested: [frame] }`);
|
|
272
|
+
else {
|
|
273
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "spikes"`, k, ['found', 'untested']);
|
|
274
|
+
if (!Array.isArray(k.found)) p.add('POSE_CLEANED', `${cw} "spikes" "found" is ${typeName(k.found)}; it must be an array of { frame, joint }`);
|
|
275
|
+
else k.found.forEach((s: unknown, i: number) => {
|
|
276
|
+
const sw = `${cw} spikes.found[${i}]`;
|
|
277
|
+
if (!isObject(s)) {
|
|
278
|
+
p.add('POSE_CLEANED', `${sw} is ${typeName(s)}; a spike is { frame, joint }`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', sw, s, ['frame', 'joint']);
|
|
282
|
+
if (!frameOk(s.frame)) p.add('POSE_CLEANED', `${sw} "frame" is ${typeName(s.frame)}; it must be a frame index in ${frameRange}`);
|
|
283
|
+
if (typeof s.joint !== 'string' || !isBody18(s.joint)) p.add('POSE_CLEANED', `${sw} "joint" is ${typeName(s.joint)}; it must be a body-18 joint: ${BODY18.join(', ')}`);
|
|
284
|
+
});
|
|
285
|
+
if (!Array.isArray(k.untested)) p.add('POSE_CLEANED', `${cw} "spikes" "untested" is ${typeName(k.untested)}; it must be an array of frame indices`);
|
|
286
|
+
else k.untested.forEach((f: unknown, i: number) => {
|
|
287
|
+
if (!frameOk(f)) p.add('POSE_CLEANED', `${cw} spikes.untested[${i}] is ${typeName(f)}; it must be a frame index in ${frameRange}`);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if ('jitter' in c) {
|
|
292
|
+
const j = c.jitter;
|
|
293
|
+
if (!isObject(j)) p.add('POSE_CLEANED', `${cw} "jitter" is ${typeName(j)}; it must be { before, after }`);
|
|
294
|
+
else {
|
|
295
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "jitter"`, j, ['before', 'after']);
|
|
296
|
+
for (const k of ['before', 'after'] as const) {
|
|
297
|
+
const v = j[k];
|
|
298
|
+
if (!(v === null || (typeof v === 'number' && Number.isFinite(v) && v >= 0))) p.add('POSE_CLEANED', `${cw} "jitter" "${k}" is ${typeName(v)}; it must be px/f² ≥ 0, or null (not measured)`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (p.list.length > before) return undefined;
|
|
303
|
+
const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred };
|
|
304
|
+
const j = c.jitter as { before: number | null; after: number | null };
|
|
305
|
+
return {
|
|
306
|
+
spec: CLEAN_SPEC,
|
|
307
|
+
params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: { by: q.hipsInferred.by } } : {}) },
|
|
308
|
+
spikes: {
|
|
309
|
+
found: ((c.spikes as { found: Array<{ frame: number; joint: Body18 }> }).found).map((s) => ({ frame: s.frame, joint: s.joint })),
|
|
310
|
+
untested: [...(c.spikes as { untested: number[] }).untested],
|
|
311
|
+
},
|
|
312
|
+
jitter: { before: j.before, after: j.after },
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
197
316
|
function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
|
|
198
317
|
const out: Partial<Record<Body18, Joint>> = {};
|
|
199
318
|
if (!isObject(joints)) {
|
|
@@ -228,6 +347,8 @@ function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Bo
|
|
|
228
347
|
}
|
|
229
348
|
} else if (state === 'observed') {
|
|
230
349
|
p.add('POSE_JOINT_AT', `${jw} is "observed" and has no "at"; an observed joint has a position`);
|
|
350
|
+
} else if (state === 'inferred') {
|
|
351
|
+
p.add('POSE_JOINT_AT', `${jw} is "inferred" and has no "at"; an inferred joint is a position the detector supplied but did not see, so it has one`);
|
|
231
352
|
}
|
|
232
353
|
if ('score' in j) {
|
|
233
354
|
if (typeof j.score !== 'number' || !Number.isFinite(j.score)) p.add('POSE_JOINT_SCORE', `${jw} "score" is ${typeName(j.score)}; it must be a finite number`);
|
|
@@ -376,5 +497,8 @@ export function writePoseSequence(seq: PoseSequence): string {
|
|
|
376
497
|
})),
|
|
377
498
|
}));
|
|
378
499
|
const camera = seq.camera?.map((c) => ({ s: round(c.s, 6), tx: round(c.tx, 3), ty: round(c.ty, 3), inliers: c.inliers }));
|
|
379
|
-
|
|
500
|
+
const c = seq.cleaned;
|
|
501
|
+
const jit = (v: number | null) => (v === null ? null : round(v, 4));
|
|
502
|
+
const cleaned = c === undefined ? undefined : { spec: c.spec, params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: { by: c.params.hipsInferred.by } } : {}) }, spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] }, jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) } };
|
|
503
|
+
return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), frames }, null, 2)}\n`;
|
|
380
504
|
}
|