@rig-c/pose 0.37.1 → 0.39.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/pelvis.ts ADDED
@@ -0,0 +1,498 @@
1
+ /**
2
+ * `rig-pose pelvis` (rig-c/pose#288): the pelvis line read off the picture, per
3
+ * frame, written into the sequence as its own track — `pelvisTilt` on every
4
+ * frame and the sequence's `pelvisSource` (minor 1.1, `src/pose.ts`). It runs
5
+ * before `clean`, which smooths the track as its own; `retarget` keys the map's
6
+ * pelvis chain from it.
7
+ *
8
+ * A pose detector keeps little of a drawn pelvis tilt: on a frontal walk drawn
9
+ * with the pelvis line in every key, its hips held 4.5° of a 30.5° swing. The
10
+ * drawing has it, so the drawing is read. Two readers, on PNG frames (one per
11
+ * sequence frame, read with rig-c's plate; no video), each placed by the hip
12
+ * keypoints — where the hips are and which of them is the figure's right — but
13
+ * not reading their angle:
14
+ *
15
+ * - `silhouette`: the straightest edge of the drawn figure across the hip span
16
+ * near the hips' height — an edge pixel is a figure pixel (one that differs
17
+ * from the background colour) beside a background pixel, so a drawn outline and
18
+ * a filled shape both have one. Every straight line within ±40° of level is
19
+ * scored by how many columns of the hip span (between the two hip keypoints)
20
+ * carry an edge pixel on it, over rows within one hip width of the hips; the
21
+ * best is fitted by least squares on its pixels.
22
+ * - `waistband`: the top edge of a garment covering the hips — the colour at the
23
+ * hips' midpoint, where it is not the background's, and in each column of the
24
+ * hip span the first row from 1.5 hip widths above the hips where three pixels
25
+ * in a row carry it; the line fitted through those tops. The colour must hold
26
+ * (rig-c/pose#293): ≥ `HOLD` of the patch's pixels carry its median. A dithered
27
+ * or paletted garment does not hold pixel by pixel — its channels alternate
28
+ * between palette steps — so it is read through the mean of a 5×5 window,
29
+ * which does; hips on the band's top edge give a patch half garment, half
30
+ * ground, so the patch is tried below them (up to two patch radii), and the
31
+ * scan runs down to where the colour was found. The first patch that holds
32
+ * and is not the background is the garment; with none, the midpoint's median
33
+ * is read as it is.
34
+ *
35
+ * The forearms are not read: pixels near the segments elbow → wrist (and the
36
+ * hands) are masked, since a hanging arm passes the hips in a walk. A frame
37
+ * either reader cannot read is declined with its reason and its tilt is null —
38
+ * marked, not guessed; `retarget` keys the hip line there and says so.
39
+ *
40
+ * `--pelvis-keys` is the hand override, `rig-pose-pelvis-keys/1`: tilts at
41
+ * frames (fractional ones too, #294), linear between them (the short way round)
42
+ * and held outside them.
43
+ */
44
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
45
+ import { join } from 'node:path';
46
+ import { readPlate, type Plate } from 'rig-c/plate';
47
+ import type { Body18 } from './joints.ts';
48
+ import { personTrack, readPoseSequence, writePoseSequence, type Joint, type PelvisSource, type PoseSequence } from './pose.ts';
49
+ import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './problems.ts';
50
+ import { pictureAngle, wrap } from './retarget.ts';
51
+ import { makeOutDirs, planOutDirs } from './outdir.ts';
52
+
53
+ type Pt = [number, number];
54
+ type RGB = [number, number, number];
55
+
56
+ /** The two readers. */
57
+ export const PELVIS_MODES = ['silhouette', 'waistband'] as const;
58
+ export type PelvisMode = (typeof PELVIS_MODES)[number];
59
+ export const PELVIS_KEYS_SPEC = 'rig-pose-pelvis-keys/1';
60
+
61
+ /** A pixel is the figure's when a channel differs from the background colour's by more than this (0–255). */
62
+ export const FIGURE_DIFF = 64;
63
+ /** A pixel carries the garment's colour when no channel differs from it by more than this. */
64
+ export const GARMENT_DIFF = 40;
65
+ /** A garment's colour holds on a patch when at least this share of its pixels carry it (within `GARMENT_DIFF`). */
66
+ export const HOLD = 0.75;
67
+ /** A garment that does not hold pixel by pixel is read through the mean of the square window this many px either side of a pixel (5×5: a 4×4 ordered dither's period and a pixel more). */
68
+ export const DITHER_RADIUS = 2;
69
+ /** The silhouette reader scores lines within this many degrees of level. */
70
+ export const MAX_TILT = 40;
71
+ /** A line is read when it covers at least this share of the hip span's columns. */
72
+ export const MIN_COVER = 0.6;
73
+ /** ... and its pixels lie within this many px (root mean square) of the fitted line. */
74
+ export const MAX_RMS = 2.5;
75
+ /** A forearm is masked within this share of the hip span of its segment (at least 3 px), a hand within 1.5 times it of the wrist. */
76
+ export const ARM_RADIUS = 0.15;
77
+ /** Hips closer than this in the picture, px, have no span to read across (a figure seen side-on). */
78
+ export const MIN_SPAN = 6;
79
+
80
+ /**
81
+ * One frame's reading: the tilt (Spine degrees, r_hip → l_hip), or why it was declined. A waistband
82
+ * read through the window mean says so (`mean`), and one whose colour was found below the hips says
83
+ * how far (`below`, px).
84
+ */
85
+ export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number } | { kind: string; why: string };
86
+
87
+ /** The frame as RGB over white, its background colour, and which pixels are masked (forearms, hands). */
88
+ interface Frame {
89
+ w: number;
90
+ h: number;
91
+ rgb: (x: number, y: number) => RGB;
92
+ bg: RGB;
93
+ masked: (x: number, y: number) => boolean;
94
+ }
95
+
96
+ const diff = (a: RGB, b: RGB) => Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
97
+ const median = (xs: number[]) => {
98
+ const s = [...xs].sort((a, b) => a - b);
99
+ const m = s.length >> 1;
100
+ return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
101
+ };
102
+
103
+ /** Distance from p to the segment a → b. */
104
+ function toSegment(p: Pt, a: Pt, b: Pt): number {
105
+ const dx = b[0] - a[0];
106
+ const dy = b[1] - a[1];
107
+ const len2 = dx * dx + dy * dy;
108
+ const u = len2 < 1e-12 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2));
109
+ return Math.hypot(p[0] - (a[0] + u * dx), p[1] - (a[1] + u * dy));
110
+ }
111
+
112
+ function frameOf(plate: Plate, arms: Array<[Pt, Pt]>, radius: number): Frame {
113
+ const { width: w, height: h, data } = plate;
114
+ const rgb = (x: number, y: number): RGB => {
115
+ const i = (y * w + x) * 4;
116
+ const a = data[i + 3]! / 255;
117
+ return [data[i]! * a + 255 * (1 - a), data[i + 1]! * a + 255 * (1 - a), data[i + 2]! * a + 255 * (1 - a)];
118
+ };
119
+ // The background: the median of the border pixels, channel by channel.
120
+ const border: RGB[] = [];
121
+ for (let x = 0; x < w; x++) border.push(rgb(x, 0), rgb(x, h - 1));
122
+ for (let y = 1; y < h - 1; y++) border.push(rgb(0, y), rgb(w - 1, y));
123
+ const bg: RGB = [0, 1, 2].map((c) => median(border.map((p) => p[c]!))) as RGB;
124
+ const masked = (x: number, y: number) => arms.some(([e, wr]) => toSegment([x, y], e, wr) <= radius || Math.hypot(x - wr[0], y - wr[1]) <= 1.5 * radius);
125
+ return { w, h, rgb, bg, masked };
126
+ }
127
+
128
+ /** Least squares y = m·x + q, one pass rejecting points beyond 3 MADs (at least 0.5 px), refitted. */
129
+ function fitLine(pts: Pt[]): { m: number; q: number; rms: number; kept: Pt[] } | null {
130
+ const fit = (ps: Pt[]) => {
131
+ const n = ps.length;
132
+ const sx = ps.reduce((s, p) => s + p[0], 0) / n;
133
+ const sy = ps.reduce((s, p) => s + p[1], 0) / n;
134
+ let sxx = 0;
135
+ let sxy = 0;
136
+ for (const p of ps) {
137
+ sxx += (p[0] - sx) ** 2;
138
+ sxy += (p[0] - sx) * (p[1] - sy);
139
+ }
140
+ if (sxx < 1e-9) return null;
141
+ const m = sxy / sxx;
142
+ return { m, q: sy - m * sx };
143
+ };
144
+ if (pts.length < 3) return null;
145
+ const f0 = fit(pts);
146
+ if (f0 === null) return null;
147
+ const res = pts.map((p) => p[1] - (f0.m * p[0] + f0.q));
148
+ const mad = Math.max(0.5, median(res.map((r) => Math.abs(r - median(res)))));
149
+ const kept = pts.filter((_, i) => Math.abs(res[i]! - median(res)) <= 3 * mad);
150
+ const f1 = kept.length >= 3 ? fit(kept) : null;
151
+ if (f1 === null) return null;
152
+ const rms = Math.sqrt(kept.reduce((s, p) => s + (p[1] - (f1.m * p[0] + f1.q)) ** 2, 0) / kept.length);
153
+ return { ...f1, rms, kept };
154
+ }
155
+
156
+ /** The picture slope of a line, as the tilt: Spine degrees of the direction from the figure's right hip toward its left. */
157
+ function tiltOf(m: number, rHip: Pt, lHip: Pt): number {
158
+ const d: Pt = lHip[0] >= rHip[0] ? [1, m] : [-1, -m];
159
+ return pictureAngle([0, 0], d);
160
+ }
161
+
162
+ /**
163
+ * Read one frame. `joints` are in the frame's own pixels (a sequence's camera
164
+ * already undone). Pure: the same plate and joints read the same tilt.
165
+ */
166
+ export function readPelvis(plate: Plate, joints: Partial<Record<Body18, Joint>>, mode: PelvisMode): Reading {
167
+ const pt = (n: Body18): Pt | null => {
168
+ const j = joints[n];
169
+ return j === undefined || j.state === 'missing' || j.at === undefined ? null : [j.at[0], j.at[1]];
170
+ };
171
+ const rHip = pt('r_hip');
172
+ const lHip = pt('l_hip');
173
+ if (rHip === null || lHip === null) return { kind: 'no hips', why: `${rHip === null ? 'r_hip' : 'l_hip'} has no position on the frame; the hips place the reading` };
174
+ const span = Math.hypot(lHip[0] - rHip[0], lHip[1] - rHip[1]);
175
+ const x0 = Math.round(Math.min(rHip[0], lHip[0]));
176
+ const x1 = Math.round(Math.max(rHip[0], lHip[0]));
177
+ if (span < MIN_SPAN || x1 - x0 + 1 < MIN_SPAN) return { kind: 'hips side-on', why: `the hips are ${span.toFixed(1)} px apart (${x1 - x0 + 1} column(s)); a line across them needs ≥ ${MIN_SPAN} (a figure seen side-on)` };
178
+ const c: Pt = [(rHip[0] + lHip[0]) / 2, (rHip[1] + lHip[1]) / 2];
179
+ const arms: Array<[Pt, Pt]> = [];
180
+ for (const [e, w] of [['r_elbow', 'r_wrist'], ['l_elbow', 'l_wrist']] as const) {
181
+ const a = pt(e);
182
+ const b = pt(w);
183
+ if (a !== null && b !== null) arms.push([a, b]);
184
+ }
185
+ const f = frameOf(plate, arms, Math.max(3, ARM_RADIUS * span));
186
+ if (x0 < 0 || x1 >= f.w || c[1] < 0 || c[1] >= f.h) return { kind: 'hips outside the frame', why: `the hips (${c[0].toFixed(1)}, ${c[1].toFixed(1)}) lie outside the ${f.w}×${f.h} frame` };
187
+ const cols = x1 - x0 + 1;
188
+ return mode === 'silhouette' ? silhouette(f, c, span, x0, x1, cols, rHip, lHip) : waistband(f, c, span, x0, x1, cols, rHip, lHip);
189
+ }
190
+
191
+ function silhouette(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt): Reading {
192
+ const yA = Math.max(1, Math.floor(c[1] - span));
193
+ const yB = Math.min(f.h - 2, Math.ceil(c[1] + span));
194
+ const figure = (x: number, y: number) => !f.masked(x, y) && diff(f.rgb(x, y), f.bg) > FIGURE_DIFF;
195
+ const ground = (x: number, y: number) => !f.masked(x, y) && diff(f.rgb(x, y), f.bg) <= FIGURE_DIFF;
196
+ const edges: Pt[] = [];
197
+ for (let y = yA; y <= yB; y++) {
198
+ for (let x = Math.max(1, x0); x <= Math.min(f.w - 2, x1); x++) {
199
+ if (figure(x, y) && (ground(x - 1, y) || ground(x + 1, y) || ground(x, y - 1) || ground(x, y + 1))) edges.push([x, y]);
200
+ }
201
+ }
202
+ if (edges.length === 0) return { kind: 'no edge near the hips', why: `no edge of the figure within ${span.toFixed(0)} px of the hips' height across the hip span (the pelvis blank, or the figure the background's colour there)` };
203
+ // Every line within ±MAX_TILT of level, at 0.5° and 1 px: the edge pixels within 1 px of it (its score), and the columns of the span they cover.
204
+ const R = Math.ceil(2 * span) + 2;
205
+ const bins = 2 * R + 1;
206
+ let best = { count: -1, cover: 0, a: 0, rho: 0 };
207
+ const mark = new Uint8Array(bins * cols);
208
+ const hist = new Int32Array(bins);
209
+ for (let k = -2 * MAX_TILT; k <= 2 * MAX_TILT; k++) {
210
+ const a = (k / 2) * (Math.PI / 180);
211
+ const nx = -Math.sin(a);
212
+ const ny = Math.cos(a);
213
+ mark.fill(0);
214
+ hist.fill(0);
215
+ for (const [x, y] of edges) {
216
+ const b = Math.round(nx * (x - c[0]) + ny * (y - c[1])) + R;
217
+ if (b < 0 || b >= bins) continue;
218
+ mark[b * cols + (x - x0)] = 1;
219
+ hist[b]!++;
220
+ }
221
+ for (let b = 1; b < bins - 1; b++) {
222
+ const count = hist[b - 1]! + hist[b]! + hist[b + 1]!;
223
+ const rho = b - R;
224
+ // Ties: the line nearer the hips, then the more level, then the lower angle — a fixed order.
225
+ if (!(count > best.count || (count === best.count && (Math.abs(rho) < Math.abs(best.rho) || (Math.abs(rho) === Math.abs(best.rho) && Math.abs(k) < Math.abs(best.a)))))) continue;
226
+ let cover = 0;
227
+ for (let x = 0; x < cols; x++) if (mark[(b - 1) * cols + x]! | mark[b * cols + x]! | mark[(b + 1) * cols + x]!) cover++;
228
+ best = { count, cover, a: k, rho };
229
+ }
230
+ }
231
+ const share = best.cover / cols;
232
+ if (share < MIN_COVER) return { kind: 'no straight edge spans the hips', why: `the best line covers ${best.cover} of the ${cols} columns between them (${(100 * share).toFixed(0)}% < ${100 * MIN_COVER}%)` };
233
+ const a = (best.a / 2) * (Math.PI / 180);
234
+ const on = edges.filter(([x, y]) => Math.abs(-Math.sin(a) * (x - c[0]) + Math.cos(a) * (y - c[1]) - best.rho) <= 2.5);
235
+ const fit = fitLine(on);
236
+ if (fit === null) return { kind: 'no line', why: 'the best line\'s pixels do not fit a line (fewer than three, or one column)' };
237
+ if (fit.rms > MAX_RMS) return { kind: 'not a straight edge', why: `the best line's pixels lie ${fit.rms.toFixed(2)} px from it (root mean square > ${MAX_RMS}); not a straight edge` };
238
+ return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms };
239
+ }
240
+
241
+ /** The mean colour of the unmasked pixels in the window `DITHER_RADIUS` either side of (x, y), or null when all are masked. Cached per frame. */
242
+ function meanOf(f: Frame): (x: number, y: number) => RGB | null {
243
+ const cache = new Map<number, RGB | null>();
244
+ return (x, y) => {
245
+ const key = y * f.w + x;
246
+ const hit = cache.get(key);
247
+ if (hit !== undefined) return hit;
248
+ const s: RGB = [0, 0, 0];
249
+ let n = 0;
250
+ for (let v = Math.max(0, y - DITHER_RADIUS); v <= Math.min(f.h - 1, y + DITHER_RADIUS); v++) {
251
+ for (let u = Math.max(0, x - DITHER_RADIUS); u <= Math.min(f.w - 1, x + DITHER_RADIUS); u++) {
252
+ if (f.masked(u, v)) continue;
253
+ const p = f.rgb(u, v);
254
+ s[0] += p[0];
255
+ s[1] += p[1];
256
+ s[2] += p[2];
257
+ n++;
258
+ }
259
+ }
260
+ const m: RGB | null = n === 0 ? null : [s[0] / n, s[1] / n, s[2] / n];
261
+ cache.set(key, m);
262
+ return m;
263
+ };
264
+ }
265
+
266
+ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt): Reading {
267
+ // The garment's colour: the median over the unmasked pixels of a patch at the hips' midpoint (or below it), as they are or through the window mean.
268
+ const r = Math.max(2, 0.3 * span);
269
+ const raw = (x: number, y: number): RGB | null => f.rgb(x, y);
270
+ const mean = meanOf(f);
271
+ const patchAt = (cy: number, colour: (x: number, y: number) => RGB | null): { g: RGB; holds: boolean } | null => {
272
+ const patch: RGB[] = [];
273
+ for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
274
+ for (let x = Math.floor(c[0] - r); x <= Math.ceil(c[0] + r); x++) {
275
+ if (x < 0 || y < 0 || x >= f.w || y >= f.h || Math.hypot(x - c[0], y - cy) > r || f.masked(x, y)) continue;
276
+ const p = colour(x, y);
277
+ if (p !== null) patch.push(p);
278
+ }
279
+ }
280
+ if (patch.length === 0) return null;
281
+ const g: RGB = [0, 1, 2].map((k) => median(patch.map((p) => p[k]!))) as RGB;
282
+ return { g, holds: patch.filter((p) => diff(p, g) <= GARMENT_DIFF).length >= HOLD * patch.length };
283
+ };
284
+ const mid = patchAt(c[1], raw);
285
+ if (mid === null) return { kind: 'hips behind a forearm', why: 'the hips\' midpoint is covered by a forearm; no garment colour to read' };
286
+ // The first patch that holds and is not the background: at the midpoint, then one and two patch radii below; as they are, then through the window mean.
287
+ let found: { g: RGB; below: number; isMean: boolean } | null = null;
288
+ for (const below of [0, r, 2 * r]) {
289
+ for (const isMean of [false, true]) {
290
+ const pa = below === 0 && !isMean ? mid : patchAt(c[1] + below, isMean ? mean : raw);
291
+ if (pa !== null && pa.holds && diff(pa.g, f.bg) > FIGURE_DIFF) {
292
+ found = { g: pa.g, below, isMean };
293
+ break;
294
+ }
295
+ }
296
+ if (found !== null) break;
297
+ }
298
+ // None: the midpoint's median as it is.
299
+ const g = found?.g ?? mid.g;
300
+ const isMean = found?.isMean ?? false;
301
+ const below = found?.below ?? 0;
302
+ if (diff(g, f.bg) <= FIGURE_DIFF) return { kind: 'no garment over the hips', why: `the colour at the hips' midpoint (${g.map((v) => v.toFixed(0)).join(', ')}) is the background's (${f.bg.map((v) => v.toFixed(0)).join(', ')})` };
303
+ const top = Math.max(0, Math.floor(c[1] - 1.5 * span));
304
+ const bottom = Math.floor(c[1] + below);
305
+ const colourAt = isMean ? mean : raw;
306
+ const garment = (x: number, y: number) => {
307
+ if (y >= f.h) return false;
308
+ const p = colourAt(x, y);
309
+ return p !== null && diff(p, g) <= GARMENT_DIFF;
310
+ };
311
+ const tops: Pt[] = [];
312
+ let above = 0;
313
+ for (let x = x0; x <= x1; x++) {
314
+ for (let y = top; y <= bottom; y++) {
315
+ // A forearm crossing the column is passed over; a top edge right under it is hidden by it, and the column is not read.
316
+ if (f.masked(x, y)) continue;
317
+ if (garment(x, y) && garment(x, y + 1) && garment(x, y + 2)) {
318
+ if (y === top) above++;
319
+ else if (!f.masked(x, y - 1)) tops.push([x, y]);
320
+ break;
321
+ }
322
+ }
323
+ }
324
+ const share = tops.length / cols;
325
+ if (share < MIN_COVER) {
326
+ return above > cols / 2 ? { kind: 'garment above the band', why: `the garment runs above the band read (${above} of ${cols} columns carry its colour ${(1.5 * span).toFixed(0)} px above the hips); no waistband edge` } : { kind: 'waistband edge too short', why: `the garment's top edge is found in ${tops.length} of the ${cols} columns between the hips (${(100 * share).toFixed(0)}% < ${100 * MIN_COVER}%)` };
327
+ }
328
+ const fit = fitLine(tops);
329
+ if (fit === null) return { kind: 'no line', why: 'the garment\'s top edge does not fit a line (fewer than three columns)' };
330
+ if (fit.rms > MAX_RMS) return { kind: 'not a straight waistband', why: `the garment's top edge lies ${fit.rms.toFixed(2)} px from its line (root mean square > ${MAX_RMS}); not a straight waistband` };
331
+ if (Math.abs(Math.atan(fit.m)) * (180 / Math.PI) > 45) return { kind: 'waistband too steep', why: `the garment's top edge runs ${(Math.atan(fit.m) * (180 / Math.PI)).toFixed(1)}° from level; a waistband is within 45°` };
332
+ return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}) };
333
+ }
334
+
335
+ /**
336
+ * `rig-pose-pelvis-keys/1`: `{ spec, keys: [{ frame, deg }], note? }` — frames of the
337
+ * sequence, strictly increasing, within its span [0, frames − 1]; `deg` the tilt in
338
+ * the track's sense (Spine degrees of r_hip → l_hip, about 0 for a figure facing
339
+ * the camera). The tilt on every frame: linear between keys, the short way round,
340
+ * held outside.
341
+ *
342
+ * A frame may be fractional (rig-c/pose#294): a scan interpolated from drawn keys
343
+ * puts them between its frames (k × 4.75 for 8 keys over 38 frames), and a key
344
+ * rounded onto the grid moves up to half a frame and bends the tilt between. A
345
+ * fractional frame is a value of the same key, so the format stays /1; a reader
346
+ * from before #294 refuses it by name (`"frame" is number 4.75; it must be a frame
347
+ * index in [0, N]`) rather than misreading it. An integer frame interpolates
348
+ * exactly as before.
349
+ */
350
+ export function readPelvisKeys(text: string, where: string, frames: number): number[] {
351
+ const doc = parseJson(text, where, 'PELVIS_KEYS');
352
+ const p = new Problems();
353
+ const shape = `{ "spec": "${PELVIS_KEYS_SPEC}", "keys": [{ "frame", "deg" }], "note"? }`;
354
+ if (!isObject(doc)) {
355
+ p.add('PELVIS_KEYS', `${where} is ${typeName(doc)}; it must be ${shape}`);
356
+ p.throwIfAny();
357
+ throw new Error('unreachable');
358
+ }
359
+ refuseUnknownKeys(p, 'PELVIS_KEYS', where, doc, ['spec', 'keys', 'note']);
360
+ if (doc.spec !== PELVIS_KEYS_SPEC) p.add('PELVIS_KEYS', `${where} "spec" is ${typeName(doc.spec)}; it must be "${PELVIS_KEYS_SPEC}"`);
361
+ if ('note' in doc && typeof doc.note !== 'string') p.add('PELVIS_KEYS', `${where} "note" is ${typeName(doc.note)}; it must be a string`);
362
+ const keys: Array<{ frame: number; deg: number }> = [];
363
+ if (!Array.isArray(doc.keys) || doc.keys.length === 0) p.add('PELVIS_KEYS', `${where} "keys" is ${typeName(doc.keys)}; it must be a non-empty array of { frame, deg }`);
364
+ else doc.keys.forEach((k: unknown, i: number) => {
365
+ const kw = `${where} keys[${i}]`;
366
+ if (!isObject(k)) {
367
+ p.add('PELVIS_KEYS', `${kw} is ${typeName(k)}; a key is { frame, deg }`);
368
+ return;
369
+ }
370
+ refuseUnknownKeys(p, 'PELVIS_KEYS', kw, k, ['frame', 'deg']);
371
+ // rig-c/pose#294: any finite frame within the span, fractional or not; one outside it is refused naming the key and the span.
372
+ const okF = typeof k.frame === 'number' && Number.isFinite(k.frame) && k.frame >= 0 && k.frame <= frames - 1;
373
+ const okD = typeof k.deg === 'number' && Number.isFinite(k.deg) && k.deg > -180 && k.deg <= 180;
374
+ if (!okF) p.add('PELVIS_KEYS', `${kw} "frame" is ${typeName(k.frame)}; it must be a frame index in [0, ${frames - 1}]`);
375
+ if (!okD) p.add('PELVIS_KEYS', `${kw} "deg" is ${typeName(k.deg)}; it must be Spine degrees in (-180, 180] (r_hip → l_hip, counter-clockwise, y up)`);
376
+ if (!okF || !okD) return;
377
+ const prev = keys[keys.length - 1];
378
+ if (prev !== undefined && (k.frame as number) <= prev.frame) p.add('PELVIS_KEYS', `${kw} "frame" is ${k.frame as number}, not after the key before it (${prev.frame}); frames strictly increase`);
379
+ else keys.push({ frame: k.frame as number, deg: k.deg as number });
380
+ });
381
+ p.throwIfAny();
382
+ return Array.from({ length: frames }, (_, i) => {
383
+ const after = keys.findIndex((k) => k.frame >= i);
384
+ if (after < 0) return keys[keys.length - 1]!.deg;
385
+ const b = keys[after]!;
386
+ if (b.frame === i || after === 0) return b.deg;
387
+ const a = keys[after - 1]!;
388
+ return wrap(a.deg + ((i - a.frame) / (b.frame - a.frame)) * wrap(b.deg - a.deg));
389
+ });
390
+ }
391
+
392
+ export interface PelvisArgs {
393
+ pose: string;
394
+ out: string;
395
+ frames?: string;
396
+ mode: PelvisMode;
397
+ keys?: string;
398
+ person?: string;
399
+ }
400
+
401
+ /** Runs of one kind of decline, as the DECLINED lines group them: the first frame's reason, and whether the others' figures differ. */
402
+ function declinedRuns(readings: Reading[]): Array<{ from: number; to: number; kind: string; why: string; same: boolean }> {
403
+ const out: Array<{ from: number; to: number; kind: string; why: string; same: boolean }> = [];
404
+ readings.forEach((r, i) => {
405
+ if (!('why' in r)) return;
406
+ const last = out[out.length - 1];
407
+ if (last !== undefined && last.to === i - 1 && last.kind === r.kind) {
408
+ last.to = i;
409
+ last.same &&= last.why === r.why;
410
+ } else out.push({ from: i, to: i, kind: r.kind, why: r.why, same: true });
411
+ });
412
+ return out;
413
+ }
414
+
415
+ /** How the waistband found its colour where it was not the midpoint's pixels as they are (rig-c/pose#293); empty when every frame read them. */
416
+ function viaNote(readings: Reading[]): string {
417
+ const ok = readings.flatMap((r) => ('tilt' in r ? [r] : []));
418
+ const mean = ok.filter((r) => r.mean === true).length;
419
+ const below = ok.filter((r) => r.below !== undefined).length;
420
+ const parts: string[] = [];
421
+ if (mean > 0) parts.push(`${mean} through the ${2 * DITHER_RADIUS + 1}×${2 * DITHER_RADIUS + 1} window mean (the garment's colour does not hold pixel by pixel: a dithered or paletted picture)`);
422
+ if (below > 0) parts.push(`${below} with the colour found below the hips (up to ${Math.max(...ok.map((r) => r.below ?? 0)).toFixed(1)} px; the hips on the band's edge)`);
423
+ return parts.length === 0 ? '' : `; of the frames read, ${parts.join(', ')}`;
424
+ }
425
+
426
+ /** The sequence with the track: the one person, `pelvisTilt` on every frame, `pelvisSource`. */
427
+ export function withPelvis(seq: PoseSequence, person: string, tilt: ReadonlyArray<number | null>, source: PelvisSource): PoseSequence {
428
+ return {
429
+ ...seq,
430
+ pelvisSource: source,
431
+ frames: seq.frames.map((f, i) => ({ t: f.t, people: f.people.filter((q) => q.id === person), pelvisTilt: tilt[i] ?? null })),
432
+ };
433
+ }
434
+
435
+ /** `rig-pose pelvis` end to end: read, refuse (all at once), read every frame, print, write. */
436
+ export function runPelvis(a: PelvisArgs, say: (l: string) => void): void {
437
+ const seq = readPoseSequence(readFileSync(a.pose, 'utf8'), a.pose);
438
+ const p = new Problems();
439
+ if (seq.cleaned !== undefined) p.add('PELVIS_AFTER_CLEAN', `${a.pose} carries a "cleaned" record; rig-pose pelvis reads a sequence before clean, so that clean smooths the track it writes — run it on the sequence clean read`);
440
+ const outDirs = planOutDirs([['--out', a.out]], p);
441
+ if (seq.pelvisSource !== undefined) p.add('PELVIS_TRACK', `${a.pose} already carries a pelvis track (pelvisSource "${seq.pelvisSource}"); a sequence is read once — run rig-pose pelvis on the sequence without it`);
442
+ p.throwIfAny();
443
+ const track = personTrack(seq, a.person, a.pose);
444
+ const person = a.person ?? [...new Set(seq.frames.flatMap((f) => f.people.map((q) => q.id)))].sort()[0]!;
445
+ const n = seq.frames.length;
446
+ const swingOf = (vs: number[]) => {
447
+ if (vs.length === 0) return 0;
448
+ const first = vs[0]!;
449
+ const about = vs.map((v) => first + wrap(v - first));
450
+ return Math.max(...about) - Math.min(...about);
451
+ };
452
+ if (a.keys !== undefined) {
453
+ const tilt = readPelvisKeys(readFileSync(a.keys, 'utf8'), a.keys, n);
454
+ say(`rig-pose pelvis: ${n} frame(s) of person "${person}" from ${a.pose}, from the hand keys in ${a.keys}`);
455
+ say(` PELVIS keys: linear between the keys (the short way round) and held outside them, on all ${n} frame(s); swing ${swingOf(tilt).toFixed(2)}° (Spine degrees, r_hip → l_hip)`);
456
+ makeOutDirs(outDirs, say);
457
+ writeFileSync(a.out, writePoseSequence(withPelvis(seq, person, tilt, 'keys')));
458
+ say(` wrote ${a.out} (pelvisSource "keys", rig-pose-sequence/1.1)`);
459
+ return;
460
+ }
461
+ const dir = a.frames!;
462
+ const files = readdirSync(dir).filter((f) => /\.png$/i.test(f) && statSync(join(dir, f)).isFile()).sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
463
+ if (files.length !== n) p.add('PELVIS_FRAMES', `--frames ${dir} holds ${files.length} PNG file(s) and ${a.pose} ${n} frame(s); the frames are read one PNG per sequence frame, in file-name order`);
464
+ p.throwIfAny();
465
+ const readings: Reading[] = files.map((file, i) => {
466
+ const path = join(dir, file);
467
+ let plate: Plate;
468
+ try {
469
+ plate = readPlate(path);
470
+ } catch (e) {
471
+ p.add('PELVIS_FRAME', `${path} (frame ${i}): ${(e as Error).message}`);
472
+ return { kind: 'unreadable', why: 'unreadable' };
473
+ }
474
+ if (plate.width !== seq.width || plate.height !== seq.height) {
475
+ p.add('PELVIS_FRAME', `${path} (frame ${i}) is ${plate.width}×${plate.height} and the sequence's picture ${seq.width}×${seq.height}; the joints are in the frames' pixels`);
476
+ return { kind: 'size', why: 'size' };
477
+ }
478
+ // A sequence with a camera carries joints normalised to frame 0's: back into this frame's pixels, p = s·p′ + t.
479
+ const cam = seq.camera?.[i];
480
+ const joints = cam === undefined ? track[i]! : Object.fromEntries(Object.entries(track[i]!).map(([k, j]) => [k, j.at === undefined ? j : { ...j, at: [cam.s * j.at[0] + cam.tx, cam.s * j.at[1] + cam.ty] }]));
481
+ return readPelvis(plate, joints, a.mode);
482
+ });
483
+ p.throwIfAny();
484
+ const tilt = readings.map((r) => ('tilt' in r ? r.tilt : null));
485
+ const read = tilt.filter((v): v is number => v !== null);
486
+ say(`rig-pose pelvis: ${n} frame(s) of person "${person}" from ${a.pose}, read by ${a.mode} off ${dir} (${files.length} PNG)`);
487
+ for (const d of declinedRuns(readings)) say(` DECLINED frame${d.from === d.to ? ` ${d.from}` : `s ${d.from}–${d.to}`} (${d.to - d.from + 1}): ${d.kind} — ${d.same ? d.why : `frame ${d.from}: ${d.why}; the figures differ frame to frame`}`);
488
+ if (read.length > 0) {
489
+ const covers = readings.flatMap((r) => ('tilt' in r ? [r.cover] : []));
490
+ const rms = readings.flatMap((r) => ('tilt' in r ? [r.rms] : []));
491
+ say(` PELVIS ${a.mode} read ${read.length} of ${n} frame(s), ${n - read.length} declined (null: marked, not guessed); swing ${swingOf(read).toFixed(2)}° over the frames read (Spine degrees, r_hip → l_hip); cover ${(100 * Math.min(...covers)).toFixed(0)}–${(100 * Math.max(...covers)).toFixed(0)}% of the hip span, fit within ${Math.max(...rms).toFixed(2)} px${viaNote(readings)}`);
492
+ } else {
493
+ say(` PELVIS ${a.mode} read none of the ${n} frame(s) (every frame null: marked, not guessed); retarget keys the hip line and its PELVIS line says so`);
494
+ }
495
+ makeOutDirs(outDirs, say);
496
+ writeFileSync(a.out, writePoseSequence(withPelvis(seq, person, tilt, a.mode)));
497
+ say(` wrote ${a.out} (pelvisSource "${a.mode}", rig-pose-sequence/1.1)`);
498
+ }