@todrawn/scribe-mcp 0.0.0-stage → 0.1.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.
@@ -0,0 +1,504 @@
1
+ /* Photo -> Scribe painting. A TypeScript port of the greedy painter used for
2
+ Todrawn's own Scribe ads (brag-output-2026-10-12-pita/draw/trace.py).
3
+
4
+ 1. Underpaint: straight horizontal rows of colour runs from a blurred copy,
5
+ so the photo rectangle is covered edge to edge with no paper showing.
6
+ 2. Painting: batches of strokes, coarse to fine. Each starts where the
7
+ simulated canvas differs most from the photo, follows the image
8
+ structure (perpendicular to the structure-tensor gradient), takes the
9
+ mean photo colour under its own footprint, and is kept only if it
10
+ lowers the error.
11
+
12
+ Everything is simulated the way Scribe renders a Draw stroke: one pen
13
+ width, round caps, the pen's opacity, later strokes over earlier ones.
14
+ Pure: no decoders, no I/O, so the frontend contract test can import it. */
15
+ /** Width in Draw units and opacity of each pen (frontend lib/scribe/tools.ts PEN_CHARACTER). */
16
+ export const PEN_STYLE = {
17
+ ballpoint: { width: 9, alpha: 0.92 },
18
+ marker: { width: 16, alpha: 0.78 },
19
+ fineliner: { width: 5, alpha: 0.95 },
20
+ pencil: { width: 7, alpha: 0.62 },
21
+ };
22
+ /** Artboard size per aspect in Draw units (DRAW_CANVAS_LONG_EDGE_PX = 960). */
23
+ export const ARTBOARD = {
24
+ landscape: [960, 540],
25
+ portrait: [540, 960],
26
+ square: [960, 960],
27
+ feed: [768, 960],
28
+ };
29
+ export const DETAIL_BUDGET = { low: 1200, medium: 2000, high: 2800 };
30
+ /** Paper left around the photo, in Draw units. */
31
+ const MARGIN = 16;
32
+ function mulberry32(seed) {
33
+ let a = seed >>> 0;
34
+ return () => {
35
+ a = (a + 0x6d2b79f5) >>> 0;
36
+ let t = a;
37
+ t = Math.imul(t ^ (t >>> 15), t | 1);
38
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
39
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
40
+ };
41
+ }
42
+ /** Area-average downscale, bilinear upscale. */
43
+ function resizePlane(src, sw, sh, tw, th) {
44
+ const out = new Float32Array(tw * th);
45
+ const fx = sw / tw;
46
+ const fy = sh / th;
47
+ if (fx >= 1 && fy >= 1) {
48
+ for (let y = 0; y < th; y++) {
49
+ const y0 = Math.floor(y * fy);
50
+ const y1 = Math.max(y0 + 1, Math.floor((y + 1) * fy));
51
+ for (let x = 0; x < tw; x++) {
52
+ const x0 = Math.floor(x * fx);
53
+ const x1 = Math.max(x0 + 1, Math.floor((x + 1) * fx));
54
+ let sum = 0;
55
+ for (let yy = y0; yy < y1; yy++)
56
+ for (let xx = x0; xx < x1; xx++)
57
+ sum += src[yy * sw + xx];
58
+ out[y * tw + x] = sum / ((y1 - y0) * (x1 - x0));
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ for (let y = 0; y < th; y++) {
64
+ const sy = Math.min(Math.max((y + 0.5) * fy - 0.5, 0), sh - 1);
65
+ const y0 = Math.floor(sy);
66
+ const y1 = Math.min(y0 + 1, sh - 1);
67
+ const ty = sy - y0;
68
+ for (let x = 0; x < tw; x++) {
69
+ const sx = Math.min(Math.max((x + 0.5) * fx - 0.5, 0), sw - 1);
70
+ const x0 = Math.floor(sx);
71
+ const x1 = Math.min(x0 + 1, sw - 1);
72
+ const tx = sx - x0;
73
+ const top = src[y0 * sw + x0] * (1 - tx) + src[y0 * sw + x1] * tx;
74
+ const bot = src[y1 * sw + x0] * (1 - tx) + src[y1 * sw + x1] * tx;
75
+ out[y * tw + x] = top * (1 - ty) + bot * ty;
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ function boxBlurPass(src, w, h, r) {
81
+ if (r < 1)
82
+ return src.slice();
83
+ const tmp = new Float32Array(w * h);
84
+ const out = new Float32Array(w * h);
85
+ const n = 2 * r + 1;
86
+ for (let y = 0; y < h; y++) {
87
+ const row = y * w;
88
+ let acc = 0;
89
+ for (let k = -r; k <= r; k++)
90
+ acc += src[row + Math.min(Math.max(k, 0), w - 1)];
91
+ for (let x = 0; x < w; x++) {
92
+ tmp[row + x] = acc / n;
93
+ acc += src[row + Math.min(x + r + 1, w - 1)] - src[row + Math.max(x - r, 0)];
94
+ }
95
+ }
96
+ for (let x = 0; x < w; x++) {
97
+ let acc = 0;
98
+ for (let k = -r; k <= r; k++)
99
+ acc += tmp[Math.min(Math.max(k, 0), h - 1) * w + x];
100
+ for (let y = 0; y < h; y++) {
101
+ out[y * w + x] = acc / n;
102
+ acc += tmp[Math.min(y + r + 1, h - 1) * w + x] - tmp[Math.max(y - r, 0) * w + x];
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ /** Gaussian approximated by three box passes. */
108
+ function blur(src, w, h, sigma) {
109
+ if (sigma < 0.5)
110
+ return src.slice();
111
+ const r = Math.max(1, Math.round(Math.sqrt((12 * sigma * sigma) / 3 + 1) / 2 - 0.5));
112
+ return boxBlurPass(boxBlurPass(boxBlurPass(src, w, h, r), w, h, r), w, h, r);
113
+ }
114
+ function sobel(lum, w, h) {
115
+ const gx = new Float32Array(w * h);
116
+ const gy = new Float32Array(w * h);
117
+ const at = (x, y) => lum[Math.min(Math.max(y, 0), h - 1) * w + Math.min(Math.max(x, 0), w - 1)];
118
+ for (let y = 0; y < h; y++) {
119
+ for (let x = 0; x < w; x++) {
120
+ gx[y * w + x] = at(x + 1, y - 1) + 2 * at(x + 1, y) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2 * at(x - 1, y) - at(x - 1, y + 1);
121
+ gy[y * w + x] = at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2 * at(x, y - 1) - at(x + 1, y - 1);
122
+ }
123
+ }
124
+ return [gx, gy];
125
+ }
126
+ const luminance = (r, g, b) => {
127
+ const out = new Float32Array(r.length);
128
+ for (let i = 0; i < r.length; i++)
129
+ out[i] = 0.299 * r[i] + 0.587 * g[i] + 0.114 * b[i];
130
+ return out;
131
+ };
132
+ function hex(c) {
133
+ return Math.round(Math.min(Math.max(c, 0), 1) * 255)
134
+ .toString(16)
135
+ .padStart(2, "0");
136
+ }
137
+ const q8 = (c) => Math.round(Math.min(Math.max(c, 0), 1) * 255) / 255;
138
+ /** The artboard whose ratio is closest to the photo's, on a log scale. */
139
+ function closestAspect(ratio) {
140
+ let best = "square";
141
+ for (const aspect of Object.keys(ARTBOARD)) {
142
+ const [w, h] = ARTBOARD[aspect];
143
+ const [bw, bh] = ARTBOARD[best];
144
+ if (Math.abs(Math.log(w / h / ratio)) < Math.abs(Math.log(bw / bh / ratio)))
145
+ best = aspect;
146
+ }
147
+ return best;
148
+ }
149
+ // ---------------------------------------------------------------- painter
150
+ export function paintImage(image, options = {}) {
151
+ const pen = options.pen ?? "fineliner";
152
+ const { width: PEN, alpha: ALPHA } = PEN_STYLE[pen];
153
+ const budget = DETAIL_BUDGET[options.detail ?? "medium"];
154
+ const rnd = mulberry32(options.seed ?? 3);
155
+ const aspect = options.aspect ?? closestAspect(image.width / image.height);
156
+ const [W, H] = ARTBOARD[aspect];
157
+ // photo rectangle: fitted inside the artboard with a small paper margin
158
+ const fit = Math.min((W - 2 * MARGIN) / image.width, (H - 2 * MARGIN) / image.height);
159
+ const RW = Math.round(image.width * fit);
160
+ const RH = Math.round(image.height * fit);
161
+ const OX = Math.round((W - RW) / 2);
162
+ const OY = Math.round((H - RH) / 2);
163
+ // target and canvas at 1 px per Draw unit, full artboard, white paper outside the photo
164
+ const N = W * H;
165
+ const T = [new Float32Array(N).fill(1), new Float32Array(N).fill(1), new Float32Array(N).fill(1)];
166
+ const C = [new Float32Array(N).fill(1), new Float32Array(N).fill(1), new Float32Array(N).fill(1)];
167
+ const inside = new Uint8Array(N);
168
+ const planes = [image.r, image.g, image.b];
169
+ for (let ch = 0; ch < 3; ch++) {
170
+ const small = resizePlane(planes[ch], image.width, image.height, RW, RH);
171
+ for (let y = 0; y < RH; y++)
172
+ T[ch].set(small.subarray(y * RW, (y + 1) * RW), (OY + y) * W + OX);
173
+ }
174
+ for (let y = OY; y < OY + RH; y++)
175
+ inside.fill(1, y * W + OX, y * W + OX + RW);
176
+ // saliency: sharp regions (the subject) weigh more than a soft background
177
+ const lumT = luminance(T[0], T[1], T[2]);
178
+ const hi = blur(lumT, W, H, 3);
179
+ for (let i = 0; i < N; i++)
180
+ hi[i] = Math.abs(lumT[i] - hi[i]);
181
+ const detail = blur(hi, W, H, 12);
182
+ const sample = [];
183
+ for (let i = 0; i < N; i += 7)
184
+ if (inside[i])
185
+ sample.push(detail[i]);
186
+ sample.sort((a, b) => a - b);
187
+ const p97 = (sample[Math.floor(sample.length * 0.97)] ?? 1) + 1e-6;
188
+ const SAL = new Float32Array(N);
189
+ for (let i = 0; i < N; i++)
190
+ SAL[i] = Math.min(1.4, Math.max(0.45, 0.45 + (0.55 * detail[i]) / p97));
191
+ const strokes = [];
192
+ /** Anti-aliased round-capped coverage of a polyline, in its bounding box. */
193
+ const footprint = (pts) => {
194
+ const r = PEN / 2;
195
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
196
+ for (const [x, y] of pts) {
197
+ minX = Math.min(minX, x);
198
+ minY = Math.min(minY, y);
199
+ maxX = Math.max(maxX, x);
200
+ maxY = Math.max(maxY, y);
201
+ }
202
+ const x0 = Math.max(0, Math.floor(minX - r - 1));
203
+ const y0 = Math.max(0, Math.floor(minY - r - 1));
204
+ const x1 = Math.min(W, Math.ceil(maxX + r + 1));
205
+ const y1 = Math.min(H, Math.ceil(maxY + r + 1));
206
+ const bw = x1 - x0;
207
+ const bh = y1 - y0;
208
+ const mask = new Float32Array(Math.max(bw, 0) * Math.max(bh, 0));
209
+ for (let py = 0; py < bh; py++) {
210
+ const cy = y0 + py + 0.5;
211
+ for (let px = 0; px < bw; px++) {
212
+ const cx = x0 + px + 0.5;
213
+ let d2 = Infinity;
214
+ for (let k = 0; k < pts.length; k++) {
215
+ const [ax, ay] = pts[k];
216
+ const [bx, by] = pts[Math.min(k + 1, pts.length - 1)];
217
+ const vx = bx - ax, vy = by - ay;
218
+ const len2 = vx * vx + vy * vy;
219
+ const t = len2 > 0 ? Math.min(1, Math.max(0, ((cx - ax) * vx + (cy - ay) * vy) / len2)) : 0;
220
+ const dx = cx - (ax + t * vx), dy = cy - (ay + t * vy);
221
+ d2 = Math.min(d2, dx * dx + dy * dy);
222
+ }
223
+ mask[py * bw + px] = Math.min(1, Math.max(0, r + 0.5 - Math.sqrt(d2)));
224
+ }
225
+ }
226
+ return { x0, y0, bw, bh, mask };
227
+ };
228
+ const commit = (pts, rgb) => {
229
+ const col = [q8(rgb[0]), q8(rgb[1]), q8(rgb[2])];
230
+ const { x0, y0, bw, bh, mask } = footprint(pts);
231
+ for (let py = 0; py < bh; py++) {
232
+ for (let px = 0; px < bw; px++) {
233
+ const m = mask[py * bw + px] * ALPHA;
234
+ if (m <= 0)
235
+ continue;
236
+ const i = (y0 + py) * W + x0 + px;
237
+ for (let ch = 0; ch < 3; ch++)
238
+ C[ch][i] = C[ch][i] * (1 - m) + col[ch] * m;
239
+ }
240
+ }
241
+ strokes.push({
242
+ color: `#${hex(col[0])}${hex(col[1])}${hex(col[2])}`,
243
+ pts: pts.map(([x, y]) => [Math.round(x * 100) / 100, Math.round(y * 100) / 100]),
244
+ });
245
+ };
246
+ // ------------------------------------------------ 1. underpaint rows
247
+ {
248
+ const B = T.map((p) => blur(p, W, H, 2.2));
249
+ const row = PEN * 0.86;
250
+ const ys = [];
251
+ for (let y = OY + PEN / 2 - 0.6; y <= OY + RH - PEN / 2 + 0.6 + 1e-6; y += row)
252
+ ys.push(y);
253
+ if (ys.length)
254
+ ys[ys.length - 1] = OY + RH - PEN / 2 + 0.6;
255
+ const plan = (tol, minRun) => {
256
+ const out = [];
257
+ ys.forEach((yu, rowIndex) => {
258
+ const yy = Math.min(Math.round(yu), H - 1) * W;
259
+ const runs = [];
260
+ let a = 0;
261
+ let sum = [0, 0, 0];
262
+ let cnt = 0;
263
+ for (let x = 0; x < RW; x++) {
264
+ const px = [B[0][yy + OX + x], B[1][yy + OX + x], B[2][yy + OX + x]];
265
+ if (cnt > 0) {
266
+ const mean = sum.map((s) => s / cnt);
267
+ if (Math.max(...px.map((v, k) => Math.abs(v - mean[k]))) > tol) {
268
+ runs.push({ a, b: x, c: mean });
269
+ a = x;
270
+ sum = [0, 0, 0];
271
+ cnt = 0;
272
+ }
273
+ }
274
+ sum = [sum[0] + px[0], sum[1] + px[1], sum[2] + px[2]];
275
+ cnt++;
276
+ }
277
+ runs.push({ a, b: RW, c: sum.map((s) => s / cnt) });
278
+ // absorb short runs into the closer neighbour
279
+ for (let merged = true; merged && runs.length > 1;) {
280
+ merged = false;
281
+ for (let k = 0; k < runs.length; k++) {
282
+ const run = runs[k];
283
+ if (run.b - run.a >= minRun)
284
+ continue;
285
+ const nbrs = [k - 1, k + 1].filter((j) => j >= 0 && j < runs.length);
286
+ const dist = (j) => runs[j].c.reduce((s, v, i) => s + Math.abs(v - run.c[i]), 0);
287
+ const j = nbrs.reduce((best, cur) => (dist(cur) < dist(best) ? cur : best));
288
+ const o = runs[j];
289
+ const wa = o.b - o.a, wb = run.b - run.a;
290
+ runs[j] = {
291
+ a: Math.min(o.a, run.a),
292
+ b: Math.max(o.b, run.b),
293
+ c: o.c.map((v, i) => (v * wa + run.c[i] * wb) / (wa + wb)),
294
+ };
295
+ runs.splice(k, 1);
296
+ merged = true;
297
+ break;
298
+ }
299
+ }
300
+ const ordered = rowIndex % 2 === 0 ? runs : [...runs].reverse();
301
+ for (const run of ordered) {
302
+ const xa = OX + run.a + (run.a === 0 ? PEN / 2 - 0.3 : -1.2);
303
+ const xb = OX + run.b + (run.b === RW ? -(PEN / 2 - 0.3) : 1.2);
304
+ out.push({ pts: rowIndex % 2 === 0 ? [[xa, yu], [xb, yu]] : [[xb, yu], [xa, yu]], c: run.c });
305
+ }
306
+ });
307
+ return out;
308
+ };
309
+ // coarser runs until the underpaint leaves most of the budget for real painting
310
+ let tol = 0.12, minRun = PEN * 5.2;
311
+ let rows = plan(tol, minRun);
312
+ for (let tries = 0; rows.length > budget * 0.6 && tries < 6; tries++) {
313
+ tol *= 1.25;
314
+ minRun *= 1.35;
315
+ rows = plan(tol, minRun);
316
+ }
317
+ for (const r of rows)
318
+ commit(r.pts, r.c);
319
+ }
320
+ const underpaintStrokes = strokes.length;
321
+ // ------------------------------------------------ 2. greedy painting
322
+ const k = PEN / 5;
323
+ const schedule = [8, 8, 6, 6, 5, 4.5, 4, 3.5, 3, 3, 2.5, 2.5, 2.2, 2.2, 2, 2].map((r) => r * k);
324
+ const limit = Math.max(budget - underpaintStrokes, 0);
325
+ const clampX = (x) => Math.min(Math.max(x, OX + PEN / 2 - 0.5), OX + RW - PEN / 2 + 0.5);
326
+ const clampY = (y) => Math.min(Math.max(y, OY + PEN / 2 - 0.5), OY + RH - PEN / 2 + 0.5);
327
+ let used = 0;
328
+ for (let b = 0; used < limit - 5 && b < 40;) {
329
+ const radius = b < schedule.length ? schedule[b] : (2.0 - 0.2 * ((b - schedule.length) % 3)) * k;
330
+ const quota = Math.floor((limit - used) / Math.max(schedule.length - b, 1));
331
+ b++;
332
+ // orientation field of the photo blurred to this radius
333
+ const R = T.map((p) => blur(p, W, H, Math.max(radius * 0.45, 0.8)));
334
+ const [sx, sy] = sobel(luminance(R[0], R[1], R[2]), W, H);
335
+ const sig = Math.max(radius * 1.2, 2);
336
+ const xx = new Float32Array(N), yy = new Float32Array(N), xy = new Float32Array(N);
337
+ for (let i = 0; i < N; i++) {
338
+ xx[i] = sx[i] * sx[i];
339
+ yy[i] = sy[i] * sy[i];
340
+ xy[i] = sx[i] * sy[i];
341
+ }
342
+ const jxx = blur(xx, W, H, sig), jyy = blur(yy, W, H, sig), jxy = blur(xy, W, H, sig);
343
+ // weighted error, smoothed to the radius
344
+ const err = new Float32Array(N);
345
+ for (let i = 0; i < N; i++) {
346
+ if (!inside[i])
347
+ continue;
348
+ const dr = C[0][i] - T[0][i], dg = C[1][i] - T[1][i], db = C[2][i] - T[2][i];
349
+ err[i] = Math.sqrt(dr * dr + dg * dg + db * db) * SAL[i];
350
+ }
351
+ const errS = blur(err, W, H, radius * 0.6);
352
+ const g = Math.max(Math.round(radius * 1.4), 4);
353
+ const cand = [];
354
+ for (let cy = OY; cy < OY + RH; cy += g) {
355
+ for (let cx = OX; cx < OX + RW; cx += g) {
356
+ let best = -1, bx = cx, by = cy;
357
+ for (let y = cy; y < Math.min(cy + g, OY + RH); y++) {
358
+ for (let x = cx; x < Math.min(cx + g, OX + RW); x++) {
359
+ const v = errS[y * W + x];
360
+ if (v > best) {
361
+ best = v;
362
+ bx = x;
363
+ by = y;
364
+ }
365
+ }
366
+ }
367
+ cand.push([best, bx, by]);
368
+ }
369
+ }
370
+ cand.sort((p, q) => q[0] - p[0]);
371
+ const step = Math.max(radius * 0.8, 2.2 * k);
372
+ const tol = 0.09 + (radius / k) * 0.012;
373
+ const kept = [];
374
+ for (const [score, cx0, cy0] of cand.slice(0, quota * 6)) {
375
+ if (kept.length >= quota || score < 0.02)
376
+ break;
377
+ // trace along the structure until the colour under the pen changes
378
+ const i0 = cy0 * W + cx0;
379
+ const start = [R[0][i0], R[1][i0], R[2][i0]];
380
+ const pts = [[cx0 + 0.5, cy0 + 0.5]];
381
+ let x = cx0 + 0.5, y = cy0 + 0.5, dx = 0, dy = 0;
382
+ for (let n = 0; n < 14; n++) {
383
+ const i = Math.min(Math.max(Math.round(y), 0), H - 1) * W + Math.min(Math.max(Math.round(x), 0), W - 1);
384
+ const theta = 0.5 * Math.atan2(2 * jxy[i], jxx[i] - jyy[i]);
385
+ const coh = Math.hypot(jxx[i] - jyy[i], 2 * jxy[i]);
386
+ let ndx, ndy;
387
+ if (coh < 1e-6) {
388
+ if (n === 0) {
389
+ const a = rnd() * Math.PI;
390
+ ndx = Math.cos(a);
391
+ ndy = Math.sin(a);
392
+ }
393
+ else {
394
+ ndx = dx;
395
+ ndy = dy;
396
+ }
397
+ }
398
+ else {
399
+ // perpendicular to the dominant gradient
400
+ ndx = -Math.sin(theta);
401
+ ndy = Math.cos(theta);
402
+ }
403
+ if (dx * ndx + dy * ndy < 0) {
404
+ ndx = -ndx;
405
+ ndy = -ndy;
406
+ }
407
+ if (n > 0) {
408
+ ndx = 0.6 * ndx + 0.4 * dx;
409
+ ndy = 0.6 * ndy + 0.4 * dy;
410
+ const nn = Math.hypot(ndx, ndy) || 1;
411
+ ndx /= nn;
412
+ ndy /= nn;
413
+ }
414
+ const nx = clampX(x + step * ndx), ny = clampY(y + step * ndy);
415
+ const j = Math.min(Math.round(ny), H - 1) * W + Math.min(Math.round(nx), W - 1);
416
+ const diff = Math.max(Math.abs(R[0][j] - start[0]), Math.abs(R[1][j] - start[1]), Math.abs(R[2][j] - start[2]));
417
+ if (diff > (n >= 3 ? tol : tol * 1.8))
418
+ break;
419
+ x = nx;
420
+ y = ny;
421
+ dx = ndx;
422
+ dy = ndy;
423
+ pts.push([x, y]);
424
+ }
425
+ if (pts.length === 1)
426
+ pts.push([pts[0][0] + 0.6, pts[0][1]]);
427
+ // colour = mean photo colour under the whole footprint; keep only if it helps
428
+ const { x0, y0, bw, bh, mask } = footprint(pts);
429
+ let msum = 0;
430
+ const acc = [0, 0, 0];
431
+ for (let py = 0; py < bh; py++) {
432
+ for (let px = 0; px < bw; px++) {
433
+ const m = mask[py * bw + px];
434
+ if (m <= 0)
435
+ continue;
436
+ const i = (y0 + py) * W + x0 + px;
437
+ msum += m;
438
+ for (let ch = 0; ch < 3; ch++)
439
+ acc[ch] += T[ch][i] * m;
440
+ }
441
+ }
442
+ if (msum < 1)
443
+ continue;
444
+ const col = acc.map((v) => q8(v / msum));
445
+ let before = 0, after = 0;
446
+ for (let py = 0; py < bh; py++) {
447
+ for (let px = 0; px < bw; px++) {
448
+ const m = mask[py * bw + px];
449
+ if (m <= 0)
450
+ continue;
451
+ const i = (y0 + py) * W + x0 + px;
452
+ const a = m * ALPHA;
453
+ let e0 = 0, e1 = 0;
454
+ for (let ch = 0; ch < 3; ch++) {
455
+ const c = C[ch][i], t = T[ch][i];
456
+ const n1 = c * (1 - a) + col[ch] * a;
457
+ e0 += (c - t) ** 2;
458
+ e1 += (n1 - t) ** 2;
459
+ }
460
+ before += Math.sqrt(e0) * SAL[i] * m;
461
+ after += Math.sqrt(e1) * SAL[i] * m;
462
+ }
463
+ }
464
+ if (after >= before * 0.97)
465
+ continue;
466
+ for (let py = 0; py < bh; py++) {
467
+ for (let px = 0; px < bw; px++) {
468
+ const a = mask[py * bw + px] * ALPHA;
469
+ if (a <= 0)
470
+ continue;
471
+ const i = (y0 + py) * W + x0 + px;
472
+ for (let ch = 0; ch < 3; ch++)
473
+ C[ch][i] = C[ch][i] * (1 - a) + col[ch] * a;
474
+ }
475
+ }
476
+ kept.push({ sy: cy0, sx: cx0, pts, col });
477
+ }
478
+ // replay in a serpentine sweep, so the pen moves like a hand filling in
479
+ const band = g * 5;
480
+ kept.sort((p, q) => {
481
+ const bp = Math.floor(p.sy / band), bq = Math.floor(q.sy / band);
482
+ if (bp !== bq)
483
+ return bp - bq;
484
+ return bp % 2 === 0 ? p.sx - q.sx : q.sx - p.sx;
485
+ });
486
+ for (const s of kept) {
487
+ strokes.push({
488
+ color: `#${hex(s.col[0])}${hex(s.col[1])}${hex(s.col[2])}`,
489
+ pts: s.pts.map(([px, py]) => [Math.round(px * 100) / 100, Math.round(py * 100) / 100]),
490
+ });
491
+ }
492
+ used += kept.length;
493
+ if (b > schedule.length && kept.length < 10)
494
+ break;
495
+ }
496
+ let diff = 0, count = 0;
497
+ for (let i = 0; i < N; i++) {
498
+ if (!inside[i])
499
+ continue;
500
+ diff += Math.abs(C[0][i] - T[0][i]) + Math.abs(C[1][i] - T[1][i]) + Math.abs(C[2][i] - T[2][i]);
501
+ count += 3;
502
+ }
503
+ return { aspect, width: W, height: H, strokes, underpaintStrokes, meanAbsDiff: diff / Math.max(count, 1) };
504
+ }