@rig-c/pose 0.41.0 → 0.43.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/src/pelvis.ts CHANGED
@@ -59,7 +59,7 @@ type Pt = [number, number];
59
59
  type RGB = [number, number, number];
60
60
 
61
61
  /** The two readers. */
62
- export const PELVIS_MODES = ['silhouette', 'waistband'] as const;
62
+ export const PELVIS_MODES = ['silhouette', 'waistband', 'below-cover'] as const;
63
63
  export type PelvisMode = (typeof PELVIS_MODES)[number];
64
64
  export const PELVIS_KEYS_SPEC = 'rig-pose-pelvis-keys/1';
65
65
 
@@ -85,13 +85,41 @@ export const MIN_SPAN = 6;
85
85
  export const CLIMB_SPREAD = 2;
86
86
  /** The band's top is climbed against at most this many garment colours (rig-c/pose#318): a paletted garment's own, or a painted one's fullest cells. */
87
87
  export const MAX_CLOTH = 16;
88
+ /** rig-c/pose#342: the below-cover cues read the same line when their tilts lie within this many degrees (max − min). */
89
+ export const CUE_SPREAD = 8;
90
+ /** ... a leg's colour is sampled this far along its thigh, hip → knee. */
91
+ export const LEG_AT = 0.7;
92
+ /** ... the hem and a thigh root are where the garment stops for this many px (rows, or along the thigh) in a row. */
93
+ export const LEAVE = 3;
88
94
 
89
95
  /**
90
96
  * One frame's reading: the tilt (Spine degrees, r_hip → l_hip), or why it was declined. A waistband
91
97
  * read through the window mean says so (`mean`), and one whose colour was found below the hips says
92
98
  * how far (`below`, px); one whose band top lay above the colour's edge in some columns says how many (`raised`, #318).
93
99
  */
94
- export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number; raised?: number } | { kind: string; why: string };
100
+ export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number; raised?: number; cues?: CueTilts } | { kind: string; why: string };
101
+
102
+ /**
103
+ * rig-c/pose#342: the below-cover cues of one frame, Spine degrees r_hip → l_hip; null where the cue was not read. `band` is
104
+ * "covered" when something other than the ground lies over the band's top. `hips` is the keypoints' line: reported, not fitted.
105
+ */
106
+ export interface CueTilts {
107
+ hem: number | null;
108
+ roots: number | null;
109
+ band: number | 'covered' | null;
110
+ hips: number;
111
+ }
112
+
113
+ /** rig-c/pose#342: a below-cover reading's figures and points (the picture's), and why each cue not read was not. */
114
+ export interface CueDetail {
115
+ tilts: CueTilts;
116
+ why: { hem?: string; roots?: string; band?: string };
117
+ garment: RGB;
118
+ legs: { r: RGB | null; l: RGB | null };
119
+ hem: { pts: Pt[]; fit: { m: number; q: number; rms: number } | null };
120
+ roots: { r: Pt | null; l: Pt | null; paths: Array<[Pt, Pt]> };
121
+ band: { m: number; q: number } | null;
122
+ }
95
123
 
96
124
  /** The frame as RGB over white, its background colour, and which pixels are masked (forearms, hands). */
