@rig-c/pose 0.8.0 → 0.8.1

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
@@ -38,6 +38,7 @@ Nothing is written until every check passes:
38
38
 
39
39
  and what the run cannot decide for you it lists:
40
40
 
41
+ - `RIGC` — the rig-c version and entry that gated and posed the run (also `rigc` in the report): a later rig-c can change the bytes.
41
42
  - `HOLD` — a joint missing, or scored under `--min-score`, holds its last good value; one line per span, with the frames and the reason.
42
43
  - `SHORT` — a segment shorter in the picture than `--short` of its median length (with `--short <fraction>`; off by default) has its angle there interpolated, not read; one line per span, with the chain, the segment, the frames and the lowest fraction. Below.
43
44
  - `END-ON` — a `SHORT` run lasting at least 0.15 s: the limb points at the camera, a pose the rig cannot draw.
@@ -109,6 +110,8 @@ Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2
109
110
 
110
111
  **From COCO-17** — the layout most pose models and datasets use — `rig-pose convert --coco in.json --out pose.json`, or `retarget --coco`. The input is `{ "format": "coco17-sequence/1", "width", "height", "source", "frames": [{ "t", "keypoints" }] }`, keypoints in COCO's order as 17 `[x, y, score]` or 51 numbers, or `null` for a frame with nobody. The mapping: COCO's `left_*`/`right_*` → body-18's `l_*`/`r_*` (both are the subject's sides), a score of 0 → `missing`, and body-18's `neck`, which COCO lacks, is the shoulders' midpoint scored the lower of the two (OpenPose's definition).
111
112
 
113
+
114
+ **The older name.** `rig-c-pose-frames/0`, written by the extraction jobs this package's demos were made from, is the same file under another spec string. It has `space.units` "painting-px" and an `fps` key; each frame's `t` is the time read. It is read as it is.
112
115
  ## The map: `rig-pose-map/1`
113
116
 
114
117
  Which bones carry which joints. You write it for your rig:
@@ -178,7 +181,7 @@ Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's
178
181
  **TURN.** The cutoff was measured on the three lines across the body before it was fixed ([#6](https://github.com/rig-c/pose/issues/6)): on the 13 s front-facing dance their picture length never drops under 0.81 of its median and none reverses; on a second sequence the hip line falls to 0.17 at frames 465–491 (side-on) and the eye line to 0.22 at 26–58 (head in profile), and 3 frames reverse. 0.5 lies between. A line is reversed when it points more than 90° from the subject's right joint to the left (front-facing, it points at 0°). The lines are read after holds and smoothing, held and smoothed as the map's points are even where the map does not use them; a line whose points are good on no frame is named as not measured.
179
182
 
180
183
 
181
- **A stopgap window: `frames`.** A set may state `"frames": [[first, last], …]`: it shows only inside those windows, and on them it is chosen over any set within tolerance that has no window. It is for two sets whose key skeletons the angles cannot tell apart. Measured on HQ's dancer, the hands clasped behind the back and the hands low in front differ by only 2–21°, and the wrist and hand-point scores do not separate them either; the cue that might is #33's depth state. It is recorded in the map as a stopgap, not a selection rule.
184
+ **A stopgap window: `frames`.** A set may state `"frames": [[first, last], …]`: it shows only inside those windows, and on them it is chosen over any set within tolerance that has no window. It is for two sets whose key skeletons the angles cannot tell apart. Measured on HQ's dancer, the hands clasped behind the back and the hands low in front differ by only 2–21°, and the wrist and hand-point scores do not separate them either; the cue that might is #33's depth state. It is recorded in the map as a stopgap, not a selection rule. A window **restricts** a set; it never forces one. Where the set does not show inside its window, the run says why in a `FRAMES` line: the stretch, and whether the pose there was past the tolerance (with the largest distance), or within it but the run was dropped.
182
185
 
183
186
  **How far a set's art may turn: `turn`.** A drawing reads right only near the angle it was painted at. A set may state, per bone with a `drawn` angle, how far its art may turn from it, `"turn": { "sleeve_r0": [-20, 20] }`. While the set shows, the bone is held within it: its target becomes drawn + clamp(wrap(target − drawn), min, max), each bone on its own world angle, so a forearm under a held upper arm keeps its own target. The landing check measures against the held angle. The overflow the pose asked for is listed as `ART-RANGE`. A `turn` on a bone the set has no `drawn` angle for is refused (`MAP_ALT_TURN`).
184
187
  ## Compare: the rig's render against the video
package/cli.ts CHANGED
@@ -33,6 +33,8 @@ retarget
33
33
  --coco read --pose as coco17-sequence/1 and convert it first
34
34
  --person <id> the person to follow when the sequence holds more than one
35
35
  --base-motion <path> the rig's motion spec, when it is not motion.json beside rig.json
36
+ --images <dir> passed to rigc build: where the rig spec's images are, when its own "images" path
37
+ does not reach them (a rig.json copied away from its images)
36
38
  --min-score <s> a joint scored below s is not good on that frame and holds its last good
37
39
  value (default: 0.3); a joint with no score is good
38
40
  --smooth <n> Savitzky–Golay over n frames of joint positions: a centred least-squares cubic,
@@ -154,7 +156,7 @@ function shortOf(f: Map<string, string>): number | 'off' {
154
156
  }
155
157
 
156
158
  function retargetArgs(argv: string[]): RetargetArgs {
157
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report'], ['coco']);
159
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report'], ['coco']);
158
160
  const bends = f.get('bends') ?? 'refuse';
159
161
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
160
162
  const switchAt = f.get('switch-at') ?? 'first';
@@ -172,6 +174,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
172
174
  coco: f.has('coco'),
173
175
  person: f.get('person'),
174
176
  baseMotion: f.get('base-motion'),
177
+ images: f.get('images'),
175
178
  minScore: num(f, 'min-score', 0.3, (n) => n >= 0, 'a number ≥ 0'),
176
179
  smooth: smoothOf(f),
177
180
  flip: flipOf(f),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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/pose.ts CHANGED
@@ -50,6 +50,16 @@ export interface PoseSequence {
50
50
 
51
51
  const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
52
52
 
53
+ /**
54
+ * The older name the same file went by: `rig-c-pose-frames/0`, written by the
55
+ * extraction jobs this package's own demos were made from. It is this format
56
+ * under another spec string, with `space.units` "painting-px" and an `fps` key
57
+ * (the time is in each frame's `t`, which wins). It is read as it is; nothing
58
+ * else about it differs.
59
+ */
60
+ export const POSE_SPEC_FRAMES0 = 'rig-c-pose-frames/0';
61
+ const FRAMES0_SPACE = { units: 'painting-px', origin: 'top-left', y: 'down' } as const;
62
+
53
63
  /** Read and check a pose sequence; every problem is collected and thrown once. */
54
64
  export function readPoseSequence(text: string, where: string): PoseSequence {
55
65
  const doc = parseJson(text, where, 'POSE_JSON');
@@ -59,12 +69,15 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
59
69
  p.throwIfAny();
60
70
  throw new Error('unreachable');
61
71
  }
62
- refuseUnknownKeys(p, 'POSE_KEYS', where, doc, TOP_KEYS);
72
+ const frames0 = doc.spec === POSE_SPEC_FRAMES0;
73
+ refuseUnknownKeys(p, 'POSE_KEYS', where, doc, frames0 ? [...TOP_KEYS, 'fps'] : TOP_KEYS);
63
74
  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(', ')}`);
64
- if ('spec' in doc && doc.spec !== POSE_SPEC) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}"`);
65
- if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(POSE_SPACE)) {
66
- p.add('POSE_SPACE', `${where} "space" is ${JSON.stringify(doc.space)}; it must be exactly ${JSON.stringify(POSE_SPACE)}`);
75
+ 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}")`);
76
+ const space = frames0 ? FRAMES0_SPACE : POSE_SPACE;
77
+ if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(space)) {
78
+ 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)}`);
67
79
  }
80
+ if (frames0 && 'fps' in doc && !(typeof doc.fps === 'number' && Number.isFinite(doc.fps) && doc.fps > 0)) p.add('POSE_FPS', `${where} "fps" is ${typeName(doc.fps)}; it must be a positive number (each frame's "t" is the time read)`);
68
81
  for (const k of ['width', 'height'] as const) {
69
82
  if (k in doc && !(Number.isInteger(doc[k]) && (doc[k] as number) > 0)) p.add('POSE_SIZE', `${where} "${k}" is ${typeName(doc[k])}; it must be a positive integer (picture pixels)`);
70
83
  }
package/src/rigc.ts CHANGED
@@ -96,11 +96,11 @@ export interface Gate {
96
96
  }
97
97
 
98
98
  /** Build `rig` with `motion` under rigc's gate into `dir/build`; nothing is read from a red build. */
99
- export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string): Gate {
99
+ export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string, images?: string): Gate {
100
100
  mkdirSync(dir, { recursive: true });
101
101
  const motionPath = join(dir, 'motion.json');
102
102
  writeFileSync(motionPath, `${JSON.stringify(motion, null, 2)}\n`);
103
- return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'));
103
+ return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'), images);
104
104
  }
