@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
package/src/sheet.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The contact sheet: the source painting and every layer or part, each on a
3
+ * checkerboard, labelled, in a grid.
4
+ *
5
+ * It exists for the one reader who CAN see — the person checking the agent's
6
+ * work — and for an agent that can read an image. What it prints to the
7
+ * console is the same information as text (every tile's name, size and opaque
8
+ * pixel count), so an agent that cannot read the image loses nothing by not
9
+ * opening it.
10
+ *
11
+ * The layout is the reference implementation's contact sheet: square cells
12
+ * (default 220 px), a 12 px checker in greys 200 and 230, each image scaled
13
+ * with Lanczos to fit `cell - 4` wide and `cell - 30` tall — enlarging a small
14
+ * layer as well as shrinking a large one — centred in the top of the cell, two
15
+ * label lines at the bottom. Two deviations, both stated: the sheet is a PNG
16
+ * (no JPEG encoder ships here), and the label is drawn in rig-c's 5x7
17
+ * bitmap font rather than PIL's default font, so labels are upper case.
18
+ */
19
+ import { existsSync } from 'node:fs';
20
+ import { basename, dirname, join } from 'node:path';
21
+ import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
22
+ import { type Problem, refuseIfAny } from './errors.ts';
23
+ import { type LayerSet, OPAQUE_ALPHA_ABOVE, readLayers } from './layers.ts';
24
+ import { readParts } from './parts.ts';
25
+ import { alphaComposite } from './raster/composite.ts';
26
+ import { readPng } from './raster/png.ts';
27
+ import { resize } from './raster/resize.ts';
28
+ import { newRaster, type Raster } from './raster/types.ts';
29
+
30
+ export interface Tile {
31
+ /** First label line: the name. */
32
+ name: string;
33
+ image: Raster;
34
+ /** Second label line, if not the default `<w>x<h> op=<n>`. */
35
+ caption?: string;
36
+ }
37
+
38
+ const CHECKER = 12;
39
+ const LABEL_SPACE = 30;
40
+ const SIDE_SPACE = 4;
41
+
42
+ function opaqueCount(r: Raster): number {
43
+ let n = 0;
44
+ for (let i = 3; i < r.data.length; i += 4) if (r.data[i] > OPAQUE_ALPHA_ABOVE) n++;
45
+ return n;
46
+ }
47
+
48
+ export function defaultCaption(r: Raster): string {
49
+ return `${r.width}x${r.height} op=${opaqueCount(r)}`;
50
+ }
51
+
52
+ function checker(cell: number): Raster {
53
+ const r = newRaster(cell, cell);
54
+ for (let y = 0; y < cell; y++) {
55
+ for (let x = 0; x < cell; x++) {
56
+ const c = ((Math.floor(x / CHECKER) + Math.floor(y / CHECKER)) % 2) * 30 + 200;
57
+ r.data.set([c, c, c, 255], (y * cell + x) * 4);
58
+ }
59
+ }
60
+ return r;
61
+ }
62
+
63
+ /** Black text into a raster, clipped. `_` is drawn as a baseline bar: the 5x7 font has no glyph for it. */
64
+ function label(r: Raster, text: string, x0: number, y0: number): void {
65
+ const plot = (x: number, y: number): void => {
66
+ if (x < 0 || y < 0 || x >= r.width || y >= r.height) return;
67
+ r.data.set([0, 0, 0, 255], (y * r.width + x) * 4);
68
+ };
69
+ let x = x0;
70
+ for (const ch of text) {
71
+ if (ch === '_') for (let i = 0; i < 5; i++) plot(x + i, y0 + GLYPH_H - 1);
72
+ else if (ch !== ' ') drawText(ch, x, y0, 1, plot);
73
+ x += 6;
74
+ }
75
+ }
76
+
77
+ export function tileImage(tile: Tile, cell: number): Raster {
78
+ const im = tile.image;
79
+ const sc = Math.min((cell - SIDE_SPACE) / im.width, (cell - LABEL_SPACE) / im.height);
80
+ const w = Math.max(1, Math.trunc(im.width * sc));
81
+ const h = Math.max(1, Math.trunc(im.height * sc));
82
+ const scaled = resize(im, w, h, 'lanczos3');
83
+ const bg = alphaComposite(checker(cell), scaled, Math.floor((cell - w) / 2), 2 + Math.floor((cell - LABEL_SPACE - h) / 2));
84
+ label(bg, tile.name, 4, cell - 27);
85
+ label(bg, tile.caption ?? defaultCaption(im), 4, cell - 27 + 12);
86
+ return bg;
87
+ }
88
+
89
+ export function buildSheet(tiles: Tile[], cols: number, cell: number): Raster {
90
+ const rows = Math.ceil(tiles.length / cols);
91
+ const sheet = newRaster(cols * cell, rows * cell);
92
+ sheet.data.fill(255);
93
+ tiles.forEach((t, i) => {
94
+ const img = tileImage(t, cell);
95
+ const ox = (i % cols) * cell;
96
+ const oy = Math.floor(i / cols) * cell;
97
+ for (let y = 0; y < cell; y++) sheet.data.set(img.data.subarray(y * cell * 4, (y + 1) * cell * 4), ((oy + y) * sheet.width + ox) * 4);
98
+ });
99
+ return sheet;
100
+ }
101
+
102
+ /**
103
+ * The tiles one `--layers` argument contributes: every layer of a See-through
104
+ * decomposition (a wrapper directory, a `layers.json`, a `.psd`) in draw order,
105
+ * or every part of a `parts.json` in plan order, read from `parts/<name>.png`
106
+ * beside it. A PNG that is not there is refused by name — the reference sheet
107
+ * skipped it silently, which is a sheet that looks complete and is not.
108
+ */
109
+ export function tilesFrom(path: string): Tile[] {
110
+ if (basename(path) === 'parts.json') {
111
+ const parts = readParts(path);
112
+ const dir = join(dirname(path), 'parts');
113
+ const problems: Problem[] = [];
114
+ const tiles: Tile[] = [];
115
+ for (const p of parts.parts) {
116
+ const png = join(dir, `${p.name}.png`);
117
+ if (!existsSync(png)) {
118
+ problems.push({ code: 'SHEET_PNG_PRESENT', object: `part "${p.name}"`, detail: `${png} does not exist; parts.json names it` });
119
+ continue;
120
+ }
121
+ tiles.push({ name: p.name, image: readPng(png) });
122
+ }
123
+ refuseIfAny(problems);
124
+ return tiles;
125
+ }
126
+ const set: LayerSet = readLayers(path);
127
+ return set.layers.map((l) => ({ name: l.name, image: l.pixels }));
128
+ }
@@ -0,0 +1,400 @@
1
+ /**
2
+ * OpenPose body-18 skeletons for the painting's structural control.
3
+ *
4
+ * The generation adapter (`src/comfy/painting.ts`) can condition the painting
5
+ * on an AUTHORED pose: one of the skeletons below, or the points of a pose file
6
+ * the config names (issue #198, read by `src/config.ts`), drawn at the latent
7
+ * size and applied through an OpenPose ControlNet. One skeleton is one figure with its
8
+ * head and feet placed inside the frame, which is the reason it exists — a pose
9
+ * asked for in words alone is a pose the sampler is free to ignore.
10
+ *
11
+ * Pure raster: no network, no clock. The adapter uploads what this draws.
12
+ *
13
+ * ⚖️ **The drawing convention is the annotator's, not a free choice.** A
14
+ * skeleton drawn in a different style is a different conditioning
15
+ * distribution, so this copies `comfyui_controlnet_aux`'s `draw_bodypose` as
16
+ * the reference implementation did: limbs first, as filled rotated ellipses
17
+ * at 0.6 x the limb colour, then every keypoint as a radius-4 full-colour disc
18
+ * on top. The stick's half-width is `4 * stickScale(max side)`, and for xinsir's
19
+ * `controlnet-openpose-sdxl-1.0` the scale is `min(2 + max_side // 1000, 7)`
20
+ * (1 below 500 px) — 12 px at the 832x1216 latent, three times the canonical
21
+ * 4 px, which is the trap the rule exists to avoid.
22
+ *
23
+ * 🔬 **Rasterised the way PIL rasterises**, because the reference drew with
24
+ * `ImageDraw.polygon` / `ImageDraw.ellipse` and a ControlNet sees every pixel:
25
+ * polygon vertices TRUNCATED to integers (`_imaging.c` `_draw_polygon`), the
26
+ * scanline fill of `libImaging/Draw.c` `polygon_generic` in float32 with its
27
+ * corner rule, consecutive horizontal edges merged as `ImagingDrawPolygon`
28
+ * does, and the disc from `ellipseNew`'s integer quarter walk. Transcribed from
29
+ * Pillow 12.2.0's sources; the measurement against Pillow 12.2.0 itself is in
30
+ * the doc comment of `renderSkeleton`.
31
+ *
32
+ * Proportions (canvas 832x1216, centre x = 416): head top ~ y 85, chin ~ 228,
33
+ * ankles at 1100 — about seven heads, an adult; ~85 px above the head and ~75
34
+ * px under the feet so neither the crown nor a hem touches the frame. Another
35
+ * latent size scales every point by `w / 832`, `h / 1216`.
36
+ */
37
+ import { newRaster, type Raster } from './raster/types.ts';
38
+
39
+ export type SkeletonName = 'stand_sides' | 'stand_clasp';
40
+
41
+ /** The built-in skeletons' names, in the order the config's refusal lists them. */
42
+ export const SKELETON_NAMES: readonly SkeletonName[] = ['stand_sides', 'stand_clasp'];
43
+
44
+ /** Whether `v` names a built-in skeleton; any other string in `generation.control.skeleton` is a pose file's path. */
45
+ export function isSkeletonName(v: unknown): v is SkeletonName {
46
+ return typeof v === 'string' && (SKELETON_NAMES as readonly string[]).includes(v);
47
+ }
48
+
49
+ /** Body-18 order. "r"/"l" are the SUBJECT's sides, so r_shoulder is on the image left for a front-facing figure. */
50
+ export const KEYPOINT_NAMES = [
51
+ 'nose', 'neck',
52
+ 'r_shoulder', 'r_elbow', 'r_wrist',
53
+ 'l_shoulder', 'l_elbow', 'l_wrist',
54
+ 'r_hip', 'r_knee', 'r_ankle',
55
+ 'l_hip', 'l_knee', 'l_ankle',
56
+ 'r_eye', 'l_eye', 'r_ear', 'l_ear',
57
+ ] as const;
58
+ export type KeypointName = (typeof KEYPOINT_NAMES)[number];
59
+
60
+ /** Upstream `limbSeq`, 1-indexed into `KEYPOINT_NAMES`, in drawing order. */
61
+ export const LIMB_SEQ: ReadonlyArray<readonly [number, number]> = [
62
+ [2, 3], [2, 6], [3, 4], [4, 5], [6, 7], [7, 8], [2, 9], [9, 10],
63
+ [10, 11], [2, 12], [12, 13], [13, 14], [2, 1], [1, 15], [15, 17], [1, 16], [16, 18],
64
+ ];
65
+
66
+ /** Upstream colours: limb i and keypoint i share `COLORS[i]`. */
67
+ export const COLORS: ReadonlyArray<readonly [number, number, number]> = [
68
+ [255, 0, 0], [255, 85, 0], [255, 170, 0], [255, 255, 0], [170, 255, 0], [85, 255, 0],
69
+ [0, 255, 0], [0, 255, 85], [0, 255, 170], [0, 255, 255], [0, 170, 255], [0, 85, 255],
70
+ [0, 0, 255], [85, 0, 255], [170, 0, 255], [255, 0, 255], [255, 0, 170], [255, 0, 85],
71
+ ];
72
+
73
+ /** The canvas the points are authored on. */
74
+ export const SKELETON_BASE: readonly [number, number] = [832, 1216];
75
+
76
+ /** The 18 body-18 keypoints of one figure, by name, in canvas pixels (x right, y down). */
77
+ export type PosePoints = Record<KeypointName, readonly [number, number]>;
78
+
79
+ const HEAD = {
80
+ nose: [416, 176], r_eye: [398, 160], l_eye: [434, 160], r_ear: [378, 170], l_ear: [454, 170], neck: [416, 252],
81
+ } as const;
82
+ /** Ankles 56 px apart, the feet close together. */
83
+ const LEGS = {
84
+ r_hip: [378, 612], l_hip: [454, 612], r_knee: [384, 862], l_knee: [448, 862], r_ankle: [388, 1100], l_ankle: [444, 1100],
85
+ } as const;
86
+
87
+ export interface SkeletonPose {
88
+ /**
89
+ * The pose in words, appended to the positive prompt when the config gives
90
+ * no `generation.pose`. These words and the framing words in
91
+ * `src/graphs.ts` are the whole of what a skeleton adds to a prompt.
92
+ */
93
+ words: string;
94
+ note: string;
95
+ points: PosePoints;
96
+ }
97
+
98
+ export const SKELETONS: Record<SkeletonName, SkeletonPose> = {
99
+ stand_sides: {
100
+ words: 'standing, arms at sides, relaxed arms, relaxed pose',
101
+ note: 'Arms relaxed at the sides, hands by the thighs; elbows just off the waist so a wide sleeve reads as its own shape.',
102
+ points: { ...HEAD, ...LEGS, r_shoulder: [340, 272], l_shoulder: [492, 272], r_elbow: [320, 462], l_elbow: [512, 462], r_wrist: [318, 640], l_wrist: [514, 640] },
103
+ },
104
+ stand_clasp: {
105
+ words: 'standing, hands clasped in front of waist, own hands together, relaxed pose',
106
+ note: 'Hands lightly clasped in front at the waist; the wrists meet on the midline just above the hip line.',
107
+ points: { ...HEAD, ...LEGS, r_shoulder: [340, 272], l_shoulder: [492, 272], r_elbow: [318, 462], l_elbow: [514, 462], r_wrist: [402, 566], l_wrist: [430, 566] },
108
+ },
109
+ };
110
+
111
+ /** xinsir's stick-scale rule for `controlnet-openpose-sdxl-1.0`: 1 below 500 px, else `min(2 + max_side // 1000, 7)`. */
112
+ export function stickScale(maxSide: number): number {
113
+ return maxSide < 500 ? 1 : Math.min(2 + Math.floor(maxSide / 1000), 7);
114
+ }
115
+
116
+ /** A skeleton's points at a `w`x`h` canvas: each authored point times `w / 832`, `h / 1216`. */
117
+ export function scaledPoints(name: SkeletonName, w: number, h: number): PosePoints {
118
+ const sx = w / SKELETON_BASE[0];
119
+ const sy = h / SKELETON_BASE[1];
120
+ const src = SKELETONS[name].points;
121
+ const out = {} as Record<KeypointName, readonly [number, number]>;
122
+ for (const k of KEYPOINT_NAMES) out[k] = [src[k][0] * sx, src[k][1] * sy];
123
+ return out;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // PIL's rasteriser, the parts this drawing uses
128
+ // ---------------------------------------------------------------------------
129
+
130
+ const f32 = Math.fround;
131
+
132
+ /** CPython's `math.radians` / `math.degrees`: one multiplication by a precomputed constant, which is not `x * 180 / pi`. */
133
+ const DEG_TO_RAD = Math.PI / 180;
134
+ const RAD_TO_DEG = 180 / Math.PI;
135
+
136
+ /** C's `(int)` on a double: truncation toward zero. */
137
+ const cint = (v: number): number => Math.trunc(v);
138
+
139
+ /** `ROUND_UP` / `ROUND_DOWN` of Draw.c: half away from zero / half toward zero, on a float. */
140
+ const roundUp = (v: number): number => (v >= 0 ? Math.floor(v + 0.5) : -Math.floor(Math.abs(v) + 0.5));
141
+ const roundDown = (v: number): number => (v >= 0 ? Math.ceil(v - 0.5) : -Math.ceil(Math.abs(v) - 0.5));
142
+ /** C's `roundf`: half away from zero. */
143
+ const roundf = (v: number): number => f32(v >= 0 ? Math.floor(v + 0.5) : -Math.floor(-v + 0.5));
144
+
145
+ interface Edge {
146
+ x0: number;
147
+ y0: number;
148
+ xmin: number;
149
+ ymin: number;
150
+ xmax: number;
151
+ ymax: number;
152
+ dx: number;
153
+ }
154
+
155
+ function addEdge(x0: number, y0: number, x1: number, y1: number): Edge {
156
+ return {
157
+ x0,
158
+ y0,
159
+ xmin: Math.min(x0, x1),
160
+ xmax: Math.max(x0, x1),
161
+ ymin: Math.min(y0, y1),
162
+ ymax: Math.max(y0, y1),
163
+ dx: y0 === y1 ? 0 : f32((x1 - x0) / (y1 - y0)),
164
+ };
165
+ }
166
+
167
+ function hline(r: Raster, x0: number, y: number, x1: number, rgb: readonly [number, number, number]): void {
168
+ if (y < 0 || y >= r.height) return;
169
+ if (x0 < 0) x0 = 0;
170
+ else if (x0 >= r.width) return;
171
+ if (x1 < 0) return;
172
+ if (x1 >= r.width) x1 = r.width - 1;
173
+ for (let x = x0; x <= x1; x++) {
174
+ const i = (y * r.width + x) * 4;
175
+ r.data[i] = rgb[0];
176
+ r.data[i + 1] = rgb[1];
177
+ r.data[i + 2] = rgb[2];
178
+ r.data[i + 3] = 255;
179
+ }
180
+ }
181
+
182
+ /** `polygon_generic` for an opaque RGB target (`hasAlpha == 0`): float32 scanline intersections and the corner rule. */
183
+ function polygonGeneric(r: Raster, e: Edge[], rgb: readonly [number, number, number]): void {
184
+ if (e.length === 0) return;
185
+ let ymin = r.height - 1;
186
+ let ymax = 0;
187
+ const table: Edge[] = [];
188
+ for (const edge of e) {
189
+ if (ymin > edge.ymin) ymin = edge.ymin;
190
+ if (ymax < edge.ymax) ymax = edge.ymax;
191
+ if (edge.ymin === edge.ymax) {
192
+ hline(r, edge.xmin, edge.ymin, edge.xmax, rgb);
193
+ continue;
194
+ }
195
+ table.push(edge);
196
+ }
197
+ if (ymin < 0) ymin = 0;
198
+ if (ymax > r.height) ymax = r.height;
199
+ const at = (edge: Edge, y: number): number => f32(f32((y - edge.y0) * edge.dx) + edge.x0);
200
+ for (; ymin <= ymax; ymin++) {
201
+ const xx: number[] = [];
202
+ for (let i = 0; i < table.length; i++) {
203
+ const cur = table[i];
204
+ if (!(ymin >= cur.ymin && ymin <= cur.ymax)) continue;
205
+ xx.push(at(cur, ymin));
206
+ const j = xx.length;
207
+ if (ymin === cur.ymax && ymin < ymax) {
208
+ xx.push(xx[j - 1]);
209
+ } else if ((ymin === cur.ymin || ymin === cur.ymax) && cur.dx !== 0) {
210
+ for (let k = 0; k < i; k++) {
211
+ const other = table[k];
212
+ if ((ymin !== other.ymin && ymin !== other.ymax) || other.dx === 0) continue;
213
+ if (roundf(xx[j - 1]) !== roundf(at(other, ymin))) continue;
214
+ const offset = ymin === cur.ymax ? -1 : 1;
215
+ const adj = at(cur, ymin + offset);
216
+ if (ymin + offset >= other.ymin && ymin + offset <= other.ymax) {
217
+ const adjOther = at(other, ymin + offset);
218
+ if (xx[j - 1] > adj + 1 && xx[j - 1] > adjOther + 1) xx[j - 1] = f32(roundf(Math.max(adj, adjOther)) + 1);
219
+ else if (xx[j - 1] < adj - 1 && xx[j - 1] < adjOther - 1) xx[j - 1] = f32(roundf(Math.min(adj, adjOther)) - 1);
220
+ break;
221
+ }
222
+ }
223
+ }
224
+ }
225
+ xx.sort((a, b) => a - b);
226
+ for (let i = 1; i < xx.length; i += 2) hline(r, roundUp(xx[i - 1]), ymin, roundDown(xx[i]), rgb);
227
+ }
228
+ }
229
+
230
+ /** `ImageDraw.polygon(points, fill=rgb)`: vertices truncated to int, consecutive horizontal edges merged, then `polygon_generic`. */
231
+ export function pilFillPolygon(r: Raster, points: ReadonlyArray<readonly [number, number]>, rgb: readonly [number, number, number]): void {
232
+ const xy: number[] = [];
233
+ for (const [x, y] of points) xy.push(cint(x), cint(y));
234
+ const count = points.length;
235
+ const e: Edge[] = [];
236
+ let i = 0;
237
+ for (; i < count - 1; i++) {
238
+ const x0 = xy[i * 2];
239
+ const y0 = xy[i * 2 + 1];
240
+ const x1 = xy[i * 2 + 2];
241
+ const y1 = xy[i * 2 + 3];
242
+ if (y0 === y1 && i !== 0 && y0 === xy[i * 2 - 1]) {
243
+ const last = e[e.length - 1];
244
+ if (x1 > x0 && x0 > xy[i * 2 - 2]) {
245
+ last.xmax = x1;
246
+ continue;
247
+ } else if (x1 < x0 && x0 < xy[i * 2 - 2]) {
248
+ last.xmin = x1;
249
+ continue;
250
+ }
251
+ }
252
+ e.push(addEdge(x0, y0, x1, y1));
253
+ }
254
+ if (xy[i * 2] !== xy[0] || xy[i * 2 + 1] !== xy[1]) e.push(addEdge(xy[i * 2], xy[i * 2 + 1], xy[0], xy[1]));
255
+ polygonGeneric(r, e, rgb);
256
+ }
257
+
258
+ interface Quarter {
259
+ cx: number;
260
+ cy: number;
261
+ ex: number;
262
+ ey: number;
263
+ a2: number;
264
+ b2: number;
265
+ a2b2: number;
266
+ finished: boolean;
267
+ }
268
+
269
+ function quarterInit(a: number, b: number): Quarter {
270
+ if (a < 0 || b < 0) return { cx: 0, cy: 0, ex: 0, ey: 0, a2: 0, b2: 0, a2b2: 0, finished: true };
271
+ return { cx: a, cy: b % 2, ex: a % 2, ey: b, a2: a * a, b2: b * b, a2b2: a * a * b * b, finished: false };
272
+ }
273
+
274
+ function quarterNext(s: Quarter): [number, number] | null {
275
+ if (s.finished) return null;
276
+ const ret: [number, number] = [s.cx, s.cy];
277
+ if (s.cx === s.ex && s.cy === s.ey) s.finished = true;
278
+ else {
279
+ const delta = (x: number, y: number): number => Math.abs(s.a2 * y * y + s.b2 * x * x - s.a2b2);
280
+ let nx = s.cx;
281
+ let ny = s.cy + 2;
282
+ let nd = delta(nx, ny);
283
+ if (nx > 1) {
284
+ let d = delta(s.cx - 2, s.cy + 2);
285
+ if (nd > d) {
286
+ nx = s.cx - 2;
287
+ ny = s.cy + 2;
288
+ nd = d;
289
+ }
290
+ d = delta(s.cx - 2, s.cy);
291
+ if (nd > d) {
292
+ nx = s.cx - 2;
293
+ ny = s.cy;
294
+ }
295
+ }
296
+ s.cx = nx;
297
+ s.cy = ny;
298
+ }
299
+ return ret;
300
+ }
301
+
302
+ /** `ImageDraw.ellipse([x0, y0, x1, y1], fill=rgb)`: the box truncated to int, then `ellipseNew` filled (`width = a + b`). */
303
+ export function pilFillEllipse(r: Raster, box: readonly [number, number, number, number], rgb: readonly [number, number, number]): void {
304
+ const [x0, y0, x1, y1] = box.map(cint);
305
+ const a = x1 - x0;
306
+ const b = y1 - y0;
307
+ if (a < 0 || b < 0) return;
308
+ const w = a + b;
309
+ const outer = quarterInit(a, b);
310
+ const leftmost = a % 2;
311
+ const spans: Array<[number, number, number]> = [];
312
+ const first = quarterNext(outer);
313
+ if (w < 1 || first === null) return;
314
+ let pr = first[0];
315
+ let py = first[1];
316
+ const inner = quarterInit(a - 2 * (w - 1), b - 2 * (w - 1));
317
+ let pl = leftmost;
318
+ let finished = false;
319
+ while (!finished) {
320
+ const y = py;
321
+ let l = pl;
322
+ const rr = pr;
323
+ let next: [number, number] | null;
324
+ while ((next = quarterNext(outer)) !== null && next[1] <= y) {
325
+ /* advance */
326
+ }
327
+ if (next === null) finished = true;
328
+ else {
329
+ pr = next[0];
330
+ py = next[1];
331
+ }
332
+ let inext: [number, number] | null;
333
+ let cx = 0;
334
+ while ((inext = quarterNext(inner)) !== null && inext[1] <= y) l = inext[0];
335
+ if (inext !== null) cx = inext[0];
336
+ pl = inext === null ? leftmost : cx;
337
+ const buf: Array<[number, number, number]> = [];
338
+ if ((l > 0 || l < rr) && y > 0) buf.push([l === 0 ? 2 : l, y, rr]);
339
+ if (y > 0) buf.push([-rr, y, -l]);
340
+ if (l > 0 || l < rr) buf.push([l === 0 ? 2 : l, -y, rr]);
341
+ buf.push([-rr, -y, -l]);
342
+ // ellipse_next hands the buffer back last-in first-out
343
+ for (let k = buf.length - 1; k >= 0; k--) spans.push(buf[k]);
344
+ }
345
+ // C's integer division truncates toward zero
346
+ const half = (v: number): number => Math.trunc(v / 2);
347
+ for (const [X0, Y, X1] of spans) hline(r, x0 + half(X0 + a), y0 + half(Y + b), x0 + half(X1 + a), rgb);
348
+ }
349
+
350
+ /** The reference's `ellipse2poly`: 360 points, one per degree, of the rotated ellipse — float64, unrounded. */
351
+ export function ellipsePoly(cx: number, cy: number, ax: number, ay: number, angleDeg: number): Array<[number, number]> {
352
+ const a = angleDeg * DEG_TO_RAD;
353
+ const ca = Math.cos(a);
354
+ const sa = Math.sin(a);
355
+ const pts: Array<[number, number]> = [];
356
+ for (let t = 0; t < 360; t++) {
357
+ const tr = t * DEG_TO_RAD;
358
+ const ct = Math.cos(tr);
359
+ const st = Math.sin(tr);
360
+ pts.push([cx + ax * ct * ca - ay * st * sa, cy + ax * ct * sa + ay * st * ca]);
361
+ }
362
+ return pts;
363
+ }
364
+
365
+ /**
366
+ * Draw one skeleton at `w`x`h`: black RGB (alpha 255), limbs at 0.6 x colour,
367
+ * keypoints on top. `pose` is a built-in's name, whose authored points are
368
+ * scaled to the canvas ({@link scaledPoints}), or the points of a pose file
369
+ * (issue #198), already in this canvas's pixels and drawn as given — the same
370
+ * drawing either way, so a file holding a built-in's points at this size draws
371
+ * the built-in's image byte for byte.
372
+ *
373
+ * Measured bit-exact against Pillow 12.2.0 running the reference's own
374
+ * renderer: both skeletons at 832x1216, 1024x1024, 600x900, 1216x832,
375
+ * 400x640 and 2048x2048 — 16,124,608 pixels, 0 different. (`math.radians` /
376
+ * `math.degrees` there are one multiplication by a constant, as here; the
377
+ * `x * 180 / pi` spelling is a different double.) The selftest's `SK` controls
378
+ * hold the properties that can be stated without PIL.
379
+ *
380
+ * `stickHalfWidth` is the limb ellipse's semi-minor axis; its default is the
381
+ * xinsir rule above. Passing the canonical 4 px draws the skeleton the
382
+ * selftest plants to show its width control goes red.
383
+ */
384
+ export function renderSkeleton(pose: SkeletonName | PosePoints, w: number, h: number, stickHalfWidth: number = 4 * stickScale(Math.max(w, h))): Raster {
385
+ const r = newRaster(w, h);
386
+ for (let i = 3; i < r.data.length; i += 4) r.data[i] = 255;
387
+ const pts = typeof pose === 'string' ? scaledPoints(pose, w, h) : pose;
388
+ const kp = KEYPOINT_NAMES.map((k) => pts[k]);
389
+ const sw = stickHalfWidth;
390
+ LIMB_SEQ.forEach(([i1, i2], i) => {
391
+ const [x1, y1] = kp[i1 - 1];
392
+ const [x2, y2] = kp[i2 - 1];
393
+ const angle = Math.atan2(y1 - y2, x1 - x2) * RAD_TO_DEG;
394
+ const poly = ellipsePoly((x1 + x2) / 2, (y1 + y2) / 2, Math.hypot(x1 - x2, y1 - y2) / 2, sw, angle);
395
+ const c = COLORS[i];
396
+ pilFillPolygon(r, poly, [Math.trunc(c[0] * 0.6), Math.trunc(c[1] * 0.6), Math.trunc(c[2] * 0.6)]);
397
+ });
398
+ kp.forEach(([x, y], i) => pilFillEllipse(r, [x - 4, y - 4, x + 4, y + 4], COLORS[i]));
399
+ return r;
400
+ }