97
125
  interface Frame {
@@ -194,7 +222,38 @@ export function readPelvis(plate: Plate, joints: Partial<Record<Body18, Joint>>,
194
222
  const f = frameOf(plate, arms, Math.max(3, ARM_RADIUS * span));
195
223
  if (x0 < 0 || x1 >= f.w || c[1] < 0 || c[1] >= f.h) return { kind: 'hips outside the frame', why: `the hips (${c[0].toFixed(1)}, ${c[1].toFixed(1)}) lie outside the ${f.w}×${f.h} frame` };
196
224
  const cols = x1 - x0 + 1;
197
- return mode === 'silhouette' ? silhouette(f, c, span, x0, x1, cols, rHip, lHip) : waistband(f, c, span, x0, x1, cols, rHip, lHip);
225
+ if (mode === 'silhouette') return silhouette(f, c, span, x0, x1, cols, rHip, lHip);
226
+ if (mode === 'waistband') return waistband(f, c, span, x0, x1, cols, rHip, lHip);
227
+ return belowCover(f, c, span, x0, x1, cols, rHip, lHip, pt).reading;
228
+ }
229
+
230
+ /**
231
+ * rig-c/pose#342: the below-cover reading of one frame with every cue's figures and points, for a picture of it. Null when
232
+ * the frame is declined before any cue is read (no hips, side-on, outside the frame); the reading is `readPelvis`'s.
233
+ */
234
+ export function readBelowCover(plate: Plate, joints: Partial<Record<Body18, Joint>>): { reading: Reading; detail: CueDetail | null } {
235
+ const reading = readPelvis(plate, joints, 'below-cover');
236
+ const pt = (n: Body18): Pt | null => {
237
+ const j = joints[n];
238
+ return j === undefined || j.state === 'missing' || j.at === undefined ? null : [j.at[0], j.at[1]];
239
+ };
240
+ const rHip = pt('r_hip');
241
+ const lHip = pt('l_hip');
242
+ if (rHip === null || lHip === null) return { reading, detail: null };
243
+ const span = Math.hypot(lHip[0] - rHip[0], lHip[1] - rHip[1]);
244
+ const x0 = Math.round(Math.min(rHip[0], lHip[0]));
245
+ const x1 = Math.round(Math.max(rHip[0], lHip[0]));
246
+ if (span < MIN_SPAN || x1 - x0 + 1 < MIN_SPAN) return { reading, detail: null };
247
+ const c: Pt = [(rHip[0] + lHip[0]) / 2, (rHip[1] + lHip[1]) / 2];
248
+ const arms: Array<[Pt, Pt]> = [];
249
+ for (const [e, w] of [['r_elbow', 'r_wrist'], ['l_elbow', 'l_wrist']] as const) {
250
+ const a = pt(e);
251
+ const b = pt(w);
252
+ if (a !== null && b !== null) arms.push([a, b]);
253
+ }
254
+ const f = frameOf(plate, arms, Math.max(3, ARM_RADIUS * span));
255
+ if (x0 < 0 || x1 >= f.w || c[1] < 0 || c[1] >= f.h) return { reading, detail: null };
256
+ return { reading, detail: belowCover(f, c, span, x0, x1, x1 - x0 + 1, rHip, lHip, pt).detail };
198
257
  }
199
258
 
200
259
  function silhouette(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt): Reading {
@@ -272,7 +331,8 @@ function meanOf(f: Frame): (x: number, y: number) => RGB | null {
272
331
  };
273
332
  }
274
333
 
275
- function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt): Reading {
334
+ /** `out.fit`, when given, receives the edge read (rig-c/pose#342 looks at what lies above it). */
335
+ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt, out?: { fit?: { m: number; q: number } }): Reading {
276
336
  // The garment's colour: the median over the unmasked pixels of a patch at the hips' midpoint (or below it), as they are or through the window mean.
277
337
  const r = Math.max(2, 0.3 * span);
278
338
  const raw = (x: number, y: number): RGB | null => f.rgb(x, y);
@@ -341,6 +401,7 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
341
401
  if (Math.abs(Math.atan(fit.m)) * (180 / Math.PI) > 45) return { kind: 'waistband too steep', why: `the garment's top edge runs ${(Math.atan(fit.m) * (180 / Math.PI)).toFixed(1)}° from level; a waistband is within 45°` };
342
402
  const off = notTheBand(f, fit, tops, top, colourAt);
343
403
  if (off !== null) return off;
404
+ if (out !== undefined) out.fit = { m: fit.m, q: fit.q };
344
405
  return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}), ...(raised > 0 ? { raised } : {}) };
345
406
  }
346
407
 
@@ -532,6 +593,193 @@ function notTheBand(f: Frame, fit: { m: number; q: number }, tops: Pt[], top: nu
532
593
  };
533
594
  }
534
595
 
