@rig-c/pose 0.1.0 → 0.2.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
@@ -29,6 +29,7 @@ Nothing is written until every check passes:
29
29
  and what the run cannot decide for you it lists:
30
30
 
31
31
  - `HOLD` — a joint missing, or scored under `--min-score`, holds its last good value; one line per span, with the frames and the reason.
32
+ - `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.
32
33
  - `BEND` — with `--bends clamp`, a hinge bent the wrong way is straightened on that frame instead of refusing.
33
34
  - `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
34
35
  - `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.
@@ -37,6 +38,12 @@ and what the run cannot decide for you it lists:
37
38
 
38
39
  **Use `--smooth 7` at 24 fps.** It was measured on a 13 s dance on a @rig-c/parts rig, read back from the gated build: the motion's timing did not move (speed cross-correlation lag 0 on the wrists, ankles and hip), the frame-to-frame second difference dropped 2.5× on the wrists, 1.7–2.1× on the ankles and 1.5× on the hip, and the hip's 95th-percentile speed fell to 0.85×. The criterion set beforehand, a 2× drop on every point, was not met on the hip. That criterion was flawed: the hip's second difference is mostly real motion, while the filter cuts white noise 4.6×. A measure that isolates noise is a later card, and the default stays off until it passes.
39
40
 
41
+ **The flip guard.** A pose model can put a fast sweep of a limb into one frame — under motion blur it takes the elbow or the wrist for another point — and the rig then whips the limb round between two keys. Position smoothing cannot remove a step like that. So after holds and smoothing, and before the hinge check, every segment the map aims (not a `reach`, which is aimed later from posed positions) is read frame to frame: a turn greater than `--flip <deg>` from frame k−1 to frame k is spread. For the smallest h ≥ 1 that keeps every one-frame turn at or under the threshold, frames k−h … k+h−1 are replaced by the straight line from frame k−h−1 to frame k+h, the turn's own way round. It is centred, so the step happens when the pose put it, over 2h frames, and both ends keep the pose's angles. A window that would cross the first or last frame stops there and the line says so; two windows that overlap become one. `--flip off` keeps every turn. The report lists each spread under `flips`.
42
+
43
+ The threshold was fixed by measuring first ([#15](https://github.com/rig-c/pose/issues/15)). On the 13 s dance (313 frames, RTMPose), the one-frame turns of the picture angle of 10 segments — both upper arms, forearms, thighs and shins, the eye line and the hip line, 3,120 turns — run p50 1.0°, p90 5.0°, p99 23.1°, p99.5 33.4°, p99.9 78.2°, max 148.6°. 90° is the p99.9 rounded up past a margin, and marks the 3 turns above it (0.1%): both forearms between frames 181 and 182 (148.6° and 114.4°, a motion-blurred stretch) and the left forearm between 53 and 54 (99.2°, the forearm pointing at the camera). None of them is a spike that comes back: each keeps the direction the limb was already turning, and the new angle holds — a real sweep put into one frame. Holding the last angle would be wrong a frame later, so the guard spreads the step instead. With it, on the @rig-c/parts rig #11 measured, the largest one-frame turn of any guarded bone in the gated build, posed by rig-c, is 78.2° (148.6° without it), and every bone still lands.
44
+
45
+ Re-measured with the guard on, against the guarded unsmoothed run, on #11's criteria (power above 6 Hz down ≥ 2× on every point, timing lag 0, p95 speed ≥ 0.85×): no window passes. `--smooth 7` comes closest — the left wrist's power drops 1.99× and the hip's p95 speed is 0.847× — so `--smooth` stays off by default, and so does the guard: the rule posted before the run made a default for either one depend on a window passing. **Use `--flip 90`**: with it, no bone of the gated build turns more than 90° in one frame (without it, 148.6°), and every bone still lands.
46
+
40
47
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
41
48
 
42
49
  ## The pose: `rig-pose-sequence/1`
package/cli.ts CHANGED
@@ -35,13 +35,16 @@ retarget
35
35
  --smooth <n> Savitzky–Golay over n frames of joint positions: a centred least-squares cubic,
36
36
  read at each frame (default: 1, none); n is odd and ≥ 5 — a cubic through 3
37
37
  points passes through all three, so 3 would change nothing
38
+ --flip <deg>|off a segment that turns more than <deg> degrees in one frame of the pose is
39
+ spread over the frames around the jump, centred on it, and listed as FLIP
40
+ (default: off, every turn kept as the pose gives it; 90 is the measured setting)
38
41
  --bends refuse|clamp a hinge (elbow, knee: any joint inside a chain) bent the wrong way refuses
39
42
  the run (default), or is straightened on that frame and listed
40
43
  --bend-tolerance <d> degrees past straight a hinge may bend the wrong way before it counts (default: 0)
41
44
  --profile spine|spine-html rigc's gate profile (default: spine, rigc's own default)
42
45
  --build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
43
46
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
44
- --report <file> write the run's measurements (holds, bends, landings, folds) as JSON
47
+ --report <file> write the run's measurements (holds, flips, bends, landings, folds) as JSON
45
48
 
46
49
  It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
47
50
  the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
@@ -96,8 +99,13 @@ function smoothOf(f: Map<string, string>): number {
96
99
  return n;
97
100
  }
98
101
 
102
+ function flipOf(f: Map<string, string>): number | 'off' {
103
+ if (!f.has('flip') || f.get('flip') === 'off') return 'off';
104
+ return num(f, 'flip', 90, (v) => v > 0 && v < 180, 'off, or degrees in (0, 180) — a one-frame turn is never more than 180°');
105
+ }
106
+
99
107
  function retargetArgs(argv: string[]): RetargetArgs {
100
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'bends', 'bend-tolerance', 'profile', 'build', 'render', 'report'], ['coco']);
108
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'bends', 'bend-tolerance', 'profile', 'build', 'render', 'report'], ['coco']);
101
109
  const bends = f.get('bends') ?? 'refuse';
102
110
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
103
111
  const profile = f.get('profile') ?? 'spine';
@@ -115,6 +123,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
115
123
  baseMotion: f.get('base-motion'),
116
124
  minScore: num(f, 'min-score', 0.3, (n) => n >= 0, 'a number ≥ 0'),
117
125
  smooth: smoothOf(f),
126
+ flip: flipOf(f),
118
127
  bends,
119
128
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
120
129
  profile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {
@@ -40,10 +40,10 @@
40
40
  "access": "public"
41
41
  },
42
42
  "scripts": {
43
- "test": "bun test",
43
+ "test": "bun test ./test",
44
44
  "typecheck": "bunx tsc --noEmit",
45
45
  "lint": "bunx eslint .",
46
- "prepublishOnly": "bun run typecheck && bun run lint && bun test"
46
+ "prepublishOnly": "bun run typecheck && bun run lint && bun run test"
47
47
  },
48
48
  "dependencies": {
49
49
  "rig-c": "^2.47.1"
package/src/retarget.ts CHANGED
@@ -23,6 +23,12 @@
23
23
  * evaluated at the frame, on each joint's positions after its holds. Centred,
24
24
  * it moves no motion in time; a cubic keeps a peak a moving average flattens.
25
25
  *
26
+ * The flip guard (rig-c/pose#15) then spreads a segment's turn greater than
27
+ * `flip` degrees in one frame over the frames around it, linearly and centred on
28
+ * the jump (`spreadFlips`). It reads the mapped segments' target angles after
29
+ * holds and smoothing and before the hinge check. A reach's target is aimed
30
+ * later, from posed positions, and is not guarded.
31
+ *
26
32
  * What this cannot know is held to account afterwards: `verify.ts` poses the
27
33
  * gated build with rig-c's poser and measures where each bone landed.
28
34
  */
@@ -45,6 +51,8 @@ export interface RetargetOptions {
45
51
  bends: 'refuse' | 'clamp';
46
52
  /** Degrees past straight a hinge may bend the wrong way before it counts. */
47
53
  bendTolerance: number;
54
+ /** A segment turning more than this many degrees in one frame is spread (`spreadFlips`); 'off' keeps every turn. */
55
+ flip: number | 'off';
48
56
  }
49
57
 
50
58
  export interface Hold {
@@ -69,6 +77,26 @@ export interface BendEvent {
69
77
  rest: number;
70
78
  }
71
79
 
80
+ /** One spread: a segment's turn over the threshold, and the frames whose angle was replaced. */
81
+ export interface Flip {
82
+ chain: string;
83
+ bone: string;
84
+ /** The segment the bone carries: from joint, to joint. */
85
+ joints: [string, string];
86
+ /** The largest input turn in the spread, degrees, signed (Spine's sense, counter-clockwise +). */
87
+ jump: number;
88
+ /** Every input turn over the threshold the spread covers: `frame` is k, the turn is from frame k−1 to k. */
89
+ turns: Array<{ frame: number; jump: number }>;
90
+ /** First and last frame whose angle was replaced; the frames either side keep the input's. */
91
+ frames: [number, number];
92
+ /** The half-width the spread needed (for spreads merged from neighbouring turns, the widest). */
93
+ h: number;
94
+ /** The one-frame turn across the spread, degrees, signed. */
95
+ step: number;
96
+ /** Which end of the series cut the window short, if one did. */
97
+ clipped: 'start' | 'end' | 'both' | null;
98
+ }
99
+
72
100
  export interface MotionKey {
73
101
  t: number;
74
102
  v: number[];
@@ -93,6 +121,7 @@ export interface Retarget {
93
121
  origin: { bone: string; at: Array<[number, number]> } | null;
94
122
  holds: Hold[];
95
123
  bends: BendEvent[];
124
+ flips: Flip[];
96
125
  /** The figure scale, rig units per picture pixel, and the lengths it came from. */
97
126
  scale: { factor: number; pose: number; rig: number } | null;
98
127
  }
@@ -329,6 +358,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
329
358
  }
330
359
  p.throwIfAny();
331
360
 
361
+ // The flip guard, on every segment the pose aims directly (not a reach).
362
+ const flips: Flip[] = [];
363
+ if (opts.flip !== 'off') {
364
+ const threshold = opts.flip;
365
+ for (const c of map.chains) {
366
+ if (c.reach) continue;
367
+ c.bones.forEach((b, k) => {
368
+ for (const s of spreadFlips(targets.get(b)!, threshold)) {
369
+ flips.push({ chain: c.name, bone: b, joints: [c.joints[k]!, c.joints[k + 1]!], ...s });
370
+ }
371
+ });
372
+ }
373
+ }
374
+
332
375
  // Hinges: each joint strictly inside a chain (a reach has one bone, so none).
333
376
  const bends: BendEvent[] = [];
334
377
  for (const c of map.chains) {
@@ -409,7 +452,90 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
409
452
  }
410
453
  if (translateTrack !== null) tracks.push(translateTrack);
411
454
 
412
- return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, scale };
455
+ return { animation: { duration: keyTimes[n - 1]!, tracks }, times: keyTimes, targets, origin, holds, bends, flips, scale };
456
+ }
457
+
458
+ type Spread = Omit<Flip, 'chain' | 'bone' | 'joints'>;
459
+
460
+ /**
461
+ * The flip guard on one angle series (degrees), in place. A turn greater than
462
+ * `threshold` from frame k−1 to frame k is spread: for the smallest h ≥ 1, the
463
+ * angles of frames k−h…k+h−1 are replaced by the straight line from frame
464
+ * k−h−1 to frame k+h, taken through the unwrapped series (each one-frame turn
465
+ * the short way round, so the jump keeps its own direction), such that every
466
+ * one-frame turn in frames k−h…k+h is at or under the threshold. It is centred
467
+ * on the jump, so the step happens when the input put it, spread over 2h frames,
468
+ * and both ends are the input's own angles. A window that would cross the first
469
+ * or the last frame stops there (`clipped`). Windows that overlap — one replaces
470
+ * a frame the other ends on — merge into one line over both, widened again
471
+ * while that line still turns faster than the threshold. Returns the spreads.
472
+ */
473
+ export function spreadFlips(angles: number[], threshold: number): Spread[] {
474
+ const n = angles.length;
475
+ if (n < 2) return [];
476
+ const u: number[] = [angles[0]!];
477
+ for (let i = 1; i < n; i++) u.push(u[i - 1]! + wrap(angles[i]! - angles[i - 1]!));
478
+ const fits = (lo: number, hi: number) => Math.abs(u[hi]! - u[lo]!) <= threshold * (hi - lo);
479
+ interface Win { lo: number; hi: number; h: number; start: boolean; end: boolean; turns: Array<{ frame: number; jump: number }> }
480
+ const wins: Win[] = [];
481
+ for (let k = 1; k < n; k++) {
482
+ const jump = u[k]! - u[k - 1]!;
483
+ if (Math.abs(jump) <= threshold) continue;
484
+ let h = 1;
485
+ let lo = Math.max(0, k - h - 1);
486
+ let hi = Math.min(n - 1, k + h);
487
+ while (!fits(lo, hi) && (lo > 0 || hi < n - 1)) {
488
+ h++;
489
+ lo = Math.max(0, k - h - 1);
490
+ hi = Math.min(n - 1, k + h);
491
+ }
492
+ wins.push({ lo, hi, h, start: k - h - 1 < 0, end: k + h > n - 1, turns: [{ frame: k, jump }] });
493
+ }
494
+ // Merge overlapping windows; a merged line that turns too fast widens by one on each side, then merges again.
495
+ for (let changed = true; changed;) {
496
+ changed = false;
497
+ wins.sort((a, b) => a.lo - b.lo);
498
+ for (let i = 0; i + 1 < wins.length; i++) {
499
+ const a = wins[i]!;
500
+ const b = wins[i + 1]!;
501
+ if (b.lo >= a.hi) continue;
502
+ wins.splice(i, 2, { lo: a.lo, hi: Math.max(a.hi, b.hi), h: Math.max(a.h, b.h), start: a.start || b.start, end: a.end || b.end, turns: [...a.turns, ...b.turns].sort((x, y) => x.frame - y.frame) });
503
+ changed = true;
504
+ break;
505
+ }
506
+ if (changed) continue;
507
+ for (const w of wins) {
508
+ if (w.turns.length < 2 || fits(w.lo, w.hi) || (w.lo === 0 && w.hi === n - 1)) continue;
509
+ if (w.lo === 0) w.start = true;
510
+ if (w.hi === n - 1) w.end = true;
511
+ w.lo = Math.max(0, w.lo - 1);
512
+ w.hi = Math.min(n - 1, w.hi + 1);
513
+ w.h++;
514
+ changed = true;
515
+ }
516
+ }
517
+ return wins.map((w) => {
518
+ const step = (u[w.hi]! - u[w.lo]!) / (w.hi - w.lo);
519
+ for (let i = w.lo + 1; i < w.hi; i++) angles[i] = wrap(u[w.lo]! + step * (i - w.lo));
520
+ const top = w.turns.reduce((m, t) => (Math.abs(t.jump) > Math.abs(m.jump) ? t : m));
521
+ return {
522
+ jump: top.jump,
523
+ turns: w.turns,
524
+ frames: [w.lo + 1, w.hi - 1] as [number, number],
525
+ h: w.h,
526
+ step,
527
+ clipped: w.start && w.end ? 'both' : w.start ? 'start' : w.end ? 'end' : null,
528
+ };
529
+ });
530
+ }
531
+
532
+ export function flipLine(f: Flip, times: readonly number[], threshold: number): string {
533
+ const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
534
+ const turns = f.turns.map((t) => `${sg(t.jump)} from frame ${t.frame - 1} to ${t.frame} (t=${times[t.frame - 1]!.toFixed(3)}–${times[t.frame]!.toFixed(3)}s)`).join(', ');
535
+ const span = f.frames[0] > f.frames[1] ? 'no frame (the pose has none between the two)' : f.frames[0] === f.frames[1] ? `frame ${f.frames[0]}` : `frames ${f.frames[0]}–${f.frames[1]}`;
536
+ const clip = f.clipped === null ? '' : f.clipped === 'both' ? '; the window was cut short by both ends of the pose' : `; the window was cut short at the ${f.clipped === 'start' ? 'first' : 'last'} frame of the pose`;
537
+ const still = Math.abs(f.step) > threshold ? ` — still over ${threshold}°: the pose is too short to spread it` : '';
538
+ return `chain "${f.chain}" bone "${f.bone}" (${f.joints[0]} → ${f.joints[1]}) turned ${turns}, over ${threshold}° in one frame; spread over ${span} (h=${f.h}), ${sg(f.step)} per frame${clip}${still}`;
413
539
  }
414
540
 
415
541
  /** Frames before the first with a direction take its value; none at all is a problem. */
package/src/run.ts CHANGED
@@ -9,7 +9,7 @@ import { dirname, join } from 'node:path';
9
9
  import { readMap, resolveMap } from './map.ts';
10
10
  import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
11
11
  import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
12
- import { bendLine, holdLine, retarget, type RetargetOptions } from './retarget.ts';
12
+ import { bendLine, flipLine, holdLine, retarget, type RetargetOptions } from './retarget.ts';
13
13
  import { baseMotionOf, folds, gate, readBuild, resolveRig, restOf } from './rigc.ts';
14
14
  import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
15
15
 
@@ -78,9 +78,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
78
78
  resolveMap(map, rest, a.map);
79
79
 
80
80
  const r = retarget(rest, map, frames, times, a);
81
- say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
81
+ say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
82
82
  if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
83
83
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
84
+ if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
84
85
  for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
85
86
 
86
87
  const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
@@ -129,10 +130,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
129
130
  animation: a.name,
130
131
  frames: seq.frames.length,
131
132
  duration: r.animation.duration,
132
- options: { minScore: a.minScore, smooth: a.smooth, bends: a.bends, bendTolerance: a.bendTolerance, profile: a.profile },
133
+ options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, bends: a.bends, bendTolerance: a.bendTolerance, profile: a.profile },
133
134
  scale: r.scale,
134
135
  holds: r.holds,
135
136
  bends: r.bends,
137
+ flips: r.flips,
136
138
  gate: g1.summary,
137
139
  landings,
138
140
  posture,