@rig-c/pose 0.11.1 → 0.13.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
@@ -57,12 +57,13 @@ and what the run cannot decide for you it lists:
57
57
  - `LEG-SCALE` — with `--leg-scale` ([below](#the-legs-foreshortening-and-planted-feet)): per leg segment, its straight length, the spread of its picture length over it, and the frames clamped at each limit.
58
58
  - `PIVOT` — when the map translates a bone and has both legs: the translated bone's origin against its thigh roots' midpoint at setup, in units and hip widths — the pivot every hip turn swings the legs about.
59
59
  - `FEET` — when the map has both legs: the supports and jumps read off the reference ankles (planted or not), and on the posed build, outside the jumps, the lowest ankle's vertical range and each support span's worst vertical range and horizontal drift of the supporting ankle, in leg lengths.
60
+ - `STANCE` — with `--stance` ([below](#the-legs-foreshortening-and-planted-feet)): one line per run of frames planted both (with the stretch keyed, its largest k) or lifted (the pose's height kept), with the free side, the frames and the tolerance; and one per run whose stretch fell past its bounds (`past the bounds`), with the k the floor needed and how far the foot was left from it.
60
61
  - `SHEAR` — with `--leg-scale`: every scaled bone and every bone under one, how far its art is sheared and how long its world axes are against setup. A reading.
61
62
  - `IDLE`, `IDLE-DROPPED`, `IDLE-KEPT-OUT`, `MERGE` — the rig's idle against the new animation, and what `--carry-idle` and `--merge` put back ([below](#the-idle-what-the-retarget-drops)).
62
63
  - `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total.
63
64
  - `TURN` — a line across the body (hips, shoulders, eyes) shorter in the picture than 0.5 of its median length, or reversed, for at least 0.15 s: the body or the head turned. A reading of the pose, made whether or not the map uses the line; it changes no key. The demand for a turned-body drawing, as `END-ON` is for a foreshortened limb.
64
65
 
65
- `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale` and `plantFeet` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `floor`, `supports`, `jumps`, `changes`, and the posed `reading`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances and each row of `REDUCE`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
66
+ `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet` and `stance` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `floor`, `supports`, `jumps`, `changes`, and the posed `reading`), `stance` (`tolerance`, `bounds`, `runs`: `{ state, side, frames, maxHeight, k }`, `over`: `{ side, frames, needed, left }`; `null` without `--stance`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances and each row of `REDUCE`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
66
67
 
67
68
  | field | each entry |
68
69
  | --- | --- |
@@ -121,6 +122,7 @@ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inh
121
122
  - A **jump** — both reference ankles **and** mid_hip more than **0.05** leg lengths above their **rolling floor**, the 95th-percentile picture y over ±1.0 s around the frame, for at least **2 frames** — releases the plant: the hip follows the pose's travel from where it was, and re-plants on landing (blended the same way). The body has to lift: an ankle's picture y drifts with depth, and under one floor for the whole sequence HQ's dancer standing still (frames 0–29 and 277–312, mid_hip moving 10 and 14 px) read as jumping, 152–155 of 313 frames in all.
122
123
  - A **step** releases the plant too: while the supporting ankle moves more than **0.03** leg lengths a frame, the other foot takes support if it is planted (not itself released or moving), and otherwise the hip follows the pose's travel. The released ankle may support again once it has moved less than **0.01** leg lengths a frame for **2 frames**. On the dancer the plant held the supporting ankle through a real step (frames 176–181, 579 → 718 px) and threw the hip sideways.
123
124
  - `FEET` lists the supports, the jumps and the steps (each with the side, its frames, who took support and where it re-planted); the report carries them under `feet` (`supports`, `jumps`, `steps`, `changes`).
125
+ - **`--stance <leg lengths>`** (only with `--plant-feet`, which it is refused without; off by default) plants **both** feet when the free one is near the floor ([#60](https://github.com/rig-c/pose/issues/60)). On a frame with a supporting side (so outside every jump), the free ankle's height above the supporting one is read in the picture, in the free side's leg length — the leg length `FEET` uses. Within the tolerance the frame is **planted both**: every bone of the free leg's chain has its `scalex` key multiplied by k = (root y − floor y) / (root y − ankle y) on the posed rig, the root being the chain's first bone's origin and the floor the supporting ankle, so the foot reaches the floor. A free ankle below the supporting one is planted too: its k is under 1, since a foot under the floor is the same fault as one floating above it. k is held within **[0.92, 1.08]**, and a frame past those bounds is listed `past the bounds`, held at the bound and not forced. Above the tolerance the foot is **lifted** and keeps the pose's height. The stretched scales are the scale targets, so the landing check holds them. Measured with `--stance 0.07` and HQ's flags (`--smooth 7 --flip 90 --short 0.35 --bends clamp --leg-scale 0.8,1.05 --plant-feet --carry-idle`): on HQ's dancer, frames 94–175 outside the jumps, the free ankle's height above the supporting one on the posed build went from median 18.3 and p90 25.4 units to 0.0 and 0.0. On a second sequence (Toca, 492 frames at 30 fps) the 29 frames labelled by eye as both feet flat, outside the jumps, read 0.0 and 0.0. Of the 6 labelled one foot lifted, 5 stay above the tolerance; the sixth (frame 360) was 0.042 leg lengths up in the picture, within the tolerance, and so planted; without `--stance` it stood 6.5 units up, also under the tolerance (22.6 units there). That is 83 %, under the 90 % the card asked.
124
126
  - The map needs one chain ending at each ankle whose last bone is aimed `"x"` (the ankle is that bone's tip), and a `translate` the legs hang from (`LEGS_MAP`). A `range`, or a set's `turn` or `offsets`, on a bone between the hip and an ankle is refused (`LEGS_RANGE`): the plant is derived before the range clamp, and a clamp after it would move the planted foot.
125
127
 
126
128
  Both are checked on the gated build: every scale lands, each ankle lands where the keys put it, and the hip's origin where the plant put it. The figures were fixed in #47 before the dancer was measured; the defaults stay off until they pass.
@@ -188,6 +190,8 @@ Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2
188
190
  **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).
189
191
 
190
192
 
193
+ **The camera** ([#63](https://github.com/rig-c/pose/issues/63)). A sequence may carry an optional `camera`, one `{ "s", "tx", "ty", "inliers" }` per frame: the picture's scale and translation against frame 0's, so a point p₀ of frame 0's picture appears at `s · p₀ + (tx, ty)` on that frame. Its joints are then already normalised by it, `p′ = (p − t) / s`, and `inliers` is how many background features the fit kept. `s` must be > 0, `tx` and `ty` finite, `inliers` an integer ≥ 0, and there is one entry per frame; anything else is refused by name (`POSE_CAMERA`). The retarget does not apply it again: it prints a `CAMERA` line with the range of `s` and the three frames where it changes fastest. With `--plant-feet` and no `camera`, the `CAMERA` line says the floor may step with the picture, since a zoom or a pan in the video moves the floor with it. An optional `cameraSource` string says where the camera came from: `"tracked"` or `"hand-labelled, N keys"`. It is refused without a `camera`, or when it is not a non-empty string (`POSE_CAMERA_SOURCE`), and the `CAMERA` line starts with it (`CAMERA tracked: …`; a sequence with a `camera` and no `cameraSource` prints `CAMERA (source not stated): …`). `tools/scan.py` (not shipped) reads the camera off the video's background and writes the normalised sequence with `"cameraSource": "tracked"`. A frame whose fit keeps too few background features is refused by name, and nothing is written. Where the background cannot be tracked, `tools/scan.py --camera-file` takes a hand-written camera instead, a `rig-pose-camera/1` file: `{ "spec": "rig-pose-camera/1", "keys": [ { "frame", "s", "tx", "ty" } ], "note"? }`. Its frames are integers of the pose and strictly increase, `s` is > 0, and `tx` and `ty` are finite. Values are linear between keys and held before the first key and after the last. `inliers` is 0 on every frame, since no feature was fitted, and anything malformed is refused by name (`CAMERA_FILE`). `tools/scan_selftest.py` is the synthetic check of both paths.
194
+
191
195
  **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.
192
196
  ## The map: `rig-pose-map/1`
193
197
 
package/cli.ts CHANGED
@@ -64,6 +64,12 @@ retarget
64
64
  travel and re-plants on landing; a supporting ankle moving > 0.03 leg lengths a frame
65
65
  releases its plant to the other foot, or to the pose's travel, until it is still
66
66
  (< 0.01 for 2 frames) (FEET lines)
67
+ --stance <leg lengths> with --plant-feet: on a frame outside the jumps, a free ankle within this many
68
+ leg lengths (FEET's) of the supporting one in the picture is planted too — its leg's
69
+ scalex keys stretched by k = (root y − floor y) / (root y − ankle y) until the foot
70
+ reaches the floor, raised or lowered, k within [0.92, 1.08] (past it, reported, not forced); above
71
+ it the foot is lifted and keeps the pose's height (STANCE lines; default: off; 0.07 is
72
+ the card's, rig-c/pose#60)
67
73
  --idle <name> the rig's animation read as its idle (default: idle). Every idle track the new
68
74
  animation does not key, on any channel, is listed as IDLE-DROPPED (a blink, a
69
75
  breath); one it keys is IDLE-KEPT-OUT, naming the track that keys it, and so is a
@@ -200,7 +206,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
200
206
  }
201
207
 
202
208
  function retargetArgs(argv: string[]): RetargetArgs {
203
- 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', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip'], ['coco', 'plant-feet', 'carry-idle']);
209
+ 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', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance'], ['coco', 'plant-feet', 'carry-idle']);
204
210
  const bends = f.get('bends') ?? 'refuse';
205
211
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
206
212
  const switchAt = f.get('switch-at') ?? 'first';
@@ -225,6 +231,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
225
231
  short: shortOf(f),
226
232
  legScale: legScaleOf(f),
227
233
  plantFeet: f.has('plant-feet'),
234
+ stance: stanceOf(f),
228
235
  bends,
229
236
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
230
237
  switchTolerance: num(f, 'switch-tolerance', 25, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
@@ -241,6 +248,14 @@ function retargetArgs(argv: string[]): RetargetArgs {
241
248
  };
242
249
  }
243
250
 
251
+ /** --stance (rig-c/pose#60): off unless given, and only with --plant-feet. */
252
+ function stanceOf(f: Map<string, string>): number | 'off' {
253
+ if (!f.has('stance') || f.get('stance') === 'off') return 'off';
254
+ const v = num(f, 'stance', 0, (n) => n > 0 && n < 1, 'off, or leg lengths in (0, 1)');
255
+ if (!f.has('plant-feet')) throw new Usage(`--stance is "${f.get('stance')}" without --plant-feet; it plants the free foot beside the supporting one that --plant-feet holds`);
256
+ return v;
257
+ }
258
+
244
259
  /** --reduce and --reduce-hip (rig-c/pose#32): off unless given; the hip only with the bones. */
245
260
  function reduceOf(f: Map<string, string>): { reduce?: number; reduceHip?: number } {
246
261
  if (!f.has('reduce')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.11.1",
3
+ "version": "0.13.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/legs.ts CHANGED
@@ -486,3 +486,107 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
486
486
  }
487
487
  return hip;
488
488
  }
489
+
490
+ // --- stance (rig-c/pose#60) --------------------------------------------------
491
+
492
+ /** A free leg is scaled within these factors to reach the floor (raised from below or lowered from above); past them the frame is reported, not forced. */
493
+ export const STANCE_BOUNDS: readonly [number, number] = [0.92, 1.08];
494
+
495
+ /** A run of frames with one free side in one state. */
496
+ export interface StanceRun {
497
+ /** `both`: the free ankle within the tolerance of the floor, so both feet are planted; `lifted`: above it, the pose's height kept. */
498
+ state: 'both' | 'lifted';
499
+ /** The side that is not supporting. */
500
+ side: Side;
501
+ frames: [number, number];
502
+ /** The free ankle's largest height above the supporting one in the picture, in that side's leg lengths. */
503
+ maxHeight: number;
504
+ /** `both`: the smallest and largest stretch keyed (bounded); `lifted`: null. */
505
+ k: { min: number; max: number } | null;
506
+ }
507
+
508
+ /** A run of planted-both frames whose stretch needed a factor past the bounds (or could not reach: `needed` null). */
509
+ export interface StanceOver {
510
+ side: Side;
511
+ frames: [number, number];
512
+ /** The stretch the floor needed furthest from 1, or null where the free ankle is not below its leg's root. */
513
+ needed: number | null;
514
+ /** The free ankle's largest distance left from the floor after the bound, world units. */
515
+ left: number;
516
+ }
517
+
518
+ export interface Stance {
519
+ /** The tolerance, in leg lengths (FEET's: the median picture hip → ankle of the free side). */
520
+ tolerance: number;
521
+ bounds: readonly [number, number];
522
+ runs: StanceRun[];
523
+ over: StanceOver[];
524
+ /** Per frame: the free side and the stretch keyed on it, or null (lifted, a jump, no support). */
525
+ stretch: Array<{ side: Side; k: number } | null>;
526
+ }
527
+
528
+ /** World positions (y up) on one frame: each leg chain's first bone's origin and each ankle. */
529
+ export interface StancePosed {
530
+ root: Record<Side, Pt>;
531
+ ankle: Record<Side, Pt>;
532
+ }
533
+
534
+ /**
535
+ * Both feet planted when the free ankle is near the floor (rig-c/pose#60). On a
536
+ * frame with a supporting side (so outside every jump), the free ankle's height
537
+ * above the supporting one is read in the picture (`ankle`, px, y down) in the
538
+ * free side's leg length — the unit of every FEET threshold. Within `tolerance`,
539
+ * the frame is planted both: the free leg is to be stretched along its chain by
540
+ * k = (root y − floor y) / (root y − ankle y) on the posed rig (`posed`, world,
541
+ * y up), the floor being the supporting ankle's posed y; k is held within
542
+ * `STANCE_BOUNDS` (a free ankle below the floor shortens its leg, one above
543
+ * lengthens it), and a frame past them is listed in `over`. Above the tolerance
544
+ * the foot is lifted and keeps the pose's height.
545
+ */
546
+ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed): Stance {
547
+ const n = feet.support.length;
548
+ const stretch: Stance['stretch'] = new Array(n).fill(null);
549
+ type Row = { state: 'both' | 'lifted'; side: Side; height: number; k: number | null; needed: number | null; over: boolean; left: number };
550
+ const rows: Array<Row | null> = new Array<Row | null>(n).fill(null);
551
+ for (let i = 0; i < n; i++) {
552
+ const s = feet.support[i] ?? null;
553
+ if (s === null) continue;
554
+ const f: Side = s === 'r' ? 'l' : 'r';
555
+ const height = (ankle[s][i]![1] - ankle[f][i]![1]) / feet.legLength[f];
556
+ if (height > tolerance) {
557
+ rows[i] = { state: 'lifted', side: f, height, k: null, needed: null, over: false, left: 0 };
558
+ continue;
559
+ }
560
+ const p = posed(i);
561
+ const floor = p.ankle[s][1];
562
+ const root = p.root[f][1];
563
+ const a = p.ankle[f][1];
564
+ const ok = root - a > 1e-9 && root - floor > 1e-9;
565
+ const needed = ok ? (root - floor) / (root - a) : null;
566
+ const k = needed === null ? 1 : Math.min(Math.max(needed, STANCE_BOUNDS[0]), STANCE_BOUNDS[1]);
567
+ const over = needed === null || needed < STANCE_BOUNDS[0] || needed > STANCE_BOUNDS[1];
568
+ const left = Math.abs(root - k * (root - a) - floor);
569
+ rows[i] = { state: 'both', side: f, height, k, needed, over, left };
570
+ if (k !== 1) stretch[i] = { side: f, k };
571
+ }
572
+ const runs: StanceRun[] = [];
573
+ const over: StanceOver[] = [];
574
+ for (let i = 0; i < n; i++) {
575
+ const r = rows[i];
576
+ if (r === null) continue;
577
+ const last = runs[runs.length - 1];
578
+ if (last !== undefined && last.frames[1] === i - 1 && last.state === r.state && last.side === r.side) {
579
+ last.frames[1] = i;
580
+ last.maxHeight = Math.max(last.maxHeight, r.height);
581
+ if (last.k !== null && r.k !== null) last.k = { min: Math.min(last.k.min, r.k), max: Math.max(last.k.max, r.k) };
582
+ } else runs.push({ state: r.state, side: r.side, frames: [i, i], maxHeight: r.height, k: r.k === null ? null : { min: r.k, max: r.k } });
583
+ if (!r.over) continue;
584
+ const o = over[over.length - 1];
585
+ if (o !== undefined && o.frames[1] === i - 1 && o.side === r.side) {
586
+ o.frames[1] = i;
587
+ o.needed = o.needed === null || r.needed === null ? null : Math.abs(r.needed - 1) > Math.abs(o.needed - 1) ? r.needed : o.needed;
588
+ o.left = Math.max(o.left, r.left);
589
+ } else over.push({ side: r.side, frames: [i, i], needed: r.needed, left: r.left });
590
+ }
591
+ return { tolerance, bounds: STANCE_BOUNDS, runs, over, stretch };
592
+ }
package/src/pose.ts CHANGED
@@ -39,16 +39,35 @@ export interface PoseFrame {
39
39
  t: number;
40
40
  people: Person[];
41
41
  }
42
+ /**
43
+ * The picture's camera on one frame against frame 0's (rig-c/pose#63), as
44
+ * `tools/scan.py` reads it off the background: a point p₀ of frame 0's picture
45
+ * appears at `s · p₀ + (tx, ty)` on this frame. The joints of a sequence that
46
+ * carries a camera are already normalised by it, `p′ = (p − t) / s`. `inliers`
47
+ * is the number of background features the fit kept.
48
+ */
49
+ export interface CameraFrame {
50
+ s: number;
51
+ tx: number;
52
+ ty: number;
53
+ inliers: number;
54
+ }
42
55
  export interface PoseSequence {
43
56
  spec: typeof POSE_SPEC;
44
57
  space: typeof POSE_SPACE;
45
58
  width: number;
46
59
  height: number;
47
60
  source: string;
61
+ /** Optional, only with `camera`: where it came from — "tracked", or "hand-labelled, N keys" (#63). */
62
+ cameraSource?: string;
63
+ /** Optional: one per frame, when a scan read the camera (#63). */
64
+ camera?: CameraFrame[];
48
65
  frames: PoseFrame[];
49
66
  }
50
67
 
51
68
  const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
69
+ const OPTIONAL_KEYS = ['cameraSource', 'camera'];
70
+ const CAMERA_KEYS = ['s', 'tx', 'ty', 'inliers'];
52
71
 
53
72
  /**
54
73
  * The older name the same file went by: `rig-c-pose-frames/0`, written by the
@@ -70,7 +89,7 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
70
89
  throw new Error('unreachable');
71
90
  }
72
91
  const frames0 = doc.spec === POSE_SPEC_FRAMES0;
73
- refuseUnknownKeys(p, 'POSE_KEYS', where, doc, frames0 ? [...TOP_KEYS, 'fps'] : TOP_KEYS);
92
+ refuseUnknownKeys(p, 'POSE_KEYS', where, doc, frames0 ? [...TOP_KEYS, ...OPTIONAL_KEYS, 'fps'] : [...TOP_KEYS, ...OPTIONAL_KEYS]);
74
93
  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(', ')}`);
75
94
  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
95
  const space = frames0 ? FRAMES0_SPACE : POSE_SPACE;
@@ -127,8 +146,52 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
127
146
  });
128
147
  }
