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