596
+ /**
597
+ * rig-c/pose#342: the pelvis read below a cover. A garment hanging over the waistband (a sweater's hem) hides the band's top
598
+ * and hangs level whatever the pelvis does, so the band-top reader reads the cover. What the cover cannot hide is read:
599
+ *
600
+ * - `hem`: the bottomwear's lower hem across the hip span — in each column, from the hips' row down, the last garment row
601
+ * before `LEAVE` rows that are not garment; a line fitted through them (≥ `MIN_COVER` of the columns);
602
+ * - `roots`: where each thigh leaves the garment — along hip → knee (keypoints), the last garment point before `LEAVE` px
603
+ * that are not; the line from the right root to the left;
604
+ * - `band`: the waistband reader's band top, used only when the ground lies above it (nothing covers it);
605
+ * - `hips`: the keypoints' line, reported but not fitted — a detector's hips keep a fraction of a drawn tilt (#288).
606
+ *
607
+ * Every colour is the window mean (`DITHER_RADIUS`); a pixel is garment when it is nearest the garment's colour among the
608
+ * garment's, each leg's (sampled `LEG_AT` along its thigh) and the ground's, so both shades of a cloth and the ink on it
609
+ * are garment and skin or ground under the hem are not. The tilt is the mean of the cues fitted (hem, roots, an
610
+ * uncovered band); fewer than two, or two that differ by more than `CUE_SPREAD`, decline the frame by name.
611
+ */
612
+ function belowCover(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt, pt: (n: Body18) => Pt | null): { reading: Reading; detail: CueDetail | null } {
613
+ const r = Math.max(2, 0.3 * span);
614
+ const mean = meanOf(f);
615
+ const disc = (at: Pt): RGB | null => {
616
+ const ps: RGB[] = [];
617
+ for (let y = Math.floor(at[1] - r); y <= Math.ceil(at[1] + r); y++) {
618
+ for (let x = Math.floor(at[0] - r); x <= Math.ceil(at[0] + r); x++) {
619
+ if (x < 0 || y < 0 || x >= f.w || y >= f.h || Math.hypot(x - at[0], y - at[1]) > r || f.masked(x, y)) continue;
620
+ const p = mean(x, y);
621
+ if (p !== null) ps.push(p);
622
+ }
623
+ }
624
+ return ps.length === 0 ? null : ([0, 1, 2].map((k) => median(ps.map((p) => p[k]!))) as RGB);
625
+ };
626
+ const holds = (at: Pt, g: RGB) => {
627
+ let n = 0;
628
+ let k = 0;
629
+ for (let y = Math.floor(at[1] - r); y <= Math.ceil(at[1] + r); y++) {
630
+ for (let x = Math.floor(at[0] - r); x <= Math.ceil(at[0] + r); x++) {
631
+ if (x < 0 || y < 0 || x >= f.w || y >= f.h || Math.hypot(x - at[0], y - at[1]) > r || f.masked(x, y)) continue;
632
+ const p = mean(x, y);
633
+ if (p === null) continue;
634
+ n++;
635
+ if (diff(p, g) <= GARMENT_DIFF) k++;
636
+ }
637
+ }
638
+ return n > 0 && k >= HOLD * n;
639
+ };
640
+ const mid = disc(c);
641
+ if (mid === null) return { reading: { kind: 'hips behind a forearm', why: 'the hips\' midpoint is covered by a forearm; no garment colour to read' }, detail: null };
642
+ let g: RGB = mid;
643
+ for (const drop of [0, r, 2 * r]) {
644
+ const at: Pt = [c[0], c[1] + drop];
645
+ const p = drop === 0 ? mid : disc(at);
646
+ if (p !== null && diff(p, f.bg) > FIGURE_DIFF && holds(at, p)) {
647
+ g = p;
648
+ break;
649
+ }
650
+ }
651
+ if (diff(g, f.bg) <= FIGURE_DIFF) return { reading: { kind: 'no garment over the hips', why: `the colour at the hips' midpoint (${g.map((v) => v.toFixed(0)).join(', ')}) is the background's (${f.bg.map((v) => v.toFixed(0)).join(', ')})` }, detail: null };
652
+ const knee = { r: pt('r_knee'), l: pt('l_knee') };
653
+ const hip = { r: rHip, l: lHip };
654
+ const legs = { r: null as RGB | null, l: null as RGB | null };
655
+ for (const s of ['r', 'l'] as const) {
656
+ const k = knee[s];
657
+ if (k === null) continue;
658
+ const L = disc([hip[s][0] + LEG_AT * (k[0] - hip[s][0]), hip[s][1] + LEG_AT * (k[1] - hip[s][1])]);
659
+ if (L !== null && diff(L, g) > GARMENT_DIFF) legs[s] = L;
660
+ }
661
+ const others = [f.bg, ...(legs.r !== null ? [legs.r] : []), ...(legs.l !== null ? [legs.l] : [])];
662
+ if (others.length === 1) return { reading: { kind: 'no leg below the garment', why: `neither thigh carries a colour of its own ${LEG_AT} of the way to its knee (${knee.r === null || knee.l === null ? 'a knee has no position' : 'both within ' + GARMENT_DIFF + ' of the garment\'s'}); nothing below the garment to tell its hem by` }, detail: null };
663
+ // Garment: nearest the garment's colour among it, the legs' and the ground's (a tie is garment). Null where every pixel of the window is masked.
664
+ const isGarment = (x: number, y: number): boolean | null => {
665
+ if (x < 0 || y < 0 || x >= f.w || y >= f.h || f.masked(x, y)) return null;
666
+ const p = mean(x, y);
667
+ if (p === null) return null;
668
+ return diff(p, g) <= Math.min(...others.map((o) => diff(p, o)));
669
+ };
670
+ const why: CueDetail['why'] = {};
671
+ // The hem.
672
+ const hemPts: Pt[] = [];
673
+ const yEnd = Math.min(f.h - 1, Math.floor(c[1] + 2.5 * span));
674
+ for (let x = x0; x <= x1; x++) {
675
+ let inside = false;
676
+ let last = -1;
677
+ let out = 0;
678
+ for (let y = Math.max(0, Math.round(c[1])); y <= yEnd; y++) {
679
+ const gm = isGarment(x, y);
680
+ if (gm === null) {
681
+ if (inside) break;
682
+ continue;
683
+ }
684
+ if (gm) {
685
+ inside = true;
686
+ last = y;
687
+ out = 0;
688
+ } else if (inside && ++out >= LEAVE) {
689
+ hemPts.push([x, last]);
690
+ break;
691
+ }
692
+ }
693
+ }
694
+ const hemFit = hemPts.length >= MIN_COVER * cols ? fitLine(hemPts) : null;
695
+ let hem: number | null = null;
696
+ if (hemFit === null) why.hem = hemPts.length < MIN_COVER * cols ? `the hem is found in ${hemPts.length} of the ${cols} columns between the hips (< ${100 * MIN_COVER}%)` : 'the hem rows do not fit a line';
697
+ else hem = tiltOf(hemFit.m, rHip, lHip);
698
+ // The thigh roots.
699
+ const root = { r: null as Pt | null, l: null as Pt | null };
700
+ const rootWhy: string[] = [];
701
+ const paths: Array<[Pt, Pt]> = [];
702
+ for (const s of ['r', 'l'] as const) {
703
+ const k = knee[s];
704
+ if (k === null) {
705
+ rootWhy.push(`${s}_knee has no position`);
706
+ continue;
707
+ }
708
+ const h = hip[s];
709
+ paths.push([h, k]);
710
+ const len = Math.hypot(k[0] - h[0], k[1] - h[1]);
711
+ const n = Math.ceil(len / 0.5);
712
+ let inside = false;
713
+ let last: Pt | null = null;
714
+ let out = 0;
715
+ let hidden = false;
716
+ for (let i = 0; i <= n; i++) {
717
+ const p: Pt = [h[0] + (i / n) * (k[0] - h[0]), h[1] + (i / n) * (k[1] - h[1])];
718
+ const gm = isGarment(Math.round(p[0]), Math.round(p[1]));
719
+ if (gm === null) {
720
+ if (inside) {
721
+ hidden = true;
722
+ break;
723
+ }
724
+ continue;
725
+ }
726
+ if (gm) {
727
+ inside = true;
728
+ last = p;
729
+ out = 0;
730
+ } else if (inside && ++out * (len / n) >= LEAVE) {
731
+ root[s] = last;
732
+ break;
733
+ }
734
+ }
735
+ if (root[s] === null) rootWhy.push(hidden ? `the ${s} thigh's root is behind a forearm` : inside ? `the ${s} thigh does not leave the garment before its knee` : `the ${s} thigh's path carries no garment`);
736
+ }
737
+ let roots: number | null = null;
738
+ if (root.r !== null && root.l !== null) {
739
+ if (Math.abs(root.l[0] - root.r[0]) < MIN_SPAN) rootWhy.push(`the roots are ${Math.abs(root.l[0] - root.r[0]).toFixed(1)} px apart across (< ${MIN_SPAN})`);
740
+ else roots = pictureAngle(root.r, root.l);
741
+ }
742
+ if (roots === null) why.roots = rootWhy.join('; ');
743
+ // The band's top, when nothing covers it.
744
+ const edge: { fit?: { m: number; q: number } } = {};
745
+ const wb = waistband(f, c, span, x0, x1, cols, rHip, lHip, edge);
746
+ let band: number | 'covered' | null = null;
747
+ if (!('tilt' in wb) || edge.fit === undefined) why.band = 'kind' in wb ? `${wb.kind}` : 'no edge';
748
+ else {
749
+ const above: RGB[] = [];
750
+ for (let x = x0; x <= x1; x++) {
751
+ const y = Math.round(edge.fit.m * x + edge.fit.q) - 3 * (DITHER_RADIUS + 1);
752
+ if (y < 0 || y >= f.h || f.masked(x, y)) continue;
753
+ const p = mean(x, y);
754
+ if (p !== null) above.push(p);
755
+ }
756
+ const a = above.length === 0 ? null : ([0, 1, 2].map((k) => median(above.map((p) => p[k]!))) as RGB);
757
+ if (a !== null && diff(a, f.bg) <= FIGURE_DIFF) band = wb.tilt;
758
+ else {
759
+ band = 'covered';
760
+ why.band = a === null ? 'nothing seen above the band\'s top' : `the band's top is covered (${a.map((v) => v.toFixed(0)).join(', ')} lies ${3 * (DITHER_RADIUS + 1)} px above it, not the ground)`;
761
+ }
762
+ }
763
+ const hips = pictureAngle(rHip, lHip);
764
+ const tilts: CueTilts = { hem, roots, band, hips };
765
+ const detail: CueDetail = { tilts, why, garment: g, legs, hem: { pts: hemPts, fit: hemFit === null ? null : { m: hemFit.m, q: hemFit.q, rms: hemFit.rms } }, roots: { ...root, paths }, band: typeof band === 'number' ? edge.fit! : null };
766
+ const fitted: Array<[string, number]> = [];
767
+ if (hem !== null) fitted.push(['hem', hem]);
768
+ if (roots !== null) fitted.push(['roots', roots]);
769
+ if (typeof band === 'number') fitted.push(['band', band]);
770
+ const fmt = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
771
+ if (fitted.length < 2) {
772
+ const missing = (['hem', 'roots', 'band'] as const).filter((k) => why[k] !== undefined).map((k) => `${k}: ${why[k]}`);
773
+ return { reading: { kind: 'too few cues below the cover', why: `${fitted.length} of the cues read (${fitted.map(([n, v]) => `${n} ${fmt(v)}`).join(', ') || 'none'}); ${missing.join('; ')}` }, detail };
774
+ }
775
+ const base = fitted[0]![1];
776
+ const about = fitted.map(([, v]) => base + wrap(v - base));
777
+ const spread = Math.max(...about) - Math.min(...about);
778
+ if (spread > CUE_SPREAD) return { reading: { kind: 'cues disagree', why: `${fitted.map(([n, v]) => `${n} ${fmt(v)}`).join(', ')}: ${spread.toFixed(1)}° apart (> ${CUE_SPREAD}°); they do not read one line (hips ${fmt(hips)}, not fitted)` }, detail };
779
+ const tilt = wrap(about.reduce((s, v) => s + v, 0) / about.length);
780
+ return { reading: { tilt, cover: hemPts.length / cols, rms: hemFit?.rms ?? 0, mean: true, cues: tilts }, detail };
781
+ }
782
+
535
783
  /**
536
784
  * `rig-pose-pelvis-keys/1`: `{ spec, keys: [{ frame, deg }], note? }` — frames of the
537
785
  * sequence, strictly increasing, within its span [0, frames − 1]; `deg` the tilt in
@@ -686,6 +934,17 @@ export function runPelvis(a: PelvisArgs, say: (l: string) => void): void {
686
934
  const tilt = readings.map((r) => ('tilt' in r ? r.tilt : null));
687
935
  const read = tilt.filter((v): v is number => v !== null);
688
936
  say(`rig-pose pelvis: ${n} frame(s) of person "${person}" from ${a.pose}, read by ${a.mode} off ${dir} (${files.length} PNG)`);
937
+ if (a.mode === 'below-cover') {
938
+ // rig-c/pose#342: every frame's cues, read or not, so a decline or a reading can be checked cue by cue.
939
+ const fmt = (v: number | 'covered' | null) => (v === null ? 'not read' : v === 'covered' ? 'covered' : `${v >= 0 ? '+' : ''}${v.toFixed(2)}°`);
940
+ readings.forEach((r, i) => {
941
+ if (!('tilt' in r) || r.cues === undefined) return;
942
+ // The hips stay out of the vote; their sign is printed beside the fit, and an opposite sign is warned.
943
+ const sign = (v: number) => (Math.abs(v) < 0.5 ? '0' : v > 0 ? '+' : '−');
944
+ say(` CUES frame ${i}: hem ${fmt(r.cues.hem)}, roots ${fmt(r.cues.roots)}, band ${fmt(r.cues.band)}, hips ${fmt(r.cues.hips)} (not fitted) → ${fmt(r.tilt)}; hips sign ${sign(r.cues.hips)}, fit sign ${sign(r.tilt)}`);
945
+ if (sign(r.cues.hips) !== '0' && sign(r.tilt) !== '0' && sign(r.cues.hips) !== sign(r.tilt)) say(` HIPS-SIGN frame ${i}: the hip keypoints tilt ${fmt(r.cues.hips)}, opposite the fit's ${fmt(r.tilt)} (the hips are not fitted)`);
946
+ });
947
+ }
689
948
  for (const d of declinedRuns(readings)) say(` DECLINED frame${d.from === d.to ? ` ${d.from}` : `s ${d.from}–${d.to}`} (${d.to - d.from + 1}): ${d.kind} — ${d.same ? d.why : `frame ${d.from}: ${d.why}; the figures differ frame to frame`}`);
690
949
  if (read.length > 0) {
691
950
  const covers = readings.flatMap((r) => ('tilt' in r ? [r.cover] : []));
package/src/pose.ts CHANGED
@@ -74,15 +74,16 @@ export interface SegmentDepth {
74
74
  source: DepthSource;
75
75
  }
76
76
  export type FrameDepth = Partial<Record<DepthSegment, SegmentDepth>>;
77
- /** The gait `clean --depth` was told the clip is: it decides the sign of a foreshortened shin. */
77
+ /** The gait `clean --depth` was told the clip is (rig-c/pose#336): the signs are the same for both; it decides the joint `--draw-order depth` orders the legs by. */
78
78
  export const DEPTH_GAITS = ['walk', 'run'] as const;
