@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,166 @@
1
+ import { type Mask, RasterError } from './types.ts';
2
+
3
+ const XY_SHIFT = 16;
4
+ const XY_ONE = 1 << XY_SHIFT;
5
+
6
+ /**
7
+ * cv2's `clipLine` for a `width`x`height` image: Cohen–Sutherland against the
8
+ * pixel grid, each moved endpoint TRUNCATED toward zero, the second endpoint
9
+ * computed from the already-moved first — cv2's order, which is what decides
10
+ * where a clipped edge starts. Returns whether anything of the line is inside.
11
+ */
12
+ function clipLine(width: number, height: number, p: [number, number, number, number]): boolean {
13
+ if (width <= 0 || height <= 0) return false;
14
+ const right = width - 1;
15
+ const bottom = height - 1;
16
+ let [x1, y1, x2, y2] = p;
17
+ let c1 = (x1 < 0 ? 1 : 0) + (x1 > right ? 2 : 0) + (y1 < 0 ? 4 : 0) + (y1 > bottom ? 8 : 0);
18
+ let c2 = (x2 < 0 ? 1 : 0) + (x2 > right ? 2 : 0) + (y2 < 0 ? 4 : 0) + (y2 > bottom ? 8 : 0);
19
+ if ((c1 & c2) === 0 && (c1 | c2) !== 0) {
20
+ if (c1 & 12) {
21
+ const a = c1 < 8 ? 0 : bottom;
22
+ x1 += Math.trunc(((a - y1) * (x2 - x1)) / (y2 - y1));
23
+ y1 = a;
24
+ c1 = (x1 < 0 ? 1 : 0) + (x1 > right ? 2 : 0);
25
+ }
26
+ if (c2 & 12) {
27
+ const a = c2 < 8 ? 0 : bottom;
28
+ x2 += Math.trunc(((a - y2) * (x2 - x1)) / (y2 - y1));
29
+ y2 = a;
30
+ c2 = (x2 < 0 ? 1 : 0) + (x2 > right ? 2 : 0);
31
+ }
32
+ if ((c1 & c2) === 0 && (c1 | c2) !== 0) {
33
+ if (c1) {
34
+ const a = c1 === 1 ? 0 : right;
35
+ y1 += Math.trunc(((a - x1) * (y2 - y1)) / (x2 - x1));
36
+ x1 = a;
37
+ c1 = 0;
38
+ }
39
+ if (c2) {
40
+ const a = c2 === 1 ? 0 : right;
41
+ y2 += Math.trunc(((a - x2) * (y2 - y1)) / (x2 - x1));
42
+ x2 = a;
43
+ c2 = 0;
44
+ }
45
+ }
46
+ }
47
+ p[0] = x1;
48
+ p[1] = y1;
49
+ p[2] = x2;
50
+ p[3] = y2;
51
+ return (c1 | c2) === 0;
52
+ }
53
+
54
+ function outside(m: Mask, x: number, y: number): boolean {
55
+ return x < 0 || y < 0 || x >= m.width || y >= m.height;
56
+ }
57
+
58
+ /**
59
+ * cv2's 8-connected line (`LineIterator`, left to right), endpoints included:
60
+ * clipped to the image FIRST and then walked from the clipped endpoints, as
61
+ * cv2 does — a line walked from outside the image and a line walked from its
62
+ * clipped endpoints do not always light the same pixels.
63
+ *
64
+ * Transcribed rather than "a Bresenham": which of two equally near pixels a
65
+ * line takes is exactly what two correct line drawers disagree on, and the
66
+ * outline pixels are part of what `fillPoly` sets.
67
+ */
68
+ function line8(m: Mask, ax: number, ay: number, bx: number, by: number): void {
69
+ const p: [number, number, number, number] = [ax, ay, bx, by];
70
+ if ((outside(m, ax, ay) || outside(m, bx, by)) && !clipLine(m.width, m.height, p)) return;
71
+ let [x0, y0, x1, y1] = p;
72
+ if (x1 < x0) {
73
+ [x0, x1] = [x1, x0];
74
+ [y0, y1] = [y1, y0];
75
+ }
76
+ let dx = x1 - x0;
77
+ let dy = Math.abs(y1 - y0);
78
+ const ystep = y1 < y0 ? -1 : 1;
79
+ const steep = dy > dx;
80
+ if (steep) [dx, dy] = [dy, dx];
81
+ let err = dx - (dy + dy);
82
+ const plusDelta = dx + dx;
83
+ const minusDelta = -(dy + dy);
84
+ let x = x0;
85
+ let y = y0;
86
+ for (let i = 0; i <= dx; i++) {
87
+ m.data[y * m.width + x] = 1;
88
+ const minor = err < 0;
89
+ err += minusDelta + (minor ? plusDelta : 0);
90
+ if (steep) {
91
+ y += ystep;
92
+ if (minor) x += 1;
93
+ } else {
94
+ x += 1;
95
+ if (minor) y += ystep;
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * `cv2.fillPoly(mask, [points], 1)` with the default 8-connected line type and
102
+ * no sub-pixel shift, on one polygon of INTEGER vertices (the reference rounds
103
+ * its triangle corners to integers before the call; a fractional vertex is
104
+ * refused rather than rounded here, so the rounding stays visible).
105
+ *
106
+ * Two parts, both cv2's: every edge is drawn with the 8-connected line above,
107
+ * and every row is filled between consecutive active edges, each edge's x kept
108
+ * in 16.16 fixed point from its upper vertex (moved to the clipped endpoint
109
+ * when the edge leaves the image) and stepped by a truncated `dx/dy` per row,
110
+ * an edge active on rows `[y_top, y_bottom)`, the left end of a span rounded UP
111
+ * and the right end down. Sets pixels to 1 in place and returns the mask.
112
+ *
113
+ * Measured bit-exact against cv2 4.13 on 250 polygons (200 triangles with
114
+ * vertices up to 5 px outside a 40x40 mask, 50 polygons of 4 to 7 vertices):
115
+ * 400,000 pixels, 0 different.
116
+ */
117
+ export function fillPoly(m: Mask, points: ReadonlyArray<readonly [number, number]>): Mask {
118
+ if (points.length < 2) throw new RasterError(`fillPoly: ${points.length} vertex(es); at least 2 are required`);
119
+ for (const [x, y] of points) {
120
+ if (!Number.isInteger(x) || !Number.isInteger(y)) {
121
+ throw new RasterError(`fillPoly: vertex ${x},${y} is not integral; round it at the call site`);
122
+ }
123
+ }
124
+ const edges: Array<{ y0: number; y1: number; x: number; dx: number }> = [];
125
+ for (let i = 0; i < points.length; i++) {
126
+ const [ax, ay] = points[(i + points.length - 1) % points.length];
127
+ const [bx, by] = points[i];
128
+ line8(m, ax, ay, bx, by);
129
+ // The fixed-point edge starts from the CLIPPED endpoints when the line
130
+ // leaves the image, with the original rows kept unless clipping moved them.
131
+ const t: [number, number, number, number] = [ax, ay, bx, by];
132
+ let cy0 = ay;
133
+ let cy1 = by;
134
+ if (outside(m, ax, ay) || outside(m, bx, by)) {
135
+ clipLine(m.width, m.height, t);
136
+ if (t[1] !== t[3]) {
137
+ cy0 = t[1];
138
+ cy1 = t[3];
139
+ }
140
+ }
141
+ if (ay === by) continue;
142
+ const cx0 = t[0] * XY_ONE;
143
+ const cx1 = t[2] * XY_ONE;
144
+ const dx = Math.trunc((cx1 - cx0) / (cy1 - cy0));
145
+ if (ay < by) edges.push({ y0: ay, y1: by, x: cx0 + (ay - cy0) * dx, dx });
146
+ else edges.push({ y0: by, y1: ay, x: cx1 + (by - cy1) * dx, dx });
147
+ }
148
+ if (edges.length < 2) return m;
149
+ const yMin = Math.min(...edges.map((e) => e.y0));
150
+ const yMax = Math.min(m.height, Math.max(...edges.map((e) => e.y1)));
151
+ for (let y = Math.max(yMin, 0); y < yMax; y++) {
152
+ const xs = edges
153
+ .filter((e) => e.y0 <= y && y < e.y1)
154
+ .map((e) => e.x + (y - e.y0) * e.dx)
155
+ .sort((a, b) => a - b);
156
+ for (let k = 0; k + 1 < xs.length; k += 2) {
157
+ let x1 = Math.floor((xs[k] + XY_ONE - 1) / XY_ONE);
158
+ let x2 = Math.floor(xs[k + 1] / XY_ONE);
159
+ if (x1 >= m.width || x2 < 0) continue;
160
+ if (x1 < 0) x1 = 0;
161
+ if (x2 >= m.width) x2 = m.width - 1;
162
+ for (let x = x1; x <= x2; x++) m.data[y * m.width + x] = 1;
163
+ }
164
+ }
165
+ return m;
166
+ }
@@ -0,0 +1,301 @@
1
+ import { newRaster, type Raster, RasterError } from './types.ts';
2
+
3
+ /**
4
+ * The three resampling filters the reference implementation uses, each with
5
+ * the semantics of the call it stands in for:
6
+ *
7
+ * - `area` — `cv2.resize(..., interpolation=cv2.INTER_AREA)` for a
8
+ * REDUCTION: every output pixel is the coverage-weighted mean of the input
9
+ * pixels under it. Channels are resampled independently, alpha included, so a
10
+ * caller that wants a premultiplied reduction premultiplies first (the
11
+ * reference does, around its warp).
12
+ * - `bicubic` — `cv2.INTER_CUBIC`: Keys cubic with a = -0.75, sample
13
+ * positions `(i + 0.5) * scale - 0.5`, edge samples replicated, channels
14
+ * independent.
15
+ * - `lanczos3` — `PIL.Image.resize(..., Image.LANCZOS)`, including what PIL
16
+ * does around it for RGBA: premultiply, resample (a 3-lobe windowed sinc whose
17
+ * support widens with the reduction factor, horizontal pass then vertical,
18
+ * each pass rounded to 8 bits in PIL's 22-bit fixed point), unpremultiply.
19
+ * Measured bit-exact against Pillow 12.2.0 over 140,568 samples, reductions
20
+ * and enlargements, opaque and translucent.
21
+ */
22
+ export type ResizeFilter = 'area' | 'bicubic' | 'lanczos3';
23
+
24
+ /** Round half to even — what cv2's `saturate_cast<uchar>(float)` does through `cvRound`. */
25
+ function roundHalfEven(v: number): number {
26
+ const f = Math.floor(v);
27
+ const d = v - f;
28
+ if (d > 0.5) return f + 1;
29
+ if (d < 0.5) return f;
30
+ return f % 2 === 0 ? f : f + 1;
31
+ }
32
+
33
+ function clamp8(v: number): number {
34
+ return v < 0 ? 0 : v > 255 ? 255 : v;
35
+ }
36
+
37
+ export function resize(src: Raster, width: number, height: number, filter: ResizeFilter): Raster {
38
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
39
+ throw new RasterError(`resize: target ${width}x${height} is not a pair of positive integers`);
40
+ }
41
+ if (src.width < 1 || src.height < 1) {
42
+ throw new RasterError(`resize: source ${src.width}x${src.height} has no pixels to resample`);
43
+ }
44
+ // PIL hands back a copy when the size does not change; running the
45
+ // premultiply round trip anyway would quantise every translucent pixel.
46
+ if (width === src.width && height === src.height) {
47
+ return { width, height, data: new Uint8ClampedArray(src.data) };
48
+ }
49
+ if (filter === 'area') return resizeArea(src, width, height);
50
+ if (filter === 'bicubic') return resizeCubic(src, width, height);
51
+ if (filter === 'lanczos3') return resizeLanczos(src, width, height);
52
+ throw new RasterError(`resize: filter "${String(filter)}"; one of area, bicubic, lanczos3 is required`);
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // area (cv2)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const f32 = Math.fround;
60
+
61
+ /** cv2's `computeResizeAreaTab`: (source index, float32 weight) runs per output index. */
62
+ function areaTable(inSize: number, outSize: number, scale: number): Array<Array<[number, number]>> {
63
+ const table: Array<Array<[number, number]>> = [];
64
+ for (let o = 0; o < outSize; o++) {
65
+ const fsx1 = o * scale;
66
+ const fsx2 = fsx1 + scale;
67
+ const cell = Math.min(scale, inSize - fsx1);
68
+ const sx2 = Math.min(Math.floor(fsx2), inSize - 1);
69
+ const sx1 = Math.min(Math.ceil(fsx1), sx2);
70
+ const taps: Array<[number, number]> = [];
71
+ if (sx1 - fsx1 > 1e-3) taps.push([sx1 - 1, f32((sx1 - fsx1) / cell)]);
72
+ for (let i = sx1; i < sx2; i++) taps.push([i, f32(1 / cell)]);
73
+ if (fsx2 - sx2 > 1e-3) taps.push([sx2, f32(Math.min(Math.min(fsx2 - sx2, 1), cell) / cell)]);
74
+ table.push(taps);
75
+ }
76
+ return table;
77
+ }
78
+
79
+ /**
80
+ * ⚠️ Reduction only. cv2's INTER_AREA is a different filter when it enlarges
81
+ * (it degrades to a linear variant), so an enlargement is refused by name
82
+ * rather than answered with a filter nobody asked for.
83
+ *
84
+ * Two of cv2's paths are reproduced: an INTEGER reduction factor on both axes
85
+ * averages whole blocks (the 2x2 case with cv2's vectorised `(sum + 2) >> 2`,
86
+ * which rounds a .5 up where the scalar path rounds it to even), and any other
87
+ * factor accumulates float32 coverage weights row by row and rounds half to
88
+ * even. Measured against cv2 4.13: integer factors 2, 3 and 4 bit-exact over
89
+ * 21,348 samples; other factors 1 sample of 21,816 one level off.
90
+ */
91
+ function resizeArea(src: Raster, width: number, height: number): Raster {
92
+ if (width > src.width || height > src.height) {
93
+ throw new RasterError(
94
+ `resize: area is defined here for reduction only; ${src.width}x${src.height} -> ${width}x${height} enlarges an axis`,
95
+ );
96
+ }
97
+ const scaleX = 1 / (width / src.width);
98
+ const scaleY = 1 / (height / src.height);
99
+ const ix = Math.round(scaleX);
100
+ const iy = Math.round(scaleY);
101
+ const out = newRaster(width, height);
102
+ if (Math.abs(scaleX - ix) < Number.EPSILON && Math.abs(scaleY - iy) < Number.EPSILON) {
103
+ const scale = f32(1 / (ix * iy));
104
+ for (let y = 0; y < height; y++) {
105
+ for (let x = 0; x < width; x++) {
106
+ for (let c = 0; c < 4; c++) {
107
+ let sum = 0;
108
+ for (let j = 0; j < iy; j++) for (let i = 0; i < ix; i++) sum += src.data[((y * iy + j) * src.width + x * ix + i) * 4 + c];
109
+ out.data[(y * width + x) * 4 + c] = ix === 2 && iy === 2 ? (sum + 2) >> 2 : clamp8(roundHalfEven(f32(sum * scale)));
110
+ }
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ const tx = areaTable(src.width, width, scaleX);
116
+ const ty = areaTable(src.height, height, scaleY);
117
+ for (let y = 0; y < height; y++) {
118
+ for (let x = 0; x < width; x++) {
119
+ for (let c = 0; c < 4; c++) {
120
+ let sum = 0;
121
+ let first = true;
122
+ for (const [sy, beta] of ty[y]) {
123
+ let buf = 0;
124
+ for (const [sx, alpha] of tx[x]) buf = f32(buf + f32(src.data[(sy * src.width + sx) * 4 + c] * alpha));
125
+ sum = first ? f32(buf * beta) : f32(sum + f32(buf * beta));
126
+ first = false;
127
+ }
128
+ out.data[(y * width + x) * 4 + c] = clamp8(roundHalfEven(sum));
129
+ }
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // bicubic (cv2)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ const COEF_BITS = 11;
140
+ const COEF_SCALE = 1 << COEF_BITS;
141
+
142
+ /** cv2's `interpolateCubic` in float32, a = -0.75. */
143
+ function cubicCoeffs(x: number): [number, number, number, number] {
144
+ const A = -0.75;
145
+ const x1 = f32(x + 1);
146
+ const c0 = f32(f32(f32(f32(f32(f32(A * x1) - 5 * A) * x1) + 8 * A) * x1) - 4 * A);
147
+ const c1 = f32(f32(f32(f32(f32((A + 2) * x) - (A + 3)) * x) * x) + 1);
148
+ const mx = f32(1 - x);
149
+ const c2 = f32(f32(f32(f32(f32((A + 2) * mx) - (A + 3)) * mx) * mx) + 1);
150
+ const c3 = f32(f32(f32(1 - c0) - c1) - c2);
151
+ return [c0, c1, c2, c3];
152
+ }
153
+
154
+ /** Per output index: four clamped taps and cv2's 11-bit integer weights. */
155
+ function cubicTable(inSize: number, outSize: number): Array<{ taps: number[]; w: number[] }> {
156
+ const scale = 1 / (outSize / inSize);
157
+ const table: Array<{ taps: number[]; w: number[] }> = [];
158
+ for (let o = 0; o < outSize; o++) {
159
+ let f = f32((o + 0.5) * scale - 0.5);
160
+ const s = Math.floor(f);
161
+ f = f32(f - s);
162
+ const w = cubicCoeffs(f).map((c) => roundHalfEven(f32(c * COEF_SCALE)));
163
+ const taps = [s - 1, s, s + 1, s + 2].map((i) => Math.min(inSize - 1, Math.max(0, i)));
164
+ table.push({ taps, w });
165
+ }
166
+ return table;
167
+ }
168
+
169
+ /**
170
+ * cv2's 8-bit cubic path: 11-bit integer weights, an integer horizontal pass,
171
+ * the vertical pass rounded once at 22 bits. Measured bit-exact against cv2
172
+ * 4.13 on random RGBA rasters, enlargement and reduction both.
173
+ */
174
+ function resizeCubic(src: Raster, width: number, height: number): Raster {
175
+ const tx = cubicTable(src.width, width);
176
+ const ty = cubicTable(src.height, height);
177
+ const out = newRaster(width, height);
178
+ const shift = 2 * COEF_BITS;
179
+ for (let y = 0; y < height; y++) {
180
+ for (let x = 0; x < width; x++) {
181
+ for (let c = 0; c < 4; c++) {
182
+ let s = 0;
183
+ for (let j = 0; j < 4; j++) {
184
+ let row = 0;
185
+ const sy = ty[y].taps[j];
186
+ for (let i = 0; i < 4; i++) row += src.data[(sy * src.width + tx[x].taps[i]) * 4 + c] * tx[x].w[i];
187
+ s += row * ty[y].w[j];
188
+ }
189
+ out.data[(y * width + x) * 4 + c] = clamp8(Math.floor((s + (1 << (shift - 1))) / (1 << shift)));
190
+ }
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // lanczos3 (PIL)
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const PRECISION_BITS = 32 - 8 - 2;
201
+
202
+ function sinc(x: number): number {
203
+ if (x === 0) return 1;
204
+ const px = x * Math.PI;
205
+ return Math.sin(px) / px;
206
+ }
207
+
208
+ function lanczos(x: number): number {
209
+ return x >= -3 && x < 3 ? sinc(x) * sinc(x / 3) : 0;
210
+ }
211
+
212
+ /** PIL's `precompute_coeffs` + `normalize_coeffs_8bpc`, transcribed: bounds and 22-bit integer weights. */
213
+ function lanczosTable(inSize: number, outSize: number): Array<{ min: number; k: number[] }> {
214
+ const scale = inSize / outSize;
215
+ const filterscale = scale < 1 ? 1 : scale;
216
+ const support = 3 * filterscale;
217
+ const ss = 1 / filterscale;
218
+ const table: Array<{ min: number; k: number[] }> = [];
219
+ for (let o = 0; o < outSize; o++) {
220
+ const center = (o + 0.5) * scale;
221
+ let xmin = Math.trunc(center - support + 0.5);
222
+ if (xmin < 0) xmin = 0;
223
+ let xmax = Math.trunc(center + support + 0.5);
224
+ if (xmax > inSize) xmax = inSize;
225
+ xmax -= xmin;
226
+ const pre: number[] = [];
227
+ let ww = 0;
228
+ for (let x = 0; x < xmax; x++) {
229
+ const w = lanczos((x + xmin - center + 0.5) * ss);
230
+ pre.push(w);
231
+ ww += w;
232
+ }
233
+ const k = pre.map((w) => {
234
+ const v = ww !== 0 ? w / ww : w;
235
+ return v < 0 ? Math.trunc(-0.5 + v * (1 << PRECISION_BITS)) : Math.trunc(0.5 + v * (1 << PRECISION_BITS));
236
+ });
237
+ table.push({ min: xmin, k });
238
+ }
239
+ return table;
240
+ }
241
+
242
+ function clipFixed(ss: number): number {
243
+ return clamp8(Math.floor(ss / (1 << PRECISION_BITS)));
244
+ }
245
+
246
+ /** PIL's `MULDIV255`: `a * b / 255`, rounded, in integers. */
247
+ function mulDiv255(a: number, b: number): number {
248
+ const t = a * b + 128;
249
+ return ((t >> 8) + t) >> 8;
250
+ }
251
+
252
+ function resizeLanczos(src: Raster, width: number, height: number): Raster {
253
+ // RGBA -> RGBa, as PIL converts before any resample but NEAREST.
254
+ let cur = newRaster(src.width, src.height);
255
+ for (let i = 0; i < src.width * src.height; i++) {
256
+ const a = src.data[i * 4 + 3];
257
+ for (let c = 0; c < 3; c++) cur.data[i * 4 + c] = a === 255 ? src.data[i * 4 + c] : mulDiv255(src.data[i * 4 + c], a);
258
+ cur.data[i * 4 + 3] = a;
259
+ }
260
+ if (width !== src.width) {
261
+ const t = lanczosTable(src.width, width);
262
+ const next = newRaster(width, cur.height);
263
+ for (let y = 0; y < cur.height; y++) {
264
+ for (let x = 0; x < width; x++) {
265
+ for (let c = 0; c < 4; c++) {
266
+ let ss = 1 << (PRECISION_BITS - 1);
267
+ const { min, k } = t[x];
268
+ for (let i = 0; i < k.length; i++) ss += cur.data[(y * cur.width + min + i) * 4 + c] * k[i];
269
+ next.data[(y * width + x) * 4 + c] = clipFixed(ss);
270
+ }
271
+ }
272
+ }
273
+ cur = next;
274
+ }
275
+ if (height !== src.height) {
276
+ const t = lanczosTable(src.height, height);
277
+ const next = newRaster(cur.width, height);
278
+ for (let y = 0; y < height; y++) {
279
+ const { min, k } = t[y];
280
+ for (let x = 0; x < cur.width; x++) {
281
+ for (let c = 0; c < 4; c++) {
282
+ let ss = 1 << (PRECISION_BITS - 1);
283
+ for (let i = 0; i < k.length; i++) ss += cur.data[((min + i) * cur.width + x) * 4 + c] * k[i];
284
+ next.data[(y * cur.width + x) * 4 + c] = clipFixed(ss);
285
+ }
286
+ }
287
+ }
288
+ cur = next;
289
+ }
290
+ // RGBa -> RGBA: integer division by alpha, clipped; alpha 0 and 255 pass through.
291
+ const out = newRaster(cur.width, cur.height);
292
+ for (let i = 0; i < cur.width * cur.height; i++) {
293
+ const a = cur.data[i * 4 + 3];
294
+ for (let c = 0; c < 3; c++) {
295
+ const v = cur.data[i * 4 + c];
296
+ out.data[i * 4 + c] = a === 255 || a === 0 ? v : clamp8(Math.floor((255 * v) / a));
297
+ }
298
+ out.data[i * 4 + 3] = a;
299
+ }
300
+ return out;
301
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The three pixel containers the pipeline passes around.
3
+ *
4
+ * - `Raster` is 8-bit RGBA with STRAIGHT (non-premultiplied) alpha, row-major,
5
+ * top-left origin, y down — what a PNG part is on disk.
6
+ * - `Mask` is one byte per pixel holding exactly 0 or 1. Every morphology and
7
+ * labelling op reads and writes this, never a thresholded `Raster`, so the
8
+ * threshold is always a visible decision at the call site.
9
+ * - `FloatImage` is `channels` float32 samples per pixel, for the ops the
10
+ * reference implementation runs in floating point (a premultiplied warp, a
11
+ * feathered blend weight).
12
+ *
13
+ * Nothing here knows about Spine's y-up world. The only conversion between the
14
+ * two lives in `rig-c/src/transform.ts`, re-exported by `src/coords.ts`.
15
+ */
16
+ export interface Raster {
17
+ readonly width: number;
18
+ readonly height: number;
19
+ readonly data: Uint8ClampedArray;
20
+ }
21
+
22
+ export interface Mask {
23
+ readonly width: number;
24
+ readonly height: number;
25
+ readonly data: Uint8Array;
26
+ }
27
+
28
+ export interface FloatImage {
29
+ readonly width: number;
30
+ readonly height: number;
31
+ readonly channels: number;
32
+ readonly data: Float32Array;
33
+ }
34
+
35
+ /** A raster op refused its arguments. The message names the op, the value found and the value required. */
36
+ export class RasterError extends Error {
37
+ constructor(message: string) {
38
+ super(message);
39
+ this.name = 'RasterError';
40
+ }
41
+ }
42
+
43
+ function assertDims(op: string, width: number, height: number): void {
44
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 0 || height < 0) {
45
+ throw new RasterError(`${op}: size ${width}x${height} is not a pair of non-negative integers`);
46
+ }
47
+ }
48
+
49
+ export function newRaster(width: number, height: number): Raster {
50
+ assertDims('newRaster', width, height);
51
+ return { width, height, data: new Uint8ClampedArray(width * height * 4) };
52
+ }
53
+
54
+ export function newMask(width: number, height: number): Mask {
55
+ assertDims('newMask', width, height);
56
+ return { width, height, data: new Uint8Array(width * height) };
57
+ }
58
+
59
+ export function newFloatImage(width: number, height: number, channels: number): FloatImage {
60
+ assertDims('newFloatImage', width, height);
61
+ if (!Number.isInteger(channels) || channels < 1) {
62
+ throw new RasterError(`newFloatImage: ${channels} channel(s) is not a positive integer`);
63
+ }
64
+ return { width, height, channels, data: new Float32Array(width * height * channels) };
65
+ }
66
+
67
+ /** Pixels whose alpha is strictly above `threshold`, as a mask. */
68
+ export function alphaAbove(r: Raster, threshold: number): Mask {
69
+ const m = newMask(r.width, r.height);
70
+ for (let i = 0; i < m.data.length; i++) m.data[i] = r.data[i * 4 + 3] > threshold ? 1 : 0;
71
+ return m;
72
+ }
73
+
74
+ /** How many pixels a mask sets. */
75
+ export function maskCount(m: Mask): number {
76
+ let n = 0;
77
+ for (let i = 0; i < m.data.length; i++) n += m.data[i];
78
+ return n;
79
+ }
80
+
81
+ /** Whether a mask holds only 0 and 1 — the precondition every mask op states. */
82
+ export function assertBinary(op: string, m: Mask): void {
83
+ for (let i = 0; i < m.data.length; i++) {
84
+ const v = m.data[i];
85
+ if (v !== 0 && v !== 1) {
86
+ throw new RasterError(`${op}: mask pixel ${i % m.width},${Math.floor(i / m.width)} holds ${v}; a mask holds 0 or 1`);
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,121 @@
1
+ import { type FloatImage, newFloatImage, RasterError } from './types.ts';
2
+
3
+ /** A forward map `dst = scale * src + translate`, per axis — the only affine the pipeline uses. */
4
+ export interface ScaleTranslate {
5
+ sx: number;
6
+ sy: number;
7
+ tx: number;
8
+ ty: number;
9
+ }
10
+
11
+ export type WarpFilter = 'bilinear' | 'bicubic';
12
+
13
+ const AB_BITS = 10;
14
+ const AB_SCALE = 1 << AB_BITS;
15
+ const INTER_BITS = 5;
16
+ const INTER_TAB_SIZE = 1 << INTER_BITS;
17
+
18
+ function cvRound(v: number): number {
19
+ const f = Math.floor(v);
20
+ const d = v - f;
21
+ if (d > 0.5) return f + 1;
22
+ if (d < 0.5) return f;
23
+ return f % 2 === 0 ? f : f + 1;
24
+ }
25
+
26
+ function cubic(x: number): [number, number, number, number] {
27
+ const A = -0.75;
28
+ const c0 = ((A * (x + 1) - 5 * A) * (x + 1) + 8 * A) * (x + 1) - 4 * A;
29
+ const c1 = ((A + 2) * x - (A + 3)) * x * x + 1;
30
+ const c2 = ((A + 2) * (1 - x) - (A + 3)) * (1 - x) * (1 - x) + 1;
31
+ return [c0, c1, c2, 1 - c0 - c1 - c2];
32
+ }
33
+
34
+ /**
35
+ * The sample position of each output index along one axis, in cv2's own
36
+ * arithmetic: the inverse map in 10-bit fixed point, rounded to 1/32 of a
37
+ * pixel (`INTER_TAB_SIZE`). Returned as the integer part and the 1/32 step.
38
+ *
39
+ * ⚠️ The two axes are rounded differently, because cv2 does: along x the
40
+ * offset and the per-column step are rounded to fixed point SEPARATELY
41
+ * (`X0 + adelta[x]`), along y their sum is rounded once per row (`Y0`). Using
42
+ * the x rule for both moved samples by up to 6.3 levels in 84,508 measured
43
+ * against cv2 4.13.
44
+ */
45
+ function positions(outSize: number, scale: number, translate: number, axis: 'x' | 'y'): Array<[number, number]> {
46
+ const inv = 1 / scale;
47
+ const offset = -translate / scale;
48
+ const half = AB_SCALE / INTER_TAB_SIZE / 2;
49
+ const out: Array<[number, number]> = [];
50
+ for (let o = 0; o < outSize; o++) {
51
+ const fixed = axis === 'x' ? cvRound(offset * AB_SCALE) + cvRound(inv * o * AB_SCALE) : cvRound((inv * o + offset) * AB_SCALE);
52
+ const q = Math.floor((fixed + half) / (1 << (AB_BITS - INTER_BITS)));
53
+ const i = Math.floor(q / INTER_TAB_SIZE);
54
+ out.push([i, q - i * INTER_TAB_SIZE]);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * `cv2.warpAffine(src, [[sx, 0, tx], [0, sy, ty]], (width, height), flags,
61
+ * borderValue=0)` on a float image, for the scale-and-translate maps the
62
+ * pipeline uses. Every channel is warped independently; a caller that wants a
63
+ * premultiplied warp premultiplies first, as the reference does.
64
+ *
65
+ * What is cv2's and reproduced here, because each moves a sample:
66
+ * - no half-pixel offset: output pixel `x` samples input `(x - tx) / sx`;
67
+ * - that position is rounded to 1/32 of a pixel through cv2's 10-bit fixed
68
+ * point before the filter weights are read;
69
+ * - taps outside the image read the border value 0 and still carry weight.
70
+ *
71
+ * Measured against cv2 4.13 over 84,508 float samples at random scales and
72
+ * offsets: bilinear within 3.1e-5, bicubic within 6.1e-5 (float32 rounding).
73
+ *
74
+ * ⚠️ `cv2.INTER_AREA` passed to `warpAffine` is not an area filter: on every
75
+ * one of those 84,508 samples cv2 returned exactly its INTER_LINEAR output.
76
+ * The reference passes INTER_AREA when the scale is below one, so the port of
77
+ * that call site asks for `bilinear`, and says why.
78
+ */
79
+ export function warpAffine(src: FloatImage, map: ScaleTranslate, width: number, height: number, filter: WarpFilter): FloatImage {
80
+ for (const [k, v] of Object.entries(map)) {
81
+ if (!Number.isFinite(v)) throw new RasterError(`warpAffine: ${k} is ${v}; a finite number is required`);
82
+ }
83
+ if (map.sx === 0 || map.sy === 0) throw new RasterError(`warpAffine: scale ${map.sx}x${map.sy} is singular`);
84
+ if (filter !== 'bilinear' && filter !== 'bicubic') {
85
+ throw new RasterError(`warpAffine: filter "${String(filter)}"; bilinear or bicubic is required`);
86
+ }
87
+ const px = positions(width, map.sx, map.tx, 'x');
88
+ const py = positions(height, map.sy, map.ty, 'y');
89
+ const weights = (step: number): number[] => {
90
+ const f = step / INTER_TAB_SIZE;
91
+ return filter === 'bilinear' ? [1 - f, f] : cubic(f);
92
+ };
93
+ const first = filter === 'bilinear' ? 0 : -1;
94
+ const taps = filter === 'bilinear' ? 2 : 4;
95
+ const { width: sw, height: sh, channels: ch } = src;
96
+ const out = newFloatImage(width, height, ch);
97
+ for (let y = 0; y < height; y++) {
98
+ const [iy, fy] = py[y];
99
+ const wy = weights(fy);
100
+ for (let x = 0; x < width; x++) {
101
+ const [ix, fx] = px[x];
102
+ const wx = weights(fx);
103
+ for (let c = 0; c < ch; c++) {
104
+ let s = 0;
105
+ for (let j = 0; j < taps; j++) {
106
+ const sy = iy + first + j;
107
+ if (sy < 0 || sy >= sh) continue;
108
+ let row = 0;
109
+ for (let i = 0; i < taps; i++) {
110
+ const sx = ix + first + i;
111
+ if (sx < 0 || sx >= sw) continue;
112
+ row += src.data[(sy * sw + sx) * ch + c] * wx[i];
113
+ }
114
+ s += row * wy[j];
115
+ }
116
+ out.data[(y * width + x) * ch + c] = s;
117
+ }
118
+ }
119
+ }
120
+ return out;
121
+ }