@rig-c/pose 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ The map names **chains**: each limb as bones that descend from one another, pare
25
25
  ## Clean first: `rig-pose clean`
26
26
 
27
27
  ```sh
28
- rig-pose clean --pose dance.scan.json --out dance.pose.json [--loop] [--legs 5] [--rest 7]
28
+ rig-pose clean --pose dance.scan.json --out dance.pose.json [--loop] [--legs 5] [--rest 7] [--hips-inferred]
29
29
  ```
30
30
 
31
31
  `retarget` reads only a sequence that `clean` wrote ([#67](https://github.com/rig-c/pose/issues/67)). A pose model reads each frame alone, so a hanging wrist moves ±20 px a frame and a cloak-hidden leg jumps 100+ px, and every key carries that shake. `clean` takes it out once, at the sequence, where it can be inspected. A sequence without the record is refused by name (`POSE_NOT_CLEANED`), and the message is the `clean` command to run on that file. There is no flag to skip the step. Windows of 1 (`--legs 1 --rest 1`) are a legitimate run that smooths nothing, and they are recorded as such.
@@ -33,7 +33,7 @@ rig-pose clean --pose dance.scan.json --out dance.pose.json [--loop] [--legs 5]
33
33
  The steps, in this order, each printed with its figures:
34
34
 
35
35
  1. **Spikes.** A joint more than 0.2 trunk lengths from its 5-frame median takes the median. The trunk is neck → mid-hip, the median over the clip. Each replacement is listed by frame and joint (`SPIKE`). Without `--loop`, a clip end cuts the window of the first and last two frames. There the median is a neighbour's value, and a joint moving steadily would read as a spike. Those frames are **not tested**, and the `SPIKES` line and the record name them. Reflecting the window does not help: the median of `[x2, x1, x0, x1, x2]` is still `x1`.
36
- 2. **Occlusion.** A joint whose state is not `observed`, or whose score is under 0.3, takes its 3-frame median (`OCCLUDED`).
36
+ 2. **Occlusion.** A joint whose state is not `observed` (or `inferred`), or whose score is under 0.3, takes its 3-frame median (`OCCLUDED`).
37
37
  3. **Smoothing.** A quadratic Savitzky–Golay per joint: the legs (both hips, knees and ankles) over `--legs` frames (default 5), every other joint over `--rest` (default 7). A window is 1 (none) or odd and at least 5. 3 is refused because a quadratic through 3 points passes through all three. Without `--loop`, the window is cut at the ends. Where fewer than three frames have a position, the degree drops to fit the ones that do.
38
38
  4. **Floor.** Only with `--loop`: the whole figure moves in y by one constant, so that the lowest ankle over the cycle is where it was before smoothing.
39
39
 
@@ -50,6 +50,8 @@ The output carries the record, here the dancer's from the table below:
50
50
 
51
51
  `jitter` is the mean |second difference| of y, in px/f², over every joint and every three consecutive frames that have a position, wrapping with `--loop`. It is measured on the sequence as read and on the cleaned positions. `retarget` prints the record as its `CLEAN` line and copies it into `--report`.
52
52
 
53
+ **`--hips-inferred`** ([#63](https://github.com/rig-c/pose/issues/63)) declares that the hips are not seen: under a skirt or a cloak the pose model still places them, but where it puts them is its guess. After the four steps, `r_hip` and `l_hip` are marked `inferred` on every frame where they have a position (`INFERRED`), and the record's params carry `"hipsInferred": { "by": "declared" }` — the person running `clean` said so. Positions and scores are unchanged, so `retarget`'s keys are the same with the flag and without it. What changes is the `POSTURE` line, which does not read a hip tilt off inferred hips (below), and the `CLEAN` line, which ends `hips inferred (declared at clean)`.
54
+
53
55
  **Measured** on HQ's references ([#67](https://github.com/rig-c/pose/issues/67)'s bars):
54
56
 
55
57
  | Clip | Run | Jitter, px/f² | Against |
@@ -89,7 +91,7 @@ and what the run cannot decide for you it lists:
89
91
  - `FLIP` — a segment that turns more than `--flip` degrees in one frame (with `--flip <deg>`; off by default) is spread over the frames around the jump; one line per spread, with the chain, the segment, the jump, the frames and h. Below.
90
92
  - `BEND` — with `--bends clamp`, a hinge bent the wrong way is straightened on that frame instead of refusing.
91
93
  - `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
92
- - `POSTURE` — when the map has a pelvis chain (joints `r_hip`, `l_hip`) and a `translate`: the hip line's tilt range, and the sway — the x-range of mid_hip − mid_shoulder — in rig units and in shoulder widths, the pose's beside the posed rig's. A reading, not a gate.
94
+ - `POSTURE` — when the map has a pelvis chain (joints `r_hip`, `l_hip`) and a `translate`: the hip line's tilt range, and the sway — the x-range of mid_hip − mid_shoulder — in rig units and in shoulder widths, the pose's beside the posed rig's. A reading, not a gate. A frame on which either hip is `inferred` is left out of the tilt. When that is every frame, as after `clean --hips-inferred`, the line is `hip tilt not read: hips inferred (declared at clean)` and the report's `posture` has `"tilt": null` with `"tiltWhy"`; on some frames only, the tilt is read over the others and the line counts the frames left out (`posture.hipsInferred`). The source is read from the clean record, or is `source not stated` when the joints carry the state and the record does not. The sway reads the hips as before.
93
95
  - `SWITCH` — with `alternates` in the map ([below](#alternate-drawings-alternates)): each span on which a set of alternate drawings shows, its slots, its frames, how far the pose was from the set's key skeleton, how far each bone with a `drawn` angle was turned from it, and where each of its boundaries was moved to (`moved`, [below](#alternate-drawings-alternates)).
94
96
  - `UNCOVERED` — with `alternates` in the map: every fold on a frame where no switch took the folding mesh off screen. This is the list of poses still to paint.
95
97
  - `RANGE` — a chain's `range` ([below](#a-joints-range-range)): a bone whose rotate key the pose asks past its range, outside every span of a set depicting its chain, is clamped at the limit; one line per run, with the frames, the seconds and the largest overflow. The demand for an alternate.
@@ -182,7 +184,7 @@ A rig's motion spec usually has an `idle` — a blink, a breath, hair and hem sw
182
184
 
183
185
  Every bone still has to land with the carried tracks in. A carried translate above the translated bone moves its origin off the pose's travel, and the run refuses with `POSE_LANDS`, naming the carried tracks above the bone that missed.
184
186
 
185
- On HQ's dancer (`--flip 90 --short 0.35 --bends clamp --carry-idle`) the idle has 26 tracks. Kept out: the four arm rotates, which the dance keys, and `chest_ctl` scalex, `neck` rotate and `head_ctl` rotate, which sit above the mapped chest, head and arms — carried, they missed the chest, the head and the arms by 0.16°–1.37°. Carried: the other 19, the blink (`group "eyes"` scaley) and the breath (`bone "chest_ctl"` translatey) among them. The gate is green, every bone lands (worst 0.00167°), and rig-c's poser shows the blink at every cycle (scaleY 0.12 at 2.37 s, 5.70 s, 9.08 s and 12.41 s, against the idle's at t mod 3.333 s within 8.9e-6) and the breath's chest_ctl offset (5.02 at 0.833, 4.167, 7.5, 10.833 s; 0 at 2.5, 5.833, 9.167, 12.5 s; 1.035 at the cut, 13 s).
187
+ On HQ's dancer (`--flip 90 --short 0.35 --bends clamp --carry-idle`) the idle has 26 tracks. Kept out: the four arm rotates, which the dance keys, and a control bone's scalex above the chest, the neck's rotate and a head control's rotate, which sit above the mapped chest, head and arms — carried, they missed the chest, the head and the arms by 0.16°–1.37°. Carried: the other 19, the blink (an eyes group's scaley) and the breath (the chest control's translatey) among them. The gate is green, every bone lands (worst 0.00167°), and rig-c's poser shows the blink at every cycle (scaleY 0.12 at 2.37 s, 5.70 s, 9.08 s and 12.41 s, against the idle's at t mod 3.333 s within 8.9e-6) and the breath's offset (5.02 at 0.833, 4.167, 7.5, 10.833 s; 0 at 2.5, 5.833, 9.167, 12.5 s; 1.035 at the cut, 13 s).
186
188
 
187
189
  **`--merge <file>`** merges a timed track fragment into the new animation, at absolute times from its start — blinks read off the video, for example:
188
190
 
@@ -222,7 +224,7 @@ Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2
222
224
  ```
223
225
 
224
226
  - `t` is seconds and strictly increases; the animation's keys are at `t − t₀`.
225
- - Joints are body-18 (`nose neck r_shoulder r_elbow r_wrist l_shoulder l_elbow l_wrist r_hip r_knee r_ankle l_hip l_knee l_ankle r_eye l_eye r_ear l_ear`); `r`/`l` are the **subject's** sides. `state` is `observed`, `occluded` or `missing`, as in the keypoints file. An unlisted joint is missing.
227
+ - Joints are body-18 (`nose neck r_shoulder r_elbow r_wrist l_shoulder l_elbow l_wrist r_hip r_knee r_ankle l_hip l_knee l_ankle r_eye l_eye r_ear l_ear`); `r`/`l` are the **subject's** sides. `state` is `observed`, `occluded` or `missing`, as in the keypoints file, or `inferred` ([#63](https://github.com/rig-c/pose/issues/63)): a position the detector supplied but did not see. An `inferred` joint carries `at` (one without it is refused, `POSE_JOINT_AT`), and `retarget` keys it as it keys `observed`. An unlisted joint is missing.
226
228
  - Two differences from the keypoints file: the unit is `picture-px`, and `at` may lie outside the picture (a hand leaves the frame; the score says how much to believe it).
227
229
  - More than one person: `--person <id>`.
228
230
  - `score` is whatever the pose model gives. RTMPose's goes above 1, so `--min-score` is a cut on that model's scale, not a probability.
@@ -273,16 +275,18 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
273
275
 
274
276
  ```json
275
277
  "alternates": [
276
- { "name": "K1_hands_behind_head",
277
- "slots": { "upperarm_r_under": "K1_upperarm_r", "forearm_r_back": "K1_forearm_r", "topwear": "K1_topwear" },
278
+ { "name": "raised_arms",
279
+ "slots": { "upperarm_r_raised": "raised/upperarm_r", "forearm_r_raised": "raised/forearm_r", "torso": "raised/torso" },
278
280
  "hides": ["upperarm_r", "forearm_r"],
279
281
  "chains": ["arm_r", "arm_l"],
280
- "pose": { "r_shoulder": [450, 640], "r_elbow": [350, 500], "r_wrist": [510, 480],
281
- "l_shoulder": [610, 640], "l_elbow": [720, 500], "l_wrist": [560, 480] },
282
- "drawn": { "sleeve_r0": 117.8, "sleeve_r1": 30.4 },
282
+ "pose": { "r_shoulder": [400, 600], "r_elbow": [300, 450], "r_wrist": [450, 400],
283
+ "l_shoulder": [600, 600], "l_elbow": [700, 450], "l_wrist": [550, 400] },
284
+ "drawn": { "sleeve_r0": 120, "sleeve_r1": 30 },
283
285
  "offsets": { "sleeve_r1": [4, -2] } } ]
284
286
  ```
285
287
 
288
+ (The names, coordinates and angles above are illustrative.)
289
+
286
290
  - `slots`: what each slot of the set shows while it shows; each attachment must be in that slot under some skin of the rig, as rig-c built it. `hides`: the slots that are empty while it shows. A slot's place in the draw order is the rig's own, so a drawing that must sit in front of the torso is an attachment in a slot that is: a switch is attachment keys only, and no draw-order key is written.
287
291
  - `chains`: the map's chains whose pose the set depicts; it is selected by them. `pose`: the key skeleton the art was painted from, body-18 (or `mid_hip`/`mid_shoulder`) in picture px, y down, any scale and place — only its directions are read; every joint the listed chains use must be in it.
288
292
  - `drawn` (optional): each bone's world angle (Spine degrees) as painted. It changes no key: while a set shows, a bone is keyed to the pose as always, and the art turns with it. What it gives is a figure: the art turn, how far each bone is from the angle its art was painted at (`artTurn`, and `ART-RANGE` past the bone's range).
package/cli.ts CHANGED
@@ -34,6 +34,10 @@ clean (required before retarget; rig-c/pose#67)
34
34
  --legs <n> Savitzky–Golay window over the legs — both hips, knees and ankles (default: 5)
35
35
  --rest <n> Savitzky–Golay window over every other joint (default: 7)
36
36
  n is 1 (none), or odd and ≥ 5: a quadratic through 3 points passes through all three
37
+ --hips-inferred declare the hips inferred (rig-c/pose#63): r_hip and l_hip are marked "inferred" — a
38
+ position the detector supplied but did not see — on every frame where they have one, and
39
+ the record says so (hipsInferred: { by: "declared" }). Positions are unchanged, so retarget's
40
+ keys are too; its POSTURE line does not read the hip tilt ("hip tilt not read: hips inferred")
37
41
 
38
42
  In order, each step printed with its figures: (1) spikes — a joint more than 0.2 trunk lengths
39
43
  (neck → mid_hip, the median over the clip) from its 5-frame median takes the median, each listed by
@@ -346,10 +350,10 @@ function runsOf(frames: number[]): string {
346
350
 
347
351
  /** `rig-pose clean` (#67): read, clean, print each step's figures, write. */
348
352
  function runClean(argv: string[], say: (l: string) => void): void {
349
- const f = parseFlags(argv, ['pose', 'out', 'legs', 'rest', 'person'], ['loop', 'coco']);
353
+ const f = parseFlags(argv, ['pose', 'out', 'legs', 'rest', 'person'], ['loop', 'coco', 'hips-inferred']);
350
354
  const input = need(f, 'pose');
351
355
  const out = need(f, 'out');
352
- const params = { loop: f.has('loop'), legs: windowOf(f, 'legs', 5), rest: windowOf(f, 'rest', 7) };
356
+ const params = { loop: f.has('loop'), legs: windowOf(f, 'legs', 5), rest: windowOf(f, 'rest', 7), hipsInferred: f.has('hips-inferred') };
353
357
  const text = readFileSync(input, 'utf8');
354
358
  const seq = f.has('coco') ? convertCoco17(text, input) : readPoseSequence(text, input);
355
359
  const c = clean(seq, params, f.get('person'), input);
@@ -372,6 +376,7 @@ function runClean(argv: string[], say: (l: string) => void): void {
372
376
  }
373
377
  const jit = (v: number | null) => (v === null ? 'not measured' : `${fmt(v, 3)}`);
374
378
  say(` JITTER mean |second difference| of y over ${c.triples} joint-frame triple(s): ${jit(c.record.jitter.before)} → ${jit(c.record.jitter.after)} px/f²${c.record.jitter.before !== null && c.record.jitter.after !== null && c.record.jitter.before > 0 ? ` (${fmt(100 * (1 - c.record.jitter.after / c.record.jitter.before), 1)}% lower)` : ''}`);
379
+ if (c.inferred !== null) say(` INFERRED r_hip on ${c.inferred.r_hip} frame(s), l_hip on ${c.inferred.l_hip} frame(s) of ${n} marked "inferred" (every frame where it has a position): declared at clean (--hips-inferred); positions and scores unchanged`);
375
380
  writeFileSync(out, writePoseSequence(c.seq));
376
381
  say(` CLEAN ${cleanLine(c.record)}`);
377
382
  say(` wrote ${out}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/clean.ts CHANGED
@@ -37,7 +37,15 @@
37
37
  * median of on its own frame, and a value made from its neighbours would be a
38
38
  * guess; `retarget` holds it, and says so with a HOLD line. A joint's state and
39
39
  * score are the detector's and are written as they were read: this step moves
40
- * positions only.
40
+ * positions only — with one stated exception, `hipsInferred` below.
41
+ *
42
+ * `hipsInferred` (rig-c/pose#63, `--hips-inferred`): after the four steps, r_hip
43
+ * and l_hip are marked `inferred` on every frame where they have a position,
44
+ * and the record says who said so (`{ by: 'declared' }`, the person running
45
+ * clean). Positions and scores are not touched by it, so retarget's keys are
46
+ * the same with and without it; what changes is POSTURE, which does not read a
47
+ * hip tilt off inferred hips. The occlusion step reads `inferred` as it reads
48
+ * `observed`: a cleaned file cleaned again moves its hips the same way.
41
49
  *
42
50
  * The smoothing fits the frames of the window where the joint has a position;
43
51
  * where fewer than three are there, the polynomial's degree drops to fit the
@@ -75,8 +83,13 @@ export interface CleanParams {
75
83
  legs: number;
76
84
  /** Savitzky–Golay window over every other joint, frames: 1 (none), or odd and ≥ 5. */
77
85
  rest: number;
86
+ /** Mark r_hip and l_hip `inferred` wherever they have a position (#63): the person running clean declares it. */
87
+ hipsInferred?: boolean;
78
88
  }
79
89
 
90
+ /** The joints `--hips-inferred` marks. */
91
+ export const HIPS: readonly Body18[] = ['r_hip', 'l_hip'];
92
+
80
93
  export type Pt = [number, number];
81
94
  /** One joint's positions over the frames; null where it has none. */
82
95
  export type Series = Array<Pt | null>;
@@ -116,6 +129,8 @@ export interface Cleaned {
116
129
  floor: Floor | null;
117
130
  /** How many (joint, three-frame) runs the jitter was read over: before, after. */
118
131
  triples: number;
132
+ /** With `hipsInferred`: how many joint-frames were marked `inferred`, per hip. */
133
+ inferred: { r_hip: number; l_hip: number } | null;
119
134
  }
120
135
 
121
136
  /**
@@ -323,10 +338,10 @@ export function clean(seq: PoseSequence, params: CleanParams, wanted: string | u
323
338
  if (q === null) return;
324
339
  const j = track[i]![name]!;
325
340
  const low = j.score !== undefined && j.score < OCCLUSION_SCORE;
326
- if (j.state === 'observed' && !low) return;
341
+ if ((j.state === 'observed' || j.state === 'inferred') && !low) return;
327
342
  const m = medianAt(xs, i, OCCLUSION_WINDOW, params.loop)!;
328
343
  out[i] = m;
329
- occluded.push({ joint: name, frame: i, moved: Math.hypot(m[0] - q[0], m[1] - q[1]), why: j.state !== 'observed' ? j.state : `score ${j.score!.toFixed(3)} < ${OCCLUSION_SCORE}` });
344
+ occluded.push({ joint: name, frame: i, moved: Math.hypot(m[0] - q[0], m[1] - q[1]), why: j.state !== 'observed' && j.state !== 'inferred' ? j.state : `score ${j.score!.toFixed(3)} < ${OCCLUSION_SCORE}` });
330
345
  });
331
346
  unhidden.set(name, out);
332
347
  }
@@ -366,17 +381,23 @@ export function clean(seq: PoseSequence, params: CleanParams, wanted: string | u
366
381
  const after = jitterOf([...smoothed.values()], params.loop);
367
382
  const record: CleanRecord = {
368
383
  spec: CLEAN_SPEC,
369
- params: { loop: params.loop, legs: params.legs, rest: params.rest },
384
+ params: { loop: params.loop, legs: params.legs, rest: params.rest, ...(params.hipsInferred === true ? { hipsInferred: { by: 'declared' as const } } : {}) },
370
385
  spikes: { found: spikes.map((s) => ({ frame: s.frame, joint: s.joint })), untested },
371
386
  jitter: { before: before.value, after: after.value },
372
387
  };
388
+ const inferred = params.hipsInferred === true ? { r_hip: 0, l_hip: 0 } : null;
373
389
  const frames = seq.frames.map((f, i) => {
374
390
  const q = f.people.find((x) => x.id === person);
375
391
  if (q === undefined) return { t: f.t, people: [] };
376
392
  const joints: Partial<Record<Body18, Joint>> = {};
377
393
  for (const [name, j] of Object.entries(q.joints) as Array<[Body18, Joint]>) {
378
394
  const moved = smoothed.get(name)?.[i] ?? null;
379
- joints[name] = moved === null ? { ...j } : { ...j, at: [moved[0], moved[1]] };
395
+ const out: Joint = moved === null ? { ...j } : { ...j, at: [moved[0], moved[1]] };
396
+ if (inferred !== null && (name === 'r_hip' || name === 'l_hip') && out.state !== 'missing' && out.at !== undefined) {
397
+ out.state = 'inferred';
398
+ inferred[name]++;
399
+ }
400
+ joints[name] = out;
380
401
  }
381
402
  return { t: f.t, people: [{ id: person, joints: sortedJoints(joints) }] };
382
403
  });
@@ -392,6 +413,7 @@ export function clean(seq: PoseSequence, params: CleanParams, wanted: string | u
392
413
  untested,
393
414
  floor,
394
415
  triples: before.triples,
416
+ inferred,
395
417
  };
396
418
  }
397
419
 
@@ -409,5 +431,18 @@ export function cleanLine(r: CleanRecord): string {
409
431
  const shown = found.slice(0, 8).map((s) => `frame ${s.frame} ${s.joint}`).join(', ');
410
432
  const spikes = `${found.length === 0 ? 'no spike' : `${found.length} spike(s) replaced (${shown}${found.length > 8 ? `, … ${found.length - 8} more` : ''})`}, ${untestedText(r.spikes.untested)}`;
411
433
  const jit = (v: number | null) => (v === null ? 'not measured' : v.toFixed(2));
412
- return `${r.spec}: ${windows}, ${loop ? 'loop' : 'no loop'}; ${spikes}; jitter (mean |second difference| of y) ${jit(r.jitter.before)} → ${jit(r.jitter.after)} px/f²`;
434
+ const hips = r.params.hipsInferred === undefined ? '' : `; hips inferred (${inferredSource(r)})`;
435
+ return `${r.spec}: ${windows}, ${loop ? 'loop' : 'no loop'}; ${spikes}; jitter (mean |second difference| of y) ${jit(r.jitter.before)} → ${jit(r.jitter.after)} px/f²${hips}`;
436
+ }
437
+
438
+ /**
439
+ * Where a sequence's `inferred` hips come from, as the lines say it: "declared
440
+ * at clean" when the record says `--hips-inferred` marked them; "source not
441
+ * stated" when the joints carry the state and the record does not say why
442
+ * (a file whose states were written by hand or by another tool).
443
+ */
444
+ export function inferredSource(r: CleanRecord | undefined): string {
445
+ const h = r?.params.hipsInferred;
446
+ if (h === undefined) return 'source not stated';
447
+ return h.by === 'declared' ? 'declared at clean' : h.by;
413
448
  }
package/src/pose.ts CHANGED
@@ -23,7 +23,13 @@ import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './pr
23
23
 
24
24
  export const POSE_SPEC = 'rig-pose-sequence/1';
25
25
  export const POSE_SPACE = { units: 'picture-px', origin: 'top-left', y: 'down' } as const;
26
- const STATES = ['observed', 'occluded', 'missing'] as const;
26
+ /**
27
+ * `inferred` (rig-c/pose#63): a position the detector supplied but did not see
28
+ * — it carries `at` like `observed`, and retarget keys it the same; what it
29
+ * changes is what a reading may claim from it (POSTURE does not read a hip tilt
30
+ * off inferred hips).
31
+ */
32
+ const STATES = ['observed', 'occluded', 'missing', 'inferred'] as const;
27
33
  export type JointState = (typeof STATES)[number];
28
34
 
29
35
  export interface Joint {
@@ -62,9 +68,18 @@ export interface CleanSpike {
62
68
  frame: number;
63
69
  joint: Body18;
64
70
  }
71
+ /**
72
+ * Who said the hips are `inferred` (#63): `declared` — the person running
73
+ * `rig-pose clean --hips-inferred` said so. A measured producer would record
74
+ * itself under another `by`; this reader reads only `declared` today.
75
+ */
76
+ export interface HipsInferred {
77
+ by: 'declared';
78
+ }
65
79
  export interface CleanRecord {
66
80
  spec: typeof CLEAN_SPEC;
67
- params: { loop: boolean; legs: number; rest: number };
81
+ /** `hipsInferred` is present only when clean marked the hips (`--hips-inferred`). */
82
+ params: { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred };
68
83
  /**
69
84
  * `found`: every joint the spike step replaced, by frame (index into `frames`)
70
85
  * and joint. `untested`: the frames the spike step did not test, because a
@@ -219,10 +234,10 @@ function readCamera(p: Problems, where: string, camera: unknown, frames: number
219
234
  return out;
220
235
  }
221
236
 
222
- /** `cleaned`: { spec, params: { loop, legs, rest }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }. */
237
+ /** `cleaned`: { spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }. */
223
238
  function readCleaned(p: Problems, where: string, c: unknown, frames: number | null): CleanRecord | undefined {
224
239
  const cw = `${where} "cleaned"`;
225
- const shape = '{ spec, params: { loop, legs, rest }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }';
240
+ const shape = '{ spec, params: { loop, legs, rest, hipsInferred? }, spikes: { found: [{ frame, joint }], untested: [frame] }, jitter: { before, after } }';
226
241
  if (!isObject(c)) {
227
242
  p.add('POSE_CLEANED', `${cw} is ${typeName(c)}; it is the record rig-pose clean writes, ${shape}`);
228
243
  return undefined;
@@ -234,9 +249,17 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
234
249
  const window = (v: unknown) => Number.isInteger(v) && (v === 1 || ((v as number) >= 5 && (v as number) % 2 === 1));
235
250
  if ('params' in c) {
236
251
  const q = c.params;
237
- if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest }`);
252
+ if (!isObject(q)) p.add('POSE_CLEANED', `${cw} "params" is ${typeName(q)}; it must be { loop, legs, rest, hipsInferred? }`);
238
253
  else {
239
- refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest']);
254
+ refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params"`, q, ['loop', 'legs', 'rest', 'hipsInferred']);
255
+ if ('hipsInferred' in q) {
256
+ const h = q.hipsInferred;
257
+ if (!isObject(h)) p.add('POSE_CLEANED', `${cw} "params" "hipsInferred" is ${typeName(h)}; it must be { by: "declared" }`);
258
+ else {
259
+ refuseUnknownKeys(p, 'POSE_CLEANED', `${cw} "params" "hipsInferred"`, h, ['by']);
260
+ if (h.by !== 'declared') p.add('POSE_CLEANED', `${cw} "params" "hipsInferred" "by" is ${typeName(h.by)}; this reader reads "declared" (the person running rig-pose clean --hips-inferred said so)`);
261
+ }
262
+ }
240
263
  if (typeof q.loop !== 'boolean') p.add('POSE_CLEANED', `${cw} "params" "loop" is ${typeName(q.loop)}; it must be true or false`);
241
264
  for (const k of ['legs', 'rest'] as const) if (!window(q[k])) p.add('POSE_CLEANED', `${cw} "params" "${k}" is ${typeName(q[k])}; it must be 1 (none) or an odd whole number ≥ 5`);
242
265
  }
@@ -277,11 +300,11 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
277
300
  }
278
301
  }
279
302
  if (p.list.length > before) return undefined;
280
- const q = c.params as { loop: boolean; legs: number; rest: number };
303
+ const q = c.params as { loop: boolean; legs: number; rest: number; hipsInferred?: HipsInferred };
281
304
  const j = c.jitter as { before: number | null; after: number | null };
282
305
  return {
283
306
  spec: CLEAN_SPEC,
284
- params: { loop: q.loop, legs: q.legs, rest: q.rest },
307
+ params: { loop: q.loop, legs: q.legs, rest: q.rest, ...(q.hipsInferred !== undefined ? { hipsInferred: { by: q.hipsInferred.by } } : {}) },
285
308
  spikes: {
286
309
  found: ((c.spikes as { found: Array<{ frame: number; joint: Body18 }> }).found).map((s) => ({ frame: s.frame, joint: s.joint })),
287
310
  untested: [...(c.spikes as { untested: number[] }).untested],
@@ -324,6 +347,8 @@ function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Bo
324
347
  }
325
348
  } else if (state === 'observed') {
326
349
  p.add('POSE_JOINT_AT', `${jw} is "observed" and has no "at"; an observed joint has a position`);
350
+ } else if (state === 'inferred') {
351
+ p.add('POSE_JOINT_AT', `${jw} is "inferred" and has no "at"; an inferred joint is a position the detector supplied but did not see, so it has one`);
327
352
  }
328
353
  if ('score' in j) {
329
354
  if (typeof j.score !== 'number' || !Number.isFinite(j.score)) p.add('POSE_JOINT_SCORE', `${jw} "score" is ${typeName(j.score)}; it must be a finite number`);
@@ -474,6 +499,6 @@ export function writePoseSequence(seq: PoseSequence): string {
474
499
  const camera = seq.camera?.map((c) => ({ s: round(c.s, 6), tx: round(c.tx, 3), ty: round(c.ty, 3), inliers: c.inliers }));
475
500
  const c = seq.cleaned;
476
501
  const jit = (v: number | null) => (v === null ? null : round(v, 4));
477
- const cleaned = c === undefined ? undefined : { spec: c.spec, params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest }, spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] }, jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) } };
502
+ const cleaned = c === undefined ? undefined : { spec: c.spec, params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: { by: c.params.hipsInferred.by } } : {}) }, spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] }, jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) } };
478
503
  return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), frames }, null, 2)}\n`;
479
504
  }
package/src/run.ts CHANGED
@@ -8,7 +8,7 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeF
8
8
  import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { readMap, resolveMap, type JointMap, type Rest } from './map.ts';
11
- import { cleanLine } from './clean.ts';
11
+ import { cleanLine, inferredSource } from './clean.ts';
12
12
  import { convertCoco17, personTrack, readPoseSequence, type CameraFrame } from './pose.ts';
13
13
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
14
14
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
@@ -225,7 +225,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
225
225
  p.throwIfAny();
226
226
 
227
227
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
228
- const posture = measurePosture(posed, lengths, map, r, frames, a.minScore);
228
+ const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned));
229
229
  if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
230
230
  const legBones = legBonesOf(map);
231
231
  const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
package/src/verify.ts CHANGED
@@ -246,8 +246,19 @@ export interface Posture {
246
246
  rigShoulder: string | null;
247
247
  /** Reference frames on which the hips (tilt) and the hips and shoulders (sway) are all good. */
248
248
  frames: { tilt: number; sway: number; of: number };
249
- /** Hip-line tilt, Spine degrees (r_hip → l_hip; the rig: the pelvis chain's aim). */
250
- tilt: { reference: Range; rig: Range; ratio: number };
249
+ /**
250
+ * Hip-line tilt, Spine degrees (r_hip → l_hip; the rig: the pelvis chain's
251
+ * aim). Null when every frame with both hips good has an `inferred` hip
252
+ * (#63): the tilt is not read, and `tiltWhy` says why.
253
+ */
254
+ tilt: { reference: Range; rig: Range; ratio: number } | null;
255
+ /** Present only when `tilt` is null: why it was not read. */
256
+ tiltWhy?: string;
257
+ /**
258
+ * Present only when some frame with both hips good has an `inferred` hip:
259
+ * how many such frames the tilt left out, and where the state came from.
260
+ */
261
+ hipsInferred?: { frames: number; source: string };
251
262
  /**
252
263
  * Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
253
264
  * picture px × the figure scale) and in each body's own shoulder widths.
@@ -282,8 +293,17 @@ const medianOf = (xs: number[]): number => {
282
293
  * the joints it needs are good), the rig is the gated build posed by rig-c.
283
294
  * Null when the map has no pelvis chain (joints exactly r_hip, l_hip) or no
284
295
  * translate.
296
+ *
297
+ * Inferred hips (#63): a frame on which either hip is `inferred` is left out of
298
+ * the tilt — its hip line is a position the detector supplied but did not see,
299
+ * and a tilt read off it would be the detector's guess reported as the dancer's.
300
+ * When that is every frame (`clean --hips-inferred` marks them all), the tilt
301
+ * is null and `tiltWhy` names the reason and its source (`inferredSource`, read
302
+ * from the clean record). On some frames only, the tilt is read over the rest
303
+ * and the line counts the frames left out. Sway is read as before, inferred
304
+ * hips included: it is a different measure, and the card leaves it as it is.
285
305
  */
286
- export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number): Posture | null {
306
+ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number, inferredSource = 'source not stated'): Posture | null {
287
307
  const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
288
308
  if (pelvis === undefined || map.translate === null || r.origin === null || r.scale === null) return null;
289
309
  const s = r.scale.factor;
@@ -294,11 +314,13 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
294
314
  const refTilt: number[] = [];
295
315
  const refSway: number[] = [];
296
316
  const refSw: number[] = [];
297
- frames.forEach((_, i) => {
317
+ let inferred = 0;
318
+ frames.forEach((f, i) => {
298
319
  const rh = at(i, 'r_hip');
299
320
  const lh = at(i, 'l_hip');
300
321
  if (rh === null || lh === null) return;
301
- refTilt.push(pictureAngle(rh, lh));
322
+ if (f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred') inferred++;
323
+ else refTilt.push(pictureAngle(rh, lh));
302
324
  const rs = at(i, 'r_shoulder');
303
325
  const ls = at(i, 'l_shoulder');
304
326
  if (rs === null || ls === null) return;
@@ -307,9 +329,13 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
307
329
  });
308
330
  const bone = pelvis.bones[0]!;
309
331
  const aim = pelvis.aim[0]!;
310
- const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
311
- const tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
312
- tilt.ratio = tilt.rig.range / tilt.reference.range;
332
+ let tilt: Posture['tilt'] = null;
333
+ const why = `hips inferred (${inferredSource})`;
334
+ if (inferred === 0 || refTilt.length > 0) {
335
+ const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
336
+ tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
337
+ tilt.ratio = tilt.rig.range / tilt.reference.range;
338
+ }
313
339
 
314
340
  // The rig's mid-shoulder: the point a reach aims at, else the midpoint of the arms' first bones.
315
341
  const reach = map.chains.find((c) => c.reach);
@@ -340,7 +366,16 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
340
366
  ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
341
367
  };
342
368
  }
343
- return { pelvis: pelvis.name, rigHip: r.origin.bone, rigShoulder, frames: { tilt: refTilt.length, sway: refSway.length, of: frames.length }, tilt, sway };
369
+ return {
370
+ pelvis: pelvis.name,
371
+ rigHip: r.origin.bone,
372
+ rigShoulder,
373
+ frames: { tilt: refTilt.length, sway: refSway.length, of: frames.length },
374
+ tilt,
375
+ ...(tilt === null ? { tiltWhy: why } : {}),
376
+ ...(inferred > 0 ? { hipsInferred: { frames: inferred, source: inferredSource } } : {}),
377
+ sway,
378
+ };
344
379
  }
345
380
 
346
381
  const f1 = (x: number) => x.toFixed(1);
@@ -350,8 +385,12 @@ const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
350
385
  /** The POSTURE lines. */
351
386
  export function postureLines(q: Posture): string[] {
352
387
  const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
388
+ const t = q.tilt;
389
+ const left = q.hipsInferred === undefined ? '' : `; ${q.hipsInferred.frames} frame(s) with an inferred hip not read (${q.hipsInferred.source})`;
353
390
  const out = [
354
- `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(q.tilt.reference.range)}° (${sg(q.tilt.reference.min)}° … ${sg(q.tilt.reference.max)}°${over(q.frames.tilt)}), rig ${f1(q.tilt.rig.range)}° (${sg(q.tilt.rig.min)}° … ${sg(q.tilt.rig.max)}°) — rig/reference ${f3(q.tilt.ratio)}`,
391
+ t === null
392
+ ? `hip tilt not read: ${q.tiltWhy}`
393
+ : `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(t.reference.range)}° (${sg(t.reference.min)}° … ${sg(t.reference.max)}°${over(q.frames.tilt)}), rig ${f1(t.rig.range)}° (${sg(t.rig.min)}° … ${sg(t.rig.max)}°) — rig/reference ${f3(t.ratio)}${left}`,
355
394
  ];
356
395
  const w = q.sway;
357
396
  if (w === null) out.push('sway: the reference has no frame with both hips and both shoulders good; not measured');