79
79
  export type DepthGait = (typeof DEPTH_GAITS)[number];
80
80
  /**
81
81
  * Where a sequence's `pelvisTilt` came from (rig-c/pose#288): `silhouette` — the
82
82
  * straight edge of the drawn figure across the hips; `waistband` — the top edge
83
- * of a garment covering them; `keys` — a hand file (`--pelvis-keys`).
83
+ * of a garment covering them; `below-cover` — the cues under a garment hanging
84
+ * over the waistband (rig-c/pose#342); `keys` — a hand file (`--pelvis-keys`).
84
85
  */
85
- export const PELVIS_SOURCES = ['silhouette', 'waistband', 'keys'] as const;
86
+ export const PELVIS_SOURCES = ['silhouette', 'waistband', 'below-cover', 'keys'] as const;
86
87
  export type PelvisSource = (typeof PELVIS_SOURCES)[number];
87
88
  export const POSE_SPACE = { units: 'picture-px', origin: 'top-left', y: 'down' } as const;
88
89
  /**
package/src/retarget.ts CHANGED
@@ -76,12 +76,13 @@ import type { Body18 } from './joints.ts';
76
76
  import { isBody18, VIRTUAL } from './joints.ts';
77
77
  import type { Chain, JointMap, Rest, RigSets, RigSlots } from './map.ts';
78
78
  import { aimAngle, aimEnds, bodyYawSets, descends, faceYawSets, knownSetsOf, partBone, pointParts, rigPoint } from './map.ts';
79
- import type { Joint, Travel } from './pose.ts';
79
+ import type { DepthGait, Joint, Travel } from './pose.ts';
80
80
  import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SetRuns, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
- import { clampArt, coveredChains, isRaised, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
83
+ import { clampArt, coveredChains, isRaised, keyAtFrame, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, LegSegmentRatio, PathPose, Posing, Side, Stance } from './legs.ts';
85
+ import { contactKeysOf, type ContactKeys } from './contacts.ts';
85
86
  import { figureScaleOf, freeAnkleShiftOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, shiftOver, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
87
  import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
87
88
  import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
@@ -181,6 +182,41 @@ export interface RetargetOptions extends SwitchOptions {
181
182
  * the hip line on the frames it does not; absent, from the hip line.
182
183
  */
