@rig-c/pose 0.7.0 → 0.8.1
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 +82 -1
- package/cli.ts +67 -4
- package/package.json +1 -1
- package/src/compare.ts +1046 -0
- package/src/pose.ts +17 -4
- package/src/rigc.ts +13 -4
- package/src/run.ts +45 -5
- package/src/runcompare.ts +119 -0
- package/src/sheet.ts +106 -0
package/src/pose.ts
CHANGED
|
@@ -50,6 +50,16 @@ export interface PoseSequence {
|
|
|
50
50
|
|
|
51
51
|
const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The older name the same file went by: `rig-c-pose-frames/0`, written by the
|
|
55
|
+
* extraction jobs this package's own demos were made from. It is this format
|
|
56
|
+
* under another spec string, with `space.units` "painting-px" and an `fps` key
|
|
57
|
+
* (the time is in each frame's `t`, which wins). It is read as it is; nothing
|
|
58
|
+
* else about it differs.
|
|
59
|
+
*/
|
|
60
|
+
export const POSE_SPEC_FRAMES0 = 'rig-c-pose-frames/0';
|
|
61
|
+
const FRAMES0_SPACE = { units: 'painting-px', origin: 'top-left', y: 'down' } as const;
|
|
62
|
+
|
|
53
63
|
/** Read and check a pose sequence; every problem is collected and thrown once. */
|
|
54
64
|
export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
55
65
|
const doc = parseJson(text, where, 'POSE_JSON');
|
|
@@ -59,12 +69,15 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
|
59
69
|
p.throwIfAny();
|
|
60
70
|
throw new Error('unreachable');
|
|
61
71
|
}
|
|
62
|
-
|
|
72
|
+
const frames0 = doc.spec === POSE_SPEC_FRAMES0;
|
|
73
|
+
refuseUnknownKeys(p, 'POSE_KEYS', where, doc, frames0 ? [...TOP_KEYS, 'fps'] : TOP_KEYS);
|
|
63
74
|
for (const k of TOP_KEYS) if (!(k in doc)) p.add('POSE_KEYS', `${where} has no "${k}"; a ${POSE_SPEC} document carries ${TOP_KEYS.join(', ')}`);
|
|
64
|
-
if ('spec' in doc && doc.spec !== POSE_SPEC) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}"`);
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
if ('spec' in doc && doc.spec !== POSE_SPEC && !frames0) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}" (and the older name "${POSE_SPEC_FRAMES0}")`);
|
|
76
|
+
const space = frames0 ? FRAMES0_SPACE : POSE_SPACE;
|
|
77
|
+
if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(space)) {
|
|
78
|
+
p.add('POSE_SPACE', `${where} "space" is ${JSON.stringify(doc.space)}; for "${frames0 ? POSE_SPEC_FRAMES0 : POSE_SPEC}" it must be exactly ${JSON.stringify(space)}`);
|
|
67
79
|
}
|
|
80
|
+
if (frames0 && 'fps' in doc && !(typeof doc.fps === 'number' && Number.isFinite(doc.fps) && doc.fps > 0)) p.add('POSE_FPS', `${where} "fps" is ${typeName(doc.fps)}; it must be a positive number (each frame's "t" is the time read)`);
|
|
68
81
|
for (const k of ['width', 'height'] as const) {
|
|
69
82
|
if (k in doc && !(Number.isInteger(doc[k]) && (doc[k] as number) > 0)) p.add('POSE_SIZE', `${where} "${k}" is ${typeName(doc[k])}; it must be a positive integer (picture pixels)`);
|
|
70
83
|
}
|
package/src/rigc.ts
CHANGED
|
@@ -96,11 +96,11 @@ export interface Gate {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/** Build `rig` with `motion` under rigc's gate into `dir/build`; nothing is read from a red build. */
|
|
99
|
-
export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string): Gate {
|
|
99
|
+
export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string, images?: string): Gate {
|
|
100
100
|
mkdirSync(dir, { recursive: true });
|
|
101
101
|
const motionPath = join(dir, 'motion.json');
|
|
102
102
|
writeFileSync(motionPath, `${JSON.stringify(motion, null, 2)}\n`);
|
|
103
|
-
return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'));
|
|
103
|
+
return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'), images);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
@@ -112,8 +112,8 @@ export function gate(rig: string, motion: Record<string, unknown>, dir: string,
|
|
|
112
112
|
* never copied out, which would carry a path climbing from the scratch
|
|
113
113
|
* directory to the filesystem root. --copy-images puts the pages beside it.
|
|
114
114
|
*/
|
|
115
|
-
export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string): Gate {
|
|
116
|
-
const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(report === undefined ? [] : ['--report', report])]);
|
|
115
|
+
export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string, images?: string): Gate {
|
|
116
|
+
const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(images === undefined ? [] : ['--images', images]), ...(report === undefined ? [] : ['--report', report])]);
|
|
117
117
|
const lines = run.out.split('\n');
|
|
118
118
|
return {
|
|
119
119
|
run,
|
|
@@ -196,3 +196,12 @@ export function folds(build: string, animation: string, dir: string, fps: number
|
|
|
196
196
|
frames: setDir,
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
+
|
|
200
|
+
/** rig-c's version and the entry its launcher picked (`rigc --version`), so a run names the compiler that gated it. */
|
|
201
|
+
export function rigcVersion(): { version: string; entry: string } {
|
|
202
|
+
const r = runRigc(['--version']);
|
|
203
|
+
const lines = r.out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
204
|
+
const version = lines.find((l) => /^\d+\.\d+\.\d+/.test(l)) ?? lines[0] ?? 'unknown';
|
|
205
|
+
const entry = lines.find((l) => l.startsWith('entry:'))?.slice('entry:'.length).trim() ?? 'not stated';
|
|
206
|
+
return { version, entry };
|
|
207
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
|
-
import { readMap, resolveMap } from './map.ts';
|
|
10
|
+
import { readMap, resolveMap, type JointMap } from './map.ts';
|
|
11
11
|
import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
|
|
12
12
|
import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
|
|
13
13
|
import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
|
|
14
|
-
import { attachmentBones, baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
14
|
+
import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
15
15
|
import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
|
|
16
16
|
import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
|
|
17
17
|
|
|
@@ -24,6 +24,8 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
24
24
|
coco: boolean;
|
|
25
25
|
person?: string;
|
|
26
26
|
baseMotion?: string;
|
|
27
|
+
/** Passed to rigc build as --images: the directory the rig spec's images resolve in, when rig.json's own `images` does not point there (a copied rig). */
|
|
28
|
+
images?: string;
|
|
27
29
|
profile: 'spine' | 'spine-html';
|
|
28
30
|
/** Keep the gated build here. */
|
|
29
31
|
build?: string;
|
|
@@ -72,17 +74,19 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
72
74
|
const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
|
|
73
75
|
try {
|
|
74
76
|
// The rig as it stands, through the gate, so the rest pose is rig-c's reading of a green build.
|
|
75
|
-
const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile);
|
|
77
|
+
const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile, a.images);
|
|
76
78
|
if (!g0.green) {
|
|
77
79
|
refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
|
|
78
80
|
}
|
|
79
81
|
const doc0 = readBuild(g0.build);
|
|
82
|
+
const rigc = rigcVersion();
|
|
80
83
|
const rest = restOf(doc0);
|
|
81
84
|
const slots = slotsOf(doc0);
|
|
82
85
|
resolveMap(map, rest, a.map, slots);
|
|
83
86
|
|
|
84
87
|
const r = retarget(rest, map, frames, times, a, slots);
|
|
85
88
|
say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
|
|
89
|
+
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
86
90
|
if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
|
|
87
91
|
for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
|
|
88
92
|
if (a.short !== 'off') {
|
|
@@ -96,12 +100,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
96
100
|
if (map.alternates.length > 0) {
|
|
97
101
|
if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
|
|
98
102
|
for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
|
|
103
|
+
for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
|
|
99
104
|
}
|
|
100
105
|
for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
|
|
101
106
|
for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
|
|
102
107
|
|
|
103
108
|
const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
|
|
104
|
-
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
|
|
109
|
+
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
|
|
105
110
|
if (!g1.green) {
|
|
106
111
|
refuse([{ code: 'RIG_GATE', text: `rigc build --profile ${a.profile} is red with "${a.name}" in (exit ${g1.run.status}); nothing written` }, ...g1.fails.map((f) => ({ code: 'RIGC', text: f }))]);
|
|
107
112
|
}
|
|
@@ -139,7 +144,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
139
144
|
if (a.build !== undefined) {
|
|
140
145
|
// Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
|
|
141
146
|
rmSync(a.build, { recursive: true, force: true });
|
|
142
|
-
const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile);
|
|
147
|
+
const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile, undefined, a.images);
|
|
143
148
|
if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
|
|
144
149
|
}
|
|
145
150
|
writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
|
|
@@ -150,6 +155,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
150
155
|
if (a.report !== undefined) {
|
|
151
156
|
writeFileSync(a.report, `${JSON.stringify({
|
|
152
157
|
spec: 'rig-pose-report/1',
|
|
158
|
+
rigc,
|
|
153
159
|
rig: input.name,
|
|
154
160
|
animation: a.name,
|
|
155
161
|
frames: seq.frames.length,
|
|
@@ -195,3 +201,37 @@ function spans(ts: number[], step: number): string {
|
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
export { lineOf };
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* For each set with `frames` windows: every stretch of a window the set did not
|
|
207
|
+
* show on, and why — the pose there was past the tolerance (with the largest
|
|
208
|
+
* distance), or within it but the run was dropped (shorter than --switch-min, or
|
|
209
|
+
* taken by another set's span). A window never forces a set; this says so.
|
|
210
|
+
*/
|
|
211
|
+
function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frames: [number, number] }>, distances: ReadonlyMap<string, readonly number[]>, times: readonly number[], tolerance: number): string[] {
|
|
212
|
+
const out: string[] = [];
|
|
213
|
+
for (const set of map.alternates) {
|
|
214
|
+
if (set.frames === null) continue;
|
|
215
|
+
const shown = new Set<number>();
|
|
216
|
+
for (const sw of switches) if (sw.set === set.name) for (let i = sw.frames[0]; i <= sw.frames[1]; i++) shown.add(i);
|
|
217
|
+
const d = distances.get(set.name)!;
|
|
218
|
+
for (const [first, last] of set.frames) {
|
|
219
|
+
const lastIn = Math.min(last, times.length - 1);
|
|
220
|
+
const parts: string[] = [];
|
|
221
|
+
for (let i = first; i <= lastIn;) {
|
|
222
|
+
if (shown.has(i)) { i++; continue; }
|
|
223
|
+
let j = i;
|
|
224
|
+
while (j + 1 <= lastIn && !shown.has(j + 1)) j++;
|
|
225
|
+
const ds = d.slice(i, j + 1);
|
|
226
|
+
const worst = Math.max(...ds);
|
|
227
|
+
const over = ds.filter((x) => x > tolerance).length;
|
|
228
|
+
const span = i === j ? `frame ${i}` : `frames ${i}–${j}`;
|
|
229
|
+
parts.push(over === ds.length ? `${span} outside the tolerance (up to ${worst.toFixed(1)}° > ${tolerance}°)` : over === 0 ? `${span} within the tolerance but not shown (a run shorter than --switch-min, or another set's span)` : `${span} partly outside the tolerance (up to ${worst.toFixed(1)}°)`);
|
|
230
|
+
i = j + 1;
|
|
231
|
+
}
|
|
232
|
+
const head = `set "${set.name}" window ${first}–${last}`;
|
|
233
|
+
out.push(parts.length === 0 ? `${head}: shown on every frame` : `${head}: ${parts.join('; ')} — a window restricts a set, it never forces one`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rig-pose compare`, end to end: read the two sequences, the map and the
|
|
3
|
+
* optional inputs, compare (`compare.ts`), print a summary line per unit and a
|
|
4
|
+
* line per finding, and write the report and the sheet when asked. A finding is
|
|
5
|
+
* not a failure: the run exits 0 whenever it compared.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
import { compare, findingLine, readCloth, readHands, readSpans, summaryLine, writeComparison, type ClothWindow, type CompareInput, type Comparison } from './compare.ts';
|
|
10
|
+
import { readMap } from './map.ts';
|
|
11
|
+
import { personTrack, readPoseSequence, type PoseSequence } from './pose.ts';
|
|
12
|
+
import { Problems, Refusal } from './problems.ts';
|
|
13
|
+
import { drawable, drawSheet } from './sheet.ts';
|
|
14
|
+
|
|
15
|
+
export interface CompareArgs {
|
|
16
|
+
reference: string;
|
|
17
|
+
render: string;
|
|
18
|
+
map: string;
|
|
19
|
+
retargetReport?: string;
|
|
20
|
+
referenceHands?: string;
|
|
21
|
+
renderHands?: string;
|
|
22
|
+
clothReference?: string;
|
|
23
|
+
clothRender?: string;
|
|
24
|
+
clothWindows?: ClothWindow[];
|
|
25
|
+
report?: string;
|
|
26
|
+
sheet?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function runCompare(a: CompareArgs, log: (line: string) => void): Comparison {
|
|
30
|
+
const p = new Problems();
|
|
31
|
+
const inputs: Array<[string, string | undefined]> = [
|
|
32
|
+
['--reference', a.reference], ['--render', a.render], ['--map', a.map], ['--retarget-report', a.retargetReport],
|
|
33
|
+
['--reference-hands', a.referenceHands], ['--render-hands', a.renderHands], ['--cloth-reference', a.clothReference], ['--cloth-render', a.clothRender],
|
|
34
|
+
];
|
|
35
|
+
for (const [flag, path] of inputs) {
|
|
36
|
+
if (path === undefined) continue;
|
|
37
|
+
if (!existsSync(path)) p.add('ARG_PATH', `${flag} ${path} does not exist`);
|
|
38
|
+
else if (!statSync(path).isFile()) p.add('ARG_PATH', `${flag} ${path} is not a file`);
|
|
39
|
+
}
|
|
40
|
+
for (const [flag, path] of [['--report', a.report], ['--sheet', a.sheet]] as const) {
|
|
41
|
+
if (path === undefined) continue;
|
|
42
|
+
const dir = dirname(path);
|
|
43
|
+
if (!existsSync(dir)) p.add('ARG_PATH', `${flag} ${path} — directory ${dir} does not exist`);
|
|
44
|
+
else if (!statSync(dir).isDirectory()) p.add('ARG_PATH', `${flag} ${path} — ${dir} is not a directory`);
|
|
45
|
+
}
|
|
46
|
+
p.throwIfAny();
|
|
47
|
+
|
|
48
|
+
// Every reader runs, and every refusal is reported together.
|
|
49
|
+
const all: Refusal[] = [];
|
|
50
|
+
const read = <T>(f: () => T): T | null => {
|
|
51
|
+
try {
|
|
52
|
+
return f();
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e instanceof Refusal) {
|
|
55
|
+
all.push(e);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const seqOf = (path: string) => read(() => {
|
|
62
|
+
const seq = readPoseSequence(readFileSync(path, 'utf8'), path);
|
|
63
|
+
return { seq, frames: personTrack(seq, undefined, path) };
|
|
64
|
+
});
|
|
65
|
+
const ref = seqOf(a.reference);
|
|
66
|
+
const ren = seqOf(a.render);
|
|
67
|
+
const map = read(() => readMap(readFileSync(a.map, 'utf8'), a.map));
|
|
68
|
+
const report = a.retargetReport === undefined ? null : read(() => readSpans(readFileSync(a.retargetReport!, 'utf8'), a.retargetReport!));
|
|
69
|
+
const handsA = a.referenceHands === undefined ? null : read(() => readHands(readFileSync(a.referenceHands!, 'utf8'), a.referenceHands!));
|
|
70
|
+
const handsB = a.renderHands === undefined ? null : read(() => readHands(readFileSync(a.renderHands!, 'utf8'), a.renderHands!));
|
|
71
|
+
const clothA = a.clothReference === undefined ? null : read(() => readCloth(readFileSync(a.clothReference!, 'utf8'), a.clothReference!));
|
|
72
|
+
const clothB = a.clothRender === undefined ? null : read(() => readCloth(readFileSync(a.clothRender!, 'utf8'), a.clothRender!));
|
|
73
|
+
if (all.length > 0) throw new Refusal(all.flatMap((e) => e.problems));
|
|
74
|
+
if (report !== null && report.frames !== ref!.seq.frames.length) {
|
|
75
|
+
throw new Refusal([{ code: 'COMPARE_REPORT', text: `${a.retargetReport} is a retarget over ${report.frames} frame(s); the reference ${a.reference} has ${ref!.seq.frames.length} — it is the report of another run` }]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const side = (path: string, s: { seq: PoseSequence; frames: CompareInput['reference']['frames'] }) => ({ where: path, times: s.seq.frames.map((f) => f.t), frames: s.frames });
|
|
79
|
+
const input: CompareInput = {
|
|
80
|
+
reference: side(a.reference, ref!),
|
|
81
|
+
render: side(a.render, ren!),
|
|
82
|
+
map: map!,
|
|
83
|
+
spans: report === null ? null : report.spans,
|
|
84
|
+
retargetScale: report === null ? null : report.scale,
|
|
85
|
+
hands: handsA !== null && handsB !== null ? { reference: handsA, render: handsB } : null,
|
|
86
|
+
cloth: clothA !== null && clothB !== null ? { reference: clothA, render: clothB, windows: a.clothWindows ?? [] } : null,
|
|
87
|
+
};
|
|
88
|
+
const c = compare(input);
|
|
89
|
+
|
|
90
|
+
log(`rig-pose compare: ${c.frames} frame(s), ${c.duration.toFixed(3)}s — reference ${a.reference}, render ${a.render}; median hip width ${px(c.hipWidth.reference)} and ${px(c.hipWidth.render)}${a.retargetReport === undefined ? '' : `; spans from ${a.retargetReport}`}`);
|
|
91
|
+
for (const u of c.units) log(` ${u.unit.toUpperCase().padEnd(6)} ${summaryLine(u, c.times)}`);
|
|
92
|
+
for (const f of c.findings) log(` FIND ${findingLine(f, c.times)}`);
|
|
93
|
+
if (c.cloth !== null) {
|
|
94
|
+
for (const w of c.cloth.windows) {
|
|
95
|
+
const fig = (x: { lag: number; swingRatio: number | null; overshoot: number | null; strokes: number }) => `lag ${x.lag.toFixed(3)}, swing ${x.swingRatio === null ? '—' : x.swingRatio.toFixed(3)}, overshoot ${x.overshoot === null ? '—' : x.overshoot.toFixed(3)} (${x.strokes} strokes)`;
|
|
96
|
+
log(` CLOTH window ${w.name} ${w.t[0]}–${w.t[1]}s (frames ${w.frames[0]}–${w.frames[1]}): reference ${fig(w.reference)}; render ${fig(w.render)}; strokes from ${c.cloth.stroke.reference} and ${c.cloth.stroke.render.toFixed(4)} hip widths (reference, render)`);
|
|
97
|
+
}
|
|
98
|
+
if (c.cloth.windows.length > 0) log(` CLOTH the render's stroke threshold ${c.cloth.stroke.render.toFixed(4)} of its hip widths = ${c.cloth.stroke.carried}`);
|
|
99
|
+
}
|
|
100
|
+
if (a.report !== undefined) {
|
|
101
|
+
writeFileSync(a.report, writeComparison(c, {
|
|
102
|
+
reference: a.reference, render: a.render, map: a.map, retargetReport: a.retargetReport ?? null,
|
|
103
|
+
referenceHands: a.referenceHands ?? null, renderHands: a.renderHands ?? null, clothReference: a.clothReference ?? null, clothRender: a.clothRender ?? null,
|
|
104
|
+
}));
|
|
105
|
+
log(` wrote ${a.report}`);
|
|
106
|
+
}
|
|
107
|
+
if (a.sheet !== undefined) {
|
|
108
|
+
const plate = drawSheet({ frames: ref!.frames, width: ref!.seq.width, height: ref!.seq.height }, { frames: ren!.frames, width: ren!.seq.width, height: ren!.seq.height }, c.findings);
|
|
109
|
+
if (plate === null) log(` SHEET no finding has a frame to draw (${c.findings.length} finding(s), none outside cloth); ${a.sheet} not written`);
|
|
110
|
+
else {
|
|
111
|
+
plate.writePng(a.sheet);
|
|
112
|
+
log(` SHEET ${drawable(c.findings).length} finding(s), reference | render at each worst frame: wrote ${a.sheet}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
log(` ${c.findings.length} finding(s)`);
|
|
116
|
+
return c;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const px = (v: number | null) => (v === null ? 'none' : `${v.toFixed(1)}px`);
|
package/src/sheet.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comparison's sheet (rig-c/pose#38): for each finding, the reference
|
|
3
|
+
* skeleton beside the render skeleton at its worst frame, one row per finding,
|
|
4
|
+
* worst first within each unit. Skeletons only — no video frame is ever drawn.
|
|
5
|
+
* The finding's own points are drawn over the limbs in yellow.
|
|
6
|
+
*
|
|
7
|
+
* A cloth finding is a figure over a window with no single frame, so it has no
|
|
8
|
+
* row. Pure: it returns the plate; the caller writes it.
|
|
9
|
+
*/
|
|
10
|
+
import { textWidth } from 'rig-c/font5x7';
|
|
11
|
+
import { Plate, type RGBA } from 'rig-c/plate';
|
|
12
|
+
import type { Body18 } from './joints.ts';
|
|
13
|
+
import { positionOf, type Finding, type Frames } from './compare.ts';
|
|
14
|
+
|
|
15
|
+
const RIGHT: RGBA = [214, 69, 65, 255];
|
|
16
|
+
const LEFT: RGBA = [52, 120, 214, 255];
|
|
17
|
+
const MID: RGBA = [120, 120, 120, 255];
|
|
18
|
+
const MARK: RGBA = [236, 180, 20, 255];
|
|
19
|
+
const PANE: RGBA = [232, 232, 232, 255];
|
|
20
|
+
const PAPER: RGBA = [255, 255, 255, 255];
|
|
21
|
+
const INK: RGBA = [30, 30, 30, 255];
|
|
22
|
+
|
|
23
|
+
const LIMBS: Array<[Body18, Body18, RGBA]> = [
|
|
24
|
+
['r_shoulder', 'r_elbow', RIGHT], ['r_elbow', 'r_wrist', RIGHT],
|
|
25
|
+
['r_hip', 'r_knee', RIGHT], ['r_knee', 'r_ankle', RIGHT],
|
|
26
|
+
['l_shoulder', 'l_elbow', LEFT], ['l_elbow', 'l_wrist', LEFT],
|
|
27
|
+
['l_hip', 'l_knee', LEFT], ['l_knee', 'l_ankle', LEFT],
|
|
28
|
+
['r_shoulder', 'l_shoulder', MID], ['r_hip', 'l_hip', MID],
|
|
29
|
+
['r_shoulder', 'r_hip', RIGHT], ['l_shoulder', 'l_hip', LEFT],
|
|
30
|
+
['neck', 'nose', MID], ['nose', 'r_eye', RIGHT], ['nose', 'l_eye', LEFT],
|
|
31
|
+
['r_eye', 'r_ear', RIGHT], ['l_eye', 'l_ear', LEFT],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export interface SheetSide {
|
|
35
|
+
frames: Frames;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The pane height in px; each pane keeps its picture's aspect. */
|
|
41
|
+
export const SHEET_PANE = 320;
|
|
42
|
+
const LABEL = 28;
|
|
43
|
+
const GAP = 8;
|
|
44
|
+
|
|
45
|
+
/** The findings a sheet draws: every one with a frame (not cloth). */
|
|
46
|
+
export function drawable(findings: readonly Finding[]): Finding[] {
|
|
47
|
+
return findings.filter((f) => f.unit !== 'cloth');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The sheet as a plate, or null when no finding has a frame to draw. */
|
|
51
|
+
export function drawSheet(reference: SheetSide, render: SheetSide, findings: readonly Finding[]): Plate | null {
|
|
52
|
+
const rows = drawable(findings);
|
|
53
|
+
if (rows.length === 0) return null;
|
|
54
|
+
const wa = Math.round((reference.width * SHEET_PANE) / reference.height);
|
|
55
|
+
const wb = Math.round((render.width * SHEET_PANE) / render.height);
|
|
56
|
+
const width = Math.max(wa + GAP + wb, 480) + 2 * GAP;
|
|
57
|
+
const rowH = LABEL + SHEET_PANE + GAP;
|
|
58
|
+
const plate = new Plate(width, GAP + rows.length * rowH);
|
|
59
|
+
plate.rect(0, 0, plate.width, plate.height, PAPER);
|
|
60
|
+
rows.forEach((f, k) => {
|
|
61
|
+
const y = GAP + k * rowH;
|
|
62
|
+
const title = label(`${k + 1} ${f.unit} ${f.measure} ${f.subject} frame ${f.worst.frame} t=${f.worst.t.toFixed(3)}`);
|
|
63
|
+
plate.text(title, GAP, y, textWidth(title, 2) <= width - 2 * GAP ? 2 : 1, INK);
|
|
64
|
+
plate.text('reference', GAP, y + 16, 1, INK);
|
|
65
|
+
plate.text('render', GAP + wa + GAP, y + 16, 1, INK);
|
|
66
|
+
pane(plate, reference, f, GAP, y + LABEL, wa);
|
|
67
|
+
pane(plate, render, f, GAP + wa + GAP, y + LABEL, wb);
|
|
68
|
+
});
|
|
69
|
+
return plate;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pane(plate: Plate, side: SheetSide, f: Finding, x0: number, y0: number, w: number): void {
|
|
73
|
+
plate.rect(x0, y0, w, SHEET_PANE, PANE);
|
|
74
|
+
const k = SHEET_PANE / side.height;
|
|
75
|
+
const joints = side.frames[f.worst.frame] ?? {};
|
|
76
|
+
const at = (n: string): [number, number] | null => {
|
|
77
|
+
const p = positionOf(joints, n);
|
|
78
|
+
if (p === null) return null;
|
|
79
|
+
const x = x0 + p[0] * k;
|
|
80
|
+
const y = y0 + p[1] * k;
|
|
81
|
+
// A point outside the picture is not drawn outside its pane.
|
|
82
|
+
return x < x0 || x >= x0 + w || y < y0 || y >= y0 + SHEET_PANE ? null : [x, y];
|
|
83
|
+
};
|
|
84
|
+
const t = 3;
|
|
85
|
+
for (const [a, b, colour] of LIMBS) {
|
|
86
|
+
const pa = at(a);
|
|
87
|
+
const pb = at(b);
|
|
88
|
+
if (pa !== null && pb !== null) plate.line(pa[0], pa[1], pb[0], pb[1], t, colour);
|
|
89
|
+
}
|
|
90
|
+
// A hand's points are not body-18: its wrist is marked.
|
|
91
|
+
const marks = f.measure === 'visibility' ? [f.subject === 'hand_r' ? 'r_wrist' : 'l_wrist'] : f.points;
|
|
92
|
+
for (let i = 0; i + 1 < marks.length; i++) {
|
|
93
|
+
const pa = at(marks[i]!);
|
|
94
|
+
const pb = at(marks[i + 1]!);
|
|
95
|
+
if (pa !== null && pb !== null) plate.line(pa[0], pa[1], pb[0], pb[1], t + 2, MARK);
|
|
96
|
+
}
|
|
97
|
+
for (const q of marks) {
|
|
98
|
+
const pq = at(q);
|
|
99
|
+
if (pq !== null) plate.disc(pq[0], pq[1], t * 2, MARK);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The 5×7 font has capitals, digits and a little punctuation; anything else becomes a space. */
|
|
104
|
+
function label(s: string): string {
|
|
105
|
+
return s.toUpperCase().replace(/[^A-Z0-9 _\-.,:/()!+=<>]/g, ' ');
|
|
106
|
+
}
|