@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/pose.ts CHANGED
@@ -17,11 +17,73 @@
17
17
  * is: the unit is `picture-px` (a video frame is not a painting), and `at` may
18
18
  * lie outside the picture (a dancer's hand leaves the frame; a pose model
19
19
  * places it anyway, and its score says how much to believe it).
20
+ *
21
+ * The minor (rig-c/pose#288): `rig-pose-sequence/1.1` adds the pelvis track —
22
+ * a per-frame `pelvisTilt` and the sequence's `pelvisSource` (`rig-pose
23
+ * pelvis`). The writer writes the minor the content needs: a sequence with the
24
+ * track is "rig-pose-sequence/1.1", every other one "rig-pose-sequence/1", so
25
+ * a sequence without it stays readable by a reader of /1. The spec string is
26
+ * the version the file records of what wrote it: a file of a minor newer than
27
+ * this reader's is read, and each key it does not know is refused naming the
28
+ * minor the file says it was written as.
29
+ *
30
+ * `rig-pose-sequence/1.2` (rig-c/pose#281) adds derived depth: a per-frame
31
+ * `depth`, one `{ ratio, sign, source }` per limb segment, written by `clean
32
+ * --depth walk|run` (`source: "derived"`) or by a 3D detector (`"measured"`,
33
+ * read and never written here). A minor includes the keys of the minors before
34
+ * it, so a sequence with depth is "/1.2" whether or not it also carries the
35
+ * pelvis track; one with the track and no depth stays "/1.1".
20
36
  */
21
37
  import { BODY18, COCO17, COCO17_TO_BODY18, isBody18, type Body18 } from './joints.ts';
22
- import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './problems.ts';
38
+ import { isObject, parseJson, Problems, refuseUnknownKeys, refuseUnknownKeys as refuseKeys, typeName } from './problems.ts';
23
39
 
24
40
  export const POSE_SPEC = 'rig-pose-sequence/1';
41
+ /** The minor with the pelvis track (rig-c/pose#288). */
42
+ export const POSE_SPEC_PELVIS = 'rig-pose-sequence/1.1';
43
+ /** The minor with derived depth (rig-c/pose#281), and the newest minor this reader reads. */
44
+ export const POSE_SPEC_DEPTH = 'rig-pose-sequence/1.2';
45
+ const POSE_MINOR = 2;
46
+ /**
47
+ * The limb segments a frame's `depth` names (rig-c/pose#281), each from its
48
+ * proximal joint to its distal one: the sign is + when the distal joint is
49
+ * nearer the camera than the proximal one.
50
+ */
51
+ export const DEPTH_SEGMENTS = {
52
+ r_upper_arm: ['r_shoulder', 'r_elbow'],
53
+ r_forearm: ['r_elbow', 'r_wrist'],
54
+ r_thigh: ['r_hip', 'r_knee'],
55
+ r_shin: ['r_knee', 'r_ankle'],
56
+ l_upper_arm: ['l_shoulder', 'l_elbow'],
57
+ l_forearm: ['l_elbow', 'l_wrist'],
58
+ l_thigh: ['l_hip', 'l_knee'],
59
+ l_shin: ['l_knee', 'l_ankle'],
60
+ } as const satisfies Record<string, readonly [Body18, Body18]>;
61
+ export type DepthSegment = keyof typeof DEPTH_SEGMENTS;
62
+ export const DEPTH_SEGMENT_NAMES = Object.keys(DEPTH_SEGMENTS) as DepthSegment[];
63
+ /** Where a segment's depth came from: read off the picture's 2D lengths (`clean --depth`), or a 3D detector's. */
64
+ export const DEPTH_SOURCES = ['derived', 'measured'] as const;
65
+ export type DepthSource = (typeof DEPTH_SOURCES)[number];
66
+ /**
67
+ * One segment's depth on one frame (rig-c/pose#281): `ratio` its drawn length ÷
68
+ * its rest length; `sign` +1 when its distal joint is nearer the camera than its
69
+ * proximal one, −1 farther, 0 flat (or not decided); `source` as above.
70
+ */
71
+ export interface SegmentDepth {
72
+ ratio: number;
73
+ sign: -1 | 0 | 1;
74
+ source: DepthSource;
75
+ }
76
+ export type FrameDepth = Partial<Record<DepthSegment, SegmentDepth>>;
77
+ /** The gait `clean --depth` was told the clip is: it decides the sign of a foreshortened shin. */
78
+ export const DEPTH_GAITS = ['walk', 'run'] as const;
79
+ export type DepthGait = (typeof DEPTH_GAITS)[number];
80
+ /**
81
+ * Where a sequence's `pelvisTilt` came from (rig-c/pose#288): `silhouette` — the
82
+ * straight edge of the drawn figure across the hips; `waistband` — the top edge
83
+ * of a garment covering them; `keys` — a hand file (`--pelvis-keys`).
84
+ */
85
+ export const PELVIS_SOURCES = ['silhouette', 'waistband', 'keys'] as const;
86
+ export type PelvisSource = (typeof PELVIS_SOURCES)[number];
25
87
  export const POSE_SPACE = { units: 'picture-px', origin: 'top-left', y: 'down' } as const;
26
88
  /**
27
89
  * `inferred` (rig-c/pose#63): a position the detector supplied but did not see
@@ -44,6 +106,21 @@ export interface Person {
44
106
  export interface PoseFrame {
45
107
  t: number;
46
108
  people: Person[];
109
+ /**
110
+ * rig-c/pose#288, minor 1.1: the pelvis line on this frame, read off the picture —
111
+ * Spine degrees (counter-clockwise, y up) of the direction from the figure's
112
+ * right hip to its left hip, the angle `pictureAngle(r_hip, l_hip)` gives the
113
+ * hip keypoints (about 0 for a figure facing the camera, about ±180 facing
114
+ * away); null where the reading declined the frame. Present on every frame of
115
+ * a sequence with a `pelvisSource`, on none of one without.
116
+ */
117
+ pelvisTilt?: number | null;
118
+ /**
119
+ * rig-c/pose#281, minor 1.2: each limb segment's depth on this frame (a segment
120
+ * whose joints are not both placed on the frame is not named). Present on every
121
+ * frame of a sequence with depth, on none of one without.
122
+ */
123
+ depth?: FrameDepth;
47
124
  }
48
125
  /**
49
126
  * The picture's camera on one frame against frame 0's (rig-c/pose#63), as
@@ -175,6 +252,14 @@ export interface CleanUnheld {
175
252
  test: 'speed' | 'reach' | 'short' | 'span';
176
253
  value: number | null;
177
254
  }
255
+ /** rig-c/pose#288: clean smoothed the sequence's `pelvisTilt` as its own track, over the legs' window (`loop` as the record's). */
256
+ export interface PelvisParams {
257
+ window: number;
258
+ }
259
+ /** rig-c/pose#281: clean derived each frame's `depth` (`source: "derived"`) for this gait. */
260
+ export interface DepthParams {
261
+ gait: DepthGait;
262
+ }
178
263
  export interface CleanRecord {
179
264
  spec: typeof CLEAN_SPEC;
180
265
  /**
@@ -182,7 +267,7 @@ export interface CleanRecord {
182
267
  * `hold` (#91): the stillness hold's figures, or "off"; absent in a record
183
268
  * written before #91.
184
269
  */
185
- params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
270
+ params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off'; pelvis?: PelvisParams; depth?: DepthParams };
186
271
  /**
187
272
  * `found`: every joint the spike step replaced, by frame (index into `frames`)
188
273
  * and joint. `untested`: the frames the spike step did not test, because a
@@ -215,6 +300,8 @@ export interface PoseSequence {
215
300
  * by hand. Absent: the figure's travel in the picture is its travel.
216
301
  */
217
302
  travel?: Travel;
303
+ /** rig-c/pose#288, minor 1.1: where every frame's `pelvisTilt` came from; present exactly when the frames carry it. */
304
+ pelvisSource?: PelvisSource;
218
305
  frames: PoseFrame[];
219
306
  }
220
307
 
@@ -246,9 +333,22 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
246
333
  throw new Error('unreachable');
247
334
  }
248
335
  const frames0 = doc.spec === POSE_SPEC_FRAMES0;
249
- refuseUnknownKeys(p, 'POSE_KEYS', where, doc, frames0 ? [...TOP_KEYS, ...OPTIONAL_KEYS, 'fps'] : [...TOP_KEYS, ...OPTIONAL_KEYS]);
336
+ // rig-c/pose#288: the minor the file says it was written as; a newer one is read, and what it adds is refused naming it.
337
+ const minor = frames0 ? 0 : minorOf(doc.spec);
338
+ const note = minor !== null && minor > POSE_MINOR ? `; the file says it was written as ${JSON.stringify(doc.spec)}, a newer minor than this reader's "${POSE_SPEC_DEPTH}" — read it with a rig-pose that reads that minor` : '';
339
+ const pelvisKnown = minor !== null && minor >= 1;
340
+ // rig-c/pose#281: depth is minor 2's.
341
+ const depthKnown = minor !== null && minor >= 2;
342
+ /** A key of a later minor in a file that says an earlier one: one line naming the minor, not an unknown key. */
343
+ const minorKey = (key: string, on: string) => (key === 'depth'
344
+ ? p.add('POSE_SPEC', `${on} carries "depth", a key of "${POSE_SPEC_DEPTH}", and the file says ${JSON.stringify(doc.spec)}; a sequence carrying depth is written as "${POSE_SPEC_DEPTH}" (rig-pose clean --depth)`)
345
+ : p.add('POSE_SPEC', `${on} carries ${JSON.stringify(key)}, a key of "${POSE_SPEC_PELVIS}", and the file says ${JSON.stringify(doc.spec)}; a sequence carrying the pelvis track is written as "${POSE_SPEC_PELVIS}" (rig-pose pelvis)`));
346
+ const without = (o: Record<string, unknown>, key: string) => (pelvisKnown || !(key in o) ? o : Object.fromEntries(Object.entries(o).filter(([k]) => k !== key)));
347
+ const withoutDepth = (o: Record<string, unknown>) => (depthKnown || !('depth' in o) ? o : Object.fromEntries(Object.entries(o).filter(([k]) => k !== 'depth')));
348
+ if (!pelvisKnown && 'pelvisSource' in doc) minorKey('pelvisSource', where);
349
+ refuseUnknownKeys(p, 'POSE_KEYS', where, without(doc, 'pelvisSource'), frames0 ? [...TOP_KEYS, ...OPTIONAL_KEYS, 'fps'] : [...TOP_KEYS, ...OPTIONAL_KEYS, ...(pelvisKnown ? ['pelvisSource'] : [])], note);
250
350
  for (const k of TOP_KEYS) if (!(k in doc)) p.add('POSE_KEYS', `${where} has no "${k}"; a ${POSE_SPEC} document carries ${TOP_KEYS.join(', ')}`);
251
- if ('spec' in doc && doc.spec !== POSE_SPEC && !frames0) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}" (and the older name "${POSE_SPEC_FRAMES0}")`);
351
+ if ('spec' in doc && minor === null) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}" and its minors ("${POSE_SPEC_PELVIS}", "${POSE_SPEC_DEPTH}"; a newer minor is read, and a key it adds is refused naming it), and the older name "${POSE_SPEC_FRAMES0}"`);
252
352
  const space = frames0 ? FRAMES0_SPACE : POSE_SPACE;