105
105
 
106
106
  /**
@@ -112,8 +112,8 @@ export function gate(rig: string, motion: Record<string, unknown>, dir: string,
112
112
  * never copied out, which would carry a path climbing from the scratch
113
113
  * directory to the filesystem root. --copy-images puts the pages beside it.
114
114
  */
115
- export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string): Gate {
116
- const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(report === undefined ? [] : ['--report', report])]);
115
+ export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string, images?: string): Gate {
116
+ const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(images === undefined ? [] : ['--images', images]), ...(report === undefined ? [] : ['--report', report])]);
117
117
  const lines = run.out.split('\n');
118
118
  return {
119
119
  run,
@@ -196,3 +196,12 @@ export function folds(build: string, animation: string, dir: string, fps: number
196
196
  frames: setDir,
197
197
  };
198
198
  }
199
+
200
+ /** rig-c's version and the entry its launcher picked (`rigc --version`), so a run names the compiler that gated it. */
201
+ export function rigcVersion(): { version: string; entry: string } {
202
+ const r = runRigc(['--version']);
203
+ const lines = r.out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
204
+ const version = lines.find((l) => /^\d+\.\d+\.\d+/.test(l)) ?? lines[0] ?? 'unknown';
205
+ const entry = lines.find((l) => l.startsWith('entry:'))?.slice('entry:'.length).trim() ?? 'not stated';
206
+ return { version, entry };
207
+ }
package/src/run.ts CHANGED
@@ -7,11 +7,11 @@
7
7
  import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
