@rig-c/pose 0.17.0 → 0.19.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 +35 -16
- package/cli.ts +18 -12
- package/package.json +1 -1
- package/src/clean.ts +15 -4
- package/src/legs.ts +273 -68
- package/src/map.ts +49 -3
- package/src/pose.ts +86 -32
- package/src/reduce.ts +167 -7
- package/src/retarget.ts +118 -37
- package/src/run.ts +202 -27
- package/src/switch.ts +31 -8
- package/src/verify.ts +66 -16
package/src/map.ts
CHANGED
|
@@ -50,6 +50,9 @@
|
|
|
50
50
|
* directions are read), holding every joint its `chains` use; `drawn` is each
|
|
51
51
|
* bone's world angle as painted (a reading: `artTurn`); `offsets` is a
|
|
52
52
|
* translate per bone, in its parent's axes, held while the set shows.
|
|
53
|
+
* `place: "by-hip"` (rig-c/pose#93) places the wrist of each of its chains that
|
|
54
|
+
* `pose` puts within one hip width of the mid-hip by its offset from the hip
|
|
55
|
+
* (`byHipChains`; the retarget solves it).
|
|
53
56
|
* `switch.ts` decides when each one shows.
|
|
54
57
|
*/
|
|
55
58
|
import type { RawBone } from 'rig-c/core';
|
|
@@ -95,6 +98,8 @@ export interface AlternateSet {
|
|
|
95
98
|
turn: Record<string, [number, number]>;
|
|
96
99
|
/** A stopgap: the only frames the set may show on, a list of [first, last] windows; on them it is chosen over any set without windows. Null: any frame. */
|
|
97
100
|
frames: Array<[number, number]> | null;
|
|
101
|
+
/** `place: "by-hip"` (rig-c/pose#93): the chains whose wrist `pose` puts within one hip width of the mid-hip, their wrists placed by the offset from the hip while the set shows. Absent or null: the pose's angles. */
|
|
102
|
+
place?: { by: 'hip'; chains: string[] } | null;
|
|
98
103
|
}
|
|
99
104
|
export interface JointMap {
|
|
100
105
|
chains: Chain[];
|
|
@@ -228,7 +233,39 @@ export function readMap(text: string, where: string): JointMap {
|
|
|
228
233
|
return { chains, translate, alternates };
|
|
229
234
|
}
|
|
230
235
|
|
|
231
|
-
const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …] }';
|
|
236
|
+
const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …], place?: "by-hip" }';
|
|
237
|
+
|
|
238
|
+
/** The joints a wrist is, for `place: "by-hip"`: a chain whose last joint is one of them carries a wrist. */
|
|
239
|
+
export const WRISTS: readonly string[] = ['r_wrist', 'l_wrist'];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `place: "by-hip"` (rig-c/pose#93): the set's chains that carry a wrist (their
|
|
243
|
+
* last joint) which its key skeleton puts within one hip width of the mid-hip —
|
|
244
|
+
* |wrist − (r_hip + l_hip) / 2| ≤ |r_hip − l_hip| — and each wrist chain's
|
|
245
|
+
* distance in hip widths; or why the key skeleton cannot be read so.
|
|
246
|
+
*/
|
|
247
|
+
export function byHipChains(pose: Readonly<Record<string, readonly [number, number]>>, chains: readonly Chain[]): { chains: string[]; distances: Array<{ chain: string; wrist: string; hipWidths: number }> } | { problem: string } {
|
|
248
|
+
const r = pose.r_hip;
|
|
249
|
+
const l = pose.l_hip;
|
|
250
|
+
if (r === undefined || l === undefined) return { problem: `its "pose" has no ${[r === undefined ? '"r_hip"' : '', l === undefined ? '"l_hip"' : ''].filter((x) => x !== '').join(' or ')}; "by-hip" reads the hip width and the mid-hip off the key skeleton's two hips, which the map's author supplies` };
|
|
251
|
+
const noWrist = chains.filter((c) => WRISTS.includes(c.joints[c.joints.length - 1]!) && pose[c.joints[c.joints.length - 1]!] === undefined);
|
|
252
|
+
if (noWrist.length > 0) return { problem: noWrist.map((c) => `its "pose" has no "${c.joints[c.joints.length - 1]}", the wrist of chain "${c.name}"`).join('; ') + '; "by-hip" reads each wrist\'s distance from the mid-hip off the key skeleton' };
|
|
253
|
+
const width = Math.hypot(l[0] - r[0], l[1] - r[1]);
|
|
254
|
+
if (!(width > 1e-9)) return { problem: `its "pose" puts r_hip and l_hip at the same point; a hip width of 0 is no unit` };
|
|
255
|
+
const mid: [number, number] = [(r[0] + l[0]) / 2, (r[1] + l[1]) / 2];
|
|
256
|
+
const distances: Array<{ chain: string; wrist: string; hipWidths: number }> = [];
|
|
257
|
+
for (const c of chains) {
|
|
258
|
+
const wrist = c.joints[c.joints.length - 1]!;
|
|
259
|
+
if (!WRISTS.includes(wrist)) continue;
|
|
260
|
+
const w = pose[wrist];
|
|
261
|
+
if (w === undefined) continue;
|
|
262
|
+
distances.push({ chain: c.name, wrist, hipWidths: Math.hypot(w[0] - mid[0], w[1] - mid[1]) / width });
|
|
263
|
+
}
|
|
264
|
+
if (distances.length === 0) return { problem: `none of its chains (${chains.map((c) => c.name).join(', ')}) ends at a wrist (${WRISTS.join(', ')}); "by-hip" places a wrist` };
|
|
265
|
+
const near = distances.filter((d) => d.hipWidths <= 1);
|
|
266
|
+
if (near.length === 0) return { problem: `its "pose" puts no wrist within one hip width of the mid-hip (${distances.map((d) => `chain "${d.chain}" ${d.wrist} ${d.hipWidths.toFixed(2)} hip widths`).join(', ')}); "by-hip" places a hand that meets the body` };
|
|
267
|
+
return { chains: near.map((d) => d.chain), distances };
|
|
268
|
+
}
|
|
232
269
|
|
|
233
270
|
/** `alternates`: each set's shape, its chains against the map's, its key skeleton against those chains' joints, and its bones against those chains' bones. */
|
|
234
271
|
function readAlternates(p: Problems, where: string, raw: unknown, chains: readonly Chain[], translate: Translate | null): AlternateSet[] {
|
|
@@ -245,7 +282,7 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
|
|
|
245
282
|
p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
|
|
246
283
|
return;
|
|
247
284
|
}
|
|
248
|
-
refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames']);
|
|
285
|
+
refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place']);
|
|
249
286
|
const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
|
|
250
287
|
if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
|
|
251
288
|
else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
|
|
@@ -354,7 +391,16 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
|
|
|
354
391
|
p.add('MAP_ALT_FRAMES', `${label} "frames" is ${JSON.stringify(f)}; it must be a non-empty list of windows [[first, last], …], whole frame numbers ≥ 0 with first ≤ last`);
|
|
355
392
|
} else frames = (f as Array<[number, number]>).map((w) => [w[0], w[1]]);
|
|
356
393
|
}
|
|
357
|
-
|
|
394
|
+
let place: AlternateSet['place'] = null;
|
|
395
|
+
if ('place' in a) {
|
|
396
|
+
if (a.place !== 'by-hip') p.add('MAP_ALT_PLACE', `${label} "place" is ${JSON.stringify(a.place)}; the one placement a set may ask for is "by-hip"`);
|
|
397
|
+
else if (isObject(a.pose) && own.length > 0) {
|
|
398
|
+
const by = byHipChains(pose, own);
|
|
399
|
+
if ('problem' in by) p.add('MAP_ALT_PLACE', `${label} "place" is "by-hip", and ${by.problem}`);
|
|
400
|
+
else place = { by: 'hip', chains: by.chains };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }) });
|
|
358
404
|
});
|
|
359
405
|
return out;
|
|
360
406
|
}
|
package/src/pose.ts
CHANGED
|
@@ -74,36 +74,63 @@ export interface CleanSpike {
|
|
|
74
74
|
* pelvis line off the picture (`tools/pelvis.py`, after clean) and turned the
|
|
75
75
|
* hip pair to a measured angle on every frame. The exact shape:
|
|
76
76
|
*
|
|
77
|
-
* { by: "measured", method: string,
|
|
78
|
-
* read: Run[], interpolated: Run[], held: Run[],
|
|
77
|
+
* { by: "measured", method: string, fit?: { frames, gain, corr },
|
|
78
|
+
* read: Run[], interpolated: Run[], scaled?: Run[], held: Run[], unmeasured?: Run[],
|
|
79
79
|
* declined: Array<{ frames: Run, why: string }> } Run = [first, last]
|
|
80
80
|
*
|
|
81
81
|
* `method` names the reading. Runs are integer frame indices inside the
|
|
82
82
|
* sequence, first ≤ last, increasing and not overlapping within each list.
|
|
83
83
|
* Every frame of the sequence is in exactly one of `read` (the frame's own
|
|
84
84
|
* reading), `interpolated` (a declined frame between two reads close in time,
|
|
85
|
-
* the angle interpolated)
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* the angle interpolated), `scaled` (#106: a longer declined run taking the
|
|
86
|
+
* detector hip line scaled by `fit.gain`), `held` (the earlier read carried to
|
|
87
|
+
* the end of its run; at the clip's start, the later read) and `unmeasured`
|
|
88
|
+
* (#106: a longer declined run when `fit.corr` < 0.9). `declined` lists, with
|
|
89
|
+
* the reason, exactly the non-read frames. Anything else is refused by name
|
|
90
|
+
* (POSE_CLEANED).
|
|
88
91
|
*/
|
|
89
92
|
export type Run = [number, number];
|
|
93
|
+
export interface PelvisFit {
|
|
94
|
+
frames: number;
|
|
95
|
+
gain: number | null;
|
|
96
|
+
corr: number | null;
|
|
97
|
+
}
|
|
90
98
|
export type HipsInferred =
|
|
91
99
|
| { by: 'declared' }
|
|
92
|
-
| {
|
|
100
|
+
| {
|
|
101
|
+
by: 'measured';
|
|
102
|
+
method: string;
|
|
103
|
+
fit?: PelvisFit;
|
|
104
|
+
read: Run[];
|
|
105
|
+
interpolated: Run[];
|
|
106
|
+
scaled?: Run[];
|
|
107
|
+
held: Run[];
|
|
108
|
+
unmeasured?: Run[];
|
|
109
|
+
declined: Array<{ frames: Run; why: string }>;
|
|
110
|
+
};
|
|
93
111
|
|
|
94
112
|
/** A copy of the record, in the writer's key order. */
|
|
95
113
|
export function copyHipsInferred(h: HipsInferred): HipsInferred {
|
|
96
114
|
const runs = (rs: Run[]): Run[] => rs.map(([a, b]) => [a, b]);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
115
|
+
if (h.by === 'declared') return { by: 'declared' };
|
|
116
|
+
return {
|
|
117
|
+
by: 'measured',
|
|
118
|
+
method: h.method,
|
|
119
|
+
...(h.fit !== undefined ? { fit: { frames: h.fit.frames, gain: h.fit.gain, corr: h.fit.corr } } : {}),
|
|
120
|
+
read: runs(h.read),
|
|
121
|
+
interpolated: runs(h.interpolated),
|
|
122
|
+
...(h.scaled !== undefined ? { scaled: runs(h.scaled) } : {}),
|
|
123
|
+
held: runs(h.held),
|
|
124
|
+
...(h.unmeasured !== undefined ? { unmeasured: runs(h.unmeasured) } : {}),
|
|
125
|
+
declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })),
|
|
126
|
+
};
|
|
100
127
|
}
|
|
101
128
|
|
|
102
|
-
/** The frames a `measured` record gives an angle on (read, interpolated, held), as a set; null for any other record or none. */
|
|
129
|
+
/** The frames a `measured` record gives an angle on (read, interpolated, scaled, held), as a set; null for any other record or none. */
|
|
103
130
|
export function measuredFrames(h: HipsInferred | undefined): Set<number> | null {
|
|
104
131
|
if (h === undefined || h.by !== 'measured') return null;
|
|
105
132
|
const out = new Set<number>();
|
|
106
|
-
for (const rs of [h.read, h.interpolated, h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
|
|
133
|
+
for (const rs of [h.read, h.interpolated, h.scaled ?? [], h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
|
|
107
134
|
return out;
|
|
108
135
|
}
|
|
109
136
|
|
|
@@ -180,11 +207,22 @@ export interface PoseSequence {
|
|
|
180
207
|
camera?: CameraFrame[];
|
|
181
208
|
/** Present once `rig-pose clean` has run on the sequence (#67). */
|
|
182
209
|
cleaned?: CleanRecord;
|
|
210
|
+
/**
|
|
211
|
+
* Optional (rig-c/pose#92): the clip's frame of reference. "in-place" — a
|
|
212
|
+
* gait filmed without the figure travelling (a treadmill or reel walk, a
|
|
213
|
+
* loop) — is the one value; `clean --loop` writes it, and it may be written
|
|
214
|
+
* by hand. Absent: the figure's travel in the picture is its travel.
|
|
215
|
+
*/
|
|
216
|
+
travel?: Travel;
|
|
183
217
|
frames: PoseFrame[];
|
|
184
218
|
}
|
|
185
219
|
|
|
220
|
+
/** The one value of a sequence's `travel` (rig-c/pose#92). */
|
|
221
|
+
export const TRAVEL_IN_PLACE = 'in-place';
|
|
222
|
+
export type Travel = typeof TRAVEL_IN_PLACE;
|
|
223
|
+
|
|
186
224
|
const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
|
|
187
|
-
const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned'];
|
|
225
|
+
const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned', 'travel'];
|
|
188
226
|
const CAMERA_KEYS = ['s', 'tx', 'ty', 'inliers'];
|
|
189
227
|
|
|
190
228
|
/**
|
|
@@ -269,10 +307,11 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
|
269
307
|
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")`);
|
|
270
308
|
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`);
|
|
271
309
|
}
|
|
310
|
+
if ('travel' in doc && doc.travel !== TRAVEL_IN_PLACE) p.add('POSE_TRAVEL', `${where} "travel" is ${typeName(doc.travel)}; the one value is "${TRAVEL_IN_PLACE}" (a gait filmed without the figure travelling), and without the key the picture's travel is the figure's`);
|
|
272
311
|
const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
|
|
273
312
|
p.throwIfAny();
|
|
274
313
|
const cameraSource = typeof doc.cameraSource === 'string' ? doc.cameraSource : undefined;
|
|
275
|
-
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 };
|
|
314
|
+
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 } : {}), ...(doc.travel === TRAVEL_IN_PLACE ? { travel: TRAVEL_IN_PLACE } : {}), frames };
|
|
276
315
|
}
|
|
277
316
|
|
|
278
317
|
/** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
|
|
@@ -477,10 +516,22 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
|
|
|
477
516
|
return;
|
|
478
517
|
}
|
|
479
518
|
const before = p.list.length;
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
519
|
+
const REQ_CLASSES = ['read', 'interpolated', 'held'] as const;
|
|
520
|
+
const ALL_CLASSES = ['read', 'interpolated', 'scaled', 'held', 'unmeasured'] as const;
|
|
521
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', 'fit', ...ALL_CLASSES, 'declined']);
|
|
522
|
+
for (const k of ['method', ...REQ_CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
|
|
483
523
|
if ('method' in h && !(typeof h.method === 'string' && h.method.trim() !== '')) p.add('POSE_CLEANED', `${hw} "method" is ${typeName(h.method)}; it must be a non-empty string naming the reading`);
|
|
524
|
+
if ('fit' in h) {
|
|
525
|
+
const f = h.fit;
|
|
526
|
+
const fw = `${hw} "fit"`;
|
|
527
|
+
if (!isObject(f)) p.add('POSE_CLEANED', `${fw} is ${typeName(f)}; it must be { frames, gain, corr }`);
|
|
528
|
+
else {
|
|
529
|
+
refuseUnknownKeys(p, 'POSE_CLEANED', fw, f, ['frames', 'gain', 'corr']);
|
|
530
|
+
if (!(Number.isInteger(f.frames) && (f.frames as number) >= 0)) p.add('POSE_CLEANED', `${fw} "frames" is ${typeName(f.frames)}; it must be a whole number of frames ≥ 0`);
|
|
531
|
+
if (!(f.gain === null || (typeof f.gain === 'number' && Number.isFinite(f.gain)))) p.add('POSE_CLEANED', `${fw} "gain" is ${typeName(f.gain)}; it must be a finite number or null`);
|
|
532
|
+
if (!(f.corr === null || (typeof f.corr === 'number' && Number.isFinite(f.corr) && f.corr >= -1 && f.corr <= 1))) p.add('POSE_CLEANED', `${fw} "corr" is ${typeName(f.corr)}; it must be a correlation in [-1, 1] or null`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
484
535
|
const range = `[0, ${frames === null ? 'the frame count' : frames - 1}]`;
|
|
485
536
|
/** A run [first, last], or null after naming what is wrong with it. */
|
|
486
537
|
const run = (v: unknown, rw: string): Run | null => {
|
|
@@ -512,8 +563,8 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
|
|
|
512
563
|
if (cur.r[0] <= prev.r[1]) p.add('POSE_CLEANED', `${cur.w} [${cur.r[0]}, ${cur.r[1]}] starts at or before the end of ${prev.w} [${prev.r[0]}, ${prev.r[1]}]; ${what} runs must increase and not overlap`);
|
|
513
564
|
}
|
|
514
565
|
};
|
|
515
|
-
const classes: Record<(typeof
|
|
516
|
-
for (const c of
|
|
566
|
+
const classes: Record<(typeof ALL_CLASSES)[number], Named[]> = { read: [], interpolated: [], scaled: [], held: [], unmeasured: [] };
|
|
567
|
+
for (const c of ALL_CLASSES) {
|
|
517
568
|
if (!(c in h)) continue;
|
|
518
569
|
const list = h[c];
|
|
519
570
|
if (!Array.isArray(list)) {
|
|
@@ -548,12 +599,15 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
|
|
|
548
599
|
});
|
|
549
600
|
ordered(declined, 'declined');
|
|
550
601
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
602
|
+
const has106 = 'scaled' in h || 'unmeasured' in h || 'fit' in h;
|
|
603
|
+
const classListLabel = has106 ? 'read, interpolated, scaled, held and unmeasured' : 'read, interpolated and held';
|
|
604
|
+
const filledLabel = has106 ? 'interpolated, scaled, held or unmeasured' : 'interpolated or held';
|
|
605
|
+
// Across lists: the classes are disjoint, they cover every frame, and declined is exactly the non-read classes.
|
|
606
|
+
for (let x = 0; x < ALL_CLASSES.length; x++) {
|
|
607
|
+
for (let y = x + 1; y < ALL_CLASSES.length; y++) {
|
|
608
|
+
for (const a of classes[ALL_CLASSES[x]!]) {
|
|
609
|
+
for (const b of classes[ALL_CLASSES[y]!]) {
|
|
610
|
+
if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of ${classListLabel}`);
|
|
557
611
|
}
|
|
558
612
|
}
|
|
559
613
|
}
|
|
@@ -574,18 +628,18 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
|
|
|
574
628
|
return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
|
|
575
629
|
};
|
|
576
630
|
const read = set(classes.read);
|
|
577
|
-
const
|
|
631
|
+
const nonRead = set([...classes.interpolated, ...classes.scaled, ...classes.held, ...classes.unmeasured]);
|
|
578
632
|
const dec = set(declined);
|
|
579
633
|
if (frames !== null) {
|
|
580
|
-
const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !
|
|
581
|
-
if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of
|
|
634
|
+
const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !nonRead.has(f));
|
|
635
|
+
if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of ${classListLabel}; every frame of the sequence is one of them`);
|
|
582
636
|
}
|
|
583
637
|
const both = [...dec].filter((f) => read.has(f)).sort((a, b) => a - b);
|
|
584
638
|
if (both.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(both)} are both read and declined; a frame is read or declined, not both`);
|
|
585
|
-
const unfilled = [...dec].filter((f) => !read.has(f) && !
|
|
586
|
-
if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither
|
|
587
|
-
const undeclared = [...
|
|
588
|
-
if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are
|
|
639
|
+
const unfilled = [...dec].filter((f) => !read.has(f) && !nonRead.has(f)).sort((a, b) => a - b);
|
|
640
|
+
if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither ${filledLabel}; a declined frame takes one of them`);
|
|
641
|
+
const undeclared = [...nonRead].filter((f) => !dec.has(f)).sort((a, b) => a - b);
|
|
642
|
+
if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are ${filledLabel} but not declined; only a frame the reading declined is filled, and "declined" says why`);
|
|
589
643
|
}
|
|
590
644
|
const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off' ? 'off' : { speed: h.speed, reach: h.reach, minSeconds: h.minSeconds, blend: h.blend });
|
|
591
645
|
const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: h.jitter.before, after: h.jitter.after } });
|
|
@@ -786,5 +840,5 @@ export function writePoseSequence(seq: PoseSequence): string {
|
|
|
786
840
|
...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
|
|
787
841
|
...(c.unheld !== undefined ? { unheld: c.unheld.map((u) => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value === null ? null : round(u.value, 4) })) } : {}),
|
|
788
842
|
};
|
|
789
|
-
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`;
|
|
843
|
+
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 } : {}), ...(seq.travel !== undefined ? { travel: seq.travel } : {}), frames }, null, 2)}\n`;
|
|
790
844
|
}
|
package/src/reduce.ts
CHANGED
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
* target is its world error: errors do not pile up down a chain. A knot's value
|
|
19
19
|
* is the target's, not the dense key's.
|
|
20
20
|
*
|
|
21
|
+
* Scaled bones (rig-c/pose#94). A `scalex` track is thinned in value space
|
|
22
|
+
* within `SCALE_REDUCE` (`reduceScale`). A bone under a scaled bone is not
|
|
23
|
+
* keyed by the formula above but through its parent's posed matrix (legs.ts
|
|
24
|
+
* `posePath`), so `reduceUnderScale` solves its keys again through its parent
|
|
25
|
+
* as reduced and reads each error as the world aim, and the world length, it
|
|
26
|
+
* lands on.
|
|
27
|
+
*
|
|
21
28
|
* Every error is read after the numbers are rounded as written and evaluated as
|
|
22
29
|
* rig-c's poser evaluates them (`curveAt`): the curve is the poser's ten-piece
|
|
23
30
|
* polyline, not the cubic. The poser is the judge — run.ts measures the landing
|
|
@@ -52,12 +59,13 @@ export interface ReduceHip {
|
|
|
52
59
|
}
|
|
53
60
|
export interface ReduceRow {
|
|
54
61
|
bone: string;
|
|
55
|
-
|
|
62
|
+
/** `scale`: a `scalex` track (#94), its error the bone's world x-axis length against its setup length times the scale asked. */
|
|
63
|
+
kind: 'angle' | 'origin' | 'scale';
|
|
56
64
|
/** Dense key count. */
|
|
57
65
|
before: number;
|
|
58
66
|
/** Reduced key count. */
|
|
59
67
|
after: number;
|
|
60
|
-
/** The largest error over all frames: degrees of world aim for a bone, world units for the hip. */
|
|
68
|
+
/** The largest error over all frames: degrees of world aim for a bone, world units for the hip, a fraction of the setup length for a scale. */
|
|
61
69
|
worst: number;
|
|
62
70
|
/** The first frame where it is. */
|
|
63
71
|
frame: number;
|
|
@@ -67,6 +75,8 @@ export interface Reduced {
|
|
|
67
75
|
hip: ReducedKey[] | null;
|
|
68
76
|
/** The bones in the order given, then the hip. */
|
|
69
77
|
rows: ReduceRow[];
|
|
78
|
+
/** Each bone's world-aim error on every frame (reduced less dense, degrees), as the poser evaluates the reduced keys. */
|
|
79
|
+
errors: Map<string, number[]>;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
// ---------------------------------------------------------------------------
|
|
@@ -186,6 +196,11 @@ const r3 = (v: number): number => {
|
|
|
186
196
|
const r = Math.round(v * 1000) / 1000;
|
|
187
197
|
return Object.is(r, -0) ? 0 : r;
|
|
188
198
|
};
|
|
199
|
+
/** A scale as the retarget writes one (retarget.ts `r6`). */
|
|
200
|
+
const r6 = (v: number): number => {
|
|
201
|
+
const r = Math.round(v * 1e6) / 1e6;
|
|
202
|
+
return Object.is(r, -0) ? 0 : r;
|
|
203
|
+
};
|
|
189
204
|
|
|
190
205
|
/** One track to reduce: its targets per frame (one array per frame, a value per channel) and how a frame's error is read. */
|
|
191
206
|
interface Fit {
|
|
@@ -194,6 +209,8 @@ interface Fit {
|
|
|
194
209
|
tolerance: number;
|
|
195
210
|
/** The error at frame `i` of `values` against the target. */
|
|
196
211
|
error: (i: number, values: readonly number[]) => number;
|
|
212
|
+
/** How a written value is rounded: 0.001 (an angle, a translate) unless given. */
|
|
213
|
+
round?: (v: number) => number;
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
/** The frame with the largest error over `frames` (the first, on a tie), or null when there is none. */
|
|
@@ -242,7 +259,8 @@ function solveHandles(a: ReducedKey, b: ReducedKey, channel: number, hx1: number
|
|
|
242
259
|
function fitTrack(times: readonly number[], samples: readonly number[], fit: Fit): ReducedKey[] {
|
|
243
260
|
const n = times.length;
|
|
244
261
|
const channels = fit.targets[0]!.length;
|
|
245
|
-
const
|
|
262
|
+
const round = fit.round ?? r3;
|
|
263
|
+
const keyOn = (i: number): ReducedKey => ({ t: times[i]!, v: fit.targets[i]!.map(round) });
|
|
246
264
|
const knots = [...new Set([0, n - 1, ...fit.keep])].sort((x, y) => x - y);
|
|
247
265
|
const curves = new Map<number, number[]>();
|
|
248
266
|
const done: number[] = [];
|
|
@@ -266,7 +284,7 @@ function fitTrack(times: readonly number[], samples: readonly number[], fit: Fit
|
|
|
266
284
|
const hx2 = times[k0]! + (2 * (times[k1]! - times[k0]!)) / 3;
|
|
267
285
|
const handles = Array.from({ length: channels }, (_, c) => solveHandles(a, b, c, hx1, hx2, interior, samples, (i) => fit.targets[i]![c]!));
|
|
268
286
|
if (handles.every((h) => h !== null)) {
|
|
269
|
-
const curve = handles.flatMap(([cy1, cy2]) => [hx1,
|
|
287
|
+
const curve = handles.flatMap(([cy1, cy2]) => [hx1, round(cy1), hx2, round(cy2)]);
|
|
270
288
|
const bent = errorWith({ ...a, curve });
|
|
271
289
|
if (bent.worst <= fit.tolerance) {
|
|
272
290
|
curves.set(k0, curve);
|
|
@@ -304,8 +322,7 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
|
|
|
304
322
|
if (!(Number.isFinite(tolerance) && tolerance > 0)) throw new Error(`reduce: the tolerance is ${tolerance}°, not a positive number`);
|
|
305
323
|
if (hip !== null && !(Number.isFinite(hip.tolerance) && hip.tolerance > 0)) throw new Error(`reduce: the hip's tolerance is ${hip.tolerance} units, not a positive number`);
|
|
306
324
|
// The poser samples at the running sum of the frame steps (verify.ts `poseFrames`).
|
|
307
|
-
const samples
|
|
308
|
-
for (let i = 1; i < n; i++) samples.push(samples[i - 1]! + (times[i]! - times[i - 1]!));
|
|
325
|
+
const samples = samplesOf(times);
|
|
309
326
|
|
|
310
327
|
const byName = new Map<string, ReduceBone>();
|
|
311
328
|
for (const b of bones) {
|
|
@@ -341,6 +358,7 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
|
|
|
341
358
|
rowOf.set(b.bone, { bone: b.bone, kind: 'angle', before: n, after: keys.length, worst: worst.worst, frame: worst.frame });
|
|
342
359
|
}
|
|
343
360
|
const rows = bones.map((b) => rowOf.get(b.bone)!);
|
|
361
|
+
const errorsOrdered = new Map(bones.map((b) => [b.bone, errors.get(b.bone)!]));
|
|
344
362
|
const ordered = new Map(bones.map((b) => [b.bone, rotate.get(b.bone)!]));
|
|
345
363
|
|
|
346
364
|
let hipKeys: ReducedKey[] | null = null;
|
|
@@ -357,5 +375,147 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
|
|
|
357
375
|
const worst = worstOf(at.map((_, i) => i), (i) => world(i, at[i]!))!;
|
|
358
376
|
rows.push({ bone: hip.bone, kind: 'origin', before: n, after: hipKeys.length, worst: worst.worst, frame: worst.frame });
|
|
359
377
|
}
|
|
360
|
-
return { rotate: ordered, hip: hipKeys, rows };
|
|
378
|
+
return { rotate: ordered, hip: hipKeys, rows, errors: errorsOrdered };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The times the poser samples a track at: the running sum of the frame steps (verify.ts `poseFrames`). */
|
|
382
|
+
export function samplesOf(times: readonly number[]): number[] {
|
|
383
|
+
const samples: number[] = [times[0]!];
|
|
384
|
+
for (let i = 1; i < times.length; i++) samples.push(samples[i - 1]! + (times[i]! - times[i - 1]!));
|
|
385
|
+
return samples;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// scales, and bones keyed through a scaled parent (rig-c/pose#94)
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The tolerance a reduced `scalex` track is held to: half of the landing
|
|
394
|
+
* check's scale tolerance (verify.ts `SCALE_TOLERANCE`, 1e-4 of the setup
|
|
395
|
+
* length), so a reduced curve cannot fail the check it is re-run against.
|
|
396
|
+
*/
|
|
397
|
+
export const SCALE_REDUCE = 5e-5;
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* One `scalex` track of a bone with no scaled bone above it, reduced: its world
|
|
401
|
+
* x-axis length over setup is its own scale, so the error is the value's
|
|
402
|
+
* distance from the scale asked.
|
|
403
|
+
*/
|
|
404
|
+
export function reduceScale(times: readonly number[], bone: string, asked: readonly number[], keep: ReadonlySet<number>, tolerance: number): { keys: ReducedKey[]; at: number[]; row: ReduceRow } {
|
|
405
|
+
const n = times.length;
|
|
406
|
+
if (asked.length !== n) throw new Error(`reduce: bone "${bone}" asks ${asked.length} scale(s) for ${n} frame(s)`);
|
|
407
|
+
const samples = samplesOf(times);
|
|
408
|
+
const keys = fitTrack(times, samples, { targets: asked.map((v) => [v]), keep: keptFrames(keep, n, `bone "${bone}" scalex`), tolerance, error: (i, v) => Math.abs(v[0]! - asked[i]!), round: r6 });
|
|
409
|
+
const at = trackAt(keys, samples, 1).map((v) => v[0]!);
|
|
410
|
+
const worst = worstOf(at.map((_, i) => i), (i) => Math.abs(at[i]! - asked[i]!))!;
|
|
411
|
+
return { keys, at, row: { bone, kind: 'scale', before: n, after: keys.length, worst: worst.worst, frame: worst.frame } };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** A world 2×2: columns (a, c) — the x axis — and (b, d) — the y axis (legs.ts `M2`). */
|
|
415
|
+
export type M2 = [number, number, number, number];
|
|
416
|
+
const DEG = 180 / Math.PI;
|
|
417
|
+
const mul = (p: M2, q: M2): M2 => [p[0] * q[0] + p[1] * q[2], p[0] * q[1] + p[1] * q[3], p[2] * q[0] + p[3] * q[2], p[2] * q[1] + p[3] * q[3]];
|
|
418
|
+
const inv = (m: M2): M2 => {
|
|
419
|
+
const det = m[0] * m[3] - m[1] * m[2];
|
|
420
|
+
return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det];
|
|
421
|
+
};
|
|
422
|
+
const apply = (m: M2, v: [number, number]): [number, number] => [m[0] * v[0] + m[1] * v[1], m[2] * v[0] + m[3] * v[1]];
|
|
423
|
+
const rot = (deg: number): M2 => {
|
|
424
|
+
const r = deg / DEG;
|
|
425
|
+
return [Math.cos(r), -Math.sin(r), Math.sin(r), Math.cos(r)];
|
|
426
|
+
};
|
|
427
|
+
const scaleX = (s: number): M2 => [s, 0, 0, 1];
|
|
428
|
+
/** A bone's setup local 2×2 in its parent's axes: inv(parent's setup world) · its setup world (legs.ts `posePath`'s L0). */
|
|
429
|
+
export const setupLocal = (parentWorld0: M2, world0: M2): M2 => mul(inv(parentWorld0), world0);
|
|
430
|
+
/** `p · q`. */
|
|
431
|
+
export const mulM2 = mul;
|
|
432
|
+
const IDENTITY: M2 = [1, 0, 0, 1];
|
|
433
|
+
export { IDENTITY as M2_IDENTITY };
|
|
434
|
+
const wrap180 = (d: number): number => {
|
|
435
|
+
const x = ((d + 180) % 360 + 360) % 360 - 180;
|
|
436
|
+
return x === -180 ? 180 : x;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A mapped bone under a scaled bone (legs.ts `posePath`: keyed through its
|
|
441
|
+
* parent's posed matrix), reduced once the bones above it are. Its parent's
|
|
442
|
+
* world 2×2 is posed from keys already reduced — a bone the key formula holds
|
|
443
|
+
* for (`rigid`: R(turn) · its setup world · diag(scale, 1), its turn and scale
|
|
444
|
+
* the reduced ones), or another bone of the same call given before it — times
|
|
445
|
+
* the setup locals of the unmapped bones between (`via`).
|
|
446
|
+
*/
|
|
447
|
+
export interface UnderBone {
|
|
448
|
+
bone: string;
|
|
449
|
+
parent: { rigid: { turn: readonly number[]; world0: M2; scale: readonly number[] }; via: M2 } | { under: string; via: M2 };
|
|
450
|
+
/** Its setup local 2×2 in its parent's axes: inv(parent's setup world) · its setup world, by rig-c's setup. */
|
|
451
|
+
local: M2;
|
|
452
|
+
/** Its setup world x-axis length. */
|
|
453
|
+
len0: number;
|
|
454
|
+
/** The world angle its x axis is asked on every frame (degrees), and the scale of it asked (1: none). */
|
|
455
|
+
target: readonly number[];
|
|
456
|
+
asked: readonly number[];
|
|
457
|
+
/** The dense rotate keys (unwrapped, as written). */
|
|
458
|
+
dense: readonly number[];
|
|
459
|
+
keep: ReadonlySet<number>;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Each bone under a scaled bone, in the order given (parents first): its
|
|
464
|
+
* rotate and scalex keys re-solved through its parent's reduced posed matrix
|
|
465
|
+
* (the key that puts its x axis on the target, and the scale that gives it the
|
|
466
|
+
* length asked), then reduced — the rotate with its error read as the world aim
|
|
467
|
+
* it lands on through that matrix, the scale with its error read as the world
|
|
468
|
+
* length over setup against the scale asked, on the rotate as reduced. Its
|
|
469
|
+
* world error is its own fit error, as down a chain the key formula holds for.
|
|
470
|
+
*/
|
|
471
|
+
export function reduceUnderScale(times: readonly number[], bones: readonly UnderBone[], tolerance: number, scaleTolerance: number): { rotate: Map<string, ReducedKey[]>; scale: Map<string, ReducedKey[]>; rows: ReduceRow[] } {
|
|
472
|
+
const n = times.length;
|
|
473
|
+
const samples = samplesOf(times);
|
|
474
|
+
const world = new Map<string, M2[]>();
|
|
475
|
+
const rotate = new Map<string, ReducedKey[]>();
|
|
476
|
+
const scale = new Map<string, ReducedKey[]>();
|
|
477
|
+
const rows: ReduceRow[] = [];
|
|
478
|
+
for (const b of bones) {
|
|
479
|
+
for (const [what, xs] of [['target', b.target], ['asked', b.asked], ['dense', b.dense]] as const) {
|
|
480
|
+
if (xs.length !== n) throw new Error(`reduce: bone "${b.bone}" has ${xs.length} ${what} value(s) for ${n} frame(s)`);
|
|
481
|
+
}
|
|
482
|
+
const p = b.parent;
|
|
483
|
+
let parent: M2[];
|
|
484
|
+
if ('under' in p) {
|
|
485
|
+
const above = world.get(p.under);
|
|
486
|
+
if (above === undefined) throw new Error(`reduce: bone "${b.bone}" hangs from bone "${p.under}", which is not given before it`);
|
|
487
|
+
parent = above.map((m) => mul(m, p.via));
|
|
488
|
+
} else {
|
|
489
|
+
parent = Array.from({ length: n }, (_, i) => mul(mul(mul(rot(p.rigid.turn[i]!), p.rigid.world0), scaleX(p.rigid.scale[i]!)), p.via));
|
|
490
|
+
}
|
|
491
|
+
const l0: [number, number] = [b.local[0], b.local[2]];
|
|
492
|
+
const a0 = Math.atan2(l0[1], l0[0]) * DEG;
|
|
493
|
+
/** The x axis's world vector through the parent on frame i, at rotate key k, before its own scale. */
|
|
494
|
+
const axis = (i: number, k: number) => apply(parent[i]!, apply(rot(k), l0));
|
|
495
|
+
// The dense keys re-solved through the reduced parent (posePath's solve).
|
|
496
|
+
const solved = b.target.map((t, i) => {
|
|
497
|
+
const v = apply(inv(parent[i]!), [b.asked[i]! * b.len0 * Math.cos(t / DEG), b.asked[i]! * b.len0 * Math.sin(t / DEG)]);
|
|
498
|
+
const k = Math.atan2(v[1], v[0]) * DEG - a0;
|
|
499
|
+
return b.dense[i]! + wrap180(k - b.dense[i]!);
|
|
500
|
+
});
|
|
501
|
+
const aimError = (i: number, k: number) => {
|
|
502
|
+
const w = axis(i, k);
|
|
503
|
+
return Math.abs(wrap180(Math.atan2(w[1], w[0]) * DEG - b.target[i]!));
|
|
504
|
+
};
|
|
505
|
+
const keep = keptFrames(b.keep, n, `bone "${b.bone}"`);
|
|
506
|
+
const rk = fitTrack(times, samples, { targets: solved.map((k) => [k]), keep, tolerance, error: (i, v) => aimError(i, v[0]!) });
|
|
507
|
+
const kAt = trackAt(rk, samples, 1).map((v) => v[0]!);
|
|
508
|
+
const ae = worstOf(kAt.map((_, i) => i), (i) => aimError(i, kAt[i]!))!;
|
|
509
|
+
rows.push({ bone: b.bone, kind: 'angle', before: n, after: rk.length, worst: ae.worst, frame: ae.frame });
|
|
510
|
+
// The scale that gives it the length asked on the rotate as reduced.
|
|
511
|
+
const reach = kAt.map((k, i) => Math.hypot(...axis(i, k)) / b.len0);
|
|
512
|
+
const sk = fitTrack(times, samples, { targets: reach.map((r, i) => [b.asked[i]! / r]), keep, tolerance: scaleTolerance, error: (i, v) => Math.abs(v[0]! * reach[i]! - b.asked[i]!), round: r6 });
|
|
513
|
+
const sAt = trackAt(sk, samples, 1).map((v) => v[0]!);
|
|
514
|
+
const se = worstOf(sAt.map((_, i) => i), (i) => Math.abs(sAt[i]! * reach[i]! - b.asked[i]!))!;
|
|
515
|
+
rows.push({ bone: b.bone, kind: 'scale', before: n, after: sk.length, worst: se.worst, frame: se.frame });
|
|
516
|
+
world.set(b.bone, parent.map((m, i) => mul(mul(mul(m, rot(kAt[i]!)), b.local), scaleX(sAt[i]!))));
|
|
517
|
+
rotate.set(b.bone, rk);
|
|
518
|
+
scale.set(b.bone, sk);
|
|
519
|
+
}
|
|
520
|
+
return { rotate, scale, rows };
|
|
361
521
|
}
|