253
353
  if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(space)) {
254
354
  p.add('POSE_SPACE', `${where} "space" is ${JSON.stringify(doc.space)}; for "${frames0 ? POSE_SPEC_FRAMES0 : POSE_SPEC}" it must be exactly ${JSON.stringify(space)}`);
@@ -264,13 +364,24 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
264
364
  p.add('POSE_FRAMES', `${where} "frames" is ${typeName(doc.frames)}; it must be a non-empty array`);
265
365
  } else {
266
366
  let lastT = -Infinity;
367
+ const minorFrames: number[] = [];
368
+ const depthFrames: number[] = [];
267
369
  doc.frames.forEach((f: unknown, i: number) => {
268
370
  const fw = `${where} frames[${i}]`;
269
371
  if (!isObject(f)) {
270
372
  p.add('POSE_FRAME_SHAPE', `${fw} is ${typeName(f)}; a frame is { t, people }`);
271
373
  return;
272
374
  }
273
- refuseUnknownKeys(p, 'POSE_KEYS', fw, f, ['t', 'people']);
375
+ if (!pelvisKnown && 'pelvisTilt' in f) minorFrames.push(i);
376
+ if (!depthKnown && 'depth' in f) depthFrames.push(i);
377
+ refuseUnknownKeys(p, 'POSE_KEYS', fw, withoutDepth(without(f, 'pelvisTilt')), [...(pelvisKnown ? ['t', 'people', 'pelvisTilt'] : ['t', 'people']), ...(depthKnown ? ['depth'] : [])], note);
378
+ const depth = depthKnown && 'depth' in f ? readDepth(p, fw, f.depth) : undefined;
379
+ let pelvisTilt: number | null | undefined;
380
+ if (pelvisKnown && 'pelvisTilt' in f) {
381
+ const v = f.pelvisTilt;
382
+ if (v === null || (typeof v === 'number' && Number.isFinite(v) && v > -180 && v <= 180)) pelvisTilt = v;
383
+ else p.add('POSE_PELVIS', `${fw} "pelvisTilt" is ${typeName(v)}; it must be Spine degrees in (-180, 180] (the direction r_hip → l_hip, counter-clockwise, y up), or null where the reading declined the frame`);
384
+ }
274
385
  const t = f.t;
275
386
  if (typeof t !== 'number' || !Number.isFinite(t) || t < 0) {
276
387
  p.add('POSE_TIME', `${fw} "t" is ${typeName(t)}; it must be a finite number of seconds ≥ 0`);
@@ -289,34 +400,133 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
289
400
  p.add('POSE_PERSON_SHAPE', `${pw} is ${typeName(person)}; a person is { id, joints }`);
290
401
  return;
291
402
  }
292
- refuseUnknownKeys(p, 'POSE_KEYS', pw, person, ['id', 'joints']);
403
+ refuseUnknownKeys(p, 'POSE_KEYS', pw, person, ['id', 'joints'], note);
293
404
  if (typeof person.id !== 'string' || person.id === '') {
294
405
  p.add('POSE_PERSON_ID', `${pw} "id" is ${typeName(person.id)}; it must be a non-empty string`);
295
406
  return;
296
407
  }
297
408
  if (ids.has(person.id)) p.add('POSE_PERSON_ID', `${pw} "id" "${person.id}" appears twice in frame ${i}`);
298
409
  ids.add(person.id);
299
- people.push({ id: person.id, joints: readJoints(p, pw, person.joints) });
410
+ people.push({ id: person.id, joints: readJoints(p, pw, person.joints, note) });
300
411
  });
301
412
  }
