@rig-c/parts 0.0.0-stage → 1.5.3
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/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +431 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +137 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- 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
|
+
}
|
package/src/skeleton.ts
ADDED
|
@@ -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
|
+
}
|