129
148
  }
149
+ const camera = 'camera' in doc ? readCamera(p, where, doc.camera, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
150
+ if ('cameraSource' in doc) {
151
+ 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")`);
152
+ 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`);
153
+ }
130
154
  p.throwIfAny();
131
- return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, frames };
155
+ const cameraSource = typeof doc.cameraSource === 'string' ? doc.cameraSource : undefined;
156
+ 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 } : {}), frames };
157
+ }
158
+
159
+ /** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
160
+ function readCamera(p: Problems, where: string, camera: unknown, frames: number | null): CameraFrame[] {
161
+ const out: CameraFrame[] = [];
162
+ if (!Array.isArray(camera)) {
163
+ p.add('POSE_CAMERA', `${where} "camera" is ${typeName(camera)}; it must be an array with one { s, tx, ty, inliers } per frame`);
164
+ return out;
165
+ }
166
+ if (frames !== null && camera.length !== frames) p.add('POSE_CAMERA', `${where} "camera" has ${camera.length} entr${camera.length === 1 ? 'y' : 'ies'} and "frames" ${frames}; it must have one per frame`);
167
+ camera.forEach((c: unknown, i: number) => {
168
+ const cw = `${where} camera[${i}]`;
169
+ if (!isObject(c)) {
170
+ p.add('POSE_CAMERA', `${cw} is ${typeName(c)}; a camera entry is { s, tx, ty, inliers }`);
171
+ return;
172
+ }
173
+ refuseUnknownKeys(p, 'POSE_CAMERA', cw, c, CAMERA_KEYS);
174
+ let ok = true;
175
+ for (const k of CAMERA_KEYS) if (!(k in c)) {
176
+ p.add('POSE_CAMERA', `${cw} has no "${k}"; a camera entry carries ${CAMERA_KEYS.join(', ')}`);
177
+ ok = false;
178
+ }
179
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
180
+ if ('s' in c && !(finite(c.s) && c.s > 0)) {
181
+ p.add('POSE_CAMERA', `${cw} "s" is ${typeName(c.s)}; the scale must be a finite number > 0`);
182
+ ok = false;
183
+ }
184
+ for (const k of ['tx', 'ty'] as const) if (k in c && !finite(c[k])) {
185
+ p.add('POSE_CAMERA', `${cw} "${k}" is ${typeName(c[k])}; it must be a finite number of picture px`);
186
+ ok = false;
187
+ }
188
+ if ('inliers' in c && !(Number.isInteger(c.inliers) && (c.inliers as number) >= 0)) {
189
+ p.add('POSE_CAMERA', `${cw} "inliers" is ${typeName(c.inliers)}; it must be an integer ≥ 0`);
190
+ ok = false;
191
+ }
192
+ if (ok) out.push({ s: c.s as number, tx: c.tx as number, ty: c.ty as number, inliers: c.inliers as number });
193
+ });
194
+ return out;
132
195
  }
133
196
 
134
197
  function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
@@ -312,5 +375,6 @@ export function writePoseSequence(seq: PoseSequence): string {
312
375
  ),
313
376
  })),
314
377
  }));
315
- return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, frames }, null, 2)}\n`;
378
+ const camera = seq.camera?.map((c) => ({ s: round(c.s, 6), tx: round(c.tx, 3), ty: round(c.ty, 3), inliers: c.inliers }));
379
+ 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 } : {}), frames }, null, 2)}\n`;
316
380
  }
package/src/retarget.ts CHANGED
@@ -69,8 +69,8 @@ import type { Joint } from './pose.ts';
69
69
  import { Problems, Refusal } from './problems.ts';
70
70
  import type { ArtRange, SlotTrack, Switch, SwitchOptions } from './switch.ts';
71
71
  import { clampArt, coveredChains, measureArt, overflow, RANGE_SLACK, runsOf, selectSwitches } from './switch.ts';
72
- import type { Feet, LegScale, PathPose, Posing, Side } from './legs.ts';
73
- import { isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, supportsOf, tipOf } from './legs.ts';
72
+ import type { Feet, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
73
+ import { isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, supportsOf, tipOf } from './legs.ts';
74
74
 
75
75
  const DEG = 180 / Math.PI;
76
76
 
@@ -91,6 +91,8 @@ export interface RetargetOptions extends SwitchOptions {
91
91
  legScale?: [number, number] | 'off';
92
92
  /** The hip derived from the supporting foot (`legs.ts`); false (the default) moves it by the pose's travel. */
93
93
  plantFeet?: boolean;
94
+ /** With plantFeet: a free ankle within this many leg lengths of the floor plants both feet, its leg stretched to reach it (`legs.ts` stanceOf, #60); 'off' (the default) plants one. */
95
+ stance?: number | 'off';
94
96
  }
95
97
 
96
98
  export interface Hold {
@@ -267,6 +269,8 @@ export interface Retarget {
267
269
  feet: Feet | null;
268
270
  /** Whether the hip was derived from the supporting foot. */
269
271
  planted: boolean;
272
+ /** With --stance: the frames planted both and lifted, and the free leg's stretch (#60). */
273
+ stance: Stance | null;
270
274
  /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
271
275
  ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
272
276
  /** The translated bone's origin against its thigh roots' midpoint, at setup. */
@@ -638,6 +642,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
638
642
  const plantOn = opts.plantFeet === true;
639
643
  const legScale: LegScale[] = [];
640
644
  const scales = new Map<string, number[]>();
645
+ let stance: Stance | null = null;
641
646
  let feet: Feet | null = null;
642
647
  let legs: ReturnType<typeof legsOf> | null = null;
643
648
  let legPath: string[] | null = null;
@@ -692,6 +697,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
692
697
  }
693
698
  if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
694
699
  }
700
+ if (opts.stance !== undefined && opts.stance !== 'off' && !plantOn) p.add('STANCE', `--stance ${opts.stance} plants the free foot beside the supporting one that --plant-feet holds, and --plant-feet is not given`);
695
701
  p.throwIfAny();
696
702
  /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
697
703
  const poseLegs = (i: number): PathPose => {
@@ -714,6 +720,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
714
720
  const tb = rest.get(t!.bone)!;
715
721
  const hip = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY]);
716
722
  for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
723
+ // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
724
+ // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
725
+ if (opts.stance !== undefined && opts.stance !== 'off') {
726
+ const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
727
+ const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
728
+ const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
729
+ stance = stanceOf(feet!, ankleSeries, opts.stance, (i) => {
730
+ const lp = poseLegs(i);
731
+ return {
732
+ root: { r: world(i, lp.origin.get(L.r.chain.bones[0]!)!), l: world(i, lp.origin.get(L.l.chain.bones[0]!)!) },
733
+ ankle: { r: world(i, tipOf(rest, lp, L.r.ankleBone)), l: world(i, tipOf(rest, lp, L.l.ankleBone)) },
734
+ };
735
+ });
736
+ for (const side of SIDES) {
737
+ if (!stance.stretch.some((x) => x?.side === side)) continue;
738
+ for (const b of L[side].chain.bones) {
739
+ const own = scales.get(b);
740
+ const s = own === undefined ? new Array<number>(n).fill(1) : [...own];
741
+ stance.stretch.forEach((x, i) => {
742
+ if (x?.side === side) s[i] = s[i]! * x.k;
743
+ });
744
+ scales.set(b, s);
745
+ }
746
+ }
747
+ }
717
748
  }
718
749
  if (t !== null && scale !== null) {
719
750
  const bone = rest.get(t.bone)!;
@@ -884,7 +915,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
884
915
  animation: { duration: keyTimes[n - 1]!, tracks },
885
916
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
886
917
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
887
- legScale, scales, feet, planted: plantOn, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs),
918
+ legScale, scales, feet, planted: plantOn, stance, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs),
888
919
  };
889
920
  }
890
921
 
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 { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
11
+ import { convertCoco17, personTrack, readPoseSequence, type CameraFrame } from './pose.ts';
12
12
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
13
13
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
14
14
  import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions } from './retarget.ts';
@@ -17,7 +17,7 @@ import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch
17
17
  import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
18
18
  import { legsOf } from './legs.ts';
19
19
  import { reduceKeys, type ReduceBone, type Reduced } from './reduce.ts';
20
- import type { LegScale } from './legs.ts';
20
+ import type { LegScale, Stance } from './legs.ts';
21
21
 
22
22
  export interface RetargetArgs extends RetargetOptions {
23
23
  rig: string;
@@ -104,8 +104,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
104
104
  resolveMap(map, rest, a.map, slots);
105
105
 
106
106
  const r = retarget(rest, map, frames, times, a, slots);
107
- 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}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
107
+ 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}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
108
108
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
109
+ if (seq.camera !== undefined) say(` CAMERA ${cameraLine(seq.camera, times, seq.cameraSource)}`);
110
+ else if (a.plantFeet === true) say(' CAMERA none in the sequence: --plant-feet reads the picture as a fixed camera, so a zoom or a pan in the video moves the floor with it and the floor may step with the picture (tools/scan.py reads the camera, rig-c/pose#63)');
109
111
  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)})`);