302
- frames.push({ t: typeof t === 'number' ? t : NaN, people });
413
+ frames.push({ t: typeof t === 'number' ? t : NaN, people, ...(pelvisTilt !== undefined ? { pelvisTilt } : {}), ...(depth !== undefined ? { depth } : {}) });
303
414
  });
415
+ const framesText = (fs: number[]) => `${where} ${fs.length === 1 ? `frames[${fs[0]}]` : `${fs.length} frames (${fs[0]}–${fs[fs.length - 1]})`}`;
416
+ if (minorFrames.length > 0) minorKey('pelvisTilt', framesText(minorFrames));
417
+ if (depthFrames.length > 0) minorKey('depth', framesText(depthFrames));
418
+ }
419
+ }
420
+ let pelvisSource: PelvisSource | undefined;
421
+ if (pelvisKnown && 'pelvisSource' in doc) {
422
+ if (typeof doc.pelvisSource === 'string' && (PELVIS_SOURCES as readonly string[]).includes(doc.pelvisSource)) pelvisSource = doc.pelvisSource as PelvisSource;
423
+ else p.add('POSE_PELVIS', `${where} "pelvisSource" is ${typeName(doc.pelvisSource)}; it must be one of ${PELVIS_SOURCES.map((x) => `"${x}"`).join(', ')} (where every frame's "pelvisTilt" came from)`);
424
+ }
425
+ if (pelvisKnown && Array.isArray(doc.frames)) {
426
+ const carry = doc.frames.map((f: unknown) => isObject(f) && 'pelvisTilt' in f);
427
+ const lacking = carry.flatMap((c: boolean, i: number) => (c ? [] : [i]));
428
+ if ('pelvisSource' in doc && lacking.length > 0) p.add('POSE_PELVIS', `${where} has "pelvisSource" and frame(s) ${spansText(lacking)} carry no "pelvisTilt"; with a pelvis track every frame carries one (null where the reading declined it)`);
429
+ if (!('pelvisSource' in doc) && lacking.length < carry.length) p.add('POSE_PELVIS', `${where} has "pelvisTilt" on ${carry.length - lacking.length} frame(s) and no "pelvisSource"; a pelvis track says where it came from (${PELVIS_SOURCES.join(', ')})`);
430
+ if ('pelvisSource' in doc) {
431
+ const ids = [...new Set(frames.flatMap((f) => f.people.map((q) => q.id)))].sort();
432
+ if (ids.length > 1) p.add('POSE_PELVIS', `${where} carries a pelvis track and ${ids.length} people (${ids.join(', ')}); the track is one figure's, so the sequence holds one person (rig-pose pelvis --person writes only that one)`);
433
+ }
434
+ }
435
+ if (depthKnown && Array.isArray(doc.frames)) {
436
+ const carry = doc.frames.map((f: unknown) => isObject(f) && 'depth' in f);
437
+ const lacking = carry.flatMap((c: boolean, i: number) => (c ? [] : [i]));
438
+ if (lacking.length > 0 && lacking.length < carry.length) p.add('POSE_DEPTH', `${where} has "depth" on ${carry.length - lacking.length} frame(s) and none on frame(s) ${spansText(lacking)}; a sequence with depth carries it on every frame (an empty object where no segment is placed)`);
439
+ if (lacking.length < carry.length) {
440
+ const ids = [...new Set(frames.flatMap((f) => f.people.map((q) => q.id)))].sort();
441
+ if (ids.length > 1) p.add('POSE_DEPTH', `${where} carries depth and ${ids.length} people (${ids.join(', ')}); the depth is one figure's, so the sequence holds one person (rig-pose clean writes only the one it cleaned)`);
304
442
  }
305
443
  }
