@rig-c/pose 0.38.0 → 0.40.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 +64 -9
- package/cli.ts +64 -10
- package/package.json +1 -1
- package/src/clean.ts +24 -2
- package/src/depth.ts +190 -0
- package/src/draworder.ts +290 -0
- package/src/legs.ts +189 -0
- package/src/outdir.ts +54 -0
- package/src/pelvis.ts +100 -15
- package/src/pose.ts +150 -18
- package/src/reduce.ts +21 -12
- package/src/retarget.ts +82 -3
- package/src/run.ts +162 -35
- package/src/runcompare.ts +6 -7
- package/src/switch.ts +64 -2
- package/src/verify.ts +90 -9
package/src/legs.ts
CHANGED
|
@@ -516,6 +516,12 @@ export interface Feet {
|
|
|
516
516
|
* rule does not apply.
|
|
517
517
|
*/
|
|
518
518
|
tracked?: Array<{ side: Side; frames: [number, number] }>;
|
|
519
|
+
/**
|
|
520
|
+
* rig-c/pose#280: the second support signal (the ankle's vertical speed), how
|
|
521
|
+
* often it contradicts ankle height, and which one decided each frame. Set by
|
|
522
|
+
* `supportsOf` on every reading.
|
|
523
|
+
*/
|
|
524
|
+
signal?: SupportSignal;
|
|
519
525
|
}
|
|
520
526
|
|
|
521
527
|
/** A run of frames whose support the lower-ankle rule (rig-c/pose#113) reads differently from the reading before it. */
|
|
@@ -559,8 +565,17 @@ export function rollingFloor(ys: readonly number[], times: readonly number[]): n
|
|
|
559
565
|
* The frames whose support this changes are `lowered`. On a frame where no foot
|
|
560
566
|
* is planted because the only foot free is the higher one (rig-c/pose#119), the
|
|
561
567
|
* lower foot is tracked (`tracked`).
|
|
568
|
+
*
|
|
569
|
+
* rig-c/pose#280: the ankle's vertical speed is read beside ankle height on
|
|
570
|
+
* every clip (`speedSignalOf`), and on a frontal view it decides the support
|
|
571
|
+
* (`frontalOf`); `signal` says which signal decided each frame.
|
|
562
572
|
*/
|
|
563
573
|
export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number, inPlace = false, lowerAnkle = false): Feet {
|
|
574
|
+
return frontalOf(ankle, hip, heightSupportsOf(ankle, hip, times, secondsOf, inPlace, lowerAnkle));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The support read from ankle height alone: the rules above, #113's included (the whole reading before rig-c/pose#280). */
|
|
578
|
+
function heightSupportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number, inPlace: boolean, lowerAnkle: boolean): Feet {
|
|
564
579
|
if (inPlace || !lowerAnkle) return readSupports(ankle, hip, times, secondsOf, inPlace, false).feet;
|
|
565
580
|
const { feet, released } = readSupports(ankle, hip, times, secondsOf, false, true);
|
|
566
581
|
const before = readSupports(ankle, hip, times, secondsOf, false, false).feet.support;
|
|
@@ -727,6 +742,179 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
|
|
|
727
742
|
return { feet: { legLength, support, supports, jumps, steps, changes, ...(tracked.length > 0 ? { tracked } : {}) }, released };
|
|
728
743
|
}
|
|
729
744
|
|
|
745
|
+
// --- the second signal: the ankle's vertical speed against the ground (rig-c/pose#280) ---
|
|
746
|
+
|
|
747
|
+
/** A foot's contact on one frame, read from the ankles' vertical speed: one side, both, or neither (null). */
|
|
748
|
+
export type Contact = Side | 'both' | null;
|
|
749
|
+
|
|
750
|
+
export interface SpeedSignal {
|
|
751
|
+
/** Each side's ground speed: the median of its ankle's vertical step (picture px a frame, y down) over the frames outside every jump. */
|
|
752
|
+
ground: Record<Side, number>;
|
|
753
|
+
/** Per frame: the feet that move with the ground, on the step into the frame or the step out of it (null on a jump frame or where neither does). */
|
|
754
|
+
contact: Contact[];
|
|
755
|
+
/** The frames the two signals contradict: the lower ankle (by ≥ `PLANT_SWITCH` leg lengths) leaves the ground's speed while the higher moves with it. */
|
|
756
|
+
contradict: number[];
|
|
757
|
+
/** The frames both signals read: outside every jump. */
|
|
758
|
+
read: number;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The ankle's vertical speed as a support signal (rig-c/pose#280). A planted
|
|
763
|
+
* foot moves with the ground: still under a fixed camera, or sliding at the
|
|
764
|
+
* ground's speed in a gait drawn in place. Each side's ground speed is the
|
|
765
|
+
* median of its ankle's vertical step over the frames outside every jump —
|
|
766
|
+
* a walking foot is down on more than half of them — and a foot moves with it
|
|
767
|
+
* on a step that departs from it by less than `STILL_SPEED` of that side's leg
|
|
768
|
+
* length. A frame's foot is down when either the step into the frame or the
|
|
769
|
+
* step out of it moves with the ground, so a drawing a foot lands or lifts on
|
|
770
|
+
* reads it down. `contradict` lists the frames where this and ankle height
|
|
771
|
+
* disagree in the way a frontal view's depth makes them: the lower ankle is
|
|
772
|
+
* the moving one, the higher the one moving with the ground.
|
|
773
|
+
*/
|
|
774
|
+
export function speedSignalOf(ankle: Record<Side, readonly Pt[]>, feet: Pick<Feet, 'legLength' | 'jumps'>): SpeedSignal {
|
|
775
|
+
const n = ankle.r.length;
|
|
776
|
+
const jump = new Array<boolean>(n).fill(false);
|
|
777
|
+
for (const [a, b] of feet.jumps) for (let i = a; i <= b; i++) jump[i] = true;
|
|
778
|
+
const step = (s: Side, i: number): number | null => (i < 1 || i >= n || jump[i] || jump[i - 1] ? null : ankle[s][i]![1] - ankle[s][i - 1]![1]);
|
|
779
|
+
const ground = {} as Record<Side, number>;
|
|
780
|
+
for (const s of SIDES) {
|
|
781
|
+
const xs: number[] = [];
|
|
782
|
+
for (let i = 1; i < n; i++) {
|
|
783
|
+
const v = step(s, i);
|
|
784
|
+
if (v !== null) xs.push(v);
|
|
785
|
+
}
|
|
786
|
+
ground[s] = xs.length === 0 ? 0 : percentile(xs, 0.5);
|
|
787
|
+
}
|
|
788
|
+
const withGround = (s: Side, i: number): boolean => {
|
|
789
|
+
const v = step(s, i);
|
|
790
|
+
return v !== null && Math.abs(v - ground[s]) < STILL_SPEED * feet.legLength[s];
|
|
791
|
+
};
|
|
792
|
+
const down = (s: Side, i: number) => withGround(s, i) || withGround(s, i + 1);
|
|
793
|
+
const mean = (feet.legLength.r + feet.legLength.l) / 2;
|
|
794
|
+
const contact: Contact[] = [];
|
|
795
|
+
const contradict: number[] = [];
|
|
796
|
+
let read = 0;
|
|
797
|
+
for (let i = 0; i < n; i++) {
|
|
798
|
+
if (jump[i]) {
|
|
799
|
+
contact.push(null);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
read++;
|
|
803
|
+
const r = down('r', i);
|
|
804
|
+
const l = down('l', i);
|
|
805
|
+
contact.push(r && l ? 'both' : r ? 'r' : l ? 'l' : null);
|
|
806
|
+
const gap = ankle.l[i]![1] - ankle.r[i]![1];
|
|
807
|
+
const lower: Side | null = Math.abs(gap) < PLANT_SWITCH * mean ? null : gap > 0 ? 'l' : 'r';
|
|
808
|
+
if (lower !== null && !down(lower, i) && down(lower === 'r' ? 'l' : 'r', i)) contradict.push(i);
|
|
809
|
+
}
|
|
810
|
+
return { ground, contact, contradict, read };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* A clip whose two support signals contradict on more than this share of the
|
|
815
|
+
* frames both read is read as a frontal view (rig-c/pose#280), when its stride
|
|
816
|
+
* also shows in depth (`strideInDepth`). A frontal walk contradicts at every
|
|
817
|
+
* hand-over, for as long as the nearer foot is in the air below the planted one:
|
|
818
|
+
* 18 of 96 frames (18.8 %) on the tests' synthetic frontal walk, where ankle
|
|
819
|
+
* height alone hands support to the nearer foot 1 or 2 frames before it lands
|
|
820
|
+
* (16 frames in all, declared in place). Every clip the other tests read with
|
|
821
|
+
* its stride in depth contradicts on none; the shortest contradiction a walk
|
|
822
|
+
* can make, one frame a step, is 8.3 % of a 1 s cycle at 24 fps (2 of 24).
|
|
823
|
+
*/
|
|
824
|
+
export const FRONTAL_DISAGREE = 0.05;
|
|
825
|
+
|
|
826
|
+
/** rig-c/pose#280: the two support signals of a clip, and which one decided each frame's support. */
|
|
827
|
+
export interface SupportSignal {
|
|
828
|
+
/** The frames the signals contradict (`speedSignalOf`), of `read` frames both read. */
|
|
829
|
+
contradict: number[];
|
|
830
|
+
read: number;
|
|
831
|
+
/** Each side's ankle range in the picture, relative to its hip (5th to 95th percentile), px: the stride shows in y on a frontal view and in x on a side view. */
|
|
832
|
+
range: Record<Side, { x: number; y: number }>;
|
|
833
|
+
/** The stride shows in depth (y range over x range on both sides) and the signals contradict on more than `FRONTAL_DISAGREE` of the frames: the support comes from the vertical speed. */
|
|
834
|
+
frontal: boolean;
|
|
835
|
+
/** Per frame, the vertical speed's reading: the side down, both, or null (a jump, or neither foot moving with the ground). */
|
|
836
|
+
contact: Contact[];
|
|
837
|
+
/** Per frame, ankle height's reading (the support before #280). */
|
|
838
|
+
height: Array<Side | null>;
|
|
839
|
+
/** Per frame, the signal that decided the support: `speed`, `height`, or null where no foot supports. */
|
|
840
|
+
by: Array<'speed' | 'height' | null>;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Each side's ankle range relative to its hip, px, x and y (5th to 95th percentile). */
|
|
844
|
+
function rangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>): Record<Side, { x: number; y: number }> {
|
|
845
|
+
const span = (xs: number[]) => percentile(xs, 0.95) - percentile(xs, 0.05);
|
|
846
|
+
const out = {} as Record<Side, { x: number; y: number }>;
|
|
847
|
+
for (const s of SIDES) out[s] = { x: span(ankle[s].map((a, i) => a[0] - hip[s][i]![0])), y: span(ankle[s].map((a, i) => a[1] - hip[s][i]![1])) };
|
|
848
|
+
return out;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* rig-c/pose#280: the support from the ankle's vertical speed, on a frontal
|
|
853
|
+
* view. Seen from the front, a foot's depth is drawn as height, so the lower
|
|
854
|
+
* ankle is the nearer foot, which may be the one in the air. A clip is read as
|
|
855
|
+
* frontal when both hold: its stride shows in depth — on each side the ankle's
|
|
856
|
+
* range relative to its hip is larger in y than in x (a side view's stride is
|
|
857
|
+
* in x) — and the two signals contradict on more than `FRONTAL_DISAGREE` of the
|
|
858
|
+
* frames both read. Then each frame's support is the vertical speed's: the one
|
|
859
|
+
* foot down; on frames both are down, the foot that landed (the other side from
|
|
860
|
+
* the support before the run, or, at the clip's start, the side that stays down
|
|
861
|
+
* after it), so support passes on the frame the incoming foot lands (as #102
|
|
862
|
+
* takes it from the landing frame); and ankle height's reading where neither
|
|
863
|
+
* foot moves with the ground. A plant ends where the other foot lands, so no
|
|
864
|
+
* step releases one and none is tracked (#113's `lowered` and #119's `tracked`
|
|
865
|
+
* are not read). Otherwise the feet are ankle height's, unchanged.
|
|
866
|
+
*/
|
|
867
|
+
function frontalOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, feet: Feet): Feet {
|
|
868
|
+
const sig = speedSignalOf(ankle, feet);
|
|
869
|
+
const range = rangeOf(ankle, hip);
|
|
870
|
+
const strideInDepth = SIDES.every((s) => range[s].y > range[s].x);
|
|
871
|
+
const frontal = strideInDepth && sig.read > 0 && sig.contradict.length > FRONTAL_DISAGREE * sig.read;
|
|
872
|
+
const height = feet.support.map((s) => s ?? null);
|
|
873
|
+
const signal = (by: SupportSignal['by']): SupportSignal => ({ contradict: sig.contradict, read: sig.read, range, frontal, contact: sig.contact, height, by });
|
|
874
|
+
if (!frontal) return { ...feet, signal: signal(height.map((s) => (s === null ? null : 'height'))) };
|
|
875
|
+
const n = height.length;
|
|
876
|
+
const other = (s: Side): Side => (s === 'r' ? 'l' : 'r');
|
|
877
|
+
const support: Array<Side | null> = new Array<Side | null>(n).fill(null);
|
|
878
|
+
const by: SupportSignal['by'] = new Array<'speed' | 'height' | null>(n).fill(null);
|
|
879
|
+
const fromHeight = (i: number) => {
|
|
880
|
+
support[i] = height[i]!;
|
|
881
|
+
by[i] = height[i] === null ? null : 'height';
|
|
882
|
+
};
|
|
883
|
+
for (let i = 0; i < n; i++) {
|
|
884
|
+
const c = sig.contact[i] ?? null;
|
|
885
|
+
if (c === 'r' || c === 'l') {
|
|
886
|
+
support[i] = c;
|
|
887
|
+
by[i] = 'speed';
|
|
888
|
+
} else if (c === 'both') {
|
|
889
|
+
let b = i;
|
|
890
|
+
while (b + 1 < n && sig.contact[b + 1] === 'both') b++;
|
|
891
|
+
const before = i > 0 ? (support[i - 1] ?? null) : null;
|
|
892
|
+
const after = b + 1 < n ? (sig.contact[b + 1] ?? null) : null;
|
|
893
|
+
const landed: Side | null = before !== null ? other(before) : after === 'r' || after === 'l' ? after : null;
|
|
894
|
+
for (let k = i; k <= b; k++) {
|
|
895
|
+
if (landed === null) fromHeight(k);
|
|
896
|
+
else {
|
|
897
|
+
support[k] = landed;
|
|
898
|
+
by[k] = 'speed';
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
i = b;
|
|
902
|
+
} else fromHeight(i);
|
|
903
|
+
}
|
|
904
|
+
const supports: Feet['supports'] = [];
|
|
905
|
+
const changes: Feet['changes'] = [];
|
|
906
|
+
for (let i = 0; i < n; i++) {
|
|
907
|
+
const s = support[i];
|
|
908
|
+
if (s === null || s === undefined) continue;
|
|
909
|
+
const prev = i > 0 ? (support[i - 1] ?? null) : null;
|
|
910
|
+
if (prev !== s) changes.push({ frame: i, from: prev, to: s });
|
|
911
|
+
const last = supports[supports.length - 1];
|
|
912
|
+
if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
|
|
913
|
+
else supports.push({ side: s, frames: [i, i] });
|
|
914
|
+
}
|
|
915
|
+
return { legLength: feet.legLength, support, supports, jumps: feet.jumps, steps: [], changes, signal: signal(by) };
|
|
916
|
+
}
|
|
917
|
+
|
|
730
918
|
/** One support span's shift onto the floor (rig-c/pose#85): its side, its frames, and floor − its planted height, world units. */
|
|
731
919
|
export interface FloorShift {
|
|
732
920
|
side: Side;
|
|
@@ -1584,3 +1772,4 @@ export function holdBlend(blends: readonly BlendFrame[], n: number, posed: (i: n
|
|
|
1584
1772
|
const range = rangeUsed(lower, held);
|
|
1585
1773
|
return { bounds: STANCE_BOUNDS, ...(range === null ? {} : { range }), runs, over, stretch };
|
|
1586
1774
|
}
|
|
1775
|
+
|
package/src/outdir.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a command's output goes (rig-c/pose#239, #284). A file a command writes goes into a
|
|
3
|
+
* directory the user named: one that is not there yet is made just before the write (mkdir -p),
|
|
4
|
+
* and a `made directory` line says so and which paths it holds. A file where a directory has to
|
|
5
|
+
* be made, or a directory that cannot be written into, is refused by name (`ARG_PATH`) before
|
|
6
|
+
* anything is read or written.
|
|
7
|
+
*/
|
|
8
|
+
import { accessSync, constants, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import type { Problems } from './problems.ts';
|
|
11
|
+
|
|
12
|
+
/** The directories to make before writing, each with the `<flag> <path>`s written into it, in the order named. */
|
|
13
|
+
export type OutDirs = Map<string, string[]>;
|
|
14
|
+
|
|
15
|
+
const writable = (dir: string): boolean => {
|
|
16
|
+
try {
|
|
17
|
+
accessSync(dir, constants.W_OK);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check where each named output lands: its directory is there, or the nearest existing ancestor
|
|
26
|
+
* is a directory this process can write into. A miss is added to `p`; a directory still to make
|
|
27
|
+
* is added to `dirs`. A directory a flag names itself (--build, --render) is checked by its parent.
|
|
28
|
+
*/
|
|
29
|
+
export function planOutDirs(outputs: ReadonlyArray<readonly [string, string | undefined]>, p: Problems, dirs: OutDirs = new Map()): OutDirs {
|
|
30
|
+
for (const [flag, path] of outputs) {
|
|
31
|
+
if (path === undefined) continue;
|
|
32
|
+
const dir = dirname(path);
|
|
33
|
+
let up = dir;
|
|
34
|
+
while (!existsSync(up) && dirname(up) !== up) up = dirname(up);
|
|
35
|
+
if (!statSync(up).isDirectory()) p.add('ARG_PATH', up === dir ? `${flag} ${path} — ${dir} is not a directory` : `${flag} ${path} — ${up} is a file, so directory ${dir} cannot be made under it`);
|
|
36
|
+
else if (up !== dir && !writable(up)) p.add('ARG_PATH', `${flag} ${path} — ${up} is not writable, so directory ${dir} cannot be made under it`);
|
|
37
|
+
else if (up !== dir) dirs.set(dir, [...(dirs.get(dir) ?? []), `${flag} ${path}`]);
|
|
38
|
+
}
|
|
39
|
+
return dirs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Make each planned directory (mkdir -p) and print its line; run after the command is green, just before it writes. */
|
|
43
|
+
export function makeOutDirs(dirs: OutDirs, tell: (l: string) => void): void {
|
|
44
|
+
for (const [dir, flags] of dirs) {
|
|
45
|
+
if (existsSync(dir)) continue;
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
tell(madeLine(dir, flags));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** rig-c/pose#239: the line a directory this run made prints — the directory, and the paths that named it. */
|
|
52
|
+
export function madeLine(dir: string, flags: readonly string[]): string {
|
|
53
|
+
return ` made directory ${dir} — it did not exist; ${flags.join(' and ')} ${flags.length === 1 ? 'is' : 'are'} written into it`;
|
|
54
|
+
}
|
package/src/pelvis.ts
CHANGED
|
@@ -22,7 +22,15 @@
|
|
|
22
22
|
* - `waistband`: the top edge of a garment covering the hips — the colour at the
|
|
23
23
|
* hips' midpoint, where it is not the background's, and in each column of the
|
|
24
24
|
* hip span the first row from 1.5 hip widths above the hips where three pixels
|
|
25
|
-
* in a row carry it; the line fitted through those tops.
|
|
25
|
+
* in a row carry it; the line fitted through those tops. The colour must hold
|
|
26
|
+
* (rig-c/pose#293): ≥ `HOLD` of the patch's pixels carry its median. A dithered
|
|
27
|
+
* or paletted garment does not hold pixel by pixel — its channels alternate
|
|
28
|
+
* between palette steps — so it is read through the mean of a 5×5 window,
|
|
29
|
+
* which does; hips on the band's top edge give a patch half garment, half
|
|
30
|
+
* ground, so the patch is tried below them (up to two patch radii), and the
|
|
31
|
+
* scan runs down to where the colour was found. The first patch that holds
|
|
32
|
+
* and is not the background is the garment; with none, the midpoint's median
|
|
33
|
+
* is read as it is.
|
|
26
34
|
*
|
|
27
35
|
* The forearms are not read: pixels near the segments elbow → wrist (and the
|
|
28
36
|
* hands) are masked, since a hanging arm passes the hips in a walk. A frame
|
|
@@ -40,6 +48,7 @@ import type { Body18 } from './joints.ts';
|
|
|
40
48
|
import { personTrack, readPoseSequence, writePoseSequence, type Joint, type PelvisSource, type PoseSequence } from './pose.ts';
|
|
41
49
|
import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './problems.ts';
|
|
42
50
|
import { pictureAngle, wrap } from './retarget.ts';
|
|
51
|
+
import { makeOutDirs, planOutDirs } from './outdir.ts';
|
|
43
52
|
|
|
44
53
|
type Pt = [number, number];
|
|
45
54
|
type RGB = [number, number, number];
|
|
@@ -53,6 +62,10 @@ export const PELVIS_KEYS_SPEC = 'rig-pose-pelvis-keys/1';
|
|
|
53
62
|
export const FIGURE_DIFF = 64;
|
|
54
63
|
/** A pixel carries the garment's colour when no channel differs from it by more than this. */
|
|
55
64
|
export const GARMENT_DIFF = 40;
|
|
65
|
+
/** A garment's colour holds on a patch when at least this share of its pixels carry it (within `GARMENT_DIFF`). */
|
|
66
|
+
export const HOLD = 0.75;
|
|
67
|
+
/** A garment that does not hold pixel by pixel is read through the mean of the square window this many px either side of a pixel (5×5: a 4×4 ordered dither's period and a pixel more). */
|
|
68
|
+
export const DITHER_RADIUS = 2;
|
|
56
69
|
/** The silhouette reader scores lines within this many degrees of level. */
|
|
57
70
|
export const MAX_TILT = 40;
|
|
58
71
|
/** A line is read when it covers at least this share of the hip span's columns. */
|
|
@@ -64,8 +77,12 @@ export const ARM_RADIUS = 0.15;
|
|
|
64
77
|
/** Hips closer than this in the picture, px, have no span to read across (a figure seen side-on). */
|
|
65
78
|
export const MIN_SPAN = 6;
|
|
66
79
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
80
|
+
/**
|
|
81
|
+
* One frame's reading: the tilt (Spine degrees, r_hip → l_hip), or why it was declined. A waistband
|
|
82
|
+
* read through the window mean says so (`mean`), and one whose colour was found below the hips says
|
|
83
|
+
* how far (`below`, px).
|
|
84
|
+
*/
|
|
85
|
+
export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number } | { kind: string; why: string };
|
|
69
86
|
|
|
70
87
|
/** The frame as RGB over white, its background colour, and which pixels are masked (forearms, hands). */
|
|
71
88
|
interface Frame {
|
|
@@ -221,22 +238,76 @@ function silhouette(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
|
|
|
221
238
|
return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms };
|
|
222
239
|
}
|
|
223
240
|
|
|
241
|
+
/** The mean colour of the unmasked pixels in the window `DITHER_RADIUS` either side of (x, y), or null when all are masked. Cached per frame. */
|
|
242
|
+
function meanOf(f: Frame): (x: number, y: number) => RGB | null {
|
|
243
|
+
const cache = new Map<number, RGB | null>();
|
|
244
|
+
return (x, y) => {
|
|
245
|
+
const key = y * f.w + x;
|
|
246
|
+
const hit = cache.get(key);
|
|
247
|
+
if (hit !== undefined) return hit;
|
|
248
|
+
const s: RGB = [0, 0, 0];
|
|
249
|
+
let n = 0;
|
|
250
|
+
for (let v = Math.max(0, y - DITHER_RADIUS); v <= Math.min(f.h - 1, y + DITHER_RADIUS); v++) {
|
|
251
|
+
for (let u = Math.max(0, x - DITHER_RADIUS); u <= Math.min(f.w - 1, x + DITHER_RADIUS); u++) {
|
|
252
|
+
if (f.masked(u, v)) continue;
|
|
253
|
+
const p = f.rgb(u, v);
|
|
254
|
+
s[0] += p[0];
|
|
255
|
+
s[1] += p[1];
|
|
256
|
+
s[2] += p[2];
|
|
257
|
+
n++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const m: RGB | null = n === 0 ? null : [s[0] / n, s[1] / n, s[2] / n];
|
|
261
|
+
cache.set(key, m);
|
|
262
|
+
return m;
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
224
266
|
function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols: number, rHip: Pt, lHip: Pt): Reading {
|
|
225
|
-
// The garment's colour: the median over the unmasked pixels
|
|
267
|
+
// 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.
|
|
226
268
|
const r = Math.max(2, 0.3 * span);
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
269
|
+
const raw = (x: number, y: number): RGB | null => f.rgb(x, y);
|
|
270
|
+
const mean = meanOf(f);
|
|
271
|
+
const patchAt = (cy: number, colour: (x: number, y: number) => RGB | null): { g: RGB; holds: boolean } | null => {
|
|
272
|
+
const patch: RGB[] = [];
|
|
273
|
+
for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
|
|
274
|
+
for (let x = Math.floor(c[0] - r); x <= Math.ceil(c[0] + r); x++) {
|
|
275
|
+
if (x < 0 || y < 0 || x >= f.w || y >= f.h || Math.hypot(x - c[0], y - cy) > r || f.masked(x, y)) continue;
|
|
276
|
+
const p = colour(x, y);
|
|
277
|
+
if (p !== null) patch.push(p);
|
|
278
|
+
}
|
|
232
279
|
}
|
|
280
|
+
if (patch.length === 0) return null;
|
|
281
|
+
const g: RGB = [0, 1, 2].map((k) => median(patch.map((p) => p[k]!))) as RGB;
|
|
282
|
+
return { g, holds: patch.filter((p) => diff(p, g) <= GARMENT_DIFF).length >= HOLD * patch.length };
|
|
283
|
+
};
|
|
284
|
+
const mid = patchAt(c[1], raw);
|
|
285
|
+
if (mid === null) return { kind: 'hips behind a forearm', why: 'the hips\' midpoint is covered by a forearm; no garment colour to read' };
|
|
286
|
+
// The first patch that holds and is not the background: at the midpoint, then one and two patch radii below; as they are, then through the window mean.
|
|
287
|
+
let found: { g: RGB; below: number; isMean: boolean } | null = null;
|
|
288
|
+
for (const below of [0, r, 2 * r]) {
|
|
289
|
+
for (const isMean of [false, true]) {
|
|
290
|
+
const pa = below === 0 && !isMean ? mid : patchAt(c[1] + below, isMean ? mean : raw);
|
|
291
|
+
if (pa !== null && pa.holds && diff(pa.g, f.bg) > FIGURE_DIFF) {
|
|
292
|
+
found = { g: pa.g, below, isMean };
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (found !== null) break;
|
|
233
297
|
}
|
|
234
|
-
|
|
235
|
-
const g
|
|
298
|
+
// None: the midpoint's median as it is.
|
|
299
|
+
const g = found?.g ?? mid.g;
|
|
300
|
+
const isMean = found?.isMean ?? false;
|
|
301
|
+
const below = found?.below ?? 0;
|
|
236
302
|
if (diff(g, f.bg) <= FIGURE_DIFF) return { 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(', ')})` };
|
|
237
303
|
const top = Math.max(0, Math.floor(c[1] - 1.5 * span));
|
|
238
|
-
const bottom = Math.floor(c[1]);
|
|
239
|
-
const
|
|
304
|
+
const bottom = Math.floor(c[1] + below);
|
|
305
|
+
const colourAt = isMean ? mean : raw;
|
|
306
|
+
const garment = (x: number, y: number) => {
|
|
307
|
+
if (y >= f.h) return false;
|
|
308
|
+
const p = colourAt(x, y);
|
|
309
|
+
return p !== null && diff(p, g) <= GARMENT_DIFF;
|
|
310
|
+
};
|
|
240
311
|
const tops: Pt[] = [];
|
|
241
312
|
let above = 0;
|
|
242
313
|
for (let x = x0; x <= x1; x++) {
|
|
@@ -258,7 +329,7 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
|
|
|
258
329
|
if (fit === null) return { kind: 'no line', why: 'the garment\'s top edge does not fit a line (fewer than three columns)' };
|
|
259
330
|
if (fit.rms > MAX_RMS) return { kind: 'not a straight waistband', why: `the garment's top edge lies ${fit.rms.toFixed(2)} px from its line (root mean square > ${MAX_RMS}); not a straight waistband` };
|
|
260
331
|
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°` };
|
|
261
|
-
return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms };
|
|
332
|
+
return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}) };
|
|
262
333
|
}
|
|
263
334
|
|
|
264
335
|
/**
|
|
@@ -341,6 +412,17 @@ function declinedRuns(readings: Reading[]): Array<{ from: number; to: number; ki
|
|
|
341
412
|
return out;
|
|
342
413
|
}
|
|
343
414
|
|
|
415
|
+
/** How the waistband found its colour where it was not the midpoint's pixels as they are (rig-c/pose#293); empty when every frame read them. */
|
|
416
|
+
function viaNote(readings: Reading[]): string {
|
|
417
|
+
const ok = readings.flatMap((r) => ('tilt' in r ? [r] : []));
|
|
418
|
+
const mean = ok.filter((r) => r.mean === true).length;
|
|
419
|
+
const below = ok.filter((r) => r.below !== undefined).length;
|
|
420
|
+
const parts: string[] = [];
|
|
421
|
+
if (mean > 0) parts.push(`${mean} through the ${2 * DITHER_RADIUS + 1}×${2 * DITHER_RADIUS + 1} window mean (the garment's colour does not hold pixel by pixel: a dithered or paletted picture)`);
|
|
422
|
+
if (below > 0) parts.push(`${below} with the colour found below the hips (up to ${Math.max(...ok.map((r) => r.below ?? 0)).toFixed(1)} px; the hips on the band's edge)`);
|
|
423
|
+
return parts.length === 0 ? '' : `; of the frames read, ${parts.join(', ')}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
344
426
|
/** The sequence with the track: the one person, `pelvisTilt` on every frame, `pelvisSource`. */
|
|
345
427
|
export function withPelvis(seq: PoseSequence, person: string, tilt: ReadonlyArray<number | null>, source: PelvisSource): PoseSequence {
|
|
346
428
|
return {
|
|
@@ -355,6 +437,7 @@ export function runPelvis(a: PelvisArgs, say: (l: string) => void): void {
|
|
|
355
437
|
const seq = readPoseSequence(readFileSync(a.pose, 'utf8'), a.pose);
|
|
356
438
|
const p = new Problems();
|
|
357
439
|
if (seq.cleaned !== undefined) p.add('PELVIS_AFTER_CLEAN', `${a.pose} carries a "cleaned" record; rig-pose pelvis reads a sequence before clean, so that clean smooths the track it writes — run it on the sequence clean read`);
|
|
440
|
+
const outDirs = planOutDirs([['--out', a.out]], p);
|
|
358
441
|
if (seq.pelvisSource !== undefined) p.add('PELVIS_TRACK', `${a.pose} already carries a pelvis track (pelvisSource "${seq.pelvisSource}"); a sequence is read once — run rig-pose pelvis on the sequence without it`);
|
|
359
442
|
p.throwIfAny();
|
|
360
443
|
const track = personTrack(seq, a.person, a.pose);
|
|
@@ -370,6 +453,7 @@ export function runPelvis(a: PelvisArgs, say: (l: string) => void): void {
|
|
|
370
453
|
const tilt = readPelvisKeys(readFileSync(a.keys, 'utf8'), a.keys, n);
|
|
371
454
|
say(`rig-pose pelvis: ${n} frame(s) of person "${person}" from ${a.pose}, from the hand keys in ${a.keys}`);
|
|
372
455
|
say(` PELVIS keys: linear between the keys (the short way round) and held outside them, on all ${n} frame(s); swing ${swingOf(tilt).toFixed(2)}° (Spine degrees, r_hip → l_hip)`);
|
|
456
|
+
makeOutDirs(outDirs, say);
|
|
373
457
|
writeFileSync(a.out, writePoseSequence(withPelvis(seq, person, tilt, 'keys')));
|
|
374
458
|
say(` wrote ${a.out} (pelvisSource "keys", rig-pose-sequence/1.1)`);
|
|
375
459
|
return;
|
|
@@ -404,10 +488,11 @@ export function runPelvis(a: PelvisArgs, say: (l: string) => void): void {
|
|
|
404
488
|
if (read.length > 0) {
|
|
405
489
|
const covers = readings.flatMap((r) => ('tilt' in r ? [r.cover] : []));
|
|
406
490
|
const rms = readings.flatMap((r) => ('tilt' in r ? [r.rms] : []));
|
|
407
|
-
say(` PELVIS ${a.mode} read ${read.length} of ${n} frame(s), ${n - read.length} declined (null: marked, not guessed); swing ${swingOf(read).toFixed(2)}° over the frames read (Spine degrees, r_hip → l_hip); cover ${(100 * Math.min(...covers)).toFixed(0)}–${(100 * Math.max(...covers)).toFixed(0)}% of the hip span, fit within ${Math.max(...rms).toFixed(2)} px`);
|
|
491
|
+
say(` PELVIS ${a.mode} read ${read.length} of ${n} frame(s), ${n - read.length} declined (null: marked, not guessed); swing ${swingOf(read).toFixed(2)}° over the frames read (Spine degrees, r_hip → l_hip); cover ${(100 * Math.min(...covers)).toFixed(0)}–${(100 * Math.max(...covers)).toFixed(0)}% of the hip span, fit within ${Math.max(...rms).toFixed(2)} px${viaNote(readings)}`);
|
|
408
492
|
} else {
|
|
409
493
|
say(` PELVIS ${a.mode} read none of the ${n} frame(s) (every frame null: marked, not guessed); retarget keys the hip line and its PELVIS line says so`);
|
|
410
494
|
}
|
|
495
|
+
makeOutDirs(outDirs, say);
|
|
411
496
|
writeFileSync(a.out, writePoseSequence(withPelvis(seq, person, tilt, a.mode)));
|
|
412
497
|
say(` wrote ${a.out} (pelvisSource "${a.mode}", rig-pose-sequence/1.1)`);
|
|
413
498
|
}
|