@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/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
|
+
}
|