306
- const camera = 'camera' in doc ? readCamera(p, where, doc.camera, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
444
+ const camera = 'camera' in doc ? readCamera(p, where, doc.camera, Array.isArray(doc.frames) ? doc.frames.length : null, note) : undefined;
307
445
  if ('cameraSource' in doc) {
308
446
  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")`);
309
447
  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`);
310
448
  }
311
449
  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`);
312
- const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
450
+ const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null, pelvisKnown, note, depthKnown) : undefined;
451
+ if (depthKnown) {
452
+ // rig-c/pose#281: "derived" depth is clean's, and clean records the gait it read it for; "measured" is a 3D detector's.
453
+ const sources = new Set(frames.flatMap((f) => Object.values(f.depth ?? {}).map((d) => d.source)));
454
+ const recorded = isObject(doc.cleaned) && isObject(doc.cleaned.params) && 'depth' in doc.cleaned.params;
455
+ if (sources.size > 1) p.add('POSE_DEPTH', `${where} carries "derived" and "measured" depth together; a sequence's depth comes from one source — clean --depth derives it, a 3D detector measures it`);
456
+ else if (sources.has('derived') && !recorded) p.add('POSE_DEPTH', `${where} carries "derived" depth and ${'cleaned' in doc ? 'its "cleaned" record has no "params" "depth"' : 'no "cleaned" record'}; derived depth is written by rig-pose clean --depth walk|run, which records the gait it was read for`);
457
+ if (recorded && cleaned !== undefined && frames.some((f) => f.depth === undefined || Object.values(f.depth).some((d) => d.source !== 'derived'))) p.add('POSE_DEPTH', `${where} "cleaned" "params" has "depth" and the frames do not all carry derived depth; the record says clean derived it on every frame`);
458
+ }
459
+ if (cleaned !== undefined && pelvisKnown) {
460
+ const hasPelvis = isObject(doc.cleaned) && isObject(doc.cleaned.params) && 'pelvis' in doc.cleaned.params;
461
+ if ('pelvisSource' in doc && !hasPelvis) p.add('POSE_PELVIS', `${where} carries a pelvis track and its "cleaned" record has no "params" "pelvis"; clean smooths the track and records it, so the track was added after clean — run rig-pose pelvis before rig-pose clean`);
462
+ if (!('pelvisSource' in doc) && hasPelvis) p.add('POSE_PELVIS', `${where} "cleaned" "params" has "pelvis" and the sequence no "pelvisSource"; the record says clean smoothed a pelvis track the frames do not carry`);
463
+ }
313
464
  p.throwIfAny();
