@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.
- package/README.md +74 -2
- package/dist/constants.js +36 -0
- package/dist/drawing.js +124 -0
- package/dist/image.js +48 -0
- package/dist/index.js +17 -0
- package/dist/link.js +88 -0
- package/dist/paint.js +27 -0
- package/dist/painter.js +504 -0
- package/dist/server.js +260 -0
- package/dist/svg-path.js +238 -0
- package/package.json +47 -4
package/dist/painter.js
ADDED
|
@@ -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
|
+
}
|