183
184
  pelvis?: { source: string; tilt: ReadonlyArray<number | null> };
185
+ /**
186
+ * rig-c/pose#303: the hand contact keys (`--contact-keys`), read against the
187
+ * sequence. Applied last over the supports read (`contactKeysOf`): on every
188
+ * frame they cover they decide the support. Absent, nothing changes.
189
+ */
190
+ contactKeys?: ContactKeys;
191
+ /**
192
+ * rig-c/pose#344: the gait the sequence was cleaned as (its `cleaned.params.depth.gait`, `clean --depth`). An
193
+ * in-place walk anchors the upper body (`TorsoAnchor`): the pelvis sways under it. Absent, or a run, nothing changes.
194
+ */
195
+ gait?: DepthGait;
196
+ }
197
+
198
+ /**
199
+ * rig-c/pose#344: in an in-place walk the upper body is the anchor and the pelvis sways under it. The pelvis bone
200
+ * carries the torso (its child), so its turn and its travel would swing the chest around it; the first spine bone
201
+ * (`bone`: the pelvis bone's child on the way to the trunk chain's bone) is keyed with the counter: a rotate of minus
202
+ * the pelvis's turn (none when it is the trunk bone itself, whose aim is already in the world), and a translate that
203
+ * puts its origin back where the pelvis's turn moved it from, up and down, and sideways where it carries `point`
204
+ * (the trunk chain's aim bone: the top of the trunk) on the drawn neck's own sideways path: about the world x `x`
205
+ * (the setup x plus the pelvis's mean travel), the picture's `joint` x less its mean, times `factor` — the pelvis's
206
+ * sideways sway in the rig against the drawing's (`joints[0]`, mid_hip), each its standard deviation over the frames,
207
+ * so the neck sways against the pelvis as the drawing's does; the figure's scale where the drawing's pelvis does not
208
+ * sway. The pelvis's own keys are as read; `neck` is the carried path's peak-to-peak and `sway` the first spine bone's
209
+ * sideways counter's, world units.
210
+ */
211
+ export interface TorsoAnchor {
212
+ gait: DepthGait;
213
+ bone: string;
214
+ point: string;
215
+ joint: string;
216
+ x: number;
217
+ factor: number;
218
+ neck: number;
219
+ sway: number;
184
220
  }