110
112
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
111
113
  if (a.short !== 'off') {
@@ -133,6 +135,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
133
135
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
134
136
  say(` FEET steps (the supporting ankle > 0.03 leg lengths a frame; re-planted after 2 frames < 0.01): ${f.steps.map((x) => `${x.side} ${span(x.frames)} → ${x.to === null ? 'the pose\'s travel' : `${x.to} supports`}, ${x.replant === null ? 'never re-planted' : `re-planted at ${x.replant}`}`).join('; ') || 'none'}`);
135
137
  }
138
+ if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
136
139
 
137
140
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
138
141
  const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
@@ -263,7 +266,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
263
266
  animation: a.name,
264
267
  frames: seq.frames.length,
265
268
  duration: r.animation.duration,
266
- options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
269
+ options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
267
270
  scale: r.scale,
268
271
  holds: r.holds,
269
272
  short: r.short,
@@ -281,6 +284,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
281
284
  artRange: r.artRange,
282
285
  legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
283
286
  pivot: r.pivot,
287
+ stance: r.stance === null ? null : { tolerance: r.stance.tolerance, bounds: r.stance.bounds, runs: r.stance.runs, over: r.stance.over },
284
288
  feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, reading: feetReading },
285
289
  shear,
286
290
  idleDropped: idle === null ? null : idle.dropped,