314
465
  const cameraSource = typeof doc.cameraSource === 'string' ? doc.cameraSource : undefined;
315
- 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 };
466
+ 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 } : {}), ...(pelvisSource !== undefined ? { pelvisSource } : {}), frames };
467
+ }
468
+
469
+ /** rig-c/pose#281: a frame's `depth` — `{ "<segment>": { ratio, sign, source } }`, every part refused by name. */
470
+ function readDepth(p: Problems, fw: string, d: unknown): FrameDepth | undefined {
471
+ const dw = `${fw} "depth"`;
472
+ if (!isObject(d)) {
473
+ p.add('POSE_DEPTH', `${dw} is ${typeName(d)}; it must be an object keyed by limb segment (${DEPTH_SEGMENT_NAMES.join(', ')}), each { ratio, sign, source }`);
474
+ return undefined;
475
+ }
476
+ const out: FrameDepth = {};
477
+ for (const [name, v] of Object.entries(d)) {
478
+ const sw = `${dw} "${name}"`;
479
+ if (!(DEPTH_SEGMENT_NAMES as readonly string[]).includes(name)) {
480
+ p.add('POSE_DEPTH', `${dw} names ${JSON.stringify(name)}; the segments are ${DEPTH_SEGMENT_NAMES.join(', ')}`);
481
+ continue;
482
+ }
483
+ if (!isObject(v)) {
484
+ p.add('POSE_DEPTH', `${sw} is ${typeName(v)}; it must be { ratio, sign, source }`);
485
+ continue;
486
+ }
487
+ refuseUnknownKeys(p, 'POSE_DEPTH', sw, v, ['ratio', 'sign', 'source']);
488
+ let ok = true;
489
+ for (const k of ['ratio', 'sign', 'source']) if (!(k in v)) {
490
+ p.add('POSE_DEPTH', `${sw} has no "${k}"; a segment's depth is { ratio, sign, source }`);
491
+ ok = false;
492
+ }
493
+ if ('ratio' in v && !(typeof v.ratio === 'number' && Number.isFinite(v.ratio) && v.ratio >= 0)) {
494
+ p.add('POSE_DEPTH', `${sw} "ratio" is ${typeName(v.ratio)}; it must be a finite number ≥ 0 (the segment's drawn length ÷ its rest length)`);
495
+ ok = false;
496
+ }
497
+ if ('sign' in v && v.sign !== -1 && v.sign !== 0 && v.sign !== 1) {
498
+ p.add('POSE_DEPTH', `${sw} "sign" is ${typeName(v.sign)}; it must be 1 (the distal joint nearer the camera), -1 (farther) or 0 (flat, or not decided)`);
499
+ ok = false;
500
+ }
501
+ if ('source' in v && !(typeof v.source === 'string' && (DEPTH_SOURCES as readonly string[]).includes(v.source))) {
502
+ p.add('POSE_DEPTH', `${sw} "source" is ${typeName(v.source)}; it must be "derived" (clean --depth, from the picture's lengths) or "measured" (a 3D detector)`);
503
+ ok = false;
504
+ }
505
+ if (ok) out[name as DepthSegment] = { ratio: v.ratio as number, sign: v.sign as -1 | 0 | 1, source: v.source as DepthSource };
506
+ }
507
+ return out;
508
+ }
509
+
510
+ /** The minor a sequence's spec names: 0 for "rig-pose-sequence/1", N for "rig-pose-sequence/1.N"; null for anything else. */
511
+ function minorOf(spec: unknown): number | null {
512
+ if (spec === POSE_SPEC) return 0;
513
+ const m = typeof spec === 'string' ? /^rig-pose-sequence\/1\.([1-9]\d{0,5})$/.exec(spec) : null;
514
+ return m === null ? null : Number(m[1]);
515
+ }
516
+
517
+ /** Frame indices as runs: "3, 5–9". */
518
+ function spansText(fs: readonly number[]): string {
519
+ const out: Array<[number, number]> = [];
520
+ for (const f of fs) {
521
+ const last = out[out.length - 1];
522
+ if (last !== undefined && f === last[1] + 1) last[1] = f;
523
+ else out.push([f, f]);
524
+ }
525
+ return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
316
526
  }