185
221
 
186
222
  /**
@@ -201,6 +237,8 @@ export interface PelvisReading {
201
237
  hipLine: number;
202
238
  /** The frames keyed from the hip line on a sequence that carries a track: the frames its reading declined. */
203
239
  declined: Array<[number, number]>;
240
+ /** rig-c/pose#344: the torso held over the swaying pelvis, in an in-place walk only. */
241
+ anchor?: TorsoAnchor;
204
242
  }
205
243
 
206
244
  export interface Hold {
@@ -897,6 +935,32 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
897
935
  }
898
936
  p.throwIfAny();
899
937
 
938
+ // rig-c/pose#344: an in-place walk holds the upper body over the swaying pelvis, on the first spine bone.
939
+ const anchorOf = ((): { bone: string; trunk: string; point: string; pelvis: string; joints: [string, string] } | null => {
940
+ if (opts.gait !== 'walk' || opts.travel !== TRAVEL_IN_PLACE || pelvisChain === null) return null;
941
+ const pelvisBone = pelvisChain.bones[0]!;
942
+ const trunk = map.chains.find((c) => !c.reach && c.joints[0] === 'mid_hip' && descends(rest, c.bones[0]!, pelvisBone));
943
+ if (trunk === undefined) return null;
944
+ const tb = trunk.bones[0]!;
945
+ let first = tb;
946
+ while (rest.get(first)!.parent !== pelvisBone) first = rest.get(first)!.parent!;
947
+ // The aim's upper end (aimEnds: `neck` and `chest>neck` alike end at the neck), a rig point on the trunk bone or below it.
948
+ const aim = trunk.aim[0]!;
949
+ const top = aim === 'x' ? null : aimEnds(tb, aim)[1];
950
+ const onTrunk = top !== null && pointParts(top).every((part) => {
951
+ const b = partBone(part);
952
+ return rest.has(b) && (b === tb || descends(rest, b, tb));
953
+ });
954
+ const point = onTrunk ? top! : tb;
955
+ return { bone: first, trunk: tb, point, pelvis: pelvisBone, joints: [trunk.joints[0]!, trunk.joints[1]!] };
956
+ })();
957
+ if (anchorOf !== null && anchorOf.bone !== anchorOf.trunk && !targets.has(anchorOf.bone)) {
958
+ // Not in a chain: its world turn is held at setup, so the bones below it are keyed against none.
959
+ const a0 = restAngle(rest, anchorOf.bone, 'x');
960
+ restOf.set(anchorOf.bone, a0);
961
+ targets.set(anchorOf.bone, Array.from({ length: n }, () => a0));
962
+ }
963
+
900
964
  // The short hold, on every segment the pose aims directly (not a reach): its
901
965
  // picture length against its own median, on the points the angle was read from.
902
966
  // A horizontal line across the body (`isAcross`: pelvis, shoulders, head line)
@@ -1020,6 +1084,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1020
1084
  else if (plantOn) p.add('LEGS_MAP', `--plant-feet reads both hips and both ankles from the pose; the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`);
1021
1085
  }
1022
1086
  }
