@rig-c/pose 0.37.1 → 0.38.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 +36 -3
- package/cli.ts +40 -1
- package/package.json +1 -1
- package/src/clean.ts +50 -3
- package/src/map.ts +24 -3
- package/src/pelvis.ts +413 -0
- package/src/pose.ts +126 -23
- package/src/problems.ts +6 -3
- package/src/retarget.ts +98 -3
- package/src/run.ts +6 -3
- package/src/switch.ts +109 -21
package/src/pelvis.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
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.
|
|
26
|
+
*
|
|
27
|
+
* The forearms are not read: pixels near the segments elbow → wrist (and the
|
|
28
|
+
* hands) are masked, since a hanging arm passes the hips in a walk. A frame
|
|
29
|
+
* either reader cannot read is declined with its reason and its tilt is null —
|
|
30
|
+
* marked, not guessed; `retarget` keys the hip line there and says so.
|
|
31
|
+
*
|
|
32
|
+
* `--pelvis-keys` is the hand override, `rig-pose-pelvis-keys/1`: tilts at
|
|
33
|
+
* frames (fractional ones too, #294), linear between them (the short way round)
|
|
34
|
+
* and held outside them.
|
|
35
|
+
*/
|
|
36
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { readPlate, type Plate } from 'rig-c/plate';
|
|
39
|
+
import type { Body18 } from './joints.ts';
|
|
40
|
+
import { personTrack, readPoseSequence, writePoseSequence, type Joint, type PelvisSource, type PoseSequence } from './pose.ts';
|
|
41
|
+
import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './problems.ts';
|
|
42
|
+
import { pictureAngle, wrap } from './retarget.ts';
|
|
43
|
+
|
|
44
|
+
type Pt = [number, number];
|
|
45
|
+
type RGB = [number, number, number];
|
|
46
|
+
|
|
47
|
+
/** The two readers. */
|
|
48
|
+
export const PELVIS_MODES = ['silhouette', 'waistband'] as const;
|
|
49
|
+
export type PelvisMode = (typeof PELVIS_MODES)[number];
|
|
50
|
+
export const PELVIS_KEYS_SPEC = 'rig-pose-pelvis-keys/1';
|
|
51
|
+
|
|
52
|
+
/** A pixel is the figure's when a channel differs from the background colour's by more than this (0–255). */
|
|
53
|
+
export const FIGURE_DIFF = 64;
|
|
54
|
+
/** A pixel carries the garment's colour when no channel differs from it by more than this. */
|
|
55
|
+
export const GARMENT_DIFF = 40;
|
|
56
|
+
/** The silhouette reader scores lines within this many degrees of level. */
|
|
57
|
+
export const MAX_TILT = 40;
|
|
58
|
+
/** A line is read when it covers at least this share of the hip span's columns. */
|
|
59
|
+
export const MIN_COVER = 0.6;
|
|
60
|
+
/** ... and its pixels lie within this many px (root mean square) of the fitted line. */
|
|
61
|
+
export const MAX_RMS = 2.5;
|
|
62
|
+
/** 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. */
|
|
63
|
+
export const ARM_RADIUS = 0.15;
|
|
64
|
+
/** Hips closer than this in the picture, px, have no span to read across (a figure seen side-on). */
|
|
65
|
+
export const MIN_SPAN = 6;
|
|
66
|
+
|
|
67
|
+
/** One frame's reading: the tilt (Spine degrees, r_hip → l_hip), or why it was declined. */
|
|
68
|
+
export type Reading = { tilt: number; cover: number; rms: number } | { kind: string; why: string };
|
|
69
|
+
|
|
70
|
+
/** The frame as RGB over white, its background colour, and which pixels are masked (forearms, hands). */
|
|
71
|
+
interface Frame {
|
|
72
|
+
w: number;
|
|
73
|
+
h: number;
|
|
74
|
+
rgb: (x: number, y: number) => RGB;
|
|
75
|
+
bg: RGB;
|
|
76
|
+
masked: (x: number, y: number) => boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
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]));
|
|
80
|
+
const median = (xs: number[]) => {
|
|
81
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
82
|
+
const m = s.length >> 1;
|
|
83
|
+
return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Distance from p to the segment a → b. */
|
|
87
|
+
function toSegment(p: Pt, a: Pt, b: Pt): number {
|
|
88
|
+
const dx = b[0] - a[0];
|
|
89
|
+
const dy = b[1] - a[1];
|
|
90
|
+
const len2 = dx * dx + dy * dy;
|
|
91
|
+
const u = len2 < 1e-12 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2));
|
|
92
|
+
return Math.hypot(p[0] - (a[0] + u * dx), p[1] - (a[1] + u * dy));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function frameOf(plate: Plate, arms: Array<[Pt, Pt]>, radius: number): Frame {
|
|
96
|
+
const { width: w, height: h, data } = plate;
|
|
97
|
+
const rgb = (x: number, y: number): RGB => {
|
|
98
|
+
const i = (y * w + x) * 4;
|
|
99
|
+
const a = data[i + 3]! / 255;
|
|
100
|
+
return [data[i]! * a + 255 * (1 - a), data[i + 1]! * a + 255 * (1 - a), data[i + 2]! * a + 255 * (1 - a)];
|
|
101
|
+
};
|
|
102
|
+
// The background: the median of the border pixels, channel by channel.
|
|
103
|
+
const border: RGB[] = [];
|
|
104
|
+
for (let x = 0; x < w; x++) border.push(rgb(x, 0), rgb(x, h - 1));
|
|
105
|
+
for (let y = 1; y < h - 1; y++) border.push(rgb(0, y), rgb(w - 1, y));
|
|
106
|
+
const bg: RGB = [0, 1, 2].map((c) => median(border.map((p) => p[c]!))) as RGB;
|
|
107
|
+
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);
|
|
108
|
+
return { w, h, rgb, bg, masked };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Least squares y = m·x + q, one pass rejecting points beyond 3 MADs (at least 0.5 px), refitted. */
|
|
112
|
+
function fitLine(pts: Pt[]): { m: number; q: number; rms: number; kept: Pt[] } | null {
|
|
113
|
+
const fit = (ps: Pt[]) => {
|
|
114
|
+
const n = ps.length;
|
|
115
|
+
const sx = ps.reduce((s, p) => s + p[0], 0) / n;
|
|
116
|
+
const sy = ps.reduce((s, p) => s + p[1], 0) / n;
|
|
117
|
+
let sxx = 0;
|
|
118
|
+
let sxy = 0;
|
|
119
|
+
for (const p of ps) {
|
|
120
|
+
sxx += (p[0] - sx) ** 2;
|
|
121
|
+
sxy += (p[0] - sx) * (p[1] - sy);
|
|
122
|
+
}
|
|
123
|
+
if (sxx < 1e-9) return null;
|
|
124
|
+
const m = sxy / sxx;
|
|
125
|
+
return { m, q: sy - m * sx };
|
|
126
|
+
};
|
|
127
|
+
if (pts.length < 3) return null;
|
|
128
|
+
const f0 = fit(pts);
|
|
129
|
+
if (f0 === null) return null;
|
|
130
|
+
const res = pts.map((p) => p[1] - (f0.m * p[0] + f0.q));
|
|
131
|
+
const mad = Math.max(0.5, median(res.map((r) => Math.abs(r - median(res)))));
|
|
132
|
+
const kept = pts.filter((_, i) => Math.abs(res[i]! - median(res)) <= 3 * mad);
|
|
133
|
+
const f1 = kept.length >= 3 ? fit(kept) : null;
|
|
134
|
+
if (f1 === null) return null;
|
|
135
|
+
const rms = Math.sqrt(kept.reduce((s, p) => s + (p[1] - (f1.m * p[0] + f1.q)) ** 2, 0) / kept.length);
|
|
136
|
+
return { ...f1, rms, kept };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The picture slope of a line, as the tilt: Spine degrees of the direction from the figure's right hip toward its left. */
|
|
140
|
+
function tiltOf(m: number, rHip: Pt, lHip: Pt): number {
|
|
141
|
+
const d: Pt = lHip[0] >= rHip[0] ? [1, m] : [-1, -m];
|
|
142
|
+
return pictureAngle([0, 0], d);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read one frame. `joints` are in the frame's own pixels (a sequence's camera
|
|
147
|
+
* already undone). Pure: the same plate and joints read the same tilt.
|
|
148
|
+
*/
|
|
149
|
+
export function readPelvis(plate: Plate, joints: Partial<Record<Body18, Joint>>, mode: PelvisMode): Reading {
|
|
150
|
+
const pt = (n: Body18): Pt | null => {
|
|
151
|
+
const j = joints[n];
|
|
152
|
+
return j === undefined || j.state === 'missing' || j.at === undefined ? null : [j.at[0], j.at[1]];
|
|
153
|
+
};
|
|
154
|
+
const rHip = pt('r_hip');
|
|
155
|
+
const lHip = pt('l_hip');
|
|
156
|
+
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` };
|
|
157
|
+
const span = Math.hypot(lHip[0] - rHip[0], lHip[1] - rHip[1]);
|
|
158
|
+
const x0 = Math.round(Math.min(rHip[0], lHip[0]));
|
|
159
|
+
const x1 = Math.round(Math.max(rHip[0], lHip[0]));
|
|
160
|
+
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)` };
|
|
161
|
+
const c: Pt = [(rHip[0] + lHip[0]) / 2, (rHip[1] + lHip[1]) / 2];
|
|
162
|
+
const arms: Array<[Pt, Pt]> = [];
|
|
163
|
+
for (const [e, w] of [['r_elbow', 'r_wrist'], ['l_elbow', 'l_wrist']] as const) {
|
|
164
|
+
const a = pt(e);
|
|
165
|
+
const b = pt(w);
|
|
166
|
+
if (a !== null && b !== null) arms.push([a, b]);
|
|
167
|
+
}
|
|
168
|
+
const f = frameOf(plate, arms, Math.max(3, ARM_RADIUS * span));
|
|
169
|
+
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` };
|
|
170
|
+
const cols = x1 - x0 + 1;
|
|
171
|
+
return mode === 'silhouette' ? silhouette(f, c, span, x0, x1, cols, rHip, lHip) : waistband(f, c, span, x0, x1, cols, rHip, lHip);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function silhouette(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt): Reading {
|
|
175
|
+
const yA = Math.max(1, Math.floor(c[1] - span));
|
|
176
|
+
const yB = Math.min(f.h - 2, Math.ceil(c[1] + span));
|
|
177
|
+
const figure = (x: number, y: number) => !f.masked(x, y) && diff(f.rgb(x, y), f.bg) > FIGURE_DIFF;
|
|
178
|
+
const ground = (x: number, y: number) => !f.masked(x, y) && diff(f.rgb(x, y), f.bg) <= FIGURE_DIFF;
|
|
179
|
+
const edges: Pt[] = [];
|
|
180
|
+
for (let y = yA; y <= yB; y++) {
|
|
181
|
+
for (let x = Math.max(1, x0); x <= Math.min(f.w - 2, x1); x++) {
|
|
182
|
+
if (figure(x, y) && (ground(x - 1, y) || ground(x + 1, y) || ground(x, y - 1) || ground(x, y + 1))) edges.push([x, y]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
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)` };
|
|
186
|
+
// 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.
|
|
187
|
+
const R = Math.ceil(2 * span) + 2;
|
|
188
|
+
const bins = 2 * R + 1;
|
|
189
|
+
let best = { count: -1, cover: 0, a: 0, rho: 0 };
|
|
190
|
+
const mark = new Uint8Array(bins * cols);
|
|
191
|
+
const hist = new Int32Array(bins);
|
|
192
|
+
for (let k = -2 * MAX_TILT; k <= 2 * MAX_TILT; k++) {
|
|
193
|
+
const a = (k / 2) * (Math.PI / 180);
|
|
194
|
+
const nx = -Math.sin(a);
|
|
195
|
+
const ny = Math.cos(a);
|
|
196
|
+
mark.fill(0);
|
|
197
|
+
hist.fill(0);
|
|
198
|
+
for (const [x, y] of edges) {
|
|
199
|
+
const b = Math.round(nx * (x - c[0]) + ny * (y - c[1])) + R;
|
|
200
|
+
if (b < 0 || b >= bins) continue;
|
|
201
|
+
mark[b * cols + (x - x0)] = 1;
|
|
202
|
+
hist[b]!++;
|
|
203
|
+
}
|
|
204
|
+
for (let b = 1; b < bins - 1; b++) {
|
|
205
|
+
const count = hist[b - 1]! + hist[b]! + hist[b + 1]!;
|
|
206
|
+
const rho = b - R;
|
|
207
|
+
// Ties: the line nearer the hips, then the more level, then the lower angle — a fixed order.
|
|
208
|
+
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;
|
|
209
|
+
let cover = 0;
|
|
210
|
+
for (let x = 0; x < cols; x++) if (mark[(b - 1) * cols + x]! | mark[b * cols + x]! | mark[(b + 1) * cols + x]!) cover++;
|
|
211
|
+
best = { count, cover, a: k, rho };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const share = best.cover / cols;
|
|
215
|
+
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}%)` };
|
|
216
|
+
const a = (best.a / 2) * (Math.PI / 180);
|
|
217
|
+
const on = edges.filter(([x, y]) => Math.abs(-Math.sin(a) * (x - c[0]) + Math.cos(a) * (y - c[1]) - best.rho) <= 2.5);
|
|
218
|
+
const fit = fitLine(on);
|
|
219
|
+
if (fit === null) return { kind: 'no line', why: 'the best line\'s pixels do not fit a line (fewer than three, or one column)' };
|
|
220
|
+
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` };
|
|
221
|
+
return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt): Reading {
|
|
225
|
+
// The garment's colour: the median over the unmasked pixels near the hips' midpoint.
|
|
226
|
+
const r = Math.max(2, 0.3 * span);
|
|
227
|
+
const patch: RGB[] = [];
|
|
228
|
+
for (let y = Math.floor(c[1] - r); y <= Math.ceil(c[1] + r); y++) {
|
|
229
|
+
for (let x = Math.floor(c[0] - r); x <= Math.ceil(c[0] + r); x++) {
|
|
230
|
+
if (x < 0 || y < 0 || x >= f.w || y >= f.h || Math.hypot(x - c[0], y - c[1]) > r || f.masked(x, y)) continue;
|
|
231
|
+
patch.push(f.rgb(x, y));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (patch.length === 0) return { kind: 'hips behind a forearm', why: 'the hips\' midpoint is covered by a forearm; no garment colour to read' };
|
|
235
|
+
const g: RGB = [0, 1, 2].map((k) => median(patch.map((p) => p[k]!))) as RGB;
|
|
236
|
+
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(', ')})` };
|
|
237
|
+
const top = Math.max(0, Math.floor(c[1] - 1.5 * span));
|
|
238
|
+
const bottom = Math.floor(c[1]);
|
|
239
|
+
const garment = (x: number, y: number) => y < f.h && diff(f.rgb(x, y), g) <= GARMENT_DIFF;
|
|
240
|
+
const tops: Pt[] = [];
|
|
241
|
+
let above = 0;
|
|
242
|
+
for (let x = x0; x <= x1; x++) {
|
|
243
|
+
for (let y = top; y <= bottom; y++) {
|
|
244
|
+
// A forearm crossing the column is passed over; a top edge right under it is hidden by it, and the column is not read.
|
|
245
|
+
if (f.masked(x, y)) continue;
|
|
246
|
+
if (garment(x, y) && garment(x, y + 1) && garment(x, y + 2)) {
|
|
247
|
+
if (y === top) above++;
|
|
248
|
+
else if (!f.masked(x, y - 1)) tops.push([x, y]);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const share = tops.length / cols;
|
|
254
|
+
if (share < MIN_COVER) {
|
|
255
|
+
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}%)` };
|
|
256
|
+
}
|
|
257
|
+
const fit = fitLine(tops);
|
|
258
|
+
if (fit === null) return { kind: 'no line', why: 'the garment\'s top edge does not fit a line (fewer than three columns)' };
|
|
259
|
+
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` };
|
|
260
|
+
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°` };
|
|
261
|
+
return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* `rig-pose-pelvis-keys/1`: `{ spec, keys: [{ frame, deg }], note? }` — frames of the
|
|
266
|
+
* sequence, strictly increasing, within its span [0, frames − 1]; `deg` the tilt in
|
|
267
|
+
* the track's sense (Spine degrees of r_hip → l_hip, about 0 for a figure facing
|
|
268
|
+
* the camera). The tilt on every frame: linear between keys, the short way round,
|
|
269
|
+
* held outside.
|
|
270
|
+
*
|
|
271
|
+
* A frame may be fractional (rig-c/pose#294): a scan interpolated from drawn keys
|
|
272
|
+
* puts them between its frames (k × 4.75 for 8 keys over 38 frames), and a key
|
|
273
|
+
* rounded onto the grid moves up to half a frame and bends the tilt between. A
|
|
274
|
+
* fractional frame is a value of the same key, so the format stays /1; a reader
|
|
275
|
+
* from before #294 refuses it by name (`"frame" is number 4.75; it must be a frame
|
|
276
|
+
* index in [0, N]`) rather than misreading it. An integer frame interpolates
|
|
277
|
+
* exactly as before.
|
|
278
|
+
*/
|
|
279
|
+
export function readPelvisKeys(text: string, where: string, frames: number): number[] {
|
|
280
|
+
const doc = parseJson(text, where, 'PELVIS_KEYS');
|
|
281
|
+
const p = new Problems();
|
|
282
|
+
const shape = `{ "spec": "${PELVIS_KEYS_SPEC}", "keys": [{ "frame", "deg" }], "note"? }`;
|
|
283
|
+
if (!isObject(doc)) {
|
|
284
|
+
p.add('PELVIS_KEYS', `${where} is ${typeName(doc)}; it must be ${shape}`);
|
|
285
|
+
p.throwIfAny();
|
|
286
|
+
throw new Error('unreachable');
|
|
287
|
+
}
|
|
288
|
+
refuseUnknownKeys(p, 'PELVIS_KEYS', where, doc, ['spec', 'keys', 'note']);
|
|
289
|
+
if (doc.spec !== PELVIS_KEYS_SPEC) p.add('PELVIS_KEYS', `${where} "spec" is ${typeName(doc.spec)}; it must be "${PELVIS_KEYS_SPEC}"`);
|
|
290
|
+
if ('note' in doc && typeof doc.note !== 'string') p.add('PELVIS_KEYS', `${where} "note" is ${typeName(doc.note)}; it must be a string`);
|
|
291
|
+
const keys: Array<{ frame: number; deg: number }> = [];
|
|
292
|
+
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 }`);
|
|
293
|
+
else doc.keys.forEach((k: unknown, i: number) => {
|
|
294
|
+
const kw = `${where} keys[${i}]`;
|
|
295
|
+
if (!isObject(k)) {
|
|
296
|
+
p.add('PELVIS_KEYS', `${kw} is ${typeName(k)}; a key is { frame, deg }`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
refuseUnknownKeys(p, 'PELVIS_KEYS', kw, k, ['frame', 'deg']);
|
|
300
|
+
// rig-c/pose#294: any finite frame within the span, fractional or not; one outside it is refused naming the key and the span.
|
|
301
|
+
const okF = typeof k.frame === 'number' && Number.isFinite(k.frame) && k.frame >= 0 && k.frame <= frames - 1;
|
|
302
|
+
const okD = typeof k.deg === 'number' && Number.isFinite(k.deg) && k.deg > -180 && k.deg <= 180;
|
|
303
|
+
if (!okF) p.add('PELVIS_KEYS', `${kw} "frame" is ${typeName(k.frame)}; it must be a frame index in [0, ${frames - 1}]`);
|
|
304
|
+
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)`);
|
|
305
|
+
if (!okF || !okD) return;
|
|
306
|
+
const prev = keys[keys.length - 1];
|
|
307
|
+
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`);
|
|
308
|
+
else keys.push({ frame: k.frame as number, deg: k.deg as number });
|
|
309
|
+
});
|
|
310
|
+
p.throwIfAny();
|
|
311
|
+
return Array.from({ length: frames }, (_, i) => {
|
|
312
|
+
const after = keys.findIndex((k) => k.frame >= i);
|
|
313
|
+
if (after < 0) return keys[keys.length - 1]!.deg;
|
|
314
|
+
const b = keys[after]!;
|
|
315
|
+
if (b.frame === i || after === 0) return b.deg;
|
|
316
|
+
const a = keys[after - 1]!;
|
|
317
|
+
return wrap(a.deg + ((i - a.frame) / (b.frame - a.frame)) * wrap(b.deg - a.deg));
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface PelvisArgs {
|
|
322
|
+
pose: string;
|
|
323
|
+
out: string;
|
|
324
|
+
frames?: string;
|
|
325
|
+
mode: PelvisMode;
|
|
326
|
+
keys?: string;
|
|
327
|
+
person?: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Runs of one kind of decline, as the DECLINED lines group them: the first frame's reason, and whether the others' figures differ. */
|
|
331
|
+
function declinedRuns(readings: Reading[]): Array<{ from: number; to: number; kind: string; why: string; same: boolean }> {
|
|
332
|
+
const out: Array<{ from: number; to: number; kind: string; why: string; same: boolean }> = [];
|
|
333
|
+
readings.forEach((r, i) => {
|
|
334
|
+
if (!('why' in r)) return;
|
|
335
|
+
const last = out[out.length - 1];
|
|
336
|
+
if (last !== undefined && last.to === i - 1 && last.kind === r.kind) {
|
|
337
|
+
last.to = i;
|
|
338
|
+
last.same &&= last.why === r.why;
|
|
339
|
+
} else out.push({ from: i, to: i, kind: r.kind, why: r.why, same: true });
|
|
340
|
+
});
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The sequence with the track: the one person, `pelvisTilt` on every frame, `pelvisSource`. */
|
|
345
|
+
export function withPelvis(seq: PoseSequence, person: string, tilt: ReadonlyArray<number | null>, source: PelvisSource): PoseSequence {
|
|
346
|
+
return {
|
|
347
|
+
...seq,
|
|
348
|
+
pelvisSource: source,
|
|
349
|
+
frames: seq.frames.map((f, i) => ({ t: f.t, people: f.people.filter((q) => q.id === person), pelvisTilt: tilt[i] ?? null })),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** `rig-pose pelvis` end to end: read, refuse (all at once), read every frame, print, write. */
|
|
354
|
+
export function runPelvis(a: PelvisArgs, say: (l: string) => void): void {
|
|
355
|
+
const seq = readPoseSequence(readFileSync(a.pose, 'utf8'), a.pose);
|
|
356
|
+
const p = new Problems();
|
|
357
|
+
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`);
|
|
358
|
+
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`);
|
|
359
|
+
p.throwIfAny();
|
|
360
|
+
const track = personTrack(seq, a.person, a.pose);
|
|
361
|
+
const person = a.person ?? [...new Set(seq.frames.flatMap((f) => f.people.map((q) => q.id)))].sort()[0]!;
|
|
362
|
+
const n = seq.frames.length;
|
|
363
|
+
const swingOf = (vs: number[]) => {
|
|
364
|
+
if (vs.length === 0) return 0;
|
|
365
|
+
const first = vs[0]!;
|
|
366
|
+
const about = vs.map((v) => first + wrap(v - first));
|
|
367
|
+
return Math.max(...about) - Math.min(...about);
|
|
368
|
+
};
|
|
369
|
+
if (a.keys !== undefined) {
|
|
370
|
+
const tilt = readPelvisKeys(readFileSync(a.keys, 'utf8'), a.keys, n);
|
|
371
|
+
say(`rig-pose pelvis: ${n} frame(s) of person "${person}" from ${a.pose}, from the hand keys in ${a.keys}`);
|
|
372
|
+
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)`);
|
|
373
|
+
writeFileSync(a.out, writePoseSequence(withPelvis(seq, person, tilt, 'keys')));
|
|
374
|
+
say(` wrote ${a.out} (pelvisSource "keys", rig-pose-sequence/1.1)`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const dir = a.frames!;
|
|
378
|
+
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));
|
|
379
|
+
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`);
|
|
380
|
+
p.throwIfAny();
|
|
381
|
+
const readings: Reading[] = files.map((file, i) => {
|
|
382
|
+
const path = join(dir, file);
|
|
383
|
+
let plate: Plate;
|
|
384
|
+
try {
|
|
385
|
+
plate = readPlate(path);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
p.add('PELVIS_FRAME', `${path} (frame ${i}): ${(e as Error).message}`);
|
|
388
|
+
return { kind: 'unreadable', why: 'unreadable' };
|
|
389
|
+
}
|
|
390
|
+
if (plate.width !== seq.width || plate.height !== seq.height) {
|
|
391
|
+
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`);
|
|
392
|
+
return { kind: 'size', why: 'size' };
|
|
393
|
+
}
|
|
394
|
+
// A sequence with a camera carries joints normalised to frame 0's: back into this frame's pixels, p = s·p′ + t.
|
|
395
|
+
const cam = seq.camera?.[i];
|
|
396
|
+
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] }]));
|
|
397
|
+
return readPelvis(plate, joints, a.mode);
|
|
398
|
+
});
|
|
399
|
+
p.throwIfAny();
|
|
400
|
+
const tilt = readings.map((r) => ('tilt' in r ? r.tilt : null));
|
|
401
|
+
const read = tilt.filter((v): v is number => v !== null);
|
|
402
|
+
say(`rig-pose pelvis: ${n} frame(s) of person "${person}" from ${a.pose}, read by ${a.mode} off ${dir} (${files.length} PNG)`);
|
|
403
|
+
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`}`);
|
|
404
|
+
if (read.length > 0) {
|
|
405
|
+
const covers = readings.flatMap((r) => ('tilt' in r ? [r.cover] : []));
|
|
406
|
+
const rms = readings.flatMap((r) => ('tilt' in r ? [r.rms] : []));
|
|
407
|
+
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`);
|
|
408
|
+
} else {
|
|
409
|
+
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`);
|
|
410
|
+
}
|
|
411
|
+
writeFileSync(a.out, writePoseSequence(withPelvis(seq, person, tilt, a.mode)));
|
|
412
|
+
say(` wrote ${a.out} (pelvisSource "${a.mode}", rig-pose-sequence/1.1)`);
|
|
413
|
+
}
|