317
527
 
318
528
  /** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
319
- function readCamera(p: Problems, where: string, camera: unknown, frames: number | null): CameraFrame[] {
529
+ function readCamera(p: Problems, where: string, camera: unknown, frames: number | null, note: string): CameraFrame[] {
320
530
  const out: CameraFrame[] = [];
321
531
  if (!Array.isArray(camera)) {
322
532
  p.add('POSE_CAMERA', `${where} "camera" is ${typeName(camera)}; it must be an array with one { s, tx, ty, inliers } per frame`);
@@ -329,7 +539,7 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
329
539
  p.add('POSE_CAMERA', `${cw} is ${typeName(c)}; a camera entry is { s, tx, ty, inliers }`);
330
540
  return;
331
541
  }
332
- refuseUnknownKeys(p, 'POSE_CAMERA', cw, c, CAMERA_KEYS);
542
+ refuseUnknownKeys(p, 'POSE_CAMERA', cw, c, CAMERA_KEYS, note);
333
543
  let ok = true;
334
544
  for (const k of CAMERA_KEYS) if (!(k in c)) {
335
545
  p.add('POSE_CAMERA', `${cw} has no "${k}"; a camera entry carries ${CAMERA_KEYS.join(', ')}`);
@@ -354,7 +564,8 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
354
564
  }
355
565
 
356
566
  /** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }. */
357
- function readCleaned(p: Problems, where: string, c: unknown, frames: number | null): CleanRecord | undefined {
567
+ function readCleaned(p: Problems, where: string, c: unknown, frames: number | null, pelvisKnown: boolean, note: string, depthKnown: boolean): CleanRecord | undefined {
568
+ const refuseUnknownKeys = (q: Problems, code: string, w: string, o: Record<string, unknown>, known: readonly string[]) => refuseKeys(q, code, w, o, known, note);
358
569
  const cw = `${where} "cleaned"`;
359
570
  const shape = '{ spec, params: { loop, legs, rest, hipsInferred?, hold? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after }, holds?, unheld?, seams? }';
360
571
  if (!isObject(c)) {
@@ -370,7 +581,25 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
370
581
  const q = c.params;
371
582
  if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred?, hold? }`);
372
583
  else {
373
- refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred', 'hold']);
584
+ refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred', 'hold', ...(pelvisKnown ? ['pelvis'] : []), ...(depthKnown ? ['depth'] : [])]);
585
+ if (depthKnown && 'depth' in q) {
586
+ const v = q.depth;
587
+ const vw = `${cw} "params" "depth"`;
588
+ if (!isObject(v)) p.add('POSE_CLEANED', `${vw} is ${typeName(v)}; it must be { gait } (rig-c/pose#281: the gait clean --depth read the depth for)`);
589
+ else {
590
+ refuseUnknownKeys(p, 'POSE_CLEANED', vw, v, ['gait']);
591
+ if (!(typeof v.gait === 'string' && (DEPTH_GAITS as readonly string[]).includes(v.gait))) p.add('POSE_CLEANED', `${vw} "gait" is ${typeName(v.gait)}; it must be ${DEPTH_GAITS.map((g) => `"${g}"`).join(' or ')}`);
592
+ }
593
+ }
594
+ if (pelvisKnown && 'pelvis' in q) {
595
+ const v = q.pelvis;
596
+ const vw = `${cw} "params" "pelvis"`;
597
+ if (!isObject(v)) p.add('POSE_CLEANED', `${vw} is ${typeName(v)}; it must be { window } (rig-c/pose#288: the window clean smoothed the pelvis track over)`);
598
+ else {
599
+ refuseUnknownKeys(p, 'POSE_CLEANED', vw, v, ['window']);
600
+ if (!window(v.window)) p.add('POSE_CLEANED', `${vw} "window" is ${typeName(v.window)}; it must be 1 (none) or an odd whole number ≥ 5`);
601
+ }
602
+ }
374
603
  if ('hold' in q) {
375
604
  const h = q.hold;
376
605
  const hw = `${cw} "params" "hold"`;
@@ -384,7 +613,7 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
384
613
  for (const k of ['holds', 'unheld', 'seams']) if (!(k in c)) p.add('POSE_CLEANED', `${cw} has "params" "hold" and no "${k}"; a record whose hold ran lists its holds, the joints it did not hold and its seams (an empty array when none)`);
385
614
  }
386
615
  } else for (const k of ['holds', 'unheld', 'seams']) if (k in c) p.add('POSE_CLEANED', `${cw} has "${k}" and no "params" "hold"; the holds' figures are recorded in "params" "hold"`);
387
- if ('hipsInferred' in q) readHipsInferred(p, `${cw} "params" "hipsInferred"`, q.hipsInferred, frames);
616
+ if ('hipsInferred' in q) readHipsInferred(p, `${cw} "params" "hipsInferred"`, q.hipsInferred, frames, note);
388
617
  if (typeof q.loop !== 'boolean') p.add('POSE_CLEANED', `${cw} "params" "loop" is ${typeName(q.loop)}; it must be true or false`);
389
618
  for (const k of ['legs', 'rest'] as const) if (!window(q[k])) p.add('POSE_CLEANED', `${cw} "params" "${k}" is ${typeName(q[k])}; it must be 1 (none) or an odd whole number ≥ 5`);
390
619
  }
