@mcptoolshop/style-dataset-lab 3.4.0 → 3.5.0

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 (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.es.md +111 -59
  3. package/README.fr.md +117 -67
  4. package/README.hi.md +109 -70
  5. package/README.it.md +111 -60
  6. package/README.ja.md +116 -76
  7. package/README.md +58 -2
  8. package/README.pt-BR.md +119 -64
  9. package/README.zh.md +108 -67
  10. package/bin/sdlab.js +50 -1
  11. package/lib/adapters/ai-toolkit.js +187 -30
  12. package/lib/adapters/comfyui-workflows.js +84 -9
  13. package/lib/asset-ingest.js +575 -0
  14. package/lib/asset-masks.js +323 -0
  15. package/lib/asset-source.js +1176 -0
  16. package/lib/canon-author.js +350 -0
  17. package/lib/canon-review-gate.js +205 -0
  18. package/lib/colab-launcher-guards.js +285 -0
  19. package/lib/export.js +1 -1
  20. package/lib/freeze-gate.js +3 -0
  21. package/lib/generated-provenance.js +178 -5
  22. package/lib/lane1-render.js +668 -0
  23. package/lib/memory-store.js +663 -0
  24. package/lib/model-licences.js +305 -0
  25. package/lib/npy-meta.js +130 -0
  26. package/lib/png-meta.js +512 -0
  27. package/lib/records.js +54 -8
  28. package/lib/reingest-selected.js +15 -1
  29. package/lib/reingest.js +27 -3
  30. package/lib/run-manifest.js +59 -11
  31. package/lib/train-runs.js +214 -0
  32. package/lib/verifier.js +3 -0
  33. package/lib/verify-chain.js +367 -0
  34. package/package.json +2 -2
  35. package/scripts/asset-ingest.js +160 -0
  36. package/scripts/canon-draft.js +82 -0
  37. package/scripts/canon-freeze.js +4 -0
  38. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  39. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  40. package/scripts/colab/colab_window.sh +587 -0
  41. package/scripts/colab/lane1_render.py +970 -0
  42. package/scripts/colab/launch_guards.py +564 -0
  43. package/scripts/colab/launch_lane1_stub.sh +309 -0
  44. package/scripts/colab/launch_lane3_stub.sh +366 -0
  45. package/scripts/colab/remote_runner.py +59 -0
  46. package/scripts/doctor.js +1 -0
  47. package/scripts/generate.js +38 -12
  48. package/scripts/verify.js +67 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Asset-lane color math — the palette-conformance gate ported
3
+ * formula-faithfully from facet's `tools/palette_gate.py` (read-only port,
4
+ * 2026-08-04), plus categorical class-share measurement for declared
5
+ * render-space channels.
6
+ *
7
+ * Port fidelity notes (checked against the source, not paraphrased):
8
+ * - sRGB → linear → XYZ(D65) → Lab with the exact constants palette_gate.py
9
+ * uses (which itself matches facet's e08_deltaE.py).
10
+ * - C* = hypot(a*, b*); hue = atan2(b*, a*) in degrees, mod 360.
11
+ * - A band with lo <= hi is [lo, hi]; a band with lo > hi WRAPS AROUND 0°.
12
+ * - off-palette = (C* > min_chroma) AND (not in any band) AND inside the
13
+ * EXACT silhouette (mask value > 0.5 of full scale — a keyed mask would
14
+ * let background into the measurement; the E01 / E08-A2 failure).
15
+ * - TWO numbers, because they are two different failures: total off-palette
16
+ * (an antialiasing-spread diagnostic — its percentage bound is WITHDRAWN
17
+ * per facet 2026-08-04 and gates nothing unless the palette declares one)
18
+ * and the LARGEST 4-CONNECTED COMPONENT (an invented garment is one big
19
+ * blob), which is the load-bearing gate.
20
+ * - The chroma floor is load-bearing, not a tuning knob: hue is undefined
21
+ * at low chroma (W3's steel greatsword sits at C* 1.6–2.8 inside the
22
+ * blue band; without the floor it would flag on every view).
23
+ *
24
+ * Pure functions over decoded images (lib/png-meta.js output). No I/O, no
25
+ * channel semantics — the palette, bands, classes and tolerances all arrive
26
+ * from the asset's manifest. (Parnas boundary: this module knows color math;
27
+ * lib/asset-source.js knows the contract.)
28
+ */
29
+
30
+ import { inputError } from './errors.js';
31
+
32
+ // ─── sRGB → Lab (exact palette_gate.py constants) ────────────────────
33
+
34
+ const XYZ_M = [
35
+ [0.4124564, 0.3575761, 0.1804375],
36
+ [0.2126729, 0.7151522, 0.0721750],
37
+ [0.0193339, 0.1191920, 0.9503041],
38
+ ];
39
+ const WHITE = [0.95047, 1.0, 1.08883];
40
+ const LAB_E = 216 / 24389;
41
+ const LAB_K = 24389 / 27;
42
+
43
+ function srgbToLinear(c) {
44
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
45
+ }
46
+
47
+ function labF(t) {
48
+ return t > LAB_E ? Math.cbrt(t) : (LAB_K * t + 16) / 116;
49
+ }
50
+
51
+ /**
52
+ * Convert one sRGB pixel (0–255 ints) to Lab.
53
+ * @returns {[number, number, number]} [L, a, b]
54
+ */
55
+ export function srgbToLab(r8, g8, b8) {
56
+ const r = srgbToLinear(r8 / 255);
57
+ const g = srgbToLinear(g8 / 255);
58
+ const b = srgbToLinear(b8 / 255);
59
+ const x = (XYZ_M[0][0] * r + XYZ_M[0][1] * g + XYZ_M[0][2] * b) / WHITE[0];
60
+ const y = (XYZ_M[1][0] * r + XYZ_M[1][1] * g + XYZ_M[1][2] * b) / WHITE[1];
61
+ const z = (XYZ_M[2][0] * r + XYZ_M[2][1] * g + XYZ_M[2][2] * b) / WHITE[2];
62
+ const fx = labF(x);
63
+ const fy = labF(y);
64
+ const fz = labF(z);
65
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
66
+ }
67
+
68
+ // ─── Decoded-image pixel access ──────────────────────────────────────
69
+
70
+ /**
71
+ * Read pixel i of a decoded PNG as [r, g, b] (0–255), independent of the
72
+ * source color type (grayscale, RGB, RGBA, or indexed via its PLTE).
73
+ */
74
+ export function rgbAt(img, i) {
75
+ switch (img.colorType) {
76
+ case 0: {
77
+ const v = img.data[i];
78
+ return [v, v, v];
79
+ }
80
+ case 2: {
81
+ const o = i * 3;
82
+ return [img.data[o], img.data[o + 1], img.data[o + 2]];
83
+ }
84
+ case 6: {
85
+ const o = i * 4;
86
+ return [img.data[o], img.data[o + 1], img.data[o + 2]];
87
+ }
88
+ case 3: {
89
+ const entry = img.palette[img.data[i]];
90
+ return [entry[0], entry[1], entry[2]];
91
+ }
92
+ default:
93
+ throw inputError('PNG_UNSUPPORTED', `rgbAt: unsupported color type ${img.colorType}.`);
94
+ }
95
+ }
96
+
97
+ /** Alpha of pixel i (255 when the image has no alpha channel). */
98
+ export function alphaAt(img, i) {
99
+ return img.colorType === 6 ? img.data[i * 4 + 3] : 255;
100
+ }
101
+
102
+ /**
103
+ * Binarize a decoded grayscale silhouette: value > 127 (i.e. > 0.5 of full
104
+ * scale, matching palette_gate.py's `> 0.5` on a /255 float) = figure.
105
+ * @returns {Uint8Array} 0/1 per pixel
106
+ */
107
+ export function silhouetteToMask(img, label = 'silhouette') {
108
+ if (img.colorType !== 0) {
109
+ throw inputError(
110
+ 'ASSET_ENCODING_MISMATCH',
111
+ `${label}: silhouette masks must be grayscale PNGs (color type 0), got ${img.colorType}.`
112
+ );
113
+ }
114
+ const mask = new Uint8Array(img.width * img.height);
115
+ for (let i = 0; i < mask.length; i++) mask[i] = img.data[i] > 127 ? 1 : 0;
116
+ return mask;
117
+ }
118
+
119
+ // ─── Connected components (4-connectivity, matching scipy.ndimage.label
120
+ // with its default cross-shaped structure) ────────────────────────
121
+
122
+ /**
123
+ * Size in pixels of the largest 4-connected component of a 0/1 mask.
124
+ */
125
+ export function largestComponent4(mask, width, height) {
126
+ const visited = new Uint8Array(mask.length);
127
+ const stack = new Int32Array(mask.length);
128
+ let largest = 0;
129
+ for (let start = 0; start < mask.length; start++) {
130
+ if (!mask[start] || visited[start]) continue;
131
+ let top = 0;
132
+ stack[top++] = start;
133
+ visited[start] = 1;
134
+ let size = 0;
135
+ while (top > 0) {
136
+ const i = stack[--top];
137
+ size++;
138
+ const x = i % width;
139
+ const y = (i - x) / width;
140
+ if (x > 0 && mask[i - 1] && !visited[i - 1]) { visited[i - 1] = 1; stack[top++] = i - 1; }
141
+ if (x < width - 1 && mask[i + 1] && !visited[i + 1]) { visited[i + 1] = 1; stack[top++] = i + 1; }
142
+ if (y > 0 && mask[i - width] && !visited[i - width]) { visited[i - width] = 1; stack[top++] = i - width; }
143
+ if (y < height - 1 && mask[i + width] && !visited[i + width]) { visited[i + width] = 1; stack[top++] = i + width; }
144
+ }
145
+ if (size > largest) largest = size;
146
+ }
147
+ return largest;
148
+ }
149
+
150
+ // ─── The palette gate ────────────────────────────────────────────────
151
+
152
+ function median(values) {
153
+ if (values.length === 0) return 0;
154
+ const sorted = Float64Array.from(values).sort();
155
+ const mid = sorted.length >> 1;
156
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
157
+ }
158
+
159
+ function round(v, places) {
160
+ const f = 10 ** places;
161
+ return Math.round(v * f) / f;
162
+ }
163
+
164
+ /**
165
+ * Run the ported palette-conformance gate on one render.
166
+ *
167
+ * @param {Object} renderImg — decoded render (lib/png-meta.js decodePng)
168
+ * @param {Object} silhouetteImg — decoded grayscale silhouette, same dims
169
+ * @param {Object} palette — the manifest's palette block:
170
+ * {min_chroma, allowed_bands: [{name, hue_deg: [lo, hi]}],
171
+ * gate: {max_offpalette_pct: number|null, max_offpalette_blob_px: number}}
172
+ * @param {string} [label] — for error messages
173
+ * @returns {{figure_px:number, offpalette_px:number, offpalette_pct:number,
174
+ * largest_blob_px:number, pass:boolean, withdrawn_pct_bound:boolean,
175
+ * dominant:Object|null}}
176
+ */
177
+ export function paletteGate(renderImg, silhouetteImg, palette, label = 'render') {
178
+ if (renderImg.width !== silhouetteImg.width || renderImg.height !== silhouetteImg.height) {
179
+ // The parallel-mask ANDON from palette_gate.py: a mask that does not
180
+ // match its image measures the wrong pixels silently.
181
+ throw inputError(
182
+ 'ASSET_MASK_MISMATCH',
183
+ `${label}: silhouette is ${silhouetteImg.width}x${silhouetteImg.height} but the render is ${renderImg.width}x${renderImg.height}.`
184
+ );
185
+ }
186
+
187
+ const mask = silhouetteToMask(silhouetteImg, `${label} silhouette`);
188
+ const bands = palette.allowed_bands.map((b) => ({
189
+ lo: Number(b.hue_deg[0]),
190
+ hi: Number(b.hue_deg[1]),
191
+ }));
192
+ const cmin = Number(palette.min_chroma);
193
+ const maxPct = palette.gate.max_offpalette_pct == null ? null : Number(palette.gate.max_offpalette_pct);
194
+ const maxBlob = Number(palette.gate.max_offpalette_blob_px);
195
+
196
+ const n = renderImg.width * renderImg.height;
197
+ const off = new Uint8Array(n);
198
+ let figurePx = 0;
199
+ let offPx = 0;
200
+ const offChroma = [];
201
+ const offHue = [];
202
+ const offRgb = [];
203
+
204
+ for (let i = 0; i < n; i++) {
205
+ if (!mask[i]) continue;
206
+ figurePx++;
207
+ const [r, g, b] = rgbAt(renderImg, i);
208
+ const [, a, bb] = srgbToLab(r, g, b);
209
+ const chroma = Math.hypot(a, bb);
210
+ if (chroma <= cmin) continue; // hue undefined below the floor
211
+ let hue = (Math.atan2(bb, a) * 180) / Math.PI;
212
+ hue = ((hue % 360) + 360) % 360;
213
+ let inBand = false;
214
+ for (const { lo, hi } of bands) {
215
+ if (lo <= hi ? hue >= lo && hue <= hi : hue >= lo || hue <= hi) { inBand = true; break; }
216
+ }
217
+ if (inBand) continue;
218
+ off[i] = 1;
219
+ offPx++;
220
+ offChroma.push(chroma);
221
+ offHue.push(hue);
222
+ offRgb.push([r, g, b]);
223
+ }
224
+
225
+ const pct = (100 * offPx) / Math.max(figurePx, 1);
226
+ const blob = offPx > 0 ? largestComponent4(off, renderImg.width, renderImg.height) : 0;
227
+ const pass = blob <= maxBlob && (maxPct === null || pct <= maxPct);
228
+
229
+ // Dominant off-palette mass, so a failure is diagnosable, not just flagged
230
+ // (same 36-bin histogram + medians as the source).
231
+ let dominant = null;
232
+ if (offPx > 0) {
233
+ const hist = new Array(36).fill(0);
234
+ for (const h of offHue) hist[Math.min(35, Math.floor(h / 10))]++;
235
+ let top = 0;
236
+ for (let i = 1; i < 36; i++) if (hist[i] > hist[top]) top = i;
237
+ dominant = {
238
+ hue_lo: top * 10,
239
+ hue_hi: (top + 1) * 10,
240
+ share_of_offpalette: round((hist[top] / offPx) * 100, 1),
241
+ median_chroma: round(median(offChroma), 1),
242
+ median_rgb: [
243
+ Math.round(median(offRgb.map((p) => p[0]))),
244
+ Math.round(median(offRgb.map((p) => p[1]))),
245
+ Math.round(median(offRgb.map((p) => p[2]))),
246
+ ],
247
+ };
248
+ }
249
+
250
+ return {
251
+ figure_px: figurePx,
252
+ offpalette_px: offPx,
253
+ offpalette_pct: round(pct, 4),
254
+ largest_blob_px: blob,
255
+ pass,
256
+ withdrawn_pct_bound: maxPct === null,
257
+ dominant,
258
+ };
259
+ }
260
+
261
+ // ─── Categorical class shares ────────────────────────────────────────
262
+
263
+ /**
264
+ * Measure per-class coverage of a categorical render-space channel inside
265
+ * the silhouette. Shares are computed for EVERY declared class — this module
266
+ * never knows which class matters; the manifest's consumers do.
267
+ *
268
+ * `filter: "nearest"` ⇒ exact color match (tolerance 0).
269
+ * `filter: "linear"` ⇒ nearest declared class within
270
+ * `classification_tolerance` (RGB euclidean); pixels
271
+ * beyond it are honestly `unclassified`.
272
+ * Fully transparent pixels (alpha 0) are excluded — background, not class.
273
+ *
274
+ * @param {Object} channelImg — decoded channel instance
275
+ * @param {Object} silhouetteImg — decoded grayscale silhouette, same dims
276
+ * @param {Object} decl — the channel declaration:
277
+ * {palette: [{name, rgb: [r,g,b]}], filter, classification_tolerance?}
278
+ * @param {string} [label]
279
+ * @returns {{figure_px:number,
280
+ * classes:Object<string,{px:number, share:number}>,
281
+ * unclassified:{px:number, share:number}}}
282
+ */
283
+ export function classShares(channelImg, silhouetteImg, decl, label = 'channel') {
284
+ if (channelImg.width !== silhouetteImg.width || channelImg.height !== silhouetteImg.height) {
285
+ throw inputError(
286
+ 'ASSET_MASK_MISMATCH',
287
+ `${label}: channel is ${channelImg.width}x${channelImg.height} but the silhouette is ${silhouetteImg.width}x${silhouetteImg.height}.`
288
+ );
289
+ }
290
+ const mask = silhouetteToMask(silhouetteImg, `${label} silhouette`);
291
+ const tolerance = decl.filter === 'nearest' ? 0 : Number(decl.classification_tolerance ?? 0);
292
+ const tol2 = tolerance * tolerance;
293
+ const classes = decl.palette.map((c) => ({ name: c.name, rgb: c.rgb, px: 0 }));
294
+
295
+ let figurePx = 0;
296
+ let unclassified = 0;
297
+ const n = channelImg.width * channelImg.height;
298
+
299
+ for (let i = 0; i < n; i++) {
300
+ if (!mask[i]) continue;
301
+ if (alphaAt(channelImg, i) === 0) continue; // transparent = background
302
+ figurePx++;
303
+ const [r, g, b] = rgbAt(channelImg, i);
304
+ let best = -1;
305
+ let bestD = Infinity;
306
+ for (let c = 0; c < classes.length; c++) {
307
+ const [cr, cg, cb] = classes[c].rgb;
308
+ const d = (r - cr) * (r - cr) + (g - cg) * (g - cg) + (b - cb) * (b - cb);
309
+ if (d < bestD) { bestD = d; best = c; }
310
+ }
311
+ if (best >= 0 && bestD <= tol2) classes[best].px++;
312
+ else unclassified++;
313
+ }
314
+
315
+ const share = (px) => round((100 * px) / Math.max(figurePx, 1), 4);
316
+ const out = {};
317
+ for (const c of classes) out[c.name] = { px: c.px, share: share(c.px) };
318
+ return {
319
+ figure_px: figurePx,
320
+ classes: out,
321
+ unclassified: { px: unclassified, share: share(unclassified) },
322
+ };
323
+ }