@rig-c/parts 0.0.0-stage → 1.5.4

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * External pose keypoints: one explicit file that says where a painting's
3
+ * joints are, read by `propose --keypoints` (issue #75).
4
+ *
5
+ * `propose` places the skeleton from face-height and figure-height ratios that
6
+ * assume a standing figure. A posed, seated or reclining figure needs to be
7
+ * told where its joints are, and this file is how it is told — nothing about
8
+ * it is implied:
9
+ *
10
+ * ```json
11
+ * {
12
+ * "spec": "spine-parts-keypoints/1",
13
+ * "space": { "units": "painting-px", "origin": "top-left", "y": "down" },
14
+ * "width": 1664, "height": 2432,
15
+ * "source": "what produced the file, recorded and never interpreted",
16
+ * "people": [
17
+ * { "id": "a", "joints": {
18
+ * "neck": { "state": "observed", "at": [830, 380] },
19
+ * "l_hip": { "state": "occluded", "at": [890, 1010], "score": 0.4 },
20
+ * "r_ear": { "state": "missing" } } }
21
+ * ]
22
+ * }
23
+ * ```
24
+ *
25
+ * - **The space is stated, and refused if it is another**: painting pixels,
26
+ * origin top-left, y down. A file in normalised or y-up coordinates is not
27
+ * read as if it were in this one.
28
+ * - **`width` and `height` are the painting's**, held to the `--source`
29
+ * painting's size by {@link checkImageSize}.
30
+ * - **`source` is recorded, never interpreted**: a non-empty string naming
31
+ * what produced the file, printed in the first keypoint note.
32
+ * - **Joint names are the body-18 names** of `src/skeleton.ts`'s
33
+ * `KEYPOINT_NAMES`, read from there; `r` / `l` are the subject's sides, so
34
+ * `r_shoulder` is on the image's left of a figure that faces the viewer.
35
+ * - **Each joint says what it is**: `observed` with a position; `occluded`,
36
+ * whose position is optional and, when given, is the producer's estimate
37
+ * and recorded as that; or `missing`, with no position. A joint the file
38
+ * does not list is missing. An optional `score` is the producer's, recorded
39
+ * beside the joint and never computed or compared with a bar here.
40
+ * - **A position lies inside the image**: `0 <= x <= width`, `0 <= y <=
41
+ * height`, continuous painting coordinates (the right and bottom edges are
42
+ * the image's edges, so they are inside).
43
+ *
44
+ * Every problem is collected and thrown once, in the config loader's voice:
45
+ * a code, the field spelled as the file spells it, the value found and the
46
+ * value required.
47
+ *
48
+ * Painting pixels reach rig pixels by the one map the proposer's overlay
49
+ * already uses to draw the painting under the bones: `drawLandmarks`
50
+ * (`src/propose.ts`) resizes the whole painting onto the `W`x`H` rig canvas,
51
+ * which takes a painting coordinate `(x, y)` to `(x * W / width, y * H /
52
+ * height)` ({@link toRigJoints}). For the public examples that is `x * 0.5`,
53
+ * `y * 0.5` (a 1664x2432 painting on an 832x1216 rig).
54
+ */
55
+ import { existsSync, readFileSync } from 'node:fs';
56
+ import { type Problem, refuseIfAny } from './errors.ts';
57
+ import { KEYPOINT_NAMES, type KeypointName } from './skeleton.ts';
58
+
59
+ /** The spec tag a keypoint file carries. */
60
+ export const KEYPOINTS_SPEC = 'spine-parts-keypoints/1';
61
+
62
+ /** The one coordinate space a keypoint file may state. */
63
+ export const KEYPOINTS_SPACE: Readonly<Record<'units' | 'origin' | 'y', string>> = { units: 'painting-px', origin: 'top-left', y: 'down' };
64
+
65
+ export const JOINT_STATES = ['observed', 'occluded', 'missing'] as const;
66
+ export type JointState = (typeof JOINT_STATES)[number];
67
+
68
+ export interface Joint {
69
+ state: JointState;
70
+ /** Painting px; `null` for a missing joint and for an occluded one given no position. */
71
+ at: [number, number] | null;
72
+ /** The producer's score, recorded as given; `null` when the file carries none. */
73
+ score: number | null;
74
+ /** Whether the file lists the joint; an unlisted joint is missing. */
75
+ listed: boolean;
76
+ }
77
+
78
+ export interface KeypointPerson {
79
+ id: string;
80
+ joints: Record<KeypointName, Joint>;
81
+ }
82
+
83
+ export interface KeypointFile {
84
+ width: number;
85
+ height: number;
86
+ source: string;
87
+ people: KeypointPerson[];
88
+ }
89
+
90
+ const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'people'] as const;
91
+
92
+ function show(v: unknown): string {
93
+ if (v === undefined) return 'absent';
94
+ const s = JSON.stringify(v);
95
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
96
+ }
97
+
98
+ function isObject(v: unknown): v is Record<string, unknown> {
99
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
100
+ }
101
+
102
+ function isFinite2(v: unknown): v is [number, number] {
103
+ return Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number' && Number.isFinite(n));
104
+ }
105
+
106
+ /**
107
+ * Validate a parsed keypoint file. `label` names it in every refusal (its path
108
+ * when read from disk). Every problem found is thrown at once.
109
+ */
110
+ export function parseKeypoints(raw: unknown, label: string): KeypointFile {
111
+ const problems: Problem[] = [];
112
+ const fail = (code: string, object: string, detail: string): void => {
113
+ problems.push({ code, object, detail });
114
+ };
115
+ const known = (path: string, o: Record<string, unknown>, keys: readonly string[]): void => {
116
+ for (const k of Object.keys(o)) if (!keys.includes(k)) fail('KEYPOINTS_KEY_KNOWN', `${path}.${k}`, `is not a field here; known: ${keys.join(', ')}`);
117
+ };
118
+ if (!isObject(raw)) {
119
+ refuseIfAny([{ code: 'KEYPOINTS_FIELD_TYPE', object: label, detail: `is ${show(raw)}; an object is required` }]);
120
+ }
121
+ const t = raw as Record<string, unknown>;
122
+ known(label, t, TOP_KEYS);
123
+ for (const k of TOP_KEYS) if (!(k in t)) fail('KEYPOINTS_FIELD_PRESENT', `${label}.${k}`, 'is absent and required');
124
+ if ('spec' in t && t.spec !== KEYPOINTS_SPEC) fail('KEYPOINTS_SPEC_KNOWN', `${label}.spec`, `is ${show(t.spec)}; "${KEYPOINTS_SPEC}" is required`);
125
+ if ('space' in t) {
126
+ if (!isObject(t.space)) fail('KEYPOINTS_FIELD_TYPE', `${label}.space`, `is ${show(t.space)}; an object ${show(KEYPOINTS_SPACE)} is required`);
127
+ else {
128
+ const sp = t.space;
129
+ const keys = Object.keys(KEYPOINTS_SPACE) as Array<keyof typeof KEYPOINTS_SPACE>;
130
+ known(`${label}.space`, sp, keys);
131
+ for (const k of keys) {
132
+ if (sp[k] !== KEYPOINTS_SPACE[k]) {
133
+ fail(
134
+ 'KEYPOINTS_SPACE_STATED',
135
+ `${label}.space.${k}`,
136
+ `is ${show(sp[k])}; "${KEYPOINTS_SPACE[k]}" is required — the one space read here is painting pixels, origin top-left, y down, and a file in another is not read as if it were in this one`,
137
+ );
138
+ }
139
+ }
140
+ }
141
+ }
142
+ const dim = (k: 'width' | 'height'): number | null => {
143
+ if (!(k in t)) return null;
144
+ const v = t[k];
145
+ if (typeof v === 'number' && Number.isInteger(v) && v > 0) return v;
146
+ fail('KEYPOINTS_FIELD_TYPE', `${label}.${k}`, `is ${show(v)}; the painting's ${k} in px, an integer above 0, is required`);
147
+ return null;
148
+ };
149
+ const width = dim('width');
150
+ const height = dim('height');
151
+ if ('source' in t && !(typeof t.source === 'string' && t.source.trim() !== '')) {
152
+ fail('KEYPOINTS_FIELD_TYPE', `${label}.source`, `is ${show(t.source)}; a non-empty string naming what produced the file is required`);
153
+ }
154
+ const people: KeypointPerson[] = [];
155
+ if ('people' in t) {
156
+ if (!Array.isArray(t.people) || t.people.length === 0) fail('KEYPOINTS_FIELD_TYPE', `${label}.people`, `is ${show(t.people)}; a non-empty array of {id, joints} is required`);
157
+ else {
158
+ const seen = new Map<string, number>();
159
+ t.people.forEach((pv, i) => {
160
+ const at = `${label}.people[${i}]`;
161
+ if (!isObject(pv)) {
162
+ fail('KEYPOINTS_FIELD_TYPE', at, `is ${show(pv)}; an object {id, joints} is required`);
163
+ return;
164
+ }
165
+ known(at, pv, ['id', 'joints']);
166
+ for (const k of ['id', 'joints']) if (!(k in pv)) fail('KEYPOINTS_FIELD_PRESENT', `${at}.${k}`, 'is absent and required');
167
+ let id: string | null = null;
168
+ if ('id' in pv) {
169
+ if (typeof pv.id === 'string' && pv.id !== '') {
170
+ id = pv.id;
171
+ const first = seen.get(id);
172
+ if (first !== undefined) fail('KEYPOINTS_PERSON_UNIQUE', `${at}.id`, `is "${id}", which ${label}.people[${first}] already is; one id per person is required, or --person cannot say which one is meant`);
173
+ else seen.set(id, i);
174
+ } else fail('KEYPOINTS_FIELD_TYPE', `${at}.id`, `is ${show(pv.id)}; a non-empty string is required`);
175
+ }
176
+ if (!('joints' in pv)) return;
177
+ if (!isObject(pv.joints)) {
178
+ fail('KEYPOINTS_FIELD_TYPE', `${at}.joints`, `is ${show(pv.joints)}; an object of joint name -> {state, at?, score?} is required`);
179
+ return;
180
+ }
181
+ const joints = {} as Record<KeypointName, Joint>;
182
+ for (const n of KEYPOINT_NAMES) joints[n] = { state: 'missing', at: null, score: null, listed: false };
183
+ for (const [name, jv] of Object.entries(pv.joints)) {
184
+ const jat = `${at}.joints.${name}`;
185
+ if (!(KEYPOINT_NAMES as readonly string[]).includes(name)) {
186
+ fail('KEYPOINTS_JOINT_KNOWN', jat, `is not a body-18 joint; one of ${KEYPOINT_NAMES.join(', ')} is required (r and l are the subject's sides)`);
187
+ continue;
188
+ }
189
+ if (!isObject(jv)) {
190
+ fail('KEYPOINTS_FIELD_TYPE', jat, `is ${show(jv)}; an object {state, at?, score?} is required`);
191
+ continue;
192
+ }
193
+ known(jat, jv, ['state', 'at', 'score']);
194
+ const state = jv.state;
195
+ if (!(JOINT_STATES as readonly unknown[]).includes(state)) {
196
+ fail('KEYPOINTS_JOINT_STATE', `${jat}.state`, `is ${show(state)}; one of ${JOINT_STATES.join(', ')} is required`);
197
+ continue;
198
+ }
199
+ const st = state as JointState;
200
+ let pos: [number, number] | null = null;
201
+ if ('at' in jv) {
202
+ if (st === 'missing') fail('KEYPOINTS_POSITION_STATED', `${jat}.at`, `is ${show(jv.at)} on a missing joint; a missing joint has no position — say "occluded" if the producer estimated one`);
203
+ else if (!isFinite2(jv.at)) fail('KEYPOINTS_FIELD_TYPE', `${jat}.at`, `is ${show(jv.at)}; a point [x, y] in painting px is required`);
204
+ else {
205
+ const [x, y] = jv.at;
206
+ if (width !== null && height !== null && (x < 0 || y < 0 || x > width || y > height)) {
207
+ fail('KEYPOINTS_POSITION_INSIDE', `${jat}.at`, `is [${x}, ${y}]; a point inside the ${width}x${height} image is required (0 <= x <= ${width}, 0 <= y <= ${height})`);
208
+ } else pos = [x, y];
209
+ }
210
+ } else if (st === 'observed') fail('KEYPOINTS_POSITION_STATED', `${jat}.at`, 'is absent on an observed joint; an observed joint is a position [x, y] in painting px — say "missing" if there is none');
211
+ let score: number | null = null;
212
+ if ('score' in jv) {
213
+ if (typeof jv.score === 'number' && Number.isFinite(jv.score)) score = jv.score;
214
+ else fail('KEYPOINTS_FIELD_TYPE', `${jat}.score`, `is ${show(jv.score)}; a finite number (the producer's, recorded as given) is required`);
215
+ }
216
+ joints[name as KeypointName] = { state: st, at: pos, score, listed: true };
217
+ }
218
+ if (id !== null) people.push({ id, joints });
219
+ });
220
+ }
221
+ }
222
+ refuseIfAny(problems);
223
+ return { width: width as number, height: height as number, source: t.source as string, people };
224
+ }
225
+
226
+ /** Read and validate a keypoint file from disk. */
227
+ export function loadKeypoints(path: string): KeypointFile {
228
+ if (!existsSync(path)) refuseIfAny([{ code: 'KEYPOINTS_FILE_PRESENT', object: path, detail: 'no such file; --keypoints names a keypoint file' }]);
229
+ let raw: unknown;
230
+ try {
231
+ raw = JSON.parse(readFileSync(path, 'utf8'));
232
+ } catch (err) {
233
+ refuseIfAny([{ code: 'KEYPOINTS_IS_JSON', object: path, detail: `does not parse as JSON: ${(err as Error).message}` }]);
234
+ }
235
+ return parseKeypoints(raw, path);
236
+ }
237
+
238
+ /**
239
+ * The person `--person` names. One person and no `--person` is that person;
240
+ * more than one and no `--person` is refused naming the ids — nothing picks
241
+ * one (not the largest, not the first).
242
+ */
243
+ export function choosePerson(file: KeypointFile, id: string | undefined, label: string): KeypointPerson {
244
+ const ids = file.people.map((p) => `"${p.id}"`).join(', ');
245
+ if (id === undefined) {
246
+ if (file.people.length === 1) return file.people[0];
247
+ refuseIfAny([{ code: 'KEYPOINTS_PERSON_CHOSEN', object: label, detail: `holds ${file.people.length} people (${ids}) and no --person names one; --person <id> is required, because nothing here picks a person` }]);
248
+ }
249
+ const p = file.people.find((q) => q.id === id);
250
+ if (p === undefined) refuseIfAny([{ code: 'KEYPOINTS_PERSON_CHOSEN', object: `--person ${id}`, detail: `names no person in ${label}; its ids are ${ids}` }]);
251
+ return p as KeypointPerson;
252
+ }
253
+
254
+ /** Hold the file's image size to the painting's: a file measured on another image is not this painting's. */
255
+ export function checkImageSize(file: KeypointFile, w: number, h: number, label: string, painting: string): void {
256
+ if (file.width === w && file.height === h) return;
257
+ refuseIfAny([
258
+ {
259
+ code: 'KEYPOINTS_IMAGE_SIZE',
260
+ object: `${label} width, height`,
261
+ detail: `are ${file.width}x${file.height}; the painting ${painting} is ${w}x${h}, and a keypoint file is read only against the image it was measured on`,
262
+ },
263
+ ]);
264
+ }
265
+
266
+ /** One joint as the proposer reads it: rig px where the file gave a position. */
267
+ export interface RigJoint {
268
+ state: JointState;
269
+ /** Rig px, by the overlay's map; `null` when the file gave no position. */
270
+ at: [number, number] | null;
271
+ score: number | null;
272
+ listed: boolean;
273
+ }
274
+
275
+ /** One person's joints in rig px, with what the proposer's notes say about where they came from. */
276
+ export interface RigJoints {
277
+ person: string;
278
+ source: string;
279
+ painting: [number, number];
280
+ rig: [number, number];
281
+ joints: Record<KeypointName, RigJoint>;
282
+ }
283
+
284
+ /**
285
+ * A person's joints taken to rig px by the overlay's map: `drawLandmarks`
286
+ * resizes the painting (`width`x`height`) onto the `W`x`H` rig canvas, which
287
+ * takes `(x, y)` to `(x * W / width, y * H / height)`.
288
+ */
289
+ export function toRigJoints(file: KeypointFile, person: KeypointPerson, W: number, H: number): RigJoints {
290
+ const joints = {} as Record<KeypointName, RigJoint>;
291
+ for (const n of KEYPOINT_NAMES) {
292
+ const j = person.joints[n];
293
+ joints[n] = { state: j.state, at: j.at === null ? null : [(j.at[0] * W) / file.width, (j.at[1] * H) / file.height], score: j.score, listed: j.listed };
294
+ }
295
+ return { person: person.id, source: file.source, painting: [file.width, file.height], rig: [W, H], joints };
296
+ }