@rig-c/pose 0.39.0 → 0.41.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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Draw order from depth (rig-c/pose#304): `retarget --draw-order depth` keys
3
+ * rig-c's `drawOrder` timeline from the limbs' derived depth (`layers`, #281),
4
+ * so the limb that reads farther from the camera is drawn behind the nearer one
5
+ * on that frame.
6
+ *
7
+ * - **The format.** rig-c's motion spec carries one `drawOrder` timeline per
8
+ * animation, beside `tracks`: keys of `{ t, offsets? }`, each offset
9
+ * `{ slot, offset }` = how many places later than its SETUP index the slot is
10
+ * drawn, every key a complete statement against the setup order, and a key
11
+ * with no `offsets` the setup order again. Keys are stepped.
12
+ * - **The pairs.** The legs (`r_leg`, `l_leg`); the arms crossing the trunk are
13
+ * a later card. A limb is the map's chains naming its middle or end joint
14
+ * (knee, ankle), their bones and every bone under them no other chain maps;
15
+ * its slots are the slots on those bones, and an alternate set's slot on none
16
+ * of them goes with the limb whose chain the set depicts, when it depicts one
17
+ * of the two (a set keeps its own switch and takes the order of the limb it
18
+ * replaces). A pair is keyed when its slots overlap in the rig's draw order:
19
+ * together they fill one unbroken run of the setup order. A slot of anything
20
+ * else drawn between them would be re-layered by the swap — that is changing
21
+ * the setup order, not keying the pair — so the pair is not keyed and the line
22
+ * counts the slots between them first, then names them as far as the line's
23
+ * cap allows (#324; the report's `why` and `between` carry every one).
24
+ * - **The gap (#324).** Per pair, keyed or not, the leg depth gap |depth a −
25
+ * depth b| over the frames both limbs read — its median and max, in px and as
26
+ * a fraction of the leg (upper + lower rest length of the limb the tie is read
27
+ * from) — against the tie, and the frames that passed it: the `DRAWGAP` line
28
+ * and the report's `drawOrder.gaps`.
29
+ * - **The rule.** On each frame the limb whose depth is the lower (farther from
30
+ * the camera) is drawn behind: its slots first in the pair's run, in their
31
+ * setup order, then the other's — when the two depths differ by at least
32
+ * #281's tie: the depth a limb can hold while both its segments read flat,
33
+ * √(1 − 0.95²) × (upper + lower ÷ 2) of its rest lengths, the smaller limb's of
34
+ * the two (a flat segment carries no depth, so closer than that the band
35
+ * cannot tell them). A frame inside the tie, or where either limb is not
36
+ * ordered, holds the order before; before the first decided frame, the setup
37
+ * order — or, on a looped sequence, the order the clip ends in, held across
38
+ * the seam. A key is written where the order changes, and none while it is the
39
+ * setup order from the start.
40
+ *
41
+ * Pure: no clock, no randomness, no disk.
42
+ */
43
+ import type { Body18 } from './joints.ts';
44
+ import { FLAT_BAND, LIMBS, type FrameLayers, type Limb } from './depth.ts';
45
+ import { knownSetsOf, type JointMap, type Rest, type RigSets } from './map.ts';
46
+ import { DEPTH_SEGMENTS, type DepthSegment, type FrameDepth, type Joint } from './pose.ts';
47
+
48
+ /** The limb pairs `--draw-order depth` keys. */
49
+ export const DRAW_ORDER_PAIRS: ReadonlyArray<readonly [Limb, Limb]> = [['r_leg', 'l_leg']];
50
+ /** What one segment inside the flat band can hide out of the picture, per unit of its rest length: √(1 − 0.95²). */
51
+ export const TIE_PER_LENGTH = Math.sqrt(1 - FLAT_BAND[0] * FLAT_BAND[0]);
52
+ /** The DRAWORDER line's cap, characters (the span list is cut to it; the report carries every span). */
53
+ export const DRAW_ORDER_LINE_MAX = 260;
54
+
55
+ export interface DrawOrderOffset {
56
+ slot: string;
57
+ offset: number;
58
+ }
59
+ /** One key as written into the animation (`t` the frame's time). */
60
+ export interface DrawOrderKey {
61
+ t: number;
62
+ offsets?: DrawOrderOffset[];
63
+ }
64
+ export interface DrawOrderPair {
65
+ /** The map chain names standing for the two limbs (the first chain of each). */
66
+ a: string;
67
+ b: string;
68
+ limbs: [Limb, Limb];
69
+ /** Each limb's slots, in setup order. */
70
+ slots: { a: string[]; b: string[] };
71
+ keyed: boolean;
72
+ /** #281's tie for the pair, px: depths closer than this hold the order before (null where a rest length is unread: no tie, equal depths only). */
73
+ tie: number | null;
74
+ /** Why the pair is not keyed. */
75
+ why?: string;
76
+ /** The slots of anything else drawn between the pair's slots, in setup order (#324: the line counts them first and cuts this list). */
77
+ between?: string[];
78
+ }
79
+ /** One pair's leg depth gap against its tie (#324): the figures that say why a pair keyed little or nothing. */
80
+ export interface DrawOrderGap {
81
+ a: string;
82
+ b: string;
83
+ /** |depth a − depth b| over the frames both limbs read, px: the median and the largest. */
84
+ median: number | null;
85
+ max: number | null;
86
+ /** The same as a fraction of the leg (upper + lower rest length of the limb the tie is read from); null where a rest length is unread. */
87
+ medianLegs: number | null;
88
+ maxLegs: number | null;
89
+ /** The pair's tie, px and as a fraction of the leg. */
90
+ tie: number | null;
91
+ tieLegs: number | null;
92
+ /** Frames whose gap passed the tie (decided an order), of the frames both limbs read. */
93
+ passed: number;
94
+ frames: number;
95
+ }
96
+ export interface DrawOrderSpan {
97
+ /** The chain drawn behind, and the one in front. */
98
+ behind: string;
99
+ front: string;
100
+ frames: [number, number];
101
+ /** False where the setup order already draws it so (no key needed). */
102
+ keyed: boolean;
103
+ }
104
+ export interface DrawOrderReading {
105
+ pairs: DrawOrderPair[];
106
+ /** The `drawOrder` timeline, with the frame each key sits on. */
107
+ keys: Array<DrawOrderKey & { frame: number }>;
108
+ /** Every span of one decided order, setup or keyed, in frame order per pair. */
109
+ spans: DrawOrderSpan[];
110
+ /** Per pair, the leg depth gap against the tie (#324); keyed or not. */
111
+ gaps?: DrawOrderGap[];
112
+ }
113
+
114
+ interface Resolved {
115
+ pair: DrawOrderPair;
116
+ /** Setup index of every slot of the pair's run. */
117
+ lo: number;
118
+ hi: number;
119
+ /** Offsets with `a` behind, and with `b` behind; empty = the setup order. */
120
+ aBehind: DrawOrderOffset[];
121
+ bBehind: DrawOrderOffset[];
122
+ }
123
+
124
+ /** The joints of a limb that name its chains: the middle and the end. */
125
+ function limbJoints(limb: Limb): string[] {
126
+ const [upper, lower] = LIMBS[limb];
127
+ return [DEPTH_SEGMENTS[upper][1], DEPTH_SEGMENTS[lower][1]];
128
+ }
129
+
130
+ /** The pair's run with `behind`'s slots first: each moved slot's offset from its setup index. */
131
+ function arrangement(order: readonly string[], lo: number, behind: readonly string[], front: readonly string[]): DrawOrderOffset[] {
132
+ const placed = [...behind, ...front];
133
+ const out: DrawOrderOffset[] = [];
134
+ placed.forEach((slot, k) => {
135
+ const offset = lo + k - order.indexOf(slot);
136
+ if (offset !== 0) out.push({ slot, offset });
137
+ });
138
+ return out.sort((x, y) => order.indexOf(x.slot) - order.indexOf(y.slot));
139
+ }
140
+
141
+ function resolvePair(pairLimbs: readonly [Limb, Limb], map: JointMap, rest: Rest, order: ReadonlyArray<{ name: string; bone: string }>, sets: RigSets | undefined): Resolved {
142
+ const names = order.map((s) => s.name);
143
+ const chainsOf = (limb: Limb) => map.chains.filter((c) => limbJoints(limb).some((j) => (c.joints as readonly string[]).includes(j)));
144
+ const mapped = new Map(map.chains.flatMap((c) => c.bones.map((b) => [b, c.name] as const)));
145
+ const [ca, cb] = [chainsOf(pairLimbs[0]), chainsOf(pairLimbs[1])];
146
+ const pair: DrawOrderPair = { a: ca[0]?.name ?? pairLimbs[0], b: cb[0]?.name ?? pairLimbs[1], limbs: [pairLimbs[0], pairLimbs[1]], slots: { a: [], b: [] }, keyed: false, tie: null };
147
+ const none = (why: string): Resolved => ({ pair: { ...pair, why }, lo: 0, hi: -1, aBehind: [], bBehind: [] });
148
+ for (const [limb, cs] of [[pairLimbs[0], ca], [pairLimbs[1], cb]] as const) {
149
+ if (cs.length === 0) return none(`no chain of the map names ${limbJoints(limb).join(' or ')} (the ${limb.replace('_', ' ')})`);
150
+ }
151
+ // The limb's bones: its chains' and every bone under them that no other chain maps.
152
+ const bonesOf = (cs: typeof ca): Set<string> => {
153
+ const own = new Set(cs.flatMap((c) => c.bones));
154
+ const chainNames = new Set(cs.map((c) => c.name));
155
+ const out = new Set(own);
156
+ for (const [b] of rest) {
157
+ if (out.has(b)) continue;
158
+ const m = mapped.get(b);
159
+ if (m !== undefined && !chainNames.has(m)) continue;
160
+ for (let p = rest.get(b)?.parent ?? null; p !== null; p = rest.get(p)?.parent ?? null) {
161
+ if (own.has(p)) {
162
+ out.add(b);
163
+ break;
164
+ }
165
+ const pm = mapped.get(p);
166
+ if (pm !== undefined && !chainNames.has(pm)) break;
167
+ }
168
+ }
169
+ return out;
170
+ };
171
+ const [ba, bb] = [bonesOf(ca), bonesOf(cb)];
172
+ const slotsA = new Set(order.filter((s) => ba.has(s.bone)).map((s) => s.name));
173
+ const slotsB = new Set(order.filter((s) => bb.has(s.bone)).map((s) => s.name));
174
+ // An alternate set's slot on neither limb's bones goes with the limb whose chain the set depicts (one of the two).
175
+ const known = knownSetsOf(map, sets);
176
+ const [na, nb] = [new Set(ca.map((c) => c.name)), new Set(cb.map((c) => c.name))];
177
+ for (const alt of map.alternates) {
178
+ const depictsA = alt.chains.some((c) => na.has(c));
179
+ const depictsB = alt.chains.some((c) => nb.has(c));
180
+ if (depictsA === depictsB) continue;
181
+ for (const slot of Object.keys(known.get(alt.name)?.slots ?? alt.slots)) {
182
+ if (!names.includes(slot) || slotsA.has(slot) || slotsB.has(slot)) continue;
183
+ (depictsA ? slotsA : slotsB).add(slot);
184
+ }
185
+ }
186
+ const a = names.filter((n) => slotsA.has(n));
187
+ const b = names.filter((n) => slotsB.has(n));
188
+ pair.slots = { a, b };
189
+ if (a.length === 0 || b.length === 0) return none(`${a.length === 0 ? pair.a : pair.b} has no slot (no slot on its bones or on a set depicting it)`);
190
+ const idx = [...a, ...b].map((n) => names.indexOf(n)).sort((x, y) => x - y);
191
+ const lo = idx[0]!;
192
+ const hi = idx[idx.length - 1]!;
193
+ const between = names.slice(lo, hi + 1).filter((n) => !slotsA.has(n) && !slotsB.has(n));
194
+ if (between.length > 0) return { ...none(betweenWhy(between, between.length)), pair: { ...pair, why: betweenWhy(between, between.length), between } };
195
+ return { pair: { ...pair, keyed: true }, lo, hi, aBehind: arrangement(names, lo, a, b), bBehind: arrangement(names, lo, b, a) };
196
+ }
197
+
198
+ /** The not-keyed reason for slots drawn between the pair: the count first, then the first `shown` of the list (#324). */
199
+ function betweenWhy(between: readonly string[], shown: number): string {
200
+ const tail = 'the setup order is not changed here';
201
+ if (between.length === 1) return shown > 0 ? `1 slot: "${between[0]}" is drawn between them, and a swap would re-layer it (${tail})` : `1 slot is drawn between them (… +1 more), and a swap would re-layer it (${tail})`;
202
+ const more = between.length - shown;
203
+ const list = `${between.slice(0, shown).map((n) => `"${n}"`).join(', ')}${more > 0 ? `${shown > 0 ? ', ' : ''}… +${more} more` : ''}`;
204
+ return `${between.length} slots are drawn between them (${list}), and a swap would re-layer them (${tail})`;
205
+ }
206
+
207
+ type Joints = Partial<Record<Body18, Joint>>;
208
+
209
+ /** A segment's rest length as the depth states it (drawn ÷ ratio), the median over the frames that name it; null where none does. */
210
+ function restOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, s: DepthSegment): number | null {
211
+ const [p, q] = DEPTH_SEGMENTS[s];
212
+ const vals: number[] = [];
213
+ track.forEach((j, i) => {
214
+ const d = depth[i]?.[s];
215
+ const a = j[p];
216
+ const b = j[q];
217
+ if (d === undefined || !(d.ratio > 0) || a?.at === undefined || b?.at === undefined || a.state === 'missing' || b.state === 'missing') return;
218
+ vals.push(Math.hypot(b.at[0] - a.at[0], b.at[1] - a.at[1]) / d.ratio);
219
+ });
220
+ if (vals.length === 0) return null;
221
+ vals.sort((x, y) => x - y);
222
+ const m = vals.length >> 1;
223
+ return vals.length % 2 === 1 ? vals[m]! : (vals[m - 1]! + vals[m]!) / 2;
224
+ }
225
+
226
+ /**
227
+ * #281's tie for two limbs, px: √(1 − 0.95²) × (upper + lower ÷ 2), the smaller limb's; null where a rest length is unread.
228
+ * `leg` is that limb's upper + lower rest length (#324's unit for the gap).
229
+ */
230
+ function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, limbs: readonly [Limb, Limb]): { tie: number | null; leg: number | null } {
231
+ const rests = limbs.map((l) => {
232
+ const [u, w] = LIMBS[l].map((s) => restOf(track, depth, s));
233
+ return u === null || u === undefined || w === null || w === undefined ? null : { hidden: u + w / 2, leg: u + w };
234
+ });
235
+ if (rests[0] == null || rests[1] == null) return { tie: null, leg: null };
236
+ const r = rests[1].hidden < rests[0].hidden ? rests[1] : rests[0];
237
+ return { tie: Math.round(TIE_PER_LENGTH * r.hidden * 100) / 100, leg: r.leg };
238
+ }
239
+
240
+ const round = (x: number, k: number): number => Math.round(x * 10 ** k) / 10 ** k;
241
+
242
+ /** One pair's leg depth gap against its tie over the frames (the rule's own test: unequal and at least the tie). */
243
+ function gapOf(pair: DrawOrderPair, layers: readonly FrameLayers[], leg: number | null): DrawOrderGap {
244
+ const tie = pair.tie ?? 0;
245
+ const gaps: number[] = [];
246
+ let passed = 0;
247
+ for (const l of layers) {
248
+ const za = l.depth[pair.limbs[0]];
249
+ const zb = l.depth[pair.limbs[1]];
250
+ if (za === undefined || zb === undefined) continue;
251
+ gaps.push(Math.abs(za - zb));
252
+ if (za !== zb && Math.abs(za - zb) >= tie) passed++;
253
+ }
254
+ gaps.sort((x, y) => x - y);
255
+ const m = gaps.length >> 1;
256
+ const median = gaps.length === 0 ? null : gaps.length % 2 === 1 ? gaps[m]! : (gaps[m - 1]! + gaps[m]!) / 2;
257
+ const max = gaps.length === 0 ? null : gaps[gaps.length - 1]!;
258
+ const legs = (x: number | null): number | null => (x === null || leg === null || !(leg > 0) ? null : round(x / leg, 3));
259
+ return { a: pair.a, b: pair.b, median: median === null ? null : round(median, 2), max: max === null ? null : round(max, 2), medianLegs: legs(median), maxLegs: legs(max), tie: pair.tie, tieLegs: legs(pair.tie), passed, frames: gaps.length };
260
+ }
261
+
262
+ /**
263
+ * The draw-order keys from the frames' layers. `track` and `depth` are the sequence's
264
+ * joints and depth per frame (the rest lengths the tie reads); `order` is the rig's
265
+ * setup draw order (the compiled model's slots); `times` the frames' times.
266
+ */
267
+ export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, times: readonly number[], map: JointMap, rest: Rest, order: ReadonlyArray<{ name: string; bone: string }>, sets?: RigSets, loop = false): DrawOrderReading {
268
+ const resolved = DRAW_ORDER_PAIRS.map((p) => resolvePair(p, map, rest, order, sets));
269
+ const legs = resolved.map((r) => {
270
+ const t = tieOf(track, depth, r.pair.limbs);
271
+ r.pair.tie = t.tie;
272
+ return t.leg;
273
+ });
274
+ const names = order.map((s) => s.name);
275
+ // Per pair, per frame: who is behind ('a' | 'b'), or null for the setup order before any decision.
276
+ const states = resolved.map((r) => {
277
+ const tie = r.pair.tie ?? 0;
278
+ const pass = (start: 'a' | 'b' | null): Array<'a' | 'b' | null> => {
279
+ let state = start;
280
+ return layers.map((l) => {
281
+ if (!r.pair.keyed) return null;
282
+ const za = l.depth[r.pair.limbs[0]];
283
+ const zb = l.depth[r.pair.limbs[1]];
284
+ if (za !== undefined && zb !== undefined && za !== zb && Math.abs(za - zb) >= tie) state = za < zb ? 'a' : 'b';
285
+ return state;
286
+ });
287
+ };
288
+ const first = pass(null);
289
+ // A loop's first frames continue its last: a tie at the start holds the order the clip ends in.
290
+ return loop && first.length > 0 ? pass(first[first.length - 1]!) : first;
291
+ });
292
+ const offsetsAt = (f: number): DrawOrderOffset[] =>
293
+ resolved
294
+ .flatMap((r, k) => {
295
+ const s = states[k]![f];
296
+ return s === null || s === undefined ? [] : s === 'a' ? r.aBehind : r.bBehind;
297
+ })
298
+ .sort((x, y) => names.indexOf(x.slot) - names.indexOf(y.slot));
299
+ const keys: DrawOrderReading['keys'] = [];
300
+ let last = '[]';
301
+ for (let f = 0; f < layers.length; f++) {
302
+ const offs = offsetsAt(f);
303
+ const text = JSON.stringify(offs);
304
+ if (text === last) continue;
305
+ last = text;
306
+ keys.push(offs.length === 0 ? { frame: f, t: times[f]! } : { frame: f, t: times[f]!, offsets: offs });
307
+ }
308
+ const spans: DrawOrderSpan[] = [];
309
+ resolved.forEach((r, k) => {
310
+ const st = states[k]!;
311
+ st.forEach((s, f) => {
312
+ if (s === null) return;
313
+ if (f > 0 && st[f - 1] === s) spans[spans.length - 1]!.frames[1] = f;
314
+ else spans.push({ behind: s === 'a' ? r.pair.a : r.pair.b, front: s === 'a' ? r.pair.b : r.pair.a, frames: [f, f], keyed: (s === 'a' ? r.aBehind : r.bBehind).length > 0 });
315
+ });
316
+ });
317
+ return { pairs: resolved.map((r) => r.pair), keys, spans, gaps: resolved.map((r, k) => gapOf(r.pair, layers, legs[k]!)) };
318
+ }
319
+
320
+ /** The animation's `drawOrder` value: the keys without their frames. */
321
+ export const drawOrderKeys = (d: DrawOrderReading): DrawOrderKey[] => d.keys.map(({ frame: _frame, ...k }) => k);
322
+
323
+ /** Frame runs as text: "3–12". */
324
+ const span = (f: [number, number]): string => (f[0] === f[1] ? `${f[0]}` : `${f[0]}–${f[1]}`);
325
+
326
+ /**
327
+ * The DRAWORDER lines (rig-c/pose#304): the keys and the spans they key, per
328
+ * "behind" relation, cut to `DRAW_ORDER_LINE_MAX` characters; then one line per
329
+ * pair not keyed, saying why.
330
+ */
331
+ export function drawOrderLines(d: DrawOrderReading, prefix = 'DRAWORDER '): string[] {
332
+ const keyed = d.spans.filter((s) => s.keyed);
333
+ const head = `${prefix}from depth, ${d.keys.length} key(s) over ${keyed.length} span(s)`;
334
+ const groups = new Map<string, string[]>();
335
+ for (const s of keyed) {
336
+ const g = `${s.behind} behind ${s.front}`;
337
+ groups.set(g, [...(groups.get(g) ?? []), span(s.frames)]);
338
+ }
339
+ // The spans as text, grouped by relation, cut where the next one would pass the cap with its "more" tail.
340
+ const text = (n: number): string => {
341
+ const parts: string[] = [];
342
+ let left = n;
343
+ for (const [g, list] of groups) {
344
+ if (left === 0) break;
345
+ parts.push(`${g} ${list.slice(0, left).join(', ')}`);
346
+ left -= Math.min(left, list.length);
347
+ }
348
+ const more = keyed.length - n;
349
+ return `${head} (${parts.join('; ')}${more > 0 ? ` … +${more} more in the report's drawOrder` : ''})`;
350
+ };
351
+ let n = keyed.length;
352
+ while (n > 1 && text(n).length > DRAW_ORDER_LINE_MAX) n--;
353
+ const why = d.pairs.every((p) => !p.keyed) ? 'no pair keyed' : 'the setup order holds on every frame';
354
+ const lines = [keyed.length === 0 ? `${head} (${why})` : text(n)];
355
+ for (const p of d.pairs) {
356
+ if (p.keyed) continue;
357
+ const line = (why: string | undefined): string => `${prefix}${p.a} / ${p.b} not keyed: ${why}`;
358
+ // Slots between the pair: the count first, the list cut until the line fits (#324).
359
+ let shown = p.between?.length ?? 0;
360
+ while (p.between !== undefined && shown > 0 && line(betweenWhy(p.between, shown)).length > DRAW_ORDER_LINE_MAX) shown--;
361
+ lines.push(line(p.between === undefined ? p.why : betweenWhy(p.between, shown)).slice(0, DRAW_ORDER_LINE_MAX));
362
+ }
363
+ return lines;
364
+ }
365
+
366
+ /** px with its fraction of the leg: "12.3 px (0.084 leg)". */
367
+ const pxLeg = (px: number | null, legs: number | null): string => (px === null ? 'unread' : `${px} px${legs === null ? '' : ` (${legs} leg)`}`);
368
+
369
+ /**
370
+ * The DRAWGAP lines (rig-c/pose#324): one per pair, keyed or not — the leg depth gap's median and
371
+ * max against the tie, and how many frames passed it. Cut to `DRAW_ORDER_LINE_MAX` characters.
372
+ */
373
+ export function drawGapLines(d: DrawOrderReading, prefix = 'DRAWGAP '): string[] {
374
+ return (d.gaps ?? []).map((g) =>
375
+ `${prefix}${g.a} / ${g.b}: leg depth gap median ${pxLeg(g.median, g.medianLegs)}, max ${pxLeg(g.max, g.maxLegs)}, against the tie of ${g.tie === null ? 'none (a rest length is unread; equal depths only)' : pxLeg(g.tie, g.tieLegs)}; ${g.passed} of ${g.frames} frame(s) passed it`.slice(0, DRAW_ORDER_LINE_MAX),
376
+ );
377
+ }
package/src/outdir.ts CHANGED
@@ -14,7 +14,7 @@ export type OutDirs = Map<string, string[]>;
14
14
 
15
15
  const writable = (dir: string): boolean => {
16
16
  try {
17
- accessSync(dir, constants.W_OK);
17
+ accessSync(dir, constants.W_OK | constants.X_OK);
18
18
  return true;
19
19
  } catch {
20
20
  return false;
@@ -22,9 +22,9 @@ const writable = (dir: string): boolean => {
22
22
  };
23
23
 
24
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.
25
+ * Check where each named output lands: its directory is there and this process can write into it
26
+ * (rig-c/pose#323), or the nearest existing ancestor is a directory this process can write into.
27
+ * A miss is added to `p`; a directory still to make is added to `dirs`. A directory a flag names itself (--build, --render) is checked by its parent.
28
28
  */
29
29
  export function planOutDirs(outputs: ReadonlyArray<readonly [string, string | undefined]>, p: Problems, dirs: OutDirs = new Map()): OutDirs {
30
30
  for (const [flag, path] of outputs) {
@@ -33,7 +33,7 @@ export function planOutDirs(outputs: ReadonlyArray<readonly [string, string | un
33
33
  let up = dir;
34
34
  while (!existsSync(up) && dirname(up) !== up) up = dirname(up);
35
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`);
36
+ else if (!writable(up)) p.add('ARG_PATH', up === dir ? `${flag} ${path} — ${dir} is not writable` : `${flag} ${path} — ${up} is not writable, so directory ${dir} cannot be made under it`);
37
37
  else if (up !== dir) dirs.set(dir, [...(dirs.get(dir) ?? []), `${flag} ${path}`]);
38
38
  }
39
39
  return dirs;
package/src/pelvis.ts CHANGED
@@ -31,6 +31,11 @@
31
31
  * scan runs down to where the colour was found. The first patch that holds
32
32
  * and is not the background is the garment; with none, the midpoint's median
33
33
  * is read as it is.
34
+ * The line is fitted through the band's top edge (rig-c/pose#318): where a
35
+ * strip of the garment is too thin for the colour test (a waistband drawn as
36
+ * two ink lines), the tops step between the strip's lines, and each column
37
+ * is followed up to its topmost garment pixel; an edge that cuts across the
38
+ * band under it (a fold) is declined.
34
39
  *
35
40
  * The forearms are not read: pixels near the segments elbow → wrist (and the
36
41
  * hands) are masked, since a hanging arm passes the hips in a walk. A frame
@@ -76,13 +81,17 @@ export const MAX_RMS = 2.5;
76
81
  export const ARM_RADIUS = 0.15;
77
82
  /** Hips closer than this in the picture, px, have no span to read across (a figure seen side-on). */
78
83
  export const MIN_SPAN = 6;
84
+ /** The band's top is taken above the scan's tops only when their climbs to it differ by more than this many rows (rig-c/pose#318): each end of a clean edge's climb is found to a row. */
85
+ export const CLIMB_SPREAD = 2;
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
+ export const MAX_CLOTH = 16;
79
88
 
80
89
  /**
81
90
  * One frame's reading: the tilt (Spine degrees, r_hip → l_hip), or why it was declined. A waistband
82
91
  * read through the window mean says so (`mean`), and one whose colour was found below the hips says
83
- * how far (`below`, px).
92
+ * how far (`below`, px); one whose band top lay above the colour's edge in some columns says how many (`raised`, #318).
84
93
  */
85
- export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number } | { kind: string; why: string };
94
+ export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number; raised?: number } | { kind: string; why: string };
86
95
 
87
96
  /** The frame as RGB over white, its background colour, and which pixels are masked (forearms, hands). */
88
97
  interface Frame {
@@ -321,6 +330,7 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
321
330
  }
322
331
  }
323
332
  }
333
+ const raised = toBandTop(f, tops, g, top, colourAt);
324
334
  const share = tops.length / cols;
325
335
  if (share < MIN_COVER) {
326
336
  return above > cols / 2 ? { kind: 'garment above the band', why: `the garment runs above the band read (${above} of ${cols} columns carry its colour ${(1.5 * span).toFixed(0)} px above the hips); no waistband edge` } : { kind: 'waistband edge too short', why: `the garment's top edge is found in ${tops.length} of the ${cols} columns between the hips (${(100 * share).toFixed(0)}% < ${100 * MIN_COVER}%)` };
@@ -329,7 +339,197 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
329
339
  if (fit === null) return { kind: 'no line', why: 'the garment\'s top edge does not fit a line (fewer than three columns)' };
330
340
  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` };
331
341
  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°` };
332
- return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}) };
342
+ const off = notTheBand(f, fit, tops, top, colourAt);
343
+ if (off !== null) return off;
344
+ return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}), ...(raised > 0 ? { raised } : {}) };
345
+ }
346
+
347
+ /**
348
+ * rig-c/pose#318: the band's top edge, the topmost garment boundary in each column. The three-row test misses garment
349
+ * above the row it finds when a strip of it is too thin to carry the colour three rows deep (through the window mean,
350
+ * a strip between two ink lines never does), so the tops step between a strip's top line and the cloth under its lower
351
+ * line. The garment's colours are the picture's own: the pixels of three rows in each column `DITHER_RADIUS + 1` below
352
+ * the row the scan found, clear of the edge's blend (an outline there would lend the colour above to the garment). The
353
+ * thing above has one colour, `a`: the median, over the columns read, of the rows from the scan's top down to
354
+ * `3 · (DITHER_RADIUS + 1)` above the topmost top — clear of the edge's blend, an outline and a window, and by area, so
355
+ * a line crossing it or a tilted band's outline in a few of its rows does not move it. From each top
356
+ * the column is followed up, pixel by pixel, while a pixel is no nearer to `a` than to the nearest garment pixel (cloth
357
+ * of any shade, and the ink lines on it, are); a climb that reaches the scan's top or a forearm does not see the top.
358
+ * The band's top is the topmost cloth pixel on the path (within `GARMENT_DIFF` of a garment pixel), from where the climb
359
+ * stopped down to the scan's three rows: an outline above it, or a line leaving the band upward, is ink, not garment;
360
+ * a thin strip's cloth between its two lines is garment. A clean edge climbs
361
+ * about the same rows in every column — the distance between two boundaries each found to a row — so only when the
362
+ * climbs differ by more than `CLIMB_SPREAD` rows (90th less 10th percentile) do the columns take their topmost garment
363
+ * row — and a column whose top was not seen is then not read, since its scan top is the lower level; otherwise the
364
+ * tops stay exactly as read. Nothing is done when the colour above is the garment's (its median
365
+ * within `GARMENT_DIFF`). Rewrites `tops` in place; returns how many moved.
366
+ */
367
+ function toBandTop(f: Frame, tops: Pt[], g: RGB, top: number, colourAt: (x: number, y: number) => RGB | null): number {
368
+ if (tops.length === 0) return 0;
369
+ const lift = DITHER_RADIUS + 1;
370
+ const ya = Math.min(...tops.map((p) => p[1])) - 3 * lift;
371
+ if (ya < top) return 0;
372
+ // The garment's colours, at most `MAX_CLOTH`: a paletted garment's few, as they are; a painted one's many, as the mean
373
+ // colours of the `MAX_CLOTH` fullest cells of a `GARMENT_DIFF / 2` grid (first filled first among equals). The climb
374
+ // compares each pixel against them all, so their number is bounded whatever the span.
375
+ const exact = new Map<string, RGB>();
376
+ const cells = new Map<string, { s: RGB; n: number }>();
377
+ const step = GARMENT_DIFF / 2;
378
+ for (const [x, y0] of tops) {
379
+ for (let k = 0; k < 3; k++) {
380
+ const y = y0 + lift + k;
381
+ if (y < 0 || y >= f.h || f.masked(x, y)) continue;
382
+ const p = f.rgb(x, y);
383
+ if (exact.size <= MAX_CLOTH) exact.set(p.join(','), p);
384
+ const key = p.map((v) => Math.floor(v / step)).join(',');
385
+ const c = cells.get(key);
386
+ if (c === undefined) cells.set(key, { s: [p[0], p[1], p[2]], n: 1 });
387
+ else {
388
+ c.s[0] += p[0];
389
+ c.s[1] += p[1];
390
+ c.s[2] += p[2];
391
+ c.n++;
392
+ }
393
+ }
394
+ }
395
+ const cloth: RGB[] = exact.size <= MAX_CLOTH ? [...exact.values()] : [...cells.values()].sort((u, v) => v.n - u.n).slice(0, MAX_CLOTH).map((c) => [c.s[0] / c.n, c.s[1] / c.n, c.s[2] / c.n]);
396
+ if (cloth.length === 0) return 0;
397
+ // The colour above: the median of the region, channel by channel, read twice over it rather than held.
398
+ const a = regionMedian((visit) => {
399
+ for (const [x] of tops) {
400
+ for (let y = top; y <= ya; y++) {
401
+ const p = f.masked(x, y) ? null : colourAt(x, y);
402
+ if (p !== null) visit(p);
403
+ }
404
+ }
405
+ });
406
+ if (a === null) return 0;
407
+ if (diff(a, g) <= GARMENT_DIFF) return 0;
408
+ const nearest = (p: RGB, cs: RGB[]) => Math.min(...cs.map((q) => diff(p, q)));
409
+ const climbs = tops.map(([x, y0]) => {
410
+ let y = y0;
411
+ while (true) {
412
+ if (y - 1 < top || f.masked(x, y - 1)) return null;
413
+ const p = f.rgb(x, y - 1);
414
+ if (diff(p, a) < nearest(p, cloth)) break;
415
+ y--;
416
+ }
417
+ // The band's top: the topmost cloth pixel on the path, from where the climb stopped down to the scan's three rows.
418
+ for (let v = y; v <= y0 + 2; v++) if (v < f.h && nearest(f.rgb(x, v), cloth) <= GARMENT_DIFF) return y0 - v;
419
+ return null;
420
+ });
421
+ const ks = climbs.filter((k): k is number => k !== null).sort((p, q) => p - q);
422
+ if (ks.length === 0) return 0;
423
+ const rank = (q: number) => ks[Math.min(ks.length - 1, Math.max(0, Math.ceil(q * ks.length) - 1))]!;
424
+ if (rank(0.9) - rank(0.1) <= CLIMB_SPREAD) return 0;
425
+ let moved = 0;
426
+ const read = tops.flatMap((t, i): Pt[] => {
427
+ const k = climbs[i];
428
+ if (k === null || k === undefined) return [];
429
+ if (k !== 0) moved++;
430
+ return [[t[0], t[1] - k]];
431
+ });
432
+ tops.splice(0, tops.length, ...read);
433
+ return moved;
434
+ }
435
+
436
+ /**
437
+ * The median, channel by channel, of the colours a region yields (`median`'s value: the middle one, or the mean of the
438
+ * two middle ones), without holding them: a first pass counts each channel's values by whole step (0–255), which names
439
+ * the step the middle rank falls in; a second keeps only the values in that step, as distinct values and counts. Memory
440
+ * is the 256 counts and the distinct values of one step. Null for an empty region.
441
+ */
442
+ function regionMedian(each: (visit: (p: RGB) => void) => void): RGB | null {
443
+ const bin = (v: number) => Math.max(0, Math.min(255, Math.floor(v)));
444
+ const hist = [new Float64Array(256), new Float64Array(256), new Float64Array(256)];
445
+ let n = 0;
446
+ each((p) => {
447
+ n++;
448
+ for (let k = 0; k < 3; k++) hist[k]![bin(p[k]!)]!++;
449
+ });
450
+ if (n === 0) return null;
451
+ const m = n >> 1;
452
+ const ranks = n % 2 === 1 ? [m] : [m - 1, m];
453
+ // For each channel and rank: the step it falls in and its rank within the step.
454
+ const want = [0, 1, 2].map((k) => ranks.map((r) => {
455
+ let below = 0;
456
+ for (let b = 0; b < 256; b++) {
457
+ const c = hist[k]![b]!;
458
+ if (r < below + c) return { b, r: r - below };
459
+ below += c;
460
+ }
461
+ throw new Error('unreachable: a rank beyond the count');
462
+ }));
463
+ const kept = [0, 1, 2].map(() => new Map<number, Map<number, number>>());
464
+ for (let k = 0; k < 3; k++) for (const w of want[k]!) kept[k]!.set(w.b, new Map());
465
+ each((p) => {
466
+ for (let k = 0; k < 3; k++) {
467
+ const vs = kept[k]!.get(bin(p[k]!));
468
+ if (vs !== undefined) vs.set(p[k]!, (vs.get(p[k]!) ?? 0) + 1);
469
+ }
470
+ });
471
+ const at = (k: number, w: { b: number; r: number }) => {
472
+ let below = 0;
473
+ for (const [v, c] of [...kept[k]!.get(w.b)!.entries()].sort((u, z) => u[0] - z[0])) {
474
+ if (w.r < below + c) return v;
475
+ below += c;
476
+ }
477
+ throw new Error('unreachable: a rank beyond the step');
478
+ };
479
+ return [0, 1, 2].map((k) => {
480
+ const vs = want[k]!.map((w) => at(k, w));
481
+ return vs.length === 1 ? vs[0]! : (vs[0]! + vs[1]!) / 2;
482
+ }) as RGB;
483
+ }
484
+
485
+ /**
486
+ * rig-c/pose#318: the edge the waistband reader fitted is declined when it cuts across the band instead of running
487
+ * along it — a fold's edge under a band the colour test passed over. Above the edge, in each column read, the colour
488
+ * `DITHER_RADIUS + 1` rows up (past the outline and the window's blend) is followed up while it holds within
489
+ * `GARMENT_DIFF`; where it stops is the band's top. When at least `MIN_COVER` of the columns read show such a band (not
490
+ * the ground, not open up to the scan's top), its top is fitted as a line. The two lines bound a strip of thickness
491
+ * `ta` and `tb` at the read columns' ends. A line inside a band of thickness t across a width W departs from the band's
492
+ * direction by at most atan(t / W); an edge whose strip more than doubles across the hips (|ta − tb| > min(ta, tb)), or
493
+ * crosses the band's top, cuts across the garment instead of running along the band.
494
+ *
495
+ * Null when the edge is the band's (or nothing above it is a band to hold it to).
496
+ */
497
+ function notTheBand(f: Frame, fit: { m: number; q: number }, tops: Pt[], top: number, colourAt: (x: number, y: number) => RGB | null): Reading | null {
498
+ const edgeAt = (x: number) => fit.m * x + fit.q;
499
+ const bandTops: Pt[] = [];
500
+ for (const [x, y] of tops) {
501
+ const s = y - (DITHER_RADIUS + 1);
502
+ if (s < top || f.masked(x, s)) continue;
503
+ const c0 = colourAt(x, s);
504
+ if (c0 === null || diff(c0, f.bg) <= FIGURE_DIFF) continue;
505
+ let v = s;
506
+ let closed = false;
507
+ while (v - 1 >= top) {
508
+ if (f.masked(x, v - 1)) break;
509
+ const p = colourAt(x, v - 1);
510
+ if (p === null) break;
511
+ if (diff(p, c0) > GARMENT_DIFF) {
512
+ closed = true;
513
+ break;
514
+ }
515
+ v--;
516
+ }
517
+ if (closed) bandTops.push([x, v]);
518
+ }
519
+ if (bandTops.length < MIN_COVER * tops.length) return null;
520
+ const band = fitLine(bandTops);
521
+ if (band === null || band.rms > MAX_RMS) return null;
522
+ const xa = Math.min(...tops.map((p) => p[0]));
523
+ const xb = Math.max(...tops.map((p) => p[0]));
524
+ const ta = edgeAt(xa) - (band.m * xa + band.q);
525
+ const tb = edgeAt(xb) - (band.m * xb + band.q);
526
+ if (Math.abs(ta - tb) <= Math.min(ta, tb)) return null;
527
+ const deg = (m: number) => Math.atan(m) * (180 / Math.PI);
528
+ const allow = Math.max(0, Math.min(ta, tb));
529
+ return {
530
+ kind: 'edge leaves the band',
531
+ why: `the garment's edge fitted across the hips runs ${Math.abs(deg(fit.m) - deg(band.m)).toFixed(1)}° from the top of the band above it (${bandTops.length} of ${tops.length} columns; the band ${ta.toFixed(1)} px thick at x ${xa} and ${tb.toFixed(1)} px at x ${xb}); an edge inside a band ${allow.toFixed(1)} px thick at its thinnest departs from it by at most ${deg(allow / (xb - xa)).toFixed(1)}° across the ${xb - xa} px read, so this edge cuts across the garment (a fold), not along the band`,
532
+ };
333
533
  }
334
534
 
335
535
  /**
@@ -420,6 +620,8 @@ function viaNote(readings: Reading[]): string {
420
620
  const parts: string[] = [];
421
621
  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
622
  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)`);
623
+ const raised = ok.filter((r) => r.raised !== undefined);
624
+ if (raised.length > 0) parts.push(`${raised.length} with the band's top read above the colour's edge in some columns (up to ${Math.max(...raised.map((r) => r.raised ?? 0))} columns: a strip too thin to carry the colour, read as its top line)`);
423
625
  return parts.length === 0 ? '' : `; of the frames read, ${parts.join(', ')}`;
424
626
  }
425
627