@@ -490,11 +719,11 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
490
719
  });
491
720
  }
492
721
  if (p.list.length > before) return undefined;
493
- const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off' };
722
+ const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred; hold?: HoldParams | 'off'; pelvis?: PelvisParams; depth?: DepthParams };
494
723
  const j = c.jitter as { before: number | null; after: number | null };
495
724
  return {
496
725
  spec: CLEAN_SPEC,
497
- params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(q.hipsInferred) } : {}), ...(q.hold !== undefined ? { hold: holdParamsOf(q.hold) } : {}) },
726
+ params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(q.hipsInferred) } : {}), ...(q.hold !== undefined ? { hold: holdParamsOf(q.hold) } : {}), ...(pelvisKnown && q.pelvis !== undefined ? { pelvis: { window: q.pelvis.window } } : {}), ...(depthKnown && q.depth !== undefined ? { depth: { gait: q.depth.gait } } : {}) },
498
727
  spikes: {
499
728
  found: ((c.spikes as { found: Array<{ frame: number; joint: Body18 }> }).found).map((s) => ({ frame: s.frame, joint: s.joint })),
500
729
  untested: [...(c.spikes as { untested: number[] }).untested],
@@ -507,7 +736,8 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
507
736
  }
508
737
 
509
738
  /** `hipsInferred`: { by: "declared" } or the measured record (the shape at `HipsInferred`), refused by name otherwise. */
