@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/pyfmt.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The handful of Python numeric and text semantics the proposer's outputs
3
+ * depend on, written once so no call site open-codes them.
4
+ *
5
+ * The reference proposer is Python, and three of its behaviours are not
6
+ * JavaScript's:
7
+ *
8
+ * - `round(x)` rounds half to EVEN (`round(2.5) == 2`), where `Math.round`
9
+ * rounds half up. Every bone coordinate goes through it (`rnd`), and a part
10
+ * box of even width puts its centre on a half pixel, so this decides real
11
+ * coordinates, not an edge case.
12
+ * - `format(x, '.nf')` rounds the exact binary value correctly and breaks an
13
+ * exact tie to even; `Number.prototype.toFixed` breaks it upward. The two
14
+ * agree everywhere except an exact tie, which `pyFixed` detects.
15
+ * - `repr(str)` quotes with `'` unless the string holds a `'` and no `"`. The
16
+ * LINT line prints a mesh name through it, and its format is kept verbatim.
17
+ *
18
+ * numpy's `mean`, `median` and `percentile` are here too, with numpy's own
19
+ * summation order and interpolation, so a summary line reads the same figure
20
+ * the reference printed.
21
+ */
22
+
23
+ /** Python's `round(x)` on a float: nearest integer, ties to even. */
24
+ export function pyRound(x: number): number {
25
+ const f = Math.floor(x);
26
+ const d = x - f;
27
+ if (d > 0.5) return f + 1;
28
+ if (d < 0.5) return f;
29
+ return f % 2 === 0 ? f : f + 1;
30
+ }
31
+
32
+ /** Python's `int(x)` on a float: truncation toward zero, with no negative zero. */
33
+ export function pyInt(x: number): number {
34
+ const t = Math.trunc(x);
35
+ return t === 0 ? 0 : t;
36
+ }
37
+
38
+ /**
39
+ * Python's `format(x, '.<digits>f')`. `toFixed` is correctly rounded except on
40
+ * an exact decimal tie, which it breaks upward and Python breaks to even. A
41
+ * tie is recognised from `toFixed(digits + 20)`: a double near any value
42
+ * printed here is spaced far wider than 1e-20, so digits past the tie are
43
+ * either all zero (a true tie) or not a tie at all.
44
+ */
45
+ export function pyFixed(x: number, digits: number): string {
46
+ if (!Number.isFinite(x)) return String(x);
47
+ const long = Math.abs(x).toFixed(digits + 20);
48
+ const tail = long.slice(long.length - 20);
49
+ if (tail !== `5${'0'.repeat(19)}`) return x.toFixed(digits);
50
+ // An exact tie: `head` is |x| truncated to `digits` places, and the tie goes to whichever neighbour is even.
51
+ const head = long.slice(0, long.length - 20).replace(/\.$/, '');
52
+ const last = Number(head.replace('.', '').slice(-1));
53
+ const scale = 10 ** digits;
54
+ const magnitude = last % 2 === 0 ? Number(head) : (Math.round(Number(head) * scale) + 1) / scale;
55
+ return `${x < 0 ? '-' : ''}${magnitude.toFixed(digits)}`;
56
+ }
57
+
58
+ /** Python's `repr(s)` for the strings the proposer prints: part and bone names. */
59
+ export function pyRepr(s: string): string {
60
+ const quote = s.includes("'") && !s.includes('"') ? '"' : "'";
61
+ let out = '';
62
+ for (const ch of s) {
63
+ if (ch === '\\') out += '\\\\';
64
+ else if (ch === quote) out += `\\${quote}`;
65
+ else if (ch === '\n') out += '\\n';
66
+ else if (ch === '\t') out += '\\t';
67
+ else if (ch === '\r') out += '\\r';
68
+ else out += ch;
69
+ }
70
+ return `${quote}${out}${quote}`;
71
+ }
72
+
73
+ /** Python's `str(list_of_str)`: `['a', 'b']`. */
74
+ export function pyStrList(items: readonly string[]): string {
75
+ return `[${items.map(pyRepr).join(', ')}]`;
76
+ }
77
+
78
+ /** Python's `str([int, int])`: `[335, 330]`. */
79
+ export function pyIntList(items: readonly number[]): string {
80
+ return `[${items.map((n) => String(n)).join(', ')}]`;
81
+ }
82
+
83
+ /**
84
+ * numpy's float64 `sum`: its pairwise summation, which for fewer than 8
85
+ * values is a plain loop and up to 128 values is eight strided accumulators
86
+ * combined as `((r0+r1)+(r2+r3))+((r4+r5)+(r6+r7))`, then the remainder.
87
+ */
88
+ export function npSum(a: readonly number[]): number {
89
+ const pairwise = (lo: number, n: number): number => {
90
+ if (n < 8) {
91
+ let s = 0;
92
+ for (let i = 0; i < n; i++) s += a[lo + i];
93
+ return s;
94
+ }
95
+ if (n <= 128) {
96
+ const r = [0, 1, 2, 3, 4, 5, 6, 7].map((k) => a[lo + k]);
97
+ let i = 8;
98
+ for (; i < n - (n % 8); i += 8) for (let k = 0; k < 8; k++) r[k] += a[lo + i + k];
99
+ let s = r[0] + r[1] + (r[2] + r[3]) + (r[4] + r[5] + (r[6] + r[7]));
100
+ for (; i < n; i++) s += a[lo + i];
101
+ return s;
102
+ }
103
+ let n2 = Math.floor(n / 2);
104
+ n2 -= n2 % 8;
105
+ return pairwise(lo, n2) + pairwise(lo + n2, n - n2);
106
+ };
107
+ return pairwise(0, a.length);
108
+ }
109
+
110
+ export function npMean(a: readonly number[]): number {
111
+ return npSum(a) / a.length;
112
+ }
113
+
114
+ export function npMedian(a: readonly number[]): number {
115
+ const s = [...a].sort((x, y) => x - y);
116
+ const n = s.length;
117
+ if (n % 2 === 1) return s[(n - 1) / 2];
118
+ // numpy takes the mean of the two middle values.
119
+ return npMean([s[n / 2 - 1], s[n / 2]]);
120
+ }
121
+
122
+ /** `np.percentile(a, q)` with the default linear method, numpy's index and lerp arithmetic included. */
123
+ export function npPercentile(a: readonly number[], q: number): number {
124
+ const s = [...a].sort((x, y) => x - y);
125
+ const n = s.length;
126
+ const quantile = q / 100;
127
+ const virtual = n * quantile + (1 + quantile * (1 - 1 - 1)) - 1;
128
+ const clampIndex = (i: number): number => Math.min(Math.max(i, 0), n - 1);
129
+ const previous = Math.floor(virtual);
130
+ const gamma = virtual - previous;
131
+ const lo = s[clampIndex(previous)];
132
+ const hi = s[clampIndex(previous + 1)];
133
+ const diff = hi - lo;
134
+ return gamma >= 0.5 ? hi - diff * (1 - gamma) : lo + diff * gamma;
135
+ }
@@ -0,0 +1,80 @@
1
+ import { type FloatImage, newFloatImage, RasterError } from './types.ts';
2
+
3
+ /**
4
+ * The kernel size cv2 derives when it is asked for `ksize = (0, 0)`:
5
+ * `round(sigma * 4 * 2 + 1) | 1` for a floating-point image. (For an 8-bit
6
+ * image cv2 uses 3 sigma instead of 4; every call this port makes blurs a
7
+ * float image, so that is the only rule implemented, and it is named.)
8
+ */
9
+ export function gaussianKernelSize(sigma: number): number {
10
+ return Math.round(sigma * 4 * 2 + 1) | 1;
11
+ }
12
+
13
+ /** `cv2.getGaussianKernel(ksize, sigma)`: `exp(-(i - (ksize-1)/2)^2 / (2 sigma^2))`, normalised to sum 1. */
14
+ export function gaussianKernel(ksize: number, sigma: number): Float64Array {
15
+ if (!Number.isInteger(ksize) || ksize < 1 || ksize % 2 === 0) {
16
+ throw new RasterError(`gaussianKernel: ksize ${ksize}; a positive odd integer is required`);
17
+ }
18
+ if (!(sigma > 0) || !Number.isFinite(sigma)) {
19
+ throw new RasterError(`gaussianKernel: sigma ${sigma}; a positive finite number is required`);
20
+ }
21
+ const k = new Float64Array(ksize);
22
+ const c = (ksize - 1) / 2;
23
+ let sum = 0;
24
+ for (let i = 0; i < ksize; i++) {
25
+ const x = i - c;
26
+ k[i] = Math.exp(-(x * x) / (2 * sigma * sigma));
27
+ sum += k[i];
28
+ }
29
+ for (let i = 0; i < ksize; i++) k[i] /= sum;
30
+ return k;
31
+ }
32
+
33
+ /** cv2's `BORDER_REFLECT_101` (its default): `-1 -> 1`, `n -> n - 2`, no edge sample repeated. */
34
+ function reflect101(i: number, n: number): number {
35
+ if (n === 1) return 0;
36
+ let p = i;
37
+ while (p < 0 || p >= n) {
38
+ if (p < 0) p = -p;
39
+ if (p >= n) p = 2 * (n - 1) - p;
40
+ }
41
+ return p;
42
+ }
43
+
44
+ /**
45
+ * `cv2.GaussianBlur(img, (0, 0), sigma)` on a float image, every channel
46
+ * independently: the separable kernel above, horizontal pass then vertical,
47
+ * border `REFLECT_101`.
48
+ *
49
+ * Computed in float64 and stored as float32; cv2 accumulates a float32 image
50
+ * in float32, so the two differ in the last bits of a sample. Measured against
51
+ * cv2 4.13 over 18,257 samples (1 and 4 channels, sigma 0.5 to 3): largest
52
+ * difference 3.1e-5.
53
+ */
54
+ export function gaussianBlur(src: FloatImage, sigma: number): FloatImage {
55
+ const ksize = gaussianKernelSize(sigma);
56
+ const k = gaussianKernel(ksize, sigma);
57
+ const r = (ksize - 1) / 2;
58
+ const { width: w, height: h, channels: ch } = src;
59
+ const tmp = new Float64Array(w * h * ch);
60
+ for (let y = 0; y < h; y++) {
61
+ for (let x = 0; x < w; x++) {
62
+ for (let c = 0; c < ch; c++) {
63
+ let s = 0;
64
+ for (let i = 0; i < ksize; i++) s += k[i] * src.data[(y * w + reflect101(x + i - r, w)) * ch + c];
65
+ tmp[(y * w + x) * ch + c] = s;
66
+ }
67
+ }
68
+ }
69
+ const out = newFloatImage(w, h, ch);
70
+ for (let y = 0; y < h; y++) {
71
+ for (let x = 0; x < w; x++) {
72
+ for (let c = 0; c < ch; c++) {
73
+ let s = 0;
74
+ for (let i = 0; i < ksize; i++) s += k[i] * tmp[(reflect101(y + i - r, h) * w + x) * ch + c];
75
+ out.data[(y * w + x) * ch + c] = s;
76
+ }
77
+ }
78
+ }
79
+ return out;
80
+ }
@@ -0,0 +1,165 @@
1
+ import { assertBinary, type Mask, RasterError } from './types.ts';
2
+
3
+ /** One component's statistics, in the fields `cv2.connectedComponentsWithStats` returns. */
4
+ export interface ComponentStats {
5
+ label: number;
6
+ left: number;
7
+ top: number;
8
+ width: number;
9
+ height: number;
10
+ area: number;
11
+ /** Mean x and y of the component's pixels — cv2's `centroids` row. */
12
+ cx: number;
13
+ cy: number;
14
+ }
15
+
16
+ export interface Components {
17
+ /** Labels including the background, which is label 0 — the `n` cv2 returns. */
18
+ count: number;
19
+ /** One label per pixel, row-major. */
20
+ labels: Int32Array;
21
+ /** `stats[0]` is the background (the zero pixels), as in cv2; components are 1..count-1. */
22
+ stats: ComponentStats[];
23
+ }
24
+
25
+ /**
26
+ * Label the connected components of a binary mask.
27
+ *
28
+ * Semantics of `cv2.connectedComponentsWithStats(mask, connectivity)` (the
29
+ * reference calls it with 8 for ghost clean-up and blob finding) and of
30
+ * `scipy.ndimage.label` with its default cross structure (connectivity 4, the
31
+ * reference's grid-cell islands): foreground is every non-zero pixel, label 0
32
+ * is the background, and components are numbered from 1 in the raster-scan
33
+ * order of their first pixel.
34
+ *
35
+ * Measured against cv2 4.13 and SciPy 1.17 on 40 random masks: component
36
+ * count, every label, every statistic and every centroid identical, for
37
+ * `connectedComponentsWithStats` at 4 and 8 and for `ndimage.label`.
38
+ *
39
+ * The numbering follows cv2 as well (see the block-order note in the body),
40
+ * but a caller that needs "the largest" or "the one touching a line" should
41
+ * still select by statistics: the numbering is an artefact of a scan order,
42
+ * and the statistics are the measurement.
43
+ */
44
+ export function connectedComponents(mask: Mask, connectivity: 4 | 8): Components {
45
+ if (connectivity !== 4 && connectivity !== 8) {
46
+ throw new RasterError(`connectedComponents: connectivity ${String(connectivity)}; 4 or 8 is required`);
47
+ }
48
+ assertBinary('connectedComponents', mask);
49
+ const { width: w, height: h, data } = mask;
50
+ const provisional = new Int32Array(w * h);
51
+ const parent: number[] = [0];
52
+ const find = (x: number): number => {
53
+ let r = x;
54
+ while (parent[r] !== r) r = parent[r];
55
+ while (parent[x] !== r) {
56
+ const next = parent[x];
57
+ parent[x] = r;
58
+ x = next;
59
+ }
60
+ return r;
61
+ };
62
+ const union = (a: number, b: number): number => {
63
+ const ra = find(a);
64
+ const rb = find(b);
65
+ if (ra === rb) return ra;
66
+ // The smaller root wins, so a root is always the earliest provisional label
67
+ // of its component — which is what makes the final numbering a raster scan.
68
+ if (ra < rb) {
69
+ parent[rb] = ra;
70
+ return ra;
71
+ }
72
+ parent[ra] = rb;
73
+ return rb;
74
+ };
75
+ for (let y = 0; y < h; y++) {
76
+ for (let x = 0; x < w; x++) {
77
+ const i = y * w + x;
78
+ if (data[i] === 0) continue;
79
+ let label = 0;
80
+ const take = (j: number): void => {
81
+ const l = provisional[j];
82
+ if (l === 0) return;
83
+ label = label === 0 ? find(l) : union(label, l);
84
+ };
85
+ if (x > 0) take(i - 1);
86
+ if (y > 0) {
87
+ take(i - w);
88
+ if (connectivity === 8) {
89
+ if (x > 0) take(i - w - 1);
90
+ if (x < w - 1) take(i - w + 1);
91
+ }
92
+ }
93
+ if (label === 0) {
94
+ label = parent.length;
95
+ parent.push(label);
96
+ }
97
+ provisional[i] = label;
98
+ }
99
+ }
100
+ // Final numbering. With 4-connectivity it is the raster order of each
101
+ // component's first pixel. With 8-connectivity it is the raster order of the
102
+ // first 2x2 BLOCK (rows 2k..2k+1, columns 2j..2j+1) holding one of its
103
+ // pixels — which is what cv2's block-based labeller produces, measured on 40
104
+ // random masks where a pixel-order numbering disagreed with cv2 on 31. One
105
+ // block can open at most one component under 8-connectivity, since its four
106
+ // pixels all touch, so the block order is total.
107
+ const final = new Int32Array(parent.length);
108
+ let count = 1;
109
+ const labels = new Int32Array(w * h);
110
+ const visit = (i: number): void => {
111
+ const p = provisional[i];
112
+ if (p === 0) return;
113
+ const root = find(p);
114
+ if (final[root] === 0) final[root] = count++;
115
+ labels[i] = final[root];
116
+ };
117
+ if (connectivity === 4) {
118
+ for (let i = 0; i < w * h; i++) visit(i);
119
+ } else {
120
+ for (let by = 0; by < h; by += 2) {
121
+ for (let bx = 0; bx < w; bx += 2) {
122
+ for (let dy = 0; dy < 2 && by + dy < h; dy++) {
123
+ for (let dx = 0; dx < 2 && bx + dx < w; dx++) visit((by + dy) * w + bx + dx);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ const acc = Array.from({ length: count }, (_, label) => ({
129
+ label,
130
+ minX: Infinity,
131
+ minY: Infinity,
132
+ maxX: -Infinity,
133
+ maxY: -Infinity,
134
+ area: 0,
135
+ sx: 0,
136
+ sy: 0,
137
+ }));
138
+ for (let y = 0; y < h; y++) {
139
+ for (let x = 0; x < w; x++) {
140
+ const a = acc[labels[y * w + x]];
141
+ a.area++;
142
+ a.sx += x;
143
+ a.sy += y;
144
+ if (x < a.minX) a.minX = x;
145
+ if (x > a.maxX) a.maxX = x;
146
+ if (y < a.minY) a.minY = y;
147
+ if (y > a.maxY) a.maxY = y;
148
+ }
149
+ }
150
+ const stats = acc.map((a) =>
151
+ a.area === 0
152
+ ? { label: a.label, left: 0, top: 0, width: 0, height: 0, area: 0, cx: Number.NaN, cy: Number.NaN }
153
+ : {
154
+ label: a.label,
155
+ left: a.minX,
156
+ top: a.minY,
157
+ width: a.maxX - a.minX + 1,
158
+ height: a.maxY - a.minY + 1,
159
+ area: a.area,
160
+ cx: a.sx / a.area,
161
+ cy: a.sy / a.area,
162
+ },
163
+ );
164
+ return { count, labels, stats };
165
+ }
@@ -0,0 +1,81 @@
1
+ import { newRaster, type Raster, RasterError } from './types.ts';
2
+
3
+ /** `((a >> 8) + a) >> 8` — PIL's divide-by-255 for the alpha compositor. */
4
+ function shiftForDiv255(a: number): number {
5
+ return ((a >>> 8) + a) >>> 8;
6
+ }
7
+
8
+ const PRECISION_BITS = 7;
9
+
10
+ /**
11
+ * `PIL.Image.alpha_composite` / `Image.alpha_composite(im, dest=(x, y))`:
12
+ * straight-alpha "over" of `src` onto a copy of `dst` with `src`'s top-left
13
+ * at (x, y), in PIL's own integer arithmetic (7-bit precision colour weights,
14
+ * divide-by-255 by shift). Pixels of `src` that fall outside `dst` are clipped;
15
+ * `dst` itself is not modified. Measured bit-exact against Pillow 12.2.0 over
16
+ * 24,296 samples.
17
+ */
18
+ export function alphaComposite(dst: Raster, src: Raster, x = 0, y = 0): Raster {
19
+ if (!Number.isInteger(x) || !Number.isInteger(y)) {
20
+ throw new RasterError(`alphaComposite: offset ${x},${y} is not integral`);
21
+ }
22
+ const out: Raster = { width: dst.width, height: dst.height, data: new Uint8ClampedArray(dst.data) };
23
+ for (let sy = 0; sy < src.height; sy++) {
24
+ const dy = sy + y;
25
+ if (dy < 0 || dy >= dst.height) continue;
26
+ for (let sx = 0; sx < src.width; sx++) {
27
+ const dx = sx + x;
28
+ if (dx < 0 || dx >= dst.width) continue;
29
+ const s = (sy * src.width + sx) * 4;
30
+ const d = (dy * dst.width + dx) * 4;
31
+ const sa = src.data[s + 3];
32
+ if (sa === 0) continue;
33
+ const da = out.data[d + 3];
34
+ const blend = da * (255 - sa);
35
+ const outa255 = sa * 255 + blend;
36
+ const coef1 = Math.floor((sa * 255 * 255 * (1 << PRECISION_BITS)) / outa255);
37
+ const coef2 = 255 * (1 << PRECISION_BITS) - coef1;
38
+ for (let c = 0; c < 3; c++) {
39
+ const t = src.data[s + c] * coef1 + out.data[d + c] * coef2;
40
+ out.data[d + c] = shiftForDiv255(t + (0x80 << PRECISION_BITS)) >>> PRECISION_BITS;
41
+ }
42
+ out.data[d + 3] = shiftForDiv255(outa255 + 0x80);
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** The `w`x`h` window of `src` at (x, y). A window reaching outside `src` is refused, not padded. */
49
+ export function crop(src: Raster, x: number, y: number, w: number, h: number): Raster {
50
+ if (![x, y, w, h].every(Number.isInteger) || w < 0 || h < 0) {
51
+ throw new RasterError(`crop: window ${x},${y} ${w}x${h} is not four integers with a non-negative size`);
52
+ }
53
+ if (x < 0 || y < 0 || x + w > src.width || y + h > src.height) {
54
+ throw new RasterError(`crop: window ${x},${y} ${w}x${h} reaches outside the ${src.width}x${src.height} raster`);
55
+ }
56
+ const out = newRaster(w, h);
57
+ for (let r = 0; r < h; r++) {
58
+ out.data.set(src.data.subarray(((y + r) * src.width + x) * 4, ((y + r) * src.width + x + w) * 4), r * w * 4);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** `src` with `left`/`top`/`right`/`bottom` pixels of `fill` added round it (`np.pad` with a constant). */
64
+ export function pad(
65
+ src: Raster,
66
+ left: number,
67
+ top: number,
68
+ right: number,
69
+ bottom: number,
70
+ fill: readonly [number, number, number, number],
71
+ ): Raster {
72
+ if (![left, top, right, bottom].every((v) => Number.isInteger(v) && v >= 0)) {
73
+ throw new RasterError(`pad: margins ${left},${top},${right},${bottom} are not four non-negative integers`);
74
+ }
75
+ const out = newRaster(src.width + left + right, src.height + top + bottom);
76
+ for (let i = 0; i < out.width * out.height; i++) out.data.set(fill, i * 4);
77
+ for (let r = 0; r < src.height; r++) {
78
+ out.data.set(src.data.subarray(r * src.width * 4, (r + 1) * src.width * 4), ((r + top) * out.width + left) * 4);
79
+ }
80
+ return out;
81
+ }
@@ -0,0 +1,9 @@
1
+ export * from './types.ts';
2
+ export * from './components.ts';
3
+ export * from './morph.ts';
4
+ export * from './blur.ts';
5
+ export * from './resize.ts';
6
+ export * from './warp.ts';
7
+ export * from './poly.ts';
8
+ export * from './composite.ts';
9
+ export * from './png.ts';
@@ -0,0 +1,116 @@
1
+ import { assertBinary, type Mask, newMask, RasterError } from './types.ts';
2
+
3
+ function assertKernel(op: string, kw: number, kh: number): void {
4
+ if (!Number.isInteger(kw) || !Number.isInteger(kh) || kw < 1 || kh < 1) {
5
+ throw new RasterError(`${op}: kernel ${kw}x${kh} is not a pair of positive integers`);
6
+ }
7
+ }
8
+
9
+ /**
10
+ * One separable pass of a rectangular max (dilate) or min (erode) filter.
11
+ *
12
+ * The window for output `x` is `[x - anchor, x - anchor + k)`, anchor
13
+ * `floor(k / 2)` — cv2's default anchor `(-1, -1)` for a rectangular kernel,
14
+ * which is not centred for an even `k`. Pixels outside the image are simply
15
+ * not in the window, which is cv2's default border for morphology
16
+ * (`morphologyDefaultBorderValue`): the image edge never dilates anything in
17
+ * and never erodes anything away.
18
+ *
19
+ * `dilate`, `erode`, `morphClose`, `morphGradient` and `fillHoles` were each
20
+ * measured bit-exact against cv2 4.13 / SciPy 1.17 on 30 random masks (24,478
21
+ * pixels, kernels 1 to 7 wide and 1 to 5 tall, even sizes included).
22
+ */
23
+ function pass(src: Uint8Array, w: number, h: number, k: number, horizontal: boolean, max: boolean): Uint8Array {
24
+ const out = new Uint8Array(w * h);
25
+ const anchor = Math.floor(k / 2);
26
+ const len = horizontal ? w : h;
27
+ const lines = horizontal ? h : w;
28
+ for (let line = 0; line < lines; line++) {
29
+ for (let p = 0; p < len; p++) {
30
+ const lo = Math.max(0, p - anchor);
31
+ const hi = Math.min(len - 1, p - anchor + k - 1);
32
+ let v = max ? 0 : 1;
33
+ for (let q = lo; q <= hi; q++) {
34
+ const s = horizontal ? src[line * w + q] : src[q * w + line];
35
+ if (max ? s === 1 : s === 0) {
36
+ v = max ? 1 : 0;
37
+ break;
38
+ }
39
+ }
40
+ out[horizontal ? line * w + p : p * w + line] = v;
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** `cv2.dilate(mask, np.ones((kh, kw)))` on a binary mask. */
47
+ export function dilate(m: Mask, kw: number, kh: number = kw): Mask {
48
+ assertKernel('dilate', kw, kh);
49
+ assertBinary('dilate', m);
50
+ const a = pass(m.data, m.width, m.height, kw, true, true);
51
+ return { width: m.width, height: m.height, data: pass(a, m.width, m.height, kh, false, true) };
52
+ }
53
+
54
+ /** `cv2.erode(mask, np.ones((kh, kw)))` on a binary mask. */
55
+ export function erode(m: Mask, kw: number, kh: number = kw): Mask {
56
+ assertKernel('erode', kw, kh);
57
+ assertBinary('erode', m);
58
+ const a = pass(m.data, m.width, m.height, kw, true, false);
59
+ return { width: m.width, height: m.height, data: pass(a, m.width, m.height, kh, false, false) };
60
+ }
61
+
62
+ /** `cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((kh, kw)))`: dilate, then erode, same kernel. */
63
+ export function morphClose(m: Mask, kw: number, kh: number = kw): Mask {
64
+ return erode(dilate(m, kw, kh), kw, kh);
65
+ }
66
+
67
+ /** `cv2.morphologyEx(mask, cv2.MORPH_GRADIENT, np.ones((kh, kw)))`: dilate minus erode. */
68
+ export function morphGradient(m: Mask, kw: number, kh: number = kw): Mask {
69
+ const d = dilate(m, kw, kh);
70
+ const e = erode(m, kw, kh);
71
+ const out = newMask(m.width, m.height);
72
+ for (let i = 0; i < out.data.length; i++) out.data[i] = d.data[i] === 1 && e.data[i] === 0 ? 1 : 0;
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * `scipy.ndimage.binary_fill_holes(mask)` with its default structure: every
78
+ * background pixel that cannot reach the image border through 4-connected
79
+ * background is foreground in the result.
80
+ *
81
+ * The 4-connectivity is scipy's default cross, and it matters: a background
82
+ * pocket that touches the outside only through a diagonal gap is a hole here.
83
+ */
84
+ export function fillHoles(m: Mask): Mask {
85
+ assertBinary('fillHoles', m);
86
+ const { width: w, height: h } = m;
87
+ if (w === 0 || h === 0) return newMask(w, h);
88
+ const outside = new Uint8Array(w * h);
89
+ const stack: number[] = [];
90
+ const seed = (i: number): void => {
91
+ if (m.data[i] === 0 && outside[i] === 0) {
92
+ outside[i] = 1;
93
+ stack.push(i);
94
+ }
95
+ };
96
+ for (let x = 0; x < w; x++) {
97
+ seed(x);
98
+ seed((h - 1) * w + x);
99
+ }
100
+ for (let y = 0; y < h; y++) {
101
+ seed(y * w);
102
+ seed(y * w + w - 1);
103
+ }
104
+ while (stack.length > 0) {
105
+ const i = stack.pop() as number;
106
+ const x = i % w;
107
+ const y = (i - x) / w;
108
+ if (x > 0) seed(i - 1);
109
+ if (x < w - 1) seed(i + 1);
110
+ if (y > 0) seed(i - w);
111
+ if (y < h - 1) seed(i + w);
112
+ }
113
+ const out = newMask(w, h);
114
+ for (let i = 0; i < w * h; i++) out.data[i] = outside[i] === 1 ? 0 : 1;
115
+ return out;
116
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * PNG in and out, through rig-c's own codec.
3
+ *
4
+ * ⚖️ **Why no PNG dependency.** `rig-c` ships a complete PNG codec inside
5
+ * its published allowlist: `tools/plate.ts` exports `decodePng` (every colour
6
+ * type the PNG spec defines — greyscale, RGB, indexed with `PLTE`/`tRNS`,
7
+ * grey+alpha, RGBA — at every legal bit depth, all five scanline filters,
8
+ * expanded to straight RGBA) and `encodePng` (8-bit RGBA, filter 0, zlib level
9
+ * 9, deterministic), and `src/png.ts` exports `assertPng`, which names what a
10
+ * file is when it is not a PNG. `npm pack --dry-run` of rig-c 1.2.3 lists
11
+ * both files. The one gap is interlaced (Adam7) files, which `decodePng`
12
+ * refuses by name rather than misreading. So adding `pngjs` would have been a
13
+ * second codec for the same bytes, and two codecs are two answers.
14
+ *
15
+ * 🔌 **The deep path is the interface.** rig-c's `exports` map carries
16
+ * `./*.ts`, so `rig-c/tools/plate.ts` is how a consumer reaches the codec
17
+ * and it is what the installed package resolves. If a rigc release narrows the
18
+ * map to omit these paths, this import is what breaks, by name, and
19
+ * `bun run smoke` is where that is seen first.
20
+ */
21
+ import { assertPng } from 'rig-c/src/png.ts';
22
+ import { decodePng, encodePng } from 'rig-c/tools/plate.ts';
23
+ import { readFileSync, writeFileSync } from 'node:fs';
24
+ import { type FloatImage, newFloatImage, newRaster, type Raster, RasterError } from './types.ts';
25
+
26
+ /** Decode PNG bytes to a straight-alpha RGBA raster. `label` names the bytes in any refusal. */
27
+ export function decodePngBytes(bytes: Uint8Array, label: string): Raster {
28
+ assertPng(bytes, label);
29
+ let plate;
30
+ try {
31
+ plate = decodePng(bytes);
32
+ } catch (err) {
33
+ throw new RasterError(`cannot decode PNG ${label}: ${(err as Error).message}`);
34
+ }
35
+ return { width: plate.width, height: plate.height, data: new Uint8ClampedArray(plate.data) };
36
+ }
37
+
38
+ export function readPng(path: string): Raster {
39
+ return decodePngBytes(new Uint8Array(readFileSync(path)), path);
40
+ }
41
+
42
+ export function encodePngBytes(r: Raster): Uint8Array {
43
+ return encodePng(r.width, r.height, new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength));
44
+ }
45
+
46
+ export function writePng(path: string, r: Raster): void {
47
+ writeFileSync(path, encodePngBytes(r));
48
+ }
49
+
50
+ /**
51
+ * A raster as four float channels, optionally premultiplied (`c * a / 255`,
52
+ * unrounded) — the form the reference warps in.
53
+ */
54
+ export function toFloat(r: Raster, premultiply: boolean): FloatImage {
55
+ const f = newFloatImage(r.width, r.height, 4);
56
+ for (let i = 0; i < r.width * r.height; i++) {
57
+ const a = r.data[i * 4 + 3];
58
+ for (let c = 0; c < 3; c++) f.data[i * 4 + c] = premultiply ? (r.data[i * 4 + c] * a) / 255 : r.data[i * 4 + c];
59
+ f.data[i * 4 + 3] = a;
60
+ }
61
+ return f;
62
+ }
63
+
64
+ /**
65
+ * Four float channels back to a raster: clipped to 0..255, optionally
66
+ * un-premultiplied, then TRUNCATED to integers — `np.clip(...).astype(np.uint8)`,
67
+ * which is what the reference does, and which is not rounding.
68
+ */
69
+ export function fromFloat(f: FloatImage, premultiplied: boolean): Raster {
70
+ if (f.channels !== 4) throw new RasterError(`fromFloat: ${f.channels} channel(s); 4 are required`);
71
+ const r = newRaster(f.width, f.height);
72
+ const clip = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
73
+ for (let i = 0; i < f.width * f.height; i++) {
74
+ const a = clip(f.data[i * 4 + 3]);
75
+ for (let c = 0; c < 3; c++) {
76
+ const v = clip(f.data[i * 4 + c]);
77
+ r.data[i * 4 + c] = Math.trunc(premultiplied ? (a > 0 ? clip((v * 255) / Math.max(a, 1e-3)) : 0) : v);
78
+ }
79
+ r.data[i * 4 + 3] = Math.trunc(a);
80
+ }
81
+ return r;
82
+ }