@rig-c/pose 0.22.2 → 0.24.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 +8 -6
- package/package.json +1 -1
- package/src/face.ts +295 -2
- package/src/map.ts +165 -14
- package/src/retarget.ts +297 -31
- package/src/rigc.ts +29 -2
- package/src/run.ts +34 -5
- package/src/switch.ts +172 -18
- package/src/verify.ts +30 -1
package/README.md
CHANGED
|
@@ -143,10 +143,11 @@ and what the run cannot decide for you it lists:
|
|
|
143
143
|
- `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.
|
|
144
144
|
- `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)).
|
|
145
145
|
- `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total.
|
|
146
|
-
- `FACE` — with `face` in the map ([#138](https://github.com/rig-c/pose/issues/138)): per-frame yaw and pitch read from `nose` against the eye midpoint (`r_eye + l_eye`) and, when present, the ear midpoint (`r_ear + l_ear`), de-rolled by the eye line, then compressed into each configured axis's `[min, max]` range (`scale` and correlation `r`), and written as `face` in `--report`.
|
|
146
|
+
- `FACE` — with `face` in the map ([#138](https://github.com/rig-c/pose/issues/138), [#173](https://github.com/rig-c/pose/issues/173)): per-frame yaw and pitch read from `nose` against the eye midpoint (`r_eye + l_eye`) and, when present, the ear midpoint (`r_ear + l_ear`), de-rolled by the eye line, then compressed into each configured axis's `[min, max]` range (`scale` and correlation `r`), and, when `face.yaw.sets` (or `face.sets`) is declared, every yaw-switched set's entered spans, swap count, and any `hides` slot keyed by another track, written as `face` in `--report`.
|
|
147
|
+
- `BODY` — per-frame body yaw read from the camera-normalised shoulder width (`r_shoulder + l_shoulder`) and hip width (`r_hip + l_hip`) projected perpendicular to the trunk against the sequence's standing width (`yaw ≈ acos(w / w_standing)`, [#173](https://github.com/rig-c/pose/issues/173)): its overall and opening (`frames 0–47`) range, every span with `|yaw| ≥ 45°`, and, when `body.yaw` or `body.sets` is declared in the map, its knee-mapped dial keys and every yaw-switched set's entered spans, swap count, and any `hides` slot keyed by another track, written as `body` in `--report`.
|
|
147
148
|
- `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.
|
|
148
149
|
|
|
149
|
-
`--report <file>` writes all of it as JSON (`rig-pose-report/1`): `spec`, `rigc` (`{ version, entry }`: the rig-c that gated it), `rig`, `animation`, `frames`, `duration`, `cleaned` (the sequence's `cleaned` record, [above](#clean-first-rig-pose-clean)), `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet`, `stance`, `floor` and `raisedByHead` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate` (rig-c's gate summary, a string), `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `supports`, `jumps`, `steps`, `changes`, `lowered`, `floor`, `shifts`, `blend`, `closure`, and the posed `reading`; a jump is an inclusive `[first, last]` pair of frames; `lowered` holds `{ frames, was, now, cause? }` runs (`cause`, where `now` is `null`: `released` or `higher`), present only with `--plant-feet` on a sequence not declared in place ([#113](https://github.com/rig-c/pose/issues/113)); `floor` is the world y every support span was planted on and `shifts` holds one `{ side, frames, shift }` per support span in world units, `null` and empty without `--plant-feet`; `blend` is `{ bounds, runs: [{ side, frames, k: { min, max } }], over: [{ side, frames, needed, left }] }`, the supporting leg stretched on the blend frames, `null` without `--plant-feet`; `closure` is `{ drift, frames }`, the hip's x drift closed over an in-place loop, `null` unless the sequence is `"in-place"` and `--plant-feet` is given), `stance` (`tolerance`, `release`, `minSeconds`, `bounds`, `runs`: `{ state, side, frames, maxHeight, maxDepth?, k, absorbed? }` (`state` is `both`, `lifted` or `below`; `maxDepth`, on a `below` run, is the free ankle's largest distance below the supporting one in leg lengths), `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:
|
|
150
|
+
`--report <file>` writes all of it as JSON (`rig-pose-report/1`): `spec`, `rigc` (`{ version, entry }`: the rig-c that gated it), `rig`, `animation`, `frames`, `duration`, `cleaned` (the sequence's `cleaned` record, [above](#clean-first-rig-pose-clean)), `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet`, `stance`, `floor` and `raisedByHead` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate` (rig-c's gate summary, a string), `landings`, `posture`, `folds`, `body` (`standing`, `read`, `over45`, `framesOver45`, and with `body.yaw` or `body.sets` in the map, `yaw` and `sets`), `legScale`, `pivot`, `feet` (`planted`, `legLength`, `supports`, `jumps`, `steps`, `changes`, `lowered`, `floor`, `shifts`, `blend`, `closure`, and the posed `reading`; a jump is an inclusive `[first, last]` pair of frames; `lowered` holds `{ frames, was, now, cause? }` runs (`cause`, where `now` is `null`: `released` or `higher`), present only with `--plant-feet` on a sequence not declared in place ([#113](https://github.com/rig-c/pose/issues/113)); `floor` is the world y every support span was planted on and `shifts` holds one `{ side, frames, shift }` per support span in world units, `null` and empty without `--plant-feet`; `blend` is `{ bounds, runs: [{ side, frames, k: { min, max } }], over: [{ side, frames, needed, left }] }`, the supporting leg stretched on the blend frames, `null` without `--plant-feet`; `closure` is `{ drift, frames }`, the hip's x drift closed over an in-place loop, `null` unless the sequence is `"in-place"` and `--plant-feet` is given), `stance` (`tolerance`, `release`, `minSeconds`, `bounds`, `runs`: `{ state, side, frames, maxHeight, maxDepth?, k, absorbed? }` (`state` is `both`, `lifted` or `below`; `maxDepth`, on a `below` run, is the free ankle's largest distance below the supporting one in leg lengths), `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:
|
|
150
151
|
|
|
151
152
|
| field | each entry |
|
|
152
153
|
| --- | --- |
|
|
@@ -314,7 +315,8 @@ Which bones carry which joints. You write it for your rig:
|
|
|
314
315
|
⚠️ **Seen from the front, an elbow's side is not a joint limit.** An elbow flexes toward the camera, and its picture falls on either side of the upper arm depending on how the arm is turned. On a real 13 s front-facing dance (313 frames, RTMPose), the right elbow sat on the "wrong" side of the rig's rest on 165 frames (median 23°), and the left on 200 (median 29°, up to 179° with the hands behind the head). Every one was a real elbow bend. The knees strayed at most 8.8° and 14.7°, which is pose noise around a straight leg. So for front-facing material, arms go `"free"`. Legs on `"rest"` with `--bends clamp` hold only while the knees stay near straight. On a second dance with knee bends (Toca Toca, rig-c/pose#27), that setting straightened the left knee for most of the dance: `leg_l1` was 48.3° off at p90, against 0.0–0.3° with free legs. Leave legs `"free"` unless the dance keeps its knees straight.
|
|
315
316
|
- `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
|
|
316
317
|
- `translate.scale` is one length measured on both: two points on the rig, two points in the pose (its median over the frames where both are good). Pick a long segment that does not foreshorten much.
|
|
317
|
-
- `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] } }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`).
|
|
318
|
+
- `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164), [#173](https://github.com/rig-c/pose/issues/173)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15], "sets"?: [{ "set": "T1", "above": 45 }] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] }, "sets"?: [{ "set": "T1", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`). When `sets` (`[{ "set": "<name>", "above": <deg> }]`, `0 < above ≤ 90`) is declared on `face.yaw.sets` or `face.sets`, each set enters on frames where `|yaw| ≥ above` (or the highest `above` satisfied when multiple sets are declared) and leaves only when `|yaw| < above − 10°` (`10°` hysteresis), emitting stepped slot `attachment` keys for the set's `slots` and `hides` (resolved from `alternates` in the map or `sets.json` (`rig-parts-sets/1`) beside `rig.json`; an unknown set is refused by name as `MAP_SET_EXISTS`).
|
|
319
|
+
- `body` (optional, [#173](https://github.com/rig-c/pose/issues/173)): `{ "yaw"?: { "bone": "body_yaw", "range": [-30, 30], "sets"?: [{ "set": "T8", "above": 45 }] }, "sets"?: [{ "set": "T8", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Maps the per-frame body yaw read from the camera-normalised shoulder and hip width (`BODY`) into `range` with the same `0.8 × range` knee mapping as `face`, keying the named dial bone's `rotate` track, and switches any declared `sets` (`body.sets` or `body.yaw.sets`) with the same `above` / `above − 10°` hysteresis (overriding `face` sets and arm `alternates` on slots they share while active). Without `body` in the map, `BODY` and `--report`'s `body` still report the reading while leaving every track in `motion.json` unchanged.
|
|
318
320
|
|
|
319
321
|
### A joint's range: `range`
|
|
320
322
|
|
|
@@ -352,7 +354,7 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
|
|
|
352
354
|
- `place` (optional): `"by-hip"` places a hand that meets the body by its offset from the hip ([below](#alternate-drawings-alternates)).
|
|
353
355
|
- Each name resolves against the built rig and the map, and a miss is refused by name, listing what exists: `MAP_ALT_SLOT`, `MAP_ALT_ATTACHMENT`, `MAP_ALT_HIDES`, `MAP_ALT_CHAIN`, `MAP_ALT_JOINT`, `MAP_ALT_POSE`, `MAP_ALT_DRAWN`, `MAP_ALT_OFFSET` (a bone that is not a bone of the set's chains) and `MAP_ALT_PLACE`.
|
|
354
356
|
|
|
355
|
-
**When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton. On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; a
|
|
357
|
+
**When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton, with a foreshortened segment (`SHORT`) weighted at `0.5 ×` (`SHORT_SWITCH_WEIGHT`, [#180](https://github.com/rig-c/pose/issues/180)). On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; once entered, a set stays active until its distance exceeds `--switch-tolerance + 15°` (`SWITCH_LEAVE_HYSTERESIS`, [#180](https://github.com/rig-c/pose/issues/180)) for at least `--switch-min` (default 0.25 s) or a competing set with a lower distance enters within `--switch-tolerance`; a run of one set shorter than `--switch-min` is dropped and the base art shows there. Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
|
|
356
358
|
|
|
357
359
|
**Where a switch is keyed: `--switch-at`.** By default (`first`) a boundary — the frame a set starts showing on, and the frame the base art or the next set comes back on — is where the selection above puts it: the first frame within the tolerance, often a frame where the arm is slowing into the pose, so the change of drawing can be seen on a nearly still arm. `--switch-at peak` moves each boundary to the frame within ±`--switch-min`/2 (±3 frames at 24 fps with the default 0.25 s) where the chains it changes turn fastest, so the cut lands in motion ([#31](https://github.com/rig-c/pose/issues/31)). The speed on a frame is the largest |angular speed| in degrees per frame, a central difference of the target world angles (one-sided on the first and last frame), over every segment of the leaving set's and the arriving set's chains; it is read on the same targets as the distance, before the range clamp and any set's `turn`, which depend on where the spans are. The window is clipped so every span keeps `--switch-min`, a gap between two spans of one set keeps it too (the bridging holds), no boundary reaches its neighbour, and a set with `frames` shows only inside them. In it, a frame qualifies only if the set the move newly shows is within **2 × `--switch-tolerance`** of its key skeleton on every frame it newly shows it on; the boundary goes to the fastest qualifying frame, and stays when none qualifies. A tie goes to the frame nearest the original, then the earlier. A boundary between two sets moves once.
|
|
358
360
|
|
|
@@ -360,7 +362,7 @@ The bound exists because speed alone rewarded showing art far from its pose. Unb
|
|
|
360
362
|
|
|
361
363
|
Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's old and new frame (the same under `first`), the frames and seconds it moved, the speed on both and its window, and the report carries the same under `moved`.
|
|
362
364
|
|
|
363
|
-
**What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. Between two sets that both draw an arm behind the torso, a frame that belongs to neither set stays behind (the nearer
|
|
365
|
+
**What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. Between two sets that both draw an arm behind the torso, a frame that belongs to neither set stays behind (the set nearer in hand position and segment distance, not frame count); the default front slot is for a frame no set can claim **and** whose arm is read in front — not a fallback ([#136](https://github.com/rig-c/pose/issues/136), [#139](https://github.com/rig-c/pose/issues/139), [#162](https://github.com/rig-c/pose/issues/162), [#180](https://github.com/rig-c/pose/issues/180)). A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
|
|
364
366
|
|
|
365
367
|
**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, held 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.
|
|
366
368
|
|
|
@@ -369,7 +371,7 @@ Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's
|
|
|
369
371
|
|
|
370
372
|
**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`).
|
|
371
373
|
|
|
372
|
-
**A raised arm placed against the head: `--raised-by-head`.** The pose's angles put a hand where it is against the dancer's own shoulder. On a rig whose shoulders sit nearer its head, or whose head is larger, the same angles put a hand raised beside the head on top of it. With `--raised-by-head` (off by default, [#62](https://github.com/rig-c/pose/issues/62)) a set
|
|
374
|
+
**A raised arm placed against the head: `--raised-by-head`.** The pose's angles put a hand where it is against the dancer's own shoulder. On a rig whose shoulders sit nearer its head, or whose head is larger, the same angles put a hand raised beside the head on top of it. With `--raised-by-head` (off by default, [#62](https://github.com/rig-c/pose/issues/62), [#179](https://github.com/rig-c/pose/issues/179)) a set places a chain by the head when its key skeleton (`pose`) puts the chain's wrist within `2 × eye_dist` (`RAISED_WRIST_EYES = 2`) of the head centre (the midpoint of `r_eye` and `l_eye`, or `nose` when the eyes are absent from the key skeleton); a chain whose wrist sits farther keeps its shoulder-based pose angles regardless of its upper arm's `drawn` angle. While that set shows, the arm's wrist goes where the reference's is **against the head**:
|
|
373
375
|
|
|
374
376
|
- The unit is the **eye distance**: on each side, its median over the span. That is the reference's |r_eye − l_eye|, and on the rig the distance between the two points the head chain's `"a>b"` aim names. The head centre is the eyes' midpoint on each frame. The head chain is the one carrying the eye line (r_eye → l_eye, either way round). The flag is refused without one, or with one not aimed `"a>b"` (`RAISED_HEAD`).
|
|
375
377
|
- The target is rig head centre + (reference wrist − reference head centre) × (rig eye distance ÷ reference eye distance), the picture's y turned up, per frame.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/pose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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/face.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and map each configured axis into its declared range by compression (never
|
|
6
6
|
* clipping, so motion changes at the extremes survive).
|
|
7
7
|
*/
|
|
8
|
-
import type { FaceAxis, FaceMap } from './map.ts';
|
|
8
|
+
import type { BodyMap, FaceAxis, FaceMap, YawSet } from './map.ts';
|
|
9
9
|
|
|
10
10
|
type Pt = [number, number];
|
|
11
11
|
|
|
@@ -18,6 +18,21 @@ const DEG = 180 / Math.PI;
|
|
|
18
18
|
*/
|
|
19
19
|
export const FACE_KNEE = 0.8;
|
|
20
20
|
|
|
21
|
+
/** Hysteresis in degrees for leaving a yaw-selected set (`leave = above - 10°`, `rig-c/pose#173`). */
|
|
22
|
+
export const SET_HYSTERESIS_DEG = 10;
|
|
23
|
+
|
|
24
|
+
export interface YawSetSwitch {
|
|
25
|
+
set: string;
|
|
26
|
+
above: number;
|
|
27
|
+
leave: number;
|
|
28
|
+
/** Inclusive `[first, last]` frame spans where the set is shown. */
|
|
29
|
+
spans: Array<[number, number]>;
|
|
30
|
+
/** Total number of attachment transitions (enter + leave boundaries inside the clip). */
|
|
31
|
+
swaps: number;
|
|
32
|
+
/** Any `hides` slot of this set that is also keyed by another track, with the track that keys it. */
|
|
33
|
+
hidesKeyed: Array<{ slot: string; by: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export interface FaceSpan {
|
|
22
37
|
min: number;
|
|
23
38
|
max: number;
|
|
@@ -51,6 +66,7 @@ export interface FaceReading {
|
|
|
51
66
|
};
|
|
52
67
|
yaw?: FaceAxisReading;
|
|
53
68
|
pitch?: FaceAxisReading;
|
|
69
|
+
sets?: YawSetSwitch[];
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
const r3 = (v: number): number => {
|
|
@@ -246,6 +262,26 @@ export function readFace(
|
|
|
246
262
|
const f1 = (x: number): string => x.toFixed(1);
|
|
247
263
|
const sg = (x: number): string => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
|
|
248
264
|
|
|
265
|
+
function formatYawSets(sets: readonly YawSetSwitch[] | undefined, raw: readonly number[]): string {
|
|
266
|
+
if (sets === undefined || sets.length === 0) return '';
|
|
267
|
+
const parts = sets.map((s) => {
|
|
268
|
+
const spanStr = s.spans.length === 0
|
|
269
|
+
? 'none (0 swaps)'
|
|
270
|
+
: `${s.spans.map(([lo, hi]) => {
|
|
271
|
+
const slice = raw.slice(lo, hi + 1);
|
|
272
|
+
const sMin = Math.min(...slice);
|
|
273
|
+
const sMax = Math.max(...slice);
|
|
274
|
+
const where = lo === hi ? `frame ${lo}` : `frames ${lo}–${hi}`;
|
|
275
|
+
return `${where} (${lo === hi ? `${sg(sMax)}°` : `${sg(sMin)}° … ${sg(sMax)}°`})`;
|
|
276
|
+
}).join(', ')} (${s.swaps} swap${s.swaps === 1 ? '' : 's'})`;
|
|
277
|
+
const hidesStr = s.hidesKeyed.length === 0
|
|
278
|
+
? ''
|
|
279
|
+
: `; hides keyed by another track: ${s.hidesKeyed.map((h) => `slot "${h.slot}" (${h.by})`).join(', ')}`;
|
|
280
|
+
return `set "${s.set}" (≥ ${s.above}°, leave < ${s.leave}°): ${spanStr}${hidesStr}`;
|
|
281
|
+
});
|
|
282
|
+
return `; sets ${parts.join('; ')}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
249
285
|
export function faceLine(f: FaceReading): string {
|
|
250
286
|
const joints = f.ears ? 'nose against r_eye + l_eye, r_ear + l_ear' : 'nose against r_eye + l_eye (ears absent)';
|
|
251
287
|
const readPart = `read yaw ${sg(f.read.yaw.min)}° … ${sg(f.read.yaw.max)}° (${f1(f.read.yaw.range)}°), pitch ${sg(f.read.pitch.min)}° … ${sg(f.read.pitch.max)}° (${f1(f.read.pitch.range)}°)`;
|
|
@@ -255,5 +291,262 @@ export function faceLine(f: FaceReading): string {
|
|
|
255
291
|
if (ax === undefined) continue;
|
|
256
292
|
axes.push(`${name} → bone "${ax.bone}" [${ax.limits[0]}, ${ax.limits[1]}]° (knee [${ax.knee[0]}, ${ax.knee[1]}]°): ${sg(ax.mapped.min)}° … ${sg(ax.mapped.max)}° (scale ${ax.scale[0].toFixed(3)}/${ax.scale[1].toFixed(3)}, r=${ax.corr.toFixed(3)})`);
|
|
257
293
|
}
|
|
258
|
-
|
|
294
|
+
const mappedPart = axes.length === 0 ? '' : `; mapped ${axes.join(', ')}`;
|
|
295
|
+
return `${joints}: ${readPart}${mappedPart}${formatYawSets(f.sets, f.read.rawYaw)}`;
|
|
259
296
|
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Select yaw-switched sets (`rig-c/pose#173` Part 2):
|
|
300
|
+
* - A set is entered when `raw[i] >= above` for at least `switchMin` seconds,
|
|
301
|
+
* and left when `raw[i] < above - SET_HYSTERESIS_DEG` (`above - 10°`) for at
|
|
302
|
+
* least `switchMin` seconds.
|
|
303
|
+
* - Between two frames of the same set separated by `< switchMin`, nothing flips (`#162`).
|
|
304
|
+
*/
|
|
305
|
+
export function selectYawSets(
|
|
306
|
+
raw: readonly number[],
|
|
307
|
+
declared: readonly YawSet[] | undefined,
|
|
308
|
+
times: readonly number[],
|
|
309
|
+
switchMin: number,
|
|
310
|
+
): YawSetSwitch[] {
|
|
311
|
+
if (declared === undefined || declared.length === 0 || raw.length === 0) return [];
|
|
312
|
+
const n = raw.length;
|
|
313
|
+
const endOf = (i: number): number => (i + 1 < n ? times[i + 1]! : n > 1 ? 2 * times[i]! - times[i - 1]! : times[i]!);
|
|
314
|
+
const spanSec = (lo: number, hi: number): number => endOf(hi) - times[lo]!;
|
|
315
|
+
|
|
316
|
+
return declared.map((decl) => {
|
|
317
|
+
const above = decl.above;
|
|
318
|
+
const leave = r3(above - SET_HYSTERESIS_DEG);
|
|
319
|
+
const active = new Array<boolean>(n).fill(false);
|
|
320
|
+
let inSet = false;
|
|
321
|
+
let i = 0;
|
|
322
|
+
while (i < n) {
|
|
323
|
+
if (!inSet) {
|
|
324
|
+
if (raw[i]! >= above) {
|
|
325
|
+
let j = i;
|
|
326
|
+
while (j + 1 < n && raw[j + 1]! >= above) j++;
|
|
327
|
+
if (spanSec(i, j) >= switchMin - 1e-9) {
|
|
328
|
+
inSet = true;
|
|
329
|
+
for (let k = i; k <= j; k++) active[k] = true;
|
|
330
|
+
}
|
|
331
|
+
i = j + 1;
|
|
332
|
+
} else {
|
|
333
|
+
i++;
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
if (raw[i]! < leave) {
|
|
337
|
+
let j = i;
|
|
338
|
+
while (j + 1 < n && raw[j + 1]! < leave) j++;
|
|
339
|
+
if (spanSec(i, j) >= switchMin - 1e-9) {
|
|
340
|
+
inSet = false;
|
|
341
|
+
} else {
|
|
342
|
+
for (let k = i; k <= j; k++) active[k] = true;
|
|
343
|
+
}
|
|
344
|
+
i = j + 1;
|
|
345
|
+
} else {
|
|
346
|
+
active[i] = true;
|
|
347
|
+
i++;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// #162: bridge any remaining gap between two spans of the same set shorter than switchMin.
|
|
352
|
+
for (let k = 0; k < n;) {
|
|
353
|
+
if (active[k]) {
|
|
354
|
+
k++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
let j = k;
|
|
358
|
+
while (j + 1 < n && !active[j + 1]) j++;
|
|
359
|
+
if (k > 0 && j + 1 < n && active[k - 1] && active[j + 1] && spanSec(k, j) < switchMin - 1e-9) {
|
|
360
|
+
for (let m = k; m <= j; m++) active[m] = true;
|
|
361
|
+
}
|
|
362
|
+
k = j + 1;
|
|
363
|
+
}
|
|
364
|
+
const spans: Array<[number, number]> = [];
|
|
365
|
+
for (let k = 0; k < n; k++) {
|
|
366
|
+
if (!active[k]) continue;
|
|
367
|
+
const last = spans[spans.length - 1];
|
|
368
|
+
if (last !== undefined && last[1] === k - 1) last[1] = k;
|
|
369
|
+
else spans.push([k, k]);
|
|
370
|
+
}
|
|
371
|
+
let swaps = 0;
|
|
372
|
+
for (const [lo, hi] of spans) {
|
|
373
|
+
swaps++;
|
|
374
|
+
if (hi < n - 1) swaps++;
|
|
375
|
+
}
|
|
376
|
+
return { set: decl.set, above, leave, spans, swaps, hidesKeyed: [] };
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Threshold in degrees at or above which a frame is listed on `BODY` as needing a turned-body drawing (`rig-c/pose#173`). */
|
|
381
|
+
export const BODY_TURN_DEG = 45;
|
|
382
|
+
|
|
383
|
+
export interface BodyReading {
|
|
384
|
+
/** Camera-normalised standing widths in picture pixels (at frame 0's scale). */
|
|
385
|
+
standing: {
|
|
386
|
+
shoulder: number;
|
|
387
|
+
hip: number;
|
|
388
|
+
};
|
|
389
|
+
read: {
|
|
390
|
+
yaw: FaceSpan;
|
|
391
|
+
opening: FaceSpan;
|
|
392
|
+
rawYaw: number[];
|
|
393
|
+
};
|
|
394
|
+
/** Inclusive `[first, last]` frame spans where `|yaw| >= BODY_TURN_DEG` (`45°`). */
|
|
395
|
+
over45: Array<[number, number]>;
|
|
396
|
+
/** Frame indices where `|yaw| >= BODY_TURN_DEG` (`45°`). */
|
|
397
|
+
framesOver45: number[];
|
|
398
|
+
/** Present when the map declares `body.yaw`. */
|
|
399
|
+
yaw?: FaceAxisReading;
|
|
400
|
+
/** Present when the map declares `body.sets` or `body.yaw.sets`. */
|
|
401
|
+
sets?: YawSetSwitch[];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Read per-frame body yaw (degrees) from camera-normalised shoulder width
|
|
406
|
+
* (`r_shoulder`, `l_shoulder`) and hip width (`r_hip`, `l_hip`), projected
|
|
407
|
+
* perpendicular to the trunk (`mid_hip → neck`) so raising one shoulder or
|
|
408
|
+
* tilting the torso in 2D roll does not distort the horizontal torso width,
|
|
409
|
+
* against the sequence's standing width (`yaw ≈ acos(w / w_standing)`,
|
|
410
|
+
* `rig-c/pose#173`).
|
|
411
|
+
*
|
|
412
|
+
* Sign convention (matching `readFaceAngles`):
|
|
413
|
+
* - `yaw > 0`: torso turned toward the subject's right (viewer-left, left shoulder nearer).
|
|
414
|
+
* - `yaw < 0`: torso turned toward the subject's left (viewer-right, right shoulder nearer).
|
|
415
|
+
*/
|
|
416
|
+
export function readBodyYaw(
|
|
417
|
+
rShoulder: readonly Pt[],
|
|
418
|
+
lShoulder: readonly Pt[],
|
|
419
|
+
rHip: readonly Pt[] | null,
|
|
420
|
+
lHip: readonly Pt[] | null,
|
|
421
|
+
neck: readonly Pt[] | null,
|
|
422
|
+
scale: readonly number[] | null,
|
|
423
|
+
faceYaw: readonly number[] | null,
|
|
424
|
+
): { standing: { shoulder: number; hip: number }; yaw: number[] } {
|
|
425
|
+
const n = rShoulder.length;
|
|
426
|
+
const hasHips = rHip !== null && lHip !== null && rHip.length === n && lHip.length === n;
|
|
427
|
+
const wSh: number[] = [];
|
|
428
|
+
const wHip: number[] = [];
|
|
429
|
+
const signs: number[] = [];
|
|
430
|
+
const revSh: boolean[] = [];
|
|
431
|
+
|
|
432
|
+
for (let i = 0; i < n; i++) {
|
|
433
|
+
const rS = rShoulder[i]!;
|
|
434
|
+
const lS = lShoulder[i]!;
|
|
435
|
+
const s = scale !== null && scale[i] !== undefined && scale[i]! > 1e-6 ? scale[i]! : 1;
|
|
436
|
+
const mS: Pt = [0.5 * (rS[0] + lS[0]), 0.5 * (rS[1] + lS[1])];
|
|
437
|
+
const mH: Pt = hasHips ? [0.5 * (rHip[i]![0] + lHip[i]![0]), 0.5 * (rHip[i]![1] + lHip[i]![1])] : [mS[0], mS[1] + 100];
|
|
438
|
+
const top: Pt = neck !== null && neck[i] !== undefined ? neck[i]! : mS;
|
|
439
|
+
const tx = top[0] - mH[0];
|
|
440
|
+
const ty = top[1] - mH[1];
|
|
441
|
+
const tl = Math.hypot(tx, ty);
|
|
442
|
+
const nx = tl > 1e-6 ? -ty / tl : 1;
|
|
443
|
+
const ny = tl > 1e-6 ? tx / tl : 0;
|
|
444
|
+
|
|
445
|
+
const projSh = ((lS[0] - rS[0]) * nx + (lS[1] - rS[1]) * ny) / s;
|
|
446
|
+
const absSh = Math.abs(projSh);
|
|
447
|
+
wSh.push(absSh);
|
|
448
|
+
revSh.push(projSh < -1e-6);
|
|
449
|
+
|
|
450
|
+
if (hasHips) {
|
|
451
|
+
const rH = rHip[i]!;
|
|
452
|
+
const lH = lHip[i]!;
|
|
453
|
+
const projHip = ((lH[0] - rH[0]) * nx + (lH[1] - rH[1]) * ny) / s;
|
|
454
|
+
wHip.push(Math.abs(projHip));
|
|
455
|
+
} else {
|
|
456
|
+
wHip.push(absSh);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const neckOff = neck !== null && neck[i] !== undefined ? ((mS[0] - neck[i]![0]) * nx + (mS[1] - neck[i]![1]) * ny) / s : 0;
|
|
460
|
+
const fy = faceYaw !== null && faceYaw[i] !== undefined ? faceYaw[i]! : 0;
|
|
461
|
+
let sign = 1;
|
|
462
|
+
if (Math.abs(neckOff) > 0.05 * Math.max(1, absSh)) sign = neckOff >= 0 ? 1 : -1;
|
|
463
|
+
else if (Math.abs(fy) > 0.5) sign = fy >= 0 ? 1 : -1;
|
|
464
|
+
else {
|
|
465
|
+
const shear = (mS[0] - mH[0]) * nx + (mS[1] - mH[1]) * ny;
|
|
466
|
+
if (Math.abs(shear) > 1e-6) sign = shear >= 0 ? 1 : -1;
|
|
467
|
+
else if (i > 0) sign = signs[i - 1]!;
|
|
468
|
+
}
|
|
469
|
+
signs.push(sign);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const openN = Math.min(n, 48);
|
|
473
|
+
const sh0 = Math.max(1e-6, Math.min(median(wSh.slice(0, openN)), median(wSh)));
|
|
474
|
+
const hip0 = Math.max(1e-6, Math.min(median(wHip.slice(0, openN)), median(wHip)));
|
|
475
|
+
|
|
476
|
+
const yaw: number[] = [];
|
|
477
|
+
for (let i = 0; i < n; i++) {
|
|
478
|
+
if (revSh[i]) {
|
|
479
|
+
yaw.push(signs[i]! * 90);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const rS = Math.min(1, wSh[i]! / sh0);
|
|
483
|
+
const rH = hasHips ? Math.min(1, wHip[i]! / hip0) : rS;
|
|
484
|
+
const mx = Math.min(Math.max(rS, rH), rS / 0.65);
|
|
485
|
+
const mn = Math.min(rS, rH);
|
|
486
|
+
const alpha = Math.max(0, Math.min(1, (mx - 0.85) / 0.1));
|
|
487
|
+
const w = Math.max(0, Math.min(1, alpha * Math.max(rS, rH) + (1 - alpha) * mn));
|
|
488
|
+
const mag = Math.acos(w) * DEG;
|
|
489
|
+
yaw.push(mag < 1e-6 ? 0 : signs[i]! * mag);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
standing: { shoulder: r3(sh0), hip: r3(hasHips ? hip0 : 0) },
|
|
494
|
+
yaw,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function readBody(
|
|
499
|
+
body: BodyMap | undefined,
|
|
500
|
+
rShoulder: readonly Pt[],
|
|
501
|
+
lShoulder: readonly Pt[],
|
|
502
|
+
rHip: readonly Pt[] | null,
|
|
503
|
+
lHip: readonly Pt[] | null,
|
|
504
|
+
neck: readonly Pt[] | null,
|
|
505
|
+
scale: readonly number[] | null,
|
|
506
|
+
faceYaw: readonly number[] | null,
|
|
507
|
+
): BodyReading {
|
|
508
|
+
const { standing, yaw } = readBodyYaw(rShoulder, lShoulder, rHip, lHip, neck, scale, faceYaw);
|
|
509
|
+
const openN = Math.min(yaw.length, 48);
|
|
510
|
+
const framesOver45: number[] = [];
|
|
511
|
+
const over45: Array<[number, number]> = [];
|
|
512
|
+
for (let i = 0; i < yaw.length; i++) {
|
|
513
|
+
if (Math.abs(yaw[i]!) >= BODY_TURN_DEG) {
|
|
514
|
+
framesOver45.push(i);
|
|
515
|
+
const last = over45[over45.length - 1];
|
|
516
|
+
if (last !== undefined && last[1] === i - 1) last[1] = i;
|
|
517
|
+
else over45.push([i, i]);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
standing,
|
|
522
|
+
read: {
|
|
523
|
+
yaw: spanOf(yaw),
|
|
524
|
+
opening: spanOf(yaw.slice(0, openN)),
|
|
525
|
+
rawYaw: yaw.map(r3),
|
|
526
|
+
},
|
|
527
|
+
over45,
|
|
528
|
+
framesOver45,
|
|
529
|
+
...(body?.yaw === undefined ? {} : { yaw: mapFaceAxis(yaw, body.yaw) }),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function bodyLine(b: BodyReading): string {
|
|
534
|
+
const openEnd = Math.min(b.read.rawYaw.length, 48) - 1;
|
|
535
|
+
const openAbs = Math.max(Math.abs(b.read.opening.min), Math.abs(b.read.opening.max));
|
|
536
|
+
const spans = b.over45.map(([lo, hi]) => {
|
|
537
|
+
const slice = b.read.rawYaw.slice(lo, hi + 1);
|
|
538
|
+
const sMin = Math.min(...slice);
|
|
539
|
+
const sMax = Math.max(...slice);
|
|
540
|
+
const where = lo === hi ? `frame ${lo}` : `frames ${lo}–${hi}`;
|
|
541
|
+
return `${where} (${lo === hi ? `${sg(sMax)}°` : `${sg(sMin)}° … ${sg(sMax)}°`})`;
|
|
542
|
+
});
|
|
543
|
+
const overPart = b.framesOver45.length === 0
|
|
544
|
+
? `≥ ${BODY_TURN_DEG}°: none`
|
|
545
|
+
: `≥ ${BODY_TURN_DEG}° on ${b.framesOver45.length} frame(s): ${spans.join(', ')}`;
|
|
546
|
+
const head = `r_shoulder + l_shoulder, r_hip + l_hip (standing shoulder ${b.standing.shoulder.toFixed(1)}px, hip ${b.standing.hip.toFixed(1)}px): read yaw ${sg(b.read.yaw.min)}° … ${sg(b.read.yaw.max)}° (${f1(b.read.yaw.range)}°), opening (frames 0–${openEnd}) ${sg(b.read.opening.min)}° … ${sg(b.read.opening.max)}° (max |yaw| ${f1(openAbs)}°); ${overPart}`;
|
|
547
|
+
const mappedPart = b.yaw === undefined
|
|
548
|
+
? ''
|
|
549
|
+
: `; mapped yaw → bone "${b.yaw.bone}" [${b.yaw.limits[0]}, ${b.yaw.limits[1]}]° (knee [${b.yaw.knee[0]}, ${b.yaw.knee[1]}]°): ${sg(b.yaw.mapped.min)}° … ${sg(b.yaw.mapped.max)}° (scale ${b.yaw.scale[0].toFixed(3)}/${b.yaw.scale[1].toFixed(3)}, r=${b.yaw.corr.toFixed(3)})`;
|
|
550
|
+
return `${head}${mappedPart}${formatYawSets(b.sets, b.read.rawYaw)}`;
|
|
551
|
+
}
|
|
552
|
+
|