510
- function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number | null): void {
739
+ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number | null, note: string): void {
740
+ const refuseUnknownKeys = (q: Problems, code: string, w: string, o: Record<string, unknown>, known: readonly string[]) => refuseKeys(q, code, w, o, known, note);
511
741
  const measuredShape = '{ by: "measured", method, read, interpolated, held: [[first, last]], declined: [{ frames: [first, last], why }] }';
512
742
  if (!isObject(h)) {
513
743
  p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; it must be { by: "declared" } or ${measuredShape}`);
@@ -652,7 +882,7 @@ const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off'
652
882
  const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], ...(h.excursion !== undefined ? { excursion: [h.excursion[0], h.excursion[1]] as [number, number] } : {}), jitter: { before: h.jitter.before, after: h.jitter.after } });
653
883
  const copyUnheld = (u: CleanUnheld): CleanUnheld => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value });
654
884
 
655
- function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
885
+ function readJoints(p: Problems, pw: string, joints: unknown, note: string): Partial<Record<Body18, Joint>> {
656
886
  const out: Partial<Record<Body18, Joint>> = {};
657
887
  if (!isObject(joints)) {
658
888
  p.add('POSE_JOINTS', `${pw} "joints" is ${typeName(joints)}; it must be an object keyed by body-18 joint names`);
@@ -668,7 +898,7 @@ function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Bo
668
898
  p.add('POSE_JOINT_SHAPE', `${jw} is ${typeName(j)}; a joint is { state, at?, score? }`);
669
899
  continue;
670
900
  }
671
- refuseUnknownKeys(p, 'POSE_KEYS', jw, j, ['state', 'at', 'score']);
901
+ refuseUnknownKeys(p, 'POSE_KEYS', jw, j, ['state', 'at', 'score'], note);
672
902
  const state = j.state;
673
903
  if (typeof state !== 'string' || !(STATES as readonly string[]).includes(state)) {
674
904
  p.add('POSE_JOINT_STATE', `${jw} "state" is ${typeName(state)}; it must be one of ${STATES.join(', ')}`);
@@ -821,6 +1051,9 @@ export function writePoseSequence(seq: PoseSequence): string {
821
1051
  const round = (v: number, d: number) => Number(v.toFixed(d));
822
1052
  const frames = seq.frames.map((f) => ({
823
1053
  t: f.t,
1054
+ ...(f.pelvisTilt !== undefined ? { pelvisTilt: f.pelvisTilt === null ? null : round(f.pelvisTilt, 3) } : {}),
1055
+ // rig-c/pose#281: the segments in DEPTH_SEGMENTS' order, each { ratio, sign, source }.
1056
+ ...(f.depth !== undefined ? { depth: Object.fromEntries(DEPTH_SEGMENT_NAMES.filter((k) => f.depth![k] !== undefined).map((k) => [k, { ratio: round(f.depth![k]!.ratio, 3), sign: f.depth![k]!.sign, source: f.depth![k]!.source }])) } : {}),
824
1057
  people: f.people.map((q) => ({
825
1058
  id: q.id,
826
1059
  joints: Object.fromEntries(
@@ -840,12 +1073,14 @@ export function writePoseSequence(seq: PoseSequence): string {
840
1073
  const jit = (v: number | null) => (v === null ? null : round(v, 4));
841
1074
  const cleaned = c === undefined ? undefined : {
842
1075
  spec: c.spec,
843
- params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(c.params.hipsInferred) } : {}), ...(c.params.hold !== undefined ? { hold: holdParamsOf(c.params.hold) } : {}) },
1076
+ params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(c.params.hipsInferred) } : {}), ...(c.params.hold !== undefined ? { hold: holdParamsOf(c.params.hold) } : {}), ...(c.params.pelvis !== undefined ? { pelvis: { window: c.params.pelvis.window } } : {}), ...(c.params.depth !== undefined ? { depth: { gait: c.params.depth.gait } } : {}) },
844
1077
  spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] },
845
1078
  jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) },
846
1079
  ...(c.holds !== undefined ? { holds: c.holds.map((h) => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], ...(h.excursion !== undefined ? { excursion: [h.excursion[0], h.excursion[1]] } : {}), jitter: { before: jit(h.jitter.before), after: jit(h.jitter.after) } })) } : {}),
847
1080
  ...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
848
1081
  ...(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) })) } : {}),
849
1082
  };
850
- 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`;
1083
+ // rig-c/pose#288, #281: the minor the content needs — /1.2 with depth (it includes 1.1's keys), /1.1 with a pelvis track, /1 otherwise.
1084
+ const spec = seq.frames.some((f) => f.depth !== undefined) ? POSE_SPEC_DEPTH : seq.pelvisSource !== undefined ? POSE_SPEC_PELVIS : seq.spec;
1085
+ return `${JSON.stringify({ 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 } : {}), ...(seq.pelvisSource !== undefined ? { pelvisSource: seq.pelvisSource } : {}), frames }, null, 2)}\n`;
851
1086
  }
package/src/problems.ts CHANGED
@@ -59,10 +59,13 @@ export function isObject(v: unknown): v is Record<string, unknown> {
59
59
  return typeof v === 'object' && v !== null && !Array.isArray(v);
60
60
  }
61
61
 
62
- /** Refuse the keys of `obj` a reader does not know, by name, listing the ones it does. */
63
- export function refuseUnknownKeys(problems: Problems, code: string, where: string, obj: Record<string, unknown>, known: readonly string[]): void {
62
+ /**
63
+ * Refuse the keys of `obj` a reader does not know, by name, listing the ones it does. `note`, when
64
+ * given, ends each line: a sequence written by a newer minor of its format says which (rig-c/pose#288).
65
+ */
66
+ export function refuseUnknownKeys(problems: Problems, code: string, where: string, obj: Record<string, unknown>, known: readonly string[], note = ''): void {
64
67
  for (const k of Object.keys(obj)) {
65
- if (!known.includes(k)) problems.add(code, `${where} has a key this reader does not know: ${JSON.stringify(k)}; the keys it reads are ${known.join(', ')}`);
68
+ if (!known.includes(k)) problems.add(code, `${where} has a key this reader does not know: ${JSON.stringify(k)}; the keys it reads are ${known.join(', ')}${note}`);
66
69
  }
67
70
  }
68
71