1087
+ // rig-c/pose#303: the contact keys decide last, over every reading above; with no support read they have nothing to decide, so they are refused.
1088
+ if (opts.contactKeys !== undefined) {
1089
+ if (feet !== null) feet = contactKeysOf(feet, opts.contactKeys);
1090
+ else p.add('CONTACT_KEYS', `--contact-keys sets which foot supports, and no support is read: ${legs !== null && 'problems' in legs ? legs.problems.join('; ') : `the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`}`);
1091
+ }
1023
1092
  const heldPt = (name: string): Pt[] | null => {
1024
1093
  const known = series.get(name);
1025
1094
  if (known !== undefined) return known;
@@ -1497,7 +1566,43 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1497
1566
  const r = turnAbove(parent, i) / DEG;
1498
1567
  const ox = own.worldX - rest.get(parent)!.worldX;
1499
1568
  const oy = own.worldY - rest.get(parent)!.worldY;
1500
- return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy + nod[0], po[1] + Math.sin(r) * ox + Math.cos(r) * oy + nod[1]];
1569
+ const at: Pt = [po[0] + Math.cos(r) * ox - Math.sin(r) * oy + nod[0], po[1] + Math.sin(r) * ox + Math.cos(r) * oy + nod[1]];
1570
+ if (anchorOf === null || b !== anchorOf.bone || anchorRaw) return at;
1571
+ const s = anchorShift(i, at, po, oy);
1572
+ return [at[0] + s[0], at[1] + s[1]];
1573
+ };
1574
+ /**
1575
+ * rig-c/pose#344: the world shift that holds the first spine bone over the pelvis: up and down, its origin where
1576
+ * the pelvis's travel alone puts it (the pelvis's turn taken out); sideways, the trunk's top point on `anchorPath`:
1577
+ * the drawn neck's sideways path about its mean, scaled by the pelvis's rig ÷ drawn sideways sway (`TorsoAnchor`).
1578
+ */
1579
+ let anchorRaw = false;
1580
+ let anchorX: number | null = null;
1581
+ let anchorFactor = 1;
1582
+ let anchorPath: number[] | null = null;
1583
+ const anchorShift = (i: number, at: Pt, po: Pt, oy: number): Pt => {
1584
+ const a = anchorOf!;
1585
+ if (anchorPath === null) {
1586
+ anchorRaw = true;
1587
+ const hipX = Array.from({ length: n }, (_, k) => posedOrigin(a.pelvis, k)[0] - rest.get(a.pelvis)!.worldX);
1588
+ anchorRaw = false;
1589
+ const mean = (xs: readonly number[]) => xs.reduce((s, x) => s + x, 0) / xs.length;
1590
+ const sd = (xs: readonly number[]) => {
1591
+ const m = mean(xs);
1592
+ return Math.sqrt(xs.reduce((s, x) => s + (x - m) ** 2, 0) / xs.length);
1593
+ };
1594
+ const drawnHip = series.get(a.joints[0])!.map((p) => p[0]);
1595
+ const drawnNeck = series.get(a.joints[1])!.map((p) => p[0]);
1596
+ const sdHip = sd(drawnHip);
1597
+ anchorFactor = sdHip > 1e-6 ? sd(hipX) / sdHip : (scale?.factor ?? 1);
1598
+ anchorX = rigPoint(rest, a.point)![0] + mean(hipX);
1599
+ const m = mean(drawnNeck);
1600
+ anchorPath = drawnNeck.map((x) => anchorX! + (x - m) * anchorFactor);
1601
+ }
1602
+ anchorRaw = true;
1603
+ const top = posedPoint(a.point, i);
1604
+ anchorRaw = false;
1605
+ return [anchorPath[i]! - top[0], po[1] + oy - at[1]];
1501
1606
  };
1502
1607
  /** A rig point (a bone, `name:tip`, `a+b`) posed on frame i the same way. */
1503
1608
  const posedPoint = (spec: string, i: number): Pt => {
@@ -2987,6 +3092,42 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2987
3092
  }
2988
3093
  }
2989
3094
  }
3095
+ // rig-c/pose#344: the first spine bone's counter — its rotate when it is not the trunk bone, and its translate.
3096
+ let anchor: TorsoAnchor | null = null;
3097
+ if (anchorOf !== null) {
3098
+ const a = anchorOf;
3099
+ if (a.bone !== a.trunk) {
3100
+ const above = mappedAbove(a.bone);
3101
+ let prev = 0;
3102
+ const keys: MotionKey[] = [];
3103
+ for (let i = 0; i < n; i++) {
3104
+ const raw = turn(a.bone, i) - (above === null ? 0 : turn(above, i));
3105
+ prev = i === 0 ? wrap(raw) : prev + wrap(raw - prev);
3106
+ keys.push({ t: keyTimes[i]!, v: [r3(prev)] });
3107
+ }
3108
+ tracks.push({ bone: a.bone, property: 'rotate', keys });
3109
+ }
3110
+ const pr = rest.get(a.pelvis)!;
3111
+ const det = pr.a * pr.d - pr.b * pr.c;
3112
+ const keys: MotionKey[] = [];
3113
+ const xs: number[] = [];
3114
+ for (let i = 0; i < n; i++) {
3115
+ const held = posedOrigin(a.bone, i);
3116
+ anchorRaw = true;
3117
+ const free = posedOrigin(a.bone, i);
3118
+ anchorRaw = false;
3119
+ const wx = held[0] - free[0];
3120
+ const wy = held[1] - free[1];
3121
+ xs.push(wx);
3122
+ // Into the pelvis's turned frame, then through its setup matrix: the translate is in the parent's local axes.
3123
+ const r = -turnAbove(a.pelvis, i) / DEG;
3124
+ const ux = Math.cos(r) * wx - Math.sin(r) * wy;
3125
+ const uy = Math.sin(r) * wx + Math.cos(r) * wy;
3126
+ keys.push({ t: keyTimes[i]!, v: [r3((pr.d * ux - pr.b * uy) / det), r3((-pr.c * ux + pr.a * uy) / det)] });
3127
+ }
3128
+ tracks.push({ bone: a.bone, property: 'translate', keys });
3129
+ anchor = { gait: 'walk', bone: a.bone, point: a.point, joint: a.joints[1], x: r3(anchorX!), factor: r6(anchorFactor), neck: r3(Math.max(...anchorPath!) - Math.min(...anchorPath!)), sway: r3(Math.max(...xs) - Math.min(...xs)) };
3130
+ }
2990
3131
  let face: FaceReading | null = null;