@@ -388,6 +392,22 @@ function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frame
388
392
  return out;
389
393
  }
390
394
 
395
+ /**
396
+ * The CAMERA line (#63): where the camera came from (the sequence's
397
+ * `cameraSource`, or "(source not stated)"), the scale's range over the sequence, and the three
398
+ * frame-to-frame changes of s largest in size (ties to the earlier frame).
399
+ */
400
+ export function cameraLine(camera: readonly CameraFrame[], times: readonly number[], source: string | undefined): string {
401
+ const ss = camera.map((c) => c.s);
402
+ const lo = ss.reduce((m, x, i) => (x < ss[m]! ? i : m), 0);
403
+ const hi = ss.reduce((m, x, i) => (x > ss[m]! ? i : m), 0);
404
+ const steps = ss.slice(1).map((x, k) => ({ to: k + 1, d: x - ss[k]! }));
405
+ const fastest = [...steps].sort((x, y) => Math.abs(y.d) - Math.abs(x.d) || x.to - y.to).slice(0, 3);
406
+ const sign = (d: number) => `${d < 0 ? '−' : '+'}${Math.abs(d).toFixed(4)}`;
407
+ const inl = camera.reduce((m, c) => Math.min(m, c.inliers), Infinity);
408
+ return `${source ?? '(source not stated)'}: the sequence carries the picture's camera against frame 0's; its joints are normalised by it (p′ = (p − t) / s): s ${ss[lo]!.toFixed(4)} (frame ${lo}) … ${ss[hi]!.toFixed(4)} (frame ${hi}) over ${camera.length} frames; s changes fastest at ${fastest.map((x) => `frame ${x.to - 1}→${x.to} (t=${times[x.to]!.toFixed(3)}s) ${sign(x.d)}`).join(', ') || 'no frame (one frame only)'}${source?.startsWith('hand-labelled') === true ? '' : `; fewest background inliers ${inl}`}`;
409
+ }
410
+
391
411
  /** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
392
412
  function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { root: string; ankle: string } } | null {
393
413
  const l = legsOf(map, 'reading the feet');
@@ -395,6 +415,20 @@ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { r
395
415
  return { r: { root: l.legs.r.chain.bones[0]!, ankle: l.legs.r.ankleBone }, l: { root: l.legs.l.chain.bones[0]!, ankle: l.legs.l.ankleBone } };
396
416
  }
397
417
 
418
+ /**
419
+ * The STANCE lines (#60): one per run of planted-both or lifted frames, with
420
+ * the tolerance and the stretch keyed; one per run a stretch was capped.
421
+ */
422
+ export function stanceLines(st: Stance, times: readonly number[]): string[] {
423
+ const span = (x: [number, number]) => (x[0] === x[1] ? `frame ${x[0]} (t=${times[x[0]]!.toFixed(3)}s)` : `frames ${x[0]}–${x[1]} (t=${times[x[0]]!.toFixed(3)}–${times[x[1]]!.toFixed(3)}s)`);
424
+ const tol = `the tolerance ${st.tolerance} leg lengths`;
425
+ const out = st.runs.map((r) => (r.state === 'both'
426
+ ? `planted both: free foot ${r.side} ${span(r.frames)} — its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture, within ${tol}; its leg stretched by k ${r.k!.min === r.k!.max ? r.k!.max.toFixed(4) : `${r.k!.min.toFixed(4)} … ${r.k!.max.toFixed(4)}`} (largest ${r.k!.max.toFixed(4)})`
427
+ : `lifted: free foot ${r.side} ${span(r.frames)} — its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture, over ${tol}; the pose's height kept`));
428
+ for (const o of st.over) out.push(`past the bounds: free foot ${o.side} ${span(o.frames)} — ${o.needed === null ? 'its ankle is not below its leg\'s root, so no stretch reaches the floor' : `the floor needed k ${o.needed.toFixed(4)}, past [${st.bounds[0]}, ${st.bounds[1]}]; held at the bound`}, the foot left up to ${o.left.toFixed(2)} units from the floor`);
429
+ return out;
430
+ }
431
+
398
432
  /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
399
433
  function legScaleLine(l: LegScale, times: readonly number[]): string {
400
434
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);