- import { readMap, resolveMap } from './map.ts';
10
+ import { readMap, resolveMap, type JointMap } from './map.ts';
11
11
  import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
12
12
  import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
13
13
  import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
14
- import { attachmentBones, baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
14
+ import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
15
15
  import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
16
16
  import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
17
17
 
@@ -24,6 +24,8 @@ export interface RetargetArgs extends RetargetOptions {
24
24
  coco: boolean;
25
25
  person?: string;
26
26
  baseMotion?: string;
27
+ /** Passed to rigc build as --images: the directory the rig spec's images resolve in, when rig.json's own `images` does not point there (a copied rig). */
28
+ images?: string;
27
29
  profile: 'spine' | 'spine-html';
28
30
  /** Keep the gated build here. */
29
31
  build?: string;
@@ -72,17 +74,19 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
72
74
  const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
73
75
  try {
74
76
  // The rig as it stands, through the gate, so the rest pose is rig-c's reading of a green build.
75
- const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile);
77
+ const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile, a.images);
76
78
  if (!g0.green) {
77
79
  refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
78
80
  }
79
81
  const doc0 = readBuild(g0.build);
82
+ const rigc = rigcVersion();
80
83
  const rest = restOf(doc0);
81
84
  const slots = slotsOf(doc0);
82
85
  resolveMap(map, rest, a.map, slots);
83
86
 
84
87
  const r = retarget(rest, map, frames, times, a, slots);
85
88
  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}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