2991
3132
  if (map.face !== undefined) {
2992
3133
  const rEar = series.get('r_ear') ?? (() => {
@@ -3048,7 +3189,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3048
3189
  if (!slotOrder.includes(sl)) slotOrder.push(sl);
3049
3190
  }
3050
3191
  }
3051
- const yawExactAt = new Map<string, Set<number>>();
3052
3192
  for (const s of activeYawSets) {
3053
3193
  const def = known.get(s.set);
3054
3194
  if (def === undefined) continue;
@@ -3058,10 +3198,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3058
3198
  const row = sw.shown.get(sl)!;
3059
3199
  for (const [lo, hi] of s.spans) {
3060
3200
  for (let i = lo; i <= hi; i++) row[i] = v;
3061
- const exact = yawExactAt.get(sl) ?? new Set<number>();
3062
- exact.add(lo);
3063
- if (hi + 1 < n) exact.add(hi + 1);
3064
- yawExactAt.set(sl, exact);
3065
3201
  }
3066
3202
  }
3067
3203
  }
@@ -3071,11 +3207,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3071
3207
  if (row === undefined) continue;
3072
3208
  let prev = slots.get(sl)?.setup ?? null;
3073
3209
  const keys: AttachmentKey[] = [];
3074
- const exact = yawExactAt.get(sl);
3075
3210
  row.forEach((v, i) => {
3076
3211
  if (v === prev) return;
3077
- const t = i === 0 ? 0 : exact?.has(i) === true ? keyTimes[i]! : r6((keyTimes[i - 1]! + keyTimes[i]!) / 2);
3078
- keys.push({ t, v });
3212
+ // Half a frame before the frame it shows on, as every discrete key pose writes (`keyAtFrame`, rig-c/pose#334):
3213
+ // written on their frames' exact times, 7 of the 10 yaw swaps of the 24 fps head turn in test/retarget.test.ts
3214
+ // showed a frame late under the render's clock.
3215
+ keys.push({ t: keyAtFrame(keyTimes, i), v });
3079
3216
  prev = v;
3080
3217
  });
3081
3218
  if (keys.length > 0) rebuilt.push({ slot: sl, property: 'attachment', keys });
@@ -3128,6 +3265,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3128
3265
 
3129
3266
  const pivot = pivotOf(rest, t?.bone ?? null, legs);
3130
3267
  const pelvis = pelvisReadingOf(pelvisChain, opts.pelvis, track, fromTrack, tracks);
3268
+ if (pelvis !== null && anchor !== null) pelvis.anchor = anchor;
3131
3269
  const scaleFit = t === null || scale === null || origin === null ? null : scaleFitOf({
3132
3270
  joints: t.scale.pose, joint: t.joint, bone: t.bone, lengths: scaleLengths, factor: scale.factor,
3133
3271
  series, good, legLength: feet?.legLength ?? null, legStraight, rigLeg,
@@ -3179,10 +3317,16 @@ export function pelvisLine(r: PelvisReading, frames: number): string {
3179
3317
  const where = r.bone === null ? '' : `bone "${r.bone}", chain "${r.chain}"`;
3180
3318
  if (r.bone === null) return `PELVIS: not keyed — the sequence carries a ${r.trackSource} track (${frames - r.declined.reduce((s, [a, b]) => s + b - a + 1, 0)} of ${frames} frames read) and the map has no pelvis chain (one bone, joints r_hip → l_hip); no bone turns with the pelvis`;
3181
3319
  const swing = r.swing === null ? '' : `, swing ${r.swing.toFixed(2)}°`;
3182
- if (r.trackSource === null) return `PELVIS: from hip line (no track)${swing} (${where}; every frame from r_hip → l_hip)`;
3183
- if (r.track === 0) return `PELVIS: from hip line (the ${r.trackSource} track read none of the ${frames} frames)${swing} (${where}; every frame from r_hip → l_hip)`;
3320
+ if (r.trackSource === null) return `PELVIS: from hip line (no track)${swing} (${where}; every frame from r_hip → l_hip)${anchorClause(r.anchor)}`;
3321
+ if (r.track === 0) return `PELVIS: from hip line (the ${r.trackSource} track read none of the ${frames} frames)${swing} (${where}; every frame from r_hip → l_hip)${anchorClause(r.anchor)}`;
3184
3322
  const rest = r.hipLine === 0 ? `all ${frames} frames from the track` : `${r.track} of ${frames} frames from the track, the hip line on the ${r.hipLine} it declined (frames ${spans(r.declined)})`;
3185
- return `PELVIS: from ${r.source}${swing} (${where}; ${rest})`;
3323
+ return `PELVIS: from ${r.source}${swing} (${where}; ${rest})${anchorClause(r.anchor)}`;
3324
+ }
3325
+
3326
+ /** rig-c/pose#344: the PELVIS line's clause for the torso held over the pelvis. */
3327
+ function anchorClause(a: TorsoAnchor | undefined): string {
3328
+ if (a === undefined) return '';
3329
+ return `; torso anchored (in-place ${a.gait}): bone "${a.bone}" keyed with the pelvis's counter, "${a.point}" carried on the drawn ${a.joint}'s sideways path about x ${a.x.toFixed(2)} (× ${a.factor.toFixed(4)}, the pelvis's rig ÷ drawn sway; ${a.neck.toFixed(2)} peak to peak, the counter ${a.sway.toFixed(2)}), the pelvis swaying under it`;
3186
3330
  }
3187
3331
 
3188
3332
  /** A reach bone's origin within this many world units of the anchor's, with nothing above it moved, is aimed along the pair: no REACH line. */