@rig-c/pose 0.18.0 → 0.19.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
@@ -83,11 +83,11 @@ A frame gives no reading, declined with the reason, when:
83
83
  - the edge is too broken or too far from a line;
84
84
  - the reading is over 25° from level, or a lone outlier: further, per frame, than the misread guard allows from both the read before it and the reading after it.
85
85
 
86
- The tool never draws a line through a hand in front. A declined frame does not fall back to the detector's hip line, which is the value known to be wrong there. If two reads lie close in time on either side, the frame takes the interpolation between them. Otherwise it holds the earlier read to the end of its run, so the written angle changes only at a read; at the clip's start, with no earlier read, it holds the later one.
86
+ The tool never draws a line through a hand in front. If two reads lie close in time on either side (at most 1 s apart), a declined frame takes the linear interpolation between them. Over the clip's unheld read frames (or all read frames when fewer than two are unheld), the tool fits a zero-intercept gain (`fit.gain`) and correlation (`fit.corr`) between the detector's hip angle and the read waistband angle ([#106](https://github.com/rig-c/pose/issues/106)). On a declined run longer than 1 s (or at the clip's start or end), when `fit.corr >= 0.9` the frame takes the detector's hip angle scaled by `fit.gain` (`scaled`); when `fit.corr < 0.9` the detector line does not track the waistband on that clip, so the run is marked `unmeasured` (keeping the detector's hip pair, refused nowhere, and named on the line).
87
87
 
88
- Both hips are marked `inferred` on every frame and turned about their midpoint to the frame's angle, keeping the midpoint and the width. The record says which frame is which: `"hipsInferred": { "by": "measured", "method", "read", "interpolated", "held": [[first, last], …], "declined": [{ "frames": [first, last], "why" }] }`. Every frame is in exactly one of `read`, `interpolated` and `held`, and `declined` lists exactly the interpolated and held frames. Runs are frame indices inside the sequence, increasing and disjoint. A malformed record is refused by name (`POSE_CLEANED`).
88
+ Both hips are marked `inferred` on every frame and turned about their midpoint to the frame's angle, keeping the midpoint and the width. The record says which frame is which: `"hipsInferred": { "by": "measured", "method", "fit"?: { "frames", "gain", "corr" }, "read", "interpolated", "scaled"?: [[first, last], …], "held": [[first, last], …], "unmeasured"?: [[first, last], …], "declined": [{ "frames": [first, last], "why" }] }`. Every frame is in exactly one of `read`, `interpolated`, `scaled`, `held` and `unmeasured`, and `declined` lists exactly the non-read frames. Runs are frame indices inside the sequence, increasing and disjoint. A malformed record is refused by name (`POSE_CLEANED`).
89
89
 
90
- `tools/pelvis_selftest.py` is its synthetic check. A planted wrist on a hand in front of the waist reads "covered", and with the hand moved away the edge's angle is read again. The misread guard, the interpolation and the hold are checked frame by frame. Its mutant (`--no-cover-check`) must read a line through the hand, and the check asserts that it does.
90
+ `tools/pelvis_selftest.py` is its synthetic check. A planted wrist on a hand in front of the waist reads "covered", and with the hand moved away the edge's angle is read again. The misread guard, the interpolation, the detector-gain scaling on long covered spans (`#106`, with `--hold-covered` as its planted mutant) and the `unmeasured` path (`r < 0.9`) are checked frame by frame. Its cover mutant (`--no-cover-check`) must read a line through the hand, and the check asserts that it does.
91
91
 
92
92
  **Measured** on HQ's references ([#67](https://github.com/rig-c/pose/issues/67)'s bars):
93
93
 
@@ -128,7 +128,7 @@ and what the run cannot decide for you it lists:
128
128
  - `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.
129
129
  - `BEND` — with `--bends clamp`, a hinge bent the wrong way is straightened on that frame instead of refusing.
130
130
  - `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
131
- - `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. With a `measured` record ([#86](https://github.com/rig-c/pose/issues/86)), the read, interpolated and held frames are the measurement, so the tilt is read on all three. The line adds `tilt measured (<method>) on N frame(s): R read, I interpolated, H held (held: <spans>)`. The report's `posture` carries `tiltMeasured: { method, frames, read, interpolated, held, heldSpans }`. The sway reads the hips as before.
131
+ - `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. With a `measured` record ([#86](https://github.com/rig-c/pose/issues/86), [#106](https://github.com/rig-c/pose/issues/106)), the read, interpolated, scaled and held frames are the measurement, so the tilt is read on all four, while `unmeasured` frames (`fit.corr < 0.9`) are left out and named. The line adds `tilt measured (<method>) on N frame(s): R read, I interpolated, H held (held: <spans>)` (plus `scaled` and `unmeasured` spans when present). The report's `posture` carries `tiltMeasured`. The sway reads the hips as before.
132
132
  - `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)).
133
133
  - `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.
134
134
  - `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.
@@ -201,12 +201,12 @@ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inh
201
201
 
202
202
  **`--plant-feet`** places the hip so the supporting foot stays where it took support, through the posed (and scaled) legs, instead of moving it by the pose's mid_hip travel:
203
203
 
204
- - The supporting foot is the one whose reference ankle is lower in the picture. It changes only when the other ankle has been at least **0.02 leg lengths** lower for at least **0.1 s** (timed as `END-ON` runs are); a leg length is each side's median picture length hip → ankle, and the switch compares against their mean.
205
- - The new support is held where it is on the frame it takes over, and the hip blends from the old derivation to the new over **3 frames**.
204
+ - The supporting foot is the one whose reference ankle is lower in the picture. It changes only when the other ankle has been at least **0.02 leg lengths** lower for at least **0.1 s** (timed as `END-ON` runs are), and once confirmed takes support from the frame it crossed that margin ([#102](https://github.com/rig-c/pose/issues/102)); a leg length is each side's median picture length hip → ankle, and the switch compares against their mean.
205
+ - The new support is held where it is on the frame it takes over, and the hip blends in y from the old derivation to the new over **3 frames** while taking the new derivation's x immediately ([#102](https://github.com/rig-c/pose/issues/102)), so the planted foot does not slide across the blend.
206
206
  - 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.
207
- - **In place** ([#92](https://github.com/rig-c/pose/issues/92)). On a sequence with `"travel": "in-place"`, the supporting foot of a figure that does not travel moves back in the picture while the body stays. Holding it where it planted turns that slide into the body's travel, so the hip walks off and never comes back. Under the declaration each plant's anchor follows its own ankle's picture x, scaled into the rig, from the frame it planted, and keeps its y on the one floor. No step releases a foot, since a foot that follows its track has no plant to drag. Every foot may take support, and the lower one does, by the rules above. The loop is then closed: the hip's x on frame i moves by −drift · i / (n − 1), where drift is its x on the last frame less its x on the first. The last frame's hip x is then the first's, and the loop carries no net travel. The y is the floor's and is not touched. `FEET` prints the drift closed, in place of the steps line, and the report's `feet.closure` carries `{ drift, frames }` (`null` otherwise). On the tests' synthetic in-place loop (40 frames, each supporting ankle sliding back 2 px a frame, the hips still), the hip moves less than 10⁻⁹ px. The same joints without the declaration put it 60 px forward over the loop. With a slide of 0.04 leg lengths a frame, both feet are released as stepping and never re-plant without the declaration, and the supports alternate with it. Jumps are read as before.
207
+ - **In place** ([#92](https://github.com/rig-c/pose/issues/92)). On a sequence with `"travel": "in-place"`, the supporting foot of a figure that does not travel moves back in the picture while the body stays. Holding it where it planted turns that slide into the body's travel, so the hip walks off and never comes back. Under the declaration each plant's anchor follows its own ankle's picture x, scaled into the rig, from the frame it planted, and keeps its y on the one floor. No step releases a foot, since a foot that follows its track has no plant to drag. Every foot may take support, and the lower one does, by the rules above. The loop is then closed: the hip's x on frame i moves by −drift · i / (n − 1), where drift is its x on the last frame less its x on the first. The last frame's hip x is then the first's, and the loop carries no net travel. The y is the floor's and is not touched. `FEET` prints the drift closed, in place of the steps line, and the report's `feet.closure` carries `{ drift, frames }` (`null` otherwise). On the tests' synthetic in-place loop (40 frames, each supporting ankle sliding back 2 px a frame, the hips still), the hip moves less than 10⁻⁹ px. The same joints without the declaration put it 68 px forward over the loop. With a slide of 0.04 leg lengths a frame, both feet are released as stepping and never re-plant without the declaration, and the supports alternate with it. Jumps are read as before.
208
208
  - 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.
209
- - **Read from the lower ankle** ([#113](https://github.com/rig-c/pose/issues/113)). On a sequence not declared `"in-place"`, a foot whose ankle is at least **0.02 leg lengths** higher in the picture than the other's never takes support: not when a plant starts, and not when a step releases the other foot. Before, a step handed support to the only foot free to take it, even a folded, lifted one, and the hip was then placed under a foot in the air. A supporting foot that the other ankle has been that much lower than for **0.1 s** hands support to it when it may take it, as before; when the lower foot may not (it is released and not yet still, or moving), the supporting foot is released, and the hip follows the pose's travel until a foot may take support. Steps and jumps are read as before, and under `"in-place"` #92's reading stands. `FEET support read from the lower ankle` names every run of frames whose support differs from the reading before #113, the side before and after (`none`: no foot supports), and for each `none` why: `released`, the supporting foot let go because the lower ankle could not take support (released by a step, or moving), or the only foot free to take support was the higher one. The report's `feet.lowered` holds them as `{ frames, was, now, cause }` (absent in place). On the tests' synthetic step under a lifted leg (the lifted ankle 0.2 leg lengths up, the grounded one stepping 0.05 leg lengths on frame 10 and still from 11), the lifted leg took support on frames 10–13 before; now frames 10–11 have no supporting foot and the grounded one supports from 12, when it is still again.
209
+ - **Read from the lower ankle** ([#113](https://github.com/rig-c/pose/issues/113)). On a sequence not declared `"in-place"`, a foot whose ankle is at least **0.02 leg lengths** higher in the picture than the other's never takes support: not when a plant starts, and not when a step releases the other foot. Before, a step handed support to the only foot free to take it, even a folded, lifted one, and the hip was then placed under a foot in the air. A supporting foot that the other ankle has been that much lower than for **0.1 s** hands support to it when it may take it, as before; when the lower foot may not (it is released and not yet still, or moving), the supporting foot is released, and the hip follows the pose's travel until a foot may take support. Steps and jumps are read as before, and under `"in-place"` #92's reading stands. `FEET support read from the lower ankle` names every run of frames whose support differs from the reading before #113, the side before and after (`none`: no foot supports), and for each `none` why: `released`, the supporting foot let go because the lower ankle could not take support (released by a step, or moving), or the only foot free to take support was the higher one. The report's `feet.lowered` holds them as `{ frames, was, now, cause }` (absent in place). On the tests' synthetic step under a lifted leg (the lifted ankle 0.2 leg lengths up, the grounded one stepping 0.05 leg lengths on frame 10 and still from 11), the lifted leg took support on frames 10–11 before; now frames 10–11 have no supporting foot and the grounded one supports from 12, when it is still again.
210
210
  - **One floor** ([#85](https://github.com/rig-c/pose/issues/85)). A 2D rig has no depth, so its floor is one line, and a planted foot is on it. The picture disagrees: a step toward the camera puts the foot lower in the frame, and a camera residual puts a whole span higher, so a plant held where the picture puts each span leaves the floor stepping between spans. Every support span's supporting ankle is therefore moved in y onto one floor, the hip and everything above with it. A span's *planted height* is the world y (on the rig, posed by rig-c) its supporting ankle would be held at; the floor is the median of the spans' planted heights, or **`--floor <y>`** (world units, y up; only with `--plant-feet`, exit 2 without it). Each span's *shift* is floor − its planted height. A change of support still blends the hip over the 3 frames, now between two spans on the floor, and a planted foot does not leave the floor: on each blend frame the **supporting** leg is stretched along its chain about its root, by the factor `--stance` uses, k = (root y − floor y) / (root y − ankle y), held within **[0.92, 1.08]**, its lower bound on the frame the supporting leg's own range as `--stance`'s ([#95](https://github.com/rig-c/pose/issues/95); where that went below 0.92, a `FEET bound from the leg's own range` line, the report's `feet.blend.range`, and each blend run's `lower`). A frame that needs more is held at the bound, the rest left to the blend, and listed `FEET past the bounds` with the k needed and how far the foot was left from the floor. An ankle already within 0.01 units (the landing check's origin tolerance) is not stretched. The stretched scales are scale targets, so the landing check holds them; `--stance` then stretches the free leg, a different chain, to the supporting ankle. A jump follows the pose's travel from the hip it left, which is on the floor, and lands on the floor, held the same way over its first 3 planted frames. `--stance` is unchanged: it stretches the free leg to the supporting ankle, which is on the floor. On the tests' synthetic stick, three spans 0, 6 and 1 px apart in the picture (0, 9 and 1.5 units on the rig) plant within 0.0001 units of one floor. With the shift forced to 0 they stay 9 units apart; without the stretch, the supporting ankle leaves the floor by 6.75 units on the first blend frame.
211
211
  - `FEET` lists the supports, the jumps and the steps (each with the side, its frames, who took support and where it re-planted; in place, the loop's closure instead), and with `--plant-feet` the floor: its world y and where it came from, each span's shift in units and in that side's rig leg at setup (thigh origin → ankle), and the shifts' range, then one line per run of blend frames whose supporting leg was stretched (`blend held on the floor`) and per run `past the bounds`. The report carries them under `feet` (`supports`, `jumps`, `steps`, `changes`, `floor`, `shifts`, `blend`, `closure`).
212
212
  - **`--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. The tolerance is a **distance** ([#114](https://github.com/rig-c/pose/issues/114)): within it, above the supporting ankle or below it, 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 within the tolerance below the supporting one is planted too: its k is under 1, since a foot slightly under the floor is the same fault as one floating above it. A free ankle **past the tolerance below** the supporting one is not a stance but a support error (the foot read as supporting is the one in the air), so that frame is never planted both and keeps the pose's height; it is named on a `STANCE past the tolerance below` line with the side, the frames and the largest distance, and read `"below"` in the report's `stance.runs`. Before #114 the gap was signed, so any free ankle below the supporting one counted as within, and a lifted foot read as supporting had the grounded leg shrunk up to it. On the tests' synthetic stance, a free ankle 0.5 leg lengths below is read below and not planted; one 0.03 below is planted with k under 1. k is held within **[0.92, 1.08]**, except that the lower bound comes from the leg's own range in the sequence ([#95](https://github.com/rig-c/pose/issues/95)): per side, r = the leg's picture length hip → ankle on the frame ÷ its straight length (the 95th percentile of that length, as `--leg-scale` reads a segment's), and the frame's lower bound is min(0.92, r), so the rig leg may fold as far as the picture folds it on that frame and never less far than 0.92. The upper bound stays 1.08, since a picture leg does not read longer than straight. A sequence whose legs never fold below 0.92 keeps exactly [0.92, 1.08]. Where the range set a bound below 0.92 on a planted-both frame, one `STANCE bound from the leg's own range` line gives each side's lowest, as does the report's `stance.range`. A frame past its bound is listed `past the bounds` with the bound it had, held at the bound and not forced. Past the tolerance above, the foot is **lifted** and keeps the pose's height. The state is read with **hysteresis** and a **shortest run** ([#87](https://github.com/rig-c/pose/issues/87)), on the distance: a planted foot leaves the stance when its distance from the supporting one goes past the tolerance, and one past it is planted again only under **0.8** of it (both figures are on the `STANCE` line). A change of state takes effect only when its condition holds for **0.1 s** (by the frames' times). A shorter crossing with the other state on both sides of it keeps their state and is listed `absorbed` with its frames and the height that crossed (`absorbed: true` in the report's `stance.runs`, beside the run that took it in). One that runs to the end of a support span is taken, since nothing of the other state follows it. A threshold read frame by frame turned noise at the threshold into a pop: one frame at 0.0708 against 0.07 dropped the stretch for one frame and the foot jumped up and back. 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.18.0",
3
+ "version": "0.19.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
@@ -780,5 +780,12 @@ export function unheldText(h: { speed: number; reach: number; minSeconds?: numbe
780
780
  export function inferredSource(r: CleanRecord | undefined): string {
781
781
  const h = r?.params.hipsInferred;
782
782
  if (h === undefined) return 'source not stated';
783
- return h.by === 'declared' ? 'declared at clean' : `measured by ${h.method}`;
783
+ if (h.by === 'declared') return 'declared at clean';
784
+ const spans = (rs: Array<[number, number]>) => rs.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
785
+ const scaled = h.scaled !== undefined && h.scaled.length > 0 ? `, scaled ${spans(h.scaled)}${h.fit?.gain !== undefined && h.fit.gain !== null && h.fit.corr !== null ? ` (gain ${h.fit.gain.toFixed(2)}×, r=${h.fit.corr.toFixed(3)})` : ''}` : '';
786
+ const unmeasured =
787
+ h.unmeasured !== undefined && (h.unmeasured.length > 0 || (h.fit !== undefined && (h.fit.corr === null || h.fit.corr < 0.9)))
788
+ ? `, unmeasured ${h.unmeasured.length === 0 ? 'none' : spans(h.unmeasured)}${h.fit !== undefined ? ` (r=${h.fit.corr === null ? 'none' : h.fit.corr.toFixed(3)} < 0.9)` : ''}`
789
+ : '';
790
+ return `measured by ${h.method}${scaled}${unmeasured}`;
784
791
  }
package/src/legs.ts CHANGED
@@ -24,11 +24,13 @@
24
24
  * **Planted feet.** The hip is no longer the pose's mid_hip travel scaled. On
25
25
  * every frame one foot supports: the one whose reference ankle is lower in the
26
26
  * picture, changing only when the other ankle has been ≥ `PLANT_SWITCH` leg
27
- * lengths lower for ≥ `PLANT_SWITCH_SECONDS`. The supporting foot is held where
27
+ * lengths lower for ≥ `PLANT_SWITCH_SECONDS`, and taking support from the frame
28
+ * it crossed that margin once confirmed (rig-c/pose#102). The supporting foot is held where
28
29
  * it was on the frame it took support, moved in y onto the one floor every span
29
30
  * shares (rig-c/pose#85, `plantHip`), and the hip is placed so it stays there,
30
31
  * through the posed (scaled) legs. A change of support blends the two
31
- * derivations over `PLANT_BLEND` frames. A jump — both ankles and mid_hip more
32
+ * derivations in y over `PLANT_BLEND` frames while taking the new derivation's x
33
+ * immediately (rig-c/pose#102). A jump — both ankles and mid_hip more
32
34
  * than `JUMP_RISE` leg lengths above their rolling floor (the 95th-percentile
33
35
  * picture y over ± `FLOOR_SECONDS`), for ≥ `JUMP_FRAMES` frames — releases the
34
36
  * plant: the hip follows the pose's travel from where it was, and re-plants on
@@ -435,6 +437,7 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
435
437
  const open: Partial<Record<Side, Step>> = {};
436
438
  let cur: Side | null = null;
437
439
  let lowerFrom = -1;
440
+ let freeFrom = -1;
438
441
  const released: number[] = [];
439
442
  const lower = (cands: Side[], i: number): Side => (cands.length === 2 ? (ankle.l[i]![1] > ankle.r[i]![1] ? 'l' : 'r') : cands[0]!);
440
443
  for (let i = 0; i < n; i++) {
@@ -450,6 +453,7 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
450
453
  if (jump[i]) {
451
454
  cur = null;
452
455
  lowerFrom = -1;
456
+ freeFrom = -1;
453
457
  continue;
454
458
  }
455
459
  const free = (s: Side) => open[s] === undefined && !moving(s, i);
@@ -465,28 +469,41 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
465
469
  if (to !== null) changes.push({ frame: i, from: cur, to });
466
470
  cur = to;
467
471
  lowerFrom = -1;
472
+ freeFrom = -1;
468
473
  } else if (cur === null) {
469
474
  const cands = SIDES.filter(may);
470
475
  if (cands.length > 0) {
471
476
  cur = lower(cands, i);
472
477
  changes.push({ frame: i, from: null, to: cur });
473
478
  lowerFrom = -1;
479
+ freeFrom = -1;
474
480
  }
475
481
  } else {
476
482
  const other: Side = cur === 'r' ? 'l' : 'r';
477
483
  if ((free(other) || lowerAnkle) && higher(cur)) {
478
484
  if (lowerFrom < 0) lowerFrom = i;
479
- if (secondsOf([lowerFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
480
- if (free(other)) {
481
- changes.push({ frame: i, from: cur, to: other });
485
+ if (free(other)) {
486
+ if (freeFrom < 0) freeFrom = i;
487
+ // #102: once confirmed over PLANT_SWITCH_SECONDS, the incoming foot takes support from the landing frame it crossed the margin on.
488
+ if (secondsOf([freeFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
489
+ for (let k = freeFrom; k < i; k++) support[k] = other;
490
+ changes.push({ frame: freeFrom, from: cur, to: other });
482
491
  cur = other;
483
- } else {
492
+ lowerFrom = -1;
493
+ freeFrom = -1;
494
+ }
495
+ } else {
496
+ freeFrom = -1;
497
+ if (secondsOf([lowerFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
484
498
  cur = null; // #113: the lower foot may not take support (released, or moving); neither foot supports
485
499
  released.push(i);
500
+ lowerFrom = -1;
486
501
  }
487
- lowerFrom = -1;
488
502
  }
489
- } else lowerFrom = -1;
503
+ } else {
504
+ lowerFrom = -1;
505
+ freeFrom = -1;
506
+ }
490
507
  }
491
508
  support[i] = cur;
492
509
  }
@@ -528,8 +545,9 @@ export interface Floor {
528
545
  * floor is `floor` when given (`--floor`, world units), and otherwise the median
529
546
  * of the spans' planted heights; each span's anchor moves in y by floor − its
530
547
  * planted height, and the hip with it, so everything above follows. A change of
531
- * support still blends the two derivations over `PLANT_BLEND` frames, now
532
- * between two spans on the floor; a jump follows the pose's travel from the hip
548
+ * support still blends the two derivations' y over `PLANT_BLEND` frames, now
549
+ * between two spans on the floor, while holding the planted foot's x from the
550
+ * frame it lands on (#102); a jump follows the pose's travel from the hip
533
551
  * it left (on the floor) and lands on the floor. On a blend frame the hip is
534
552
  * between the two derivations, so the supporting ankle is off its anchor:
535
553
  * `blends` lists those frames with the anchor's y, and `holdBlend` stretches
@@ -634,7 +652,8 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
634
652
  if (from !== null && i - blendAt < PLANT_BLEND) {
635
653
  const w = (i - blendAt + 1) / (PLANT_BLEND + 1);
636
654
  const f = of(from, i);
637
- hip.push([f[0] + (to[0] - f[0]) * w, f[1] + (to[1] - f[1]) * w]);
655
+ // #102: blend y only; x takes the new derivation immediately so the planted foot does not slide across the blend.
656
+ hip.push([to[0], f[1] + (to[1] - f[1]) * w]);
638
657
  const m = mode!;
639
658
  if (m.kind === 'plant') blends.push({ frame: i, side: m.side, y: m.anchor[1] });
640
659
  } else {
package/src/pose.ts CHANGED
@@ -74,36 +74,63 @@ export interface CleanSpike {
74
74
  * pelvis line off the picture (`tools/pelvis.py`, after clean) and turned the
75
75
  * hip pair to a measured angle on every frame. The exact shape:
76
76
  *
77
- * { by: "measured", method: string,
78
- * read: Run[], interpolated: Run[], held: Run[],
77
+ * { by: "measured", method: string, fit?: { frames, gain, corr },
78
+ * read: Run[], interpolated: Run[], scaled?: Run[], held: Run[], unmeasured?: Run[],
79
79
  * declined: Array<{ frames: Run, why: string }> } Run = [first, last]
80
80
  *
81
81
  * `method` names the reading. Runs are integer frame indices inside the
82
82
  * sequence, first ≤ last, increasing and not overlapping within each list.
83
83
  * Every frame of the sequence is in exactly one of `read` (the frame's own
84
84
  * reading), `interpolated` (a declined frame between two reads close in time,
85
- * the angle interpolated) and `held` (any other declined frame, the earlier
86
- * read carried to the end of its run; at the clip's start, the later read). `declined` lists, with the reason, exactly the frames that are
87
- * interpolated or held. Anything else is refused by name (POSE_CLEANED).
85
+ * the angle interpolated), `scaled` (#106: a longer declined run taking the
86
+ * detector hip line scaled by `fit.gain`), `held` (the earlier read carried to
87
+ * the end of its run; at the clip's start, the later read) and `unmeasured`
88
+ * (#106: a longer declined run when `fit.corr` < 0.9). `declined` lists, with
89
+ * the reason, exactly the non-read frames. Anything else is refused by name
90
+ * (POSE_CLEANED).
88
91
  */
89
92
  export type Run = [number, number];
93
+ export interface PelvisFit {
94
+ frames: number;
95
+ gain: number | null;
96
+ corr: number | null;
97
+ }
90
98
  export type HipsInferred =
91
99
  | { by: 'declared' }
92
- | { by: 'measured'; method: string; read: Run[]; interpolated: Run[]; held: Run[]; declined: Array<{ frames: Run; why: string }> };
100
+ | {
101
+ by: 'measured';
102
+ method: string;
103
+ fit?: PelvisFit;
104
+ read: Run[];
105
+ interpolated: Run[];
106
+ scaled?: Run[];
107
+ held: Run[];
108
+ unmeasured?: Run[];
109
+ declined: Array<{ frames: Run; why: string }>;
110
+ };
93
111
 
94
112
  /** A copy of the record, in the writer's key order. */
95
113
  export function copyHipsInferred(h: HipsInferred): HipsInferred {
96
114
  const runs = (rs: Run[]): Run[] => rs.map(([a, b]) => [a, b]);
97
- return h.by === 'declared'
98
- ? { by: 'declared' }
99
- : { by: 'measured', method: h.method, read: runs(h.read), interpolated: runs(h.interpolated), held: runs(h.held), declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })) };
115
+ if (h.by === 'declared') return { by: 'declared' };
116
+ return {
117
+ by: 'measured',
118
+ method: h.method,
119
+ ...(h.fit !== undefined ? { fit: { frames: h.fit.frames, gain: h.fit.gain, corr: h.fit.corr } } : {}),
120
+ read: runs(h.read),
121
+ interpolated: runs(h.interpolated),
122
+ ...(h.scaled !== undefined ? { scaled: runs(h.scaled) } : {}),
123
+ held: runs(h.held),
124
+ ...(h.unmeasured !== undefined ? { unmeasured: runs(h.unmeasured) } : {}),
125
+ declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })),
126
+ };
100
127
  }
101
128
 
102
- /** The frames a `measured` record gives an angle on (read, interpolated, held), as a set; null for any other record or none. */
129
+ /** The frames a `measured` record gives an angle on (read, interpolated, scaled, held), as a set; null for any other record or none. */
103
130
  export function measuredFrames(h: HipsInferred | undefined): Set<number> | null {
104
131
  if (h === undefined || h.by !== 'measured') return null;
105
132
  const out = new Set<number>();
106
- for (const rs of [h.read, h.interpolated, h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
133
+ for (const rs of [h.read, h.interpolated, h.scaled ?? [], h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
107
134
  return out;
108
135
  }
109
136
 
@@ -489,10 +516,22 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
489
516
  return;
490
517
  }
491
518
  const before = p.list.length;
492
- const CLASSES = ['read', 'interpolated', 'held'] as const;
493
- refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', ...CLASSES, 'declined']);
494
- for (const k of ['method', ...CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
519
+ const REQ_CLASSES = ['read', 'interpolated', 'held'] as const;
520
+ const ALL_CLASSES = ['read', 'interpolated', 'scaled', 'held', 'unmeasured'] as const;
521
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', 'fit', ...ALL_CLASSES, 'declined']);
522
+ for (const k of ['method', ...REQ_CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
495
523
  if ('method' in h && !(typeof h.method === 'string' && h.method.trim() !== '')) p.add('POSE_CLEANED', `${hw} "method" is ${typeName(h.method)}; it must be a non-empty string naming the reading`);
524
+ if ('fit' in h) {
525
+ const f = h.fit;
526
+ const fw = `${hw} "fit"`;
527
+ if (!isObject(f)) p.add('POSE_CLEANED', `${fw} is ${typeName(f)}; it must be { frames, gain, corr }`);
528
+ else {
529
+ refuseUnknownKeys(p, 'POSE_CLEANED', fw, f, ['frames', 'gain', 'corr']);
530
+ if (!(Number.isInteger(f.frames) && (f.frames as number) >= 0)) p.add('POSE_CLEANED', `${fw} "frames" is ${typeName(f.frames)}; it must be a whole number of frames ≥ 0`);
531
+ if (!(f.gain === null || (typeof f.gain === 'number' && Number.isFinite(f.gain)))) p.add('POSE_CLEANED', `${fw} "gain" is ${typeName(f.gain)}; it must be a finite number or null`);
532
+ if (!(f.corr === null || (typeof f.corr === 'number' && Number.isFinite(f.corr) && f.corr >= -1 && f.corr <= 1))) p.add('POSE_CLEANED', `${fw} "corr" is ${typeName(f.corr)}; it must be a correlation in [-1, 1] or null`);
533
+ }
534
+ }
496
535
  const range = `[0, ${frames === null ? 'the frame count' : frames - 1}]`;
497
536
  /** A run [first, last], or null after naming what is wrong with it. */
498
537
  const run = (v: unknown, rw: string): Run | null => {
@@ -524,8 +563,8 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
524
563
  if (cur.r[0] <= prev.r[1]) p.add('POSE_CLEANED', `${cur.w} [${cur.r[0]}, ${cur.r[1]}] starts at or before the end of ${prev.w} [${prev.r[0]}, ${prev.r[1]}]; ${what} runs must increase and not overlap`);
525
564
  }
526
565
  };
527
- const classes: Record<(typeof CLASSES)[number], Named[]> = { read: [], interpolated: [], held: [] };
528
- for (const c of CLASSES) {
566
+ const classes: Record<(typeof ALL_CLASSES)[number], Named[]> = { read: [], interpolated: [], scaled: [], held: [], unmeasured: [] };
567
+ for (const c of ALL_CLASSES) {
529
568
  if (!(c in h)) continue;
530
569
  const list = h[c];
531
570
  if (!Array.isArray(list)) {
@@ -560,12 +599,15 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
560
599
  });
561
600
  ordered(declined, 'declined');
562
601
  }
563
- // Across lists: the classes are disjoint, they cover every frame, and declined is exactly interpolated ∪ held.
564
- for (let x = 0; x < CLASSES.length; x++) {
565
- for (let y = x + 1; y < CLASSES.length; y++) {
566
- for (const a of classes[CLASSES[x]!]) {
567
- for (const b of classes[CLASSES[y]!]) {
568
- if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of read, interpolated and held`);
602
+ const has106 = 'scaled' in h || 'unmeasured' in h || 'fit' in h;
603
+ const classListLabel = has106 ? 'read, interpolated, scaled, held and unmeasured' : 'read, interpolated and held';
604
+ const filledLabel = has106 ? 'interpolated, scaled, held or unmeasured' : 'interpolated or held';
605
+ // Across lists: the classes are disjoint, they cover every frame, and declined is exactly the non-read classes.
606
+ for (let x = 0; x < ALL_CLASSES.length; x++) {
607
+ for (let y = x + 1; y < ALL_CLASSES.length; y++) {
608
+ for (const a of classes[ALL_CLASSES[x]!]) {
609
+ for (const b of classes[ALL_CLASSES[y]!]) {
610
+ if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of ${classListLabel}`);
569
611
  }
570
612
  }
571
613
  }
@@ -586,18 +628,18 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
586
628
  return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
587
629
  };
588
630
  const read = set(classes.read);
589
- const filled = set([...classes.interpolated, ...classes.held]);
631
+ const nonRead = set([...classes.interpolated, ...classes.scaled, ...classes.held, ...classes.unmeasured]);
590
632
  const dec = set(declined);
591
633
  if (frames !== null) {
592
- const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !filled.has(f));
593
- if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of read, interpolated and held; every frame of the sequence is one of them`);
634
+ const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !nonRead.has(f));
635
+ if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of ${classListLabel}; every frame of the sequence is one of them`);
594
636
  }
595
637
  const both = [...dec].filter((f) => read.has(f)).sort((a, b) => a - b);
596
638
  if (both.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(both)} are both read and declined; a frame is read or declined, not both`);
597
- const unfilled = [...dec].filter((f) => !read.has(f) && !filled.has(f)).sort((a, b) => a - b);
598
- if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither interpolated nor held; a declined frame takes one of them`);
599
- const undeclared = [...filled].filter((f) => !dec.has(f)).sort((a, b) => a - b);
600
- if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are interpolated or held but not declined; only a frame the reading declined is filled, and "declined" says why`);
639
+ const unfilled = [...dec].filter((f) => !read.has(f) && !nonRead.has(f)).sort((a, b) => a - b);
640
+ if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither ${filledLabel}; a declined frame takes one of them`);
641
+ const undeclared = [...nonRead].filter((f) => !dec.has(f)).sort((a, b) => a - b);
642
+ if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are ${filledLabel} but not declined; only a frame the reading declined is filled, and "declined" says why`);
601
643
  }
602
644
  const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off' ? 'off' : { speed: h.speed, reach: h.reach, minSeconds: h.minSeconds, blend: h.blend });
603
645
  const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: h.jitter.before, after: h.jitter.after } });
package/src/verify.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  import { poseRawAnimation, poseRawSetup, type CompiledDocument, type RawBone } from 'rig-c/core';
12
12
  import type { Body18 } from './joints.ts';
13
13
  import { aimAngle, pointAt, type JointMap } from './map.ts';
14
- import type { HipsInferred, Joint, Run } from './pose.ts';
14
+ import type { HipsInferred, Joint, PelvisFit, Run } from './pose.ts';
15
15
  import type { Retarget } from './retarget.ts';
16
16
  import { pictureAngle, pointOn, wrap } from './retarget.ts';
17
17
 
@@ -260,13 +260,25 @@ export interface Posture {
260
260
  */
261
261
  hipsInferred?: { frames: number; source: string };
262
262
  /**
263
- * Present only when the hips were measured (#86, `hipsInferred.by` is
263
+ * Present only when the hips were measured (#86, #106, `hipsInferred.by` is
264
264
  * `measured`): the method, how many frames the tilt was read on — the
265
- * record's read, interpolated and held frames with both hips good, each
266
- * class counted — and the record's held spans. Any other frame with an
267
- * inferred hip is left out and counted in `hipsInferred`.
265
+ * record's read, interpolated, scaled and held frames with both hips good,
266
+ * each class counted — and the record's spans. Any `unmeasured` frame (corr
267
+ * < 0.9) or other frame with an inferred hip is left out of `tilt`.
268
268
  */
269
- tiltMeasured?: { method: string; frames: number; read: number; interpolated: number; held: number; heldSpans: Run[] };
269
+ tiltMeasured?: {
270
+ method: string;
271
+ frames: number;
272
+ read: number;
273
+ interpolated: number;
274
+ held: number;
275
+ heldSpans: Run[];
276
+ scaled?: number;
277
+ scaledSpans?: Run[];
278
+ unmeasured?: number;
279
+ unmeasuredSpans?: Run[];
280
+ fit?: PelvisFit;
281
+ };
270
282
  /**
271
283
  * Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
272
284
  * picture px × the figure scale) and in each body's own shoulder widths.
@@ -311,11 +323,12 @@ const medianOf = (xs: number[]): number => {
311
323
  * and the line counts the frames left out. Sway is read as before, inferred
312
324
  * hips included: it is a different measure, and the card leaves it as it is.
313
325
  *
314
- * Measured hips (#86, `measured`): the hips are inferred on every frame, and
315
- * turned to a measured angle: read off the picture, interpolated between two
316
- * close reads, or held from the earlier read. Those three are the measurement,
317
- * so the tilt is read on them; any other frame with an inferred hip is left
318
- * out and counted, as above. The line names the held spans.
326
+ * Measured hips (#86, #106, `measured`): the hips are inferred on every frame,
327
+ * and turned to a measured angle: read off the picture, interpolated between two
328
+ * close reads, scaled from the detector's hip line when `fit.corr >= 0.9`, or
329
+ * held from the earlier read. Those four are the measurement, so the tilt is
330
+ * read on them; any `unmeasured` frame (`fit.corr < 0.9`) or other frame with
331
+ * an inferred hip is left out and counted.
319
332
  */
320
333
  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', measured: Extract<HipsInferred, { by: 'measured' }> | null = null): Posture | null {
321
334
  const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
@@ -330,16 +343,23 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
330
343
  const refSw: number[] = [];
331
344
  let inferred = 0;
332
345
  const inClass = (rs: Run[], i: number) => rs.some(([a, b]) => a <= i && i <= b);
333
- const counts = { read: 0, interpolated: 0, held: 0 };
346
+ const counts = { read: 0, interpolated: 0, held: 0, scaled: 0, unmeasured: 0 };
334
347
  frames.forEach((f, i) => {
335
348
  const rh = at(i, 'r_hip');
336
349
  const lh = at(i, 'l_hip');
337
350
  if (rh === null || lh === null) return;
338
351
  const isInferred = f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred';
339
352
  const cls = measured === null ? undefined : (['read', 'interpolated', 'held'] as const).find((c) => inClass(measured[c], i));
353
+ const isScaled = measured !== null && measured.scaled !== undefined && inClass(measured.scaled, i);
354
+ const isUnmeasured = measured !== null && measured.unmeasured !== undefined && inClass(measured.unmeasured, i);
340
355
  if (isInferred && cls !== undefined) {
341
356
  counts[cls]++;
342
357
  refTilt.push(pictureAngle(rh, lh));
358
+ } else if (isInferred && isScaled) {
359
+ counts.scaled++;
360
+ refTilt.push(pictureAngle(rh, lh));
361
+ } else if (isInferred && isUnmeasured) {
362
+ counts.unmeasured++;
343
363
  } else if (isInferred) inferred++;
344
364
  else refTilt.push(pictureAngle(rh, lh));
345
365
  const rs = at(i, 'r_shoulder');
@@ -352,7 +372,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
352
372
  const aim = pelvis.aim[0]!;
353
373
  let tilt: Posture['tilt'] = null;
354
374
  const why = `hips inferred (${inferredSource})`;
355
- if (inferred === 0 || refTilt.length > 0) {
375
+ if ((inferred === 0 && counts.unmeasured === 0) || refTilt.length > 0) {
356
376
  const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
357
377
  tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
358
378
  tilt.ratio = tilt.rig.range / tilt.reference.range;
@@ -387,6 +407,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
387
407
  ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
388
408
  };
389
409
  }
410
+ const has106 = measured !== null && (measured.scaled !== undefined || measured.unmeasured !== undefined || measured.fit !== undefined);
390
411
  return {
391
412
  pelvis: pelvis.name,
392
413
  rigHip: r.origin.bone,
@@ -395,7 +416,27 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
395
416
  tilt,
396
417
  ...(tilt === null ? { tiltWhy: why } : {}),
397
418
  ...(inferred > 0 ? { hipsInferred: { frames: inferred, source: inferredSource } } : {}),
398
- ...(measured !== null ? { tiltMeasured: { method: measured.method, frames: counts.read + counts.interpolated + counts.held, ...counts, heldSpans: measured.held.map(([a, b]): Run => [a, b]) } } : {}),
419
+ ...(measured !== null
420
+ ? {
421
+ tiltMeasured: {
422
+ method: measured.method,
423
+ frames: counts.read + counts.interpolated + counts.scaled + counts.held,
424
+ read: counts.read,
425
+ interpolated: counts.interpolated,
426
+ held: counts.held,
427
+ heldSpans: measured.held.map(([a, b]): Run => [a, b]),
428
+ ...(has106
429
+ ? {
430
+ scaled: counts.scaled,
431
+ scaledSpans: (measured.scaled ?? []).map(([a, b]): Run => [a, b]),
432
+ unmeasured: counts.unmeasured,
433
+ unmeasuredSpans: (measured.unmeasured ?? []).map(([a, b]): Run => [a, b]),
434
+ ...(measured.fit !== undefined ? { fit: { frames: measured.fit.frames, gain: measured.fit.gain, corr: measured.fit.corr } } : {}),
435
+ }
436
+ : {}),
437
+ },
438
+ }
439
+ : {}),
399
440
  sway,
400
441
  };
401
442
  }
@@ -403,14 +444,23 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
403
444
  const f1 = (x: number) => x.toFixed(1);
404
445
  const f3 = (x: number) => x.toFixed(3);
405
446
  const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
447
+ const spansText = (rs: Run[] | undefined) => (rs === undefined || rs.length === 0 ? 'none' : rs.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', '));
406
448
 
407
449
  /** The POSTURE lines. */
408
450
  export function postureLines(q: Posture): string[] {
409
451
  const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
410
452
  const t = q.tilt;
411
453
  const m = q.tiltMeasured;
412
- const heldSpans = m === undefined || m.heldSpans.length === 0 ? 'none' : m.heldSpans.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
413
- const measured = m === undefined ? '' : `; tilt measured (${m.method}) on ${m.frames} frame(s): ${m.read} read, ${m.interpolated} interpolated, ${m.held} held (held: ${heldSpans})`;
454
+ const heldSpans = spansText(m?.heldSpans);
455
+ const scaledPart =
456
+ m?.scaled !== undefined && m.scaled > 0
457
+ ? `, ${m.scaled} scaled (scaled: ${spansText(m.scaledSpans)}${m.fit?.gain !== undefined && m.fit.gain !== null && m.fit.corr !== null ? `, gain ${m.fit.gain.toFixed(2)}×, r=${m.fit.corr.toFixed(3)}` : ''})`
458
+ : '';
459
+ const unmeasuredPart =
460
+ m?.unmeasured !== undefined && (m.unmeasured > 0 || (m.fit !== undefined && (m.fit.corr === null || m.fit.corr < 0.9)))
461
+ ? `; ${m.unmeasured} unmeasured (${spansText(m.unmeasuredSpans)}${m.fit !== undefined ? `, r=${m.fit.corr === null ? 'none' : m.fit.corr.toFixed(3)} < 0.9` : ''})`
462
+ : '';
463
+ const measured = m === undefined ? '' : `; tilt measured (${m.method}) on ${m.frames} frame(s): ${m.read} read, ${m.interpolated} interpolated${scaledPart}, ${m.held} held (held: ${heldSpans})${unmeasuredPart}`;
414
464
  const left = q.hipsInferred === undefined ? '' : `; ${q.hipsInferred.frames} frame(s) with an inferred hip not read (${q.hipsInferred.source})`;
415
465
  const out = [
416
466
  t === null