89
+ say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
86
90
  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)})`);
87
91
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
88
92
  if (a.short !== 'off') {
@@ -96,12 +100,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
96
100
  if (map.alternates.length > 0) {
97
101
  if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
98
102
  for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
103
+ for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
99
104
  }
100
105
  for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
101
106
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
102
107
 
103
108
  const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
104
- const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
109
+ const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
105
110
  if (!g1.green) {
106
111
  refuse([{ code: 'RIG_GATE', text: `rigc build --profile ${a.profile} is red with "${a.name}" in (exit ${g1.run.status}); nothing written` }, ...g1.fails.map((f) => ({ code: 'RIGC', text: f }))]);
107
112
  }
@@ -139,7 +144,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
139
144
  if (a.build !== undefined) {
140
145
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
141
146
  rmSync(a.build, { recursive: true, force: true });
142
- const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile);
147
+ const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile, undefined, a.images);
143
148
  if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
144
149
  }
145
150
  writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
@@ -150,6 +155,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
150
155
  if (a.report !== undefined) {
151
156
  writeFileSync(a.report, `${JSON.stringify({
152
157
  spec: 'rig-pose-report/1',
158
+ rigc,
153
159
  rig: input.name,
154
160
  animation: a.name,
155
161
  frames: seq.frames.length,
@@ -195,3 +201,37 @@ function spans(ts: number[], step: number): string {
195
201
  }
196
202
 
197
203
  export { lineOf };
204
+
205
+ /**
206
+ * For each set with `frames` windows: every stretch of a window the set did not
207
+ * show on, and why — the pose there was past the tolerance (with the largest
208
+ * distance), or within it but the run was dropped (shorter than --switch-min, or
209
+ * taken by another set's span). A window never forces a set; this says so.
210
+ */
211
+ function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frames: [number, number] }>, distances: ReadonlyMap<string, readonly number[]>, times: readonly number[], tolerance: number): string[] {
212
+ const out: string[] = [];
213
+ for (const set of map.alternates) {
214
+ if (set.frames === null) continue;
215
+ const shown = new Set<number>();
216
+ for (const sw of switches) if (sw.set === set.name) for (let i = sw.frames[0]; i <= sw.frames[1]; i++) shown.add(i);
217
+ const d = distances.get(set.name)!;
218
+ for (const [first, last] of set.frames) {
219
+ const lastIn = Math.min(last, times.length - 1);
220
+ const parts: string[] = [];
221
+ for (let i = first; i <= lastIn;) {
222
+ if (shown.has(i)) { i++; continue; }
223
+ let j = i;
224
+ while (j + 1 <= lastIn && !shown.has(j + 1)) j++;
225
+ const ds = d.slice(i, j + 1);
226
+ const worst = Math.max(...ds);
227
+ const over = ds.filter((x) => x > tolerance).length;
228
+ const span = i === j ? `frame ${i}` : `frames ${i}–${j}`;
229
+ parts.push(over === ds.length ? `${span} outside the tolerance (up to ${worst.toFixed(1)}° > ${tolerance}°)` : over === 0 ? `${span} within the tolerance but not shown (a run shorter than --switch-min, or another set's span)` : `${span} partly outside the tolerance (up to ${worst.toFixed(1)}°)`);
230
+ i = j + 1;
231
+ }
232
+ const head = `set "${set.name}" window ${first}–${last}`;
233
+ out.push(parts.length === 0 ? `${head}: shown on every frame` : `${head}: ${parts.join('; ')} — a window restricts a set, it never forces one`);
234
+ }
235
+ }
236
+ return out;
237
+ }