js/pipeline.js
GitHub
/* 图片 → "一笔一笔平涂" 的绘制脚本管线(纯 JS,无 DOM 依赖,可在 Worker / Node 里跑) * * 流程: * 颜色量化 → 连通分区 → 碎块吸收 → 分区笔刷(每笔一个纯色,可按段取局部色) * → 深色细线(线稿)走骨架笔画 → 残差补笔修复(允许覆盖,越补越准) * * 输出的每一笔:rib 走廊多边形 + tp 笔尖轨迹 + 纯色 color + 裁剪组 c。 */(function (root) { 'use strict'; // 由 run() 从 opts 灌进来的开关(画笔形的工具函数在 run 外面,拿不到 opts) let CAPFIX = true; // ribbonProf 两端圆头扫正前方(老代码扫侧后方 = 端头缺半个圆 + 啃笔身) let PROF_FLAT_SAMPLE = true; // 细长层取色用"平头"变宽笔形(与 ribbon 的 noCap 一致) // ---------------- 基础工具 ---------------- function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); } function luma(c) { return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; } // 确定性伪随机:同一张图每次生成的手抖/弧度/宽窄完全一致(便于复现与测试) function rand01(a, b) { const x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453; return x - Math.floor(x); } // 就近串联(贪心最近邻):让笔画顺序"手走到哪画到哪",而不是满画布乱跳 function chainNN(items, posOf, sx, sy) { const n = items.length; const out = new Array(n); // 位置先算一次摊平存起来:原来在内层循环里每次调用 posOf(每次返回一个新数组), // 补笔一轮几千个补丁时这里就是最大的热点 const xs = new Float64Array(n), ys = new Float64Array(n); for (let i = 0; i < n; i++) { const p = posOf(items[i]); xs[i] = p[0]; ys[i] = p[1]; } // 点数太多时不做"全对"最近邻(O(n²)),直接按行列扫——回放观感一样(笔尖都在这片地方点点) if (n > 64) { const idx = Array.from({ length: n }, (_, i) => i); idx.sort((a, b) => ys[a] - ys[b] || xs[a] - xs[b]); for (let k = 0; k < n; k++) out[k] = items[idx[k]]; return out; } const used = new Uint8Array(n); let cx = sx, cy = sy; for (let k = 0; k < n; k++) { let best = -1, bd = Infinity; for (let i = 0; i < n; i++) { if (used[i]) continue; const dx = xs[i] - cx, dy = ys[i] - cy; const d = dx * dx + dy * dy; if (d < bd) { bd = d; best = i; } } used[best] = 1; out[k] = items[best]; cx = xs[best]; cy = ys[best]; } return out; } // 希尔伯特曲线序号:把二维位置映射成一维,按它排序 ⇒ "序列上相邻"就是"画面上相邻"。 // 补笔的几百上千个补丁笔如果按 (y,x) 排,同一个 y 带里 x 可以横跨整幅,笔尖就会来回横扫 // (实测笔间移动 308 万 px、≥200px 跳 5668 次);沿希尔伯特序走,笔尖是一片一片挪过去的。 function hilbertIndex(x, y, bits) { const n = 1 << bits; let xx = Math.min(n - 1, Math.max(0, x | 0)); let yy = Math.min(n - 1, Math.max(0, y | 0)); let d = 0; for (let s2 = n >> 1; s2 > 0; s2 >>= 1) { const rx = (xx & s2) > 0 ? 1 : 0; const ry = (yy & s2) > 0 ? 1 : 0; d += s2 * s2 * ((3 * rx) ^ ry); if (ry === 0) { if (rx === 1) { xx = n - 1 - xx; yy = n - 1 - yy; } const t = xx; xx = yy; yy = t; } } return d; } // sRGB(0..255) → Lab // f 提到外面:原来它在函数里,每次调用都要新建一个闭包(全图百万像素 = 百万个闭包) const LABF = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); // 热循环用这个版本:结果写进调用方的 out,不返回新数组(少一次分配/像素) function srgb2labInto(r, g, b, out) { r /= 255; g /= 255; b /= 255; r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; let X = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; let Y = (r * 0.2126 + g * 0.7152 + b * 0.0722); let Z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; X = LABF(X); Y = LABF(Y); Z = LABF(Z); out[0] = 116 * Y - 16; out[1] = 500 * (X - Y); out[2] = 200 * (Y - Z); return out; } function srgb2lab(r, g, b) { return srgb2labInto(r, g, b, [0, 0, 0]); } // ---------------- 多边形扫描线填充(偶奇),写入 bbox 局部掩膜 ---------------- function fillPoly(mask, mw, mh, pts) { const n = pts.length >> 1; if (n < 3) return; let ymin = Infinity, ymax = -Infinity; for (let i = 0; i < n; i++) { const y = pts[i * 2 + 1]; if (y < ymin) ymin = y; if (y > ymax) ymax = y; } ymin = Math.max(0, Math.floor(ymin)); ymax = Math.min(mh - 1, Math.ceil(ymax)); const xs = new Float64Array(n + 1); for (let y = ymin; y <= ymax; y++) { const yc = y + 0.5; let m = 0; for (let i = 0; i < n; i++) { const x0 = pts[i * 2], yy0 = pts[i * 2 + 1]; const j = (i + 1) % n; const x1 = pts[j * 2], yy1 = pts[j * 2 + 1]; if ((yy0 <= yc && yy1 > yc) || (yy1 <= yc && yy0 > yc)) { xs[m++] = x0 + (yc - yy0) / (yy1 - yy0) * (x1 - x0); } } if (m < 2) continue; const arr = xs.subarray(0, m); arr.sort(); for (let k = 0; k + 1 < m; k += 2) { let xa = Math.ceil(arr[k] - 0.5), xb = Math.floor(arr[k + 1] - 0.5); if (xb < 0 || xa >= mw) continue; if (xa < 0) xa = 0; if (xb >= mw) xb = mw - 1; const row = y * mw; for (let x = xa; x <= xb; x++) mask[row + x] = 1; } } } // ---------------- 覆盖率光栅(非零环绕 + 亚扫描线 + x 向精确积分) ---------------- // 网页端画的是"画布多边形(抗锯齿)∩ 1 位遮罩":一个像素只被笔形斜斜擦过 20%, // 网页端看起来还是半白的。管线要用同一口径判断"这一笔画上了没有"——否则会出现 // "管线以为画上了、网页端仍是白点"的错配(光栅用偶奇二值就漏掉这一类)。 // 返回每个像素被盖住的比例 0..1(累加写进 out,调用方负责清零)。 function fillPolyCov(out, mw, mh, pts, subY) { const n = pts.length >> 1; if (n < 3) return; let ymin = Infinity, ymax = -Infinity; for (let i = 0; i < n; i++) { const y = pts[i * 2 + 1]; if (y < ymin) ymin = y; if (y > ymax) ymax = y; } ymin = Math.max(0, Math.floor(ymin)); ymax = Math.min(mh - 1, Math.ceil(ymax)); const S = subY || 2; const wgt = 1 / S; const xs = new Float64Array(n + 1), dr = new Int8Array(n + 1), sub = new Int32Array(n + 1); for (let y = ymin; y <= ymax; y++) { const row = y * mw; for (let sy = 0; sy < S; sy++) { const yc = y + (sy + 0.5) / S; let m = 0; for (let i = 0; i < n; i++) { const x0 = pts[i * 2], yy0 = pts[i * 2 + 1]; const j = (i + 1) % n; const x1 = pts[j * 2], yy1 = pts[j * 2 + 1]; if ((yy0 <= yc && yy1 > yc) || (yy1 <= yc && yy0 > yc)) { xs[m] = x0 + (yc - yy0) / (yy1 - yy0) * (x1 - x0); dr[m] = yy1 > yy0 ? 1 : -1; m++; } } if (m < 2) continue; if (m === 2) { // 常见情形(扫描线只穿两条边):连排序都省了 if (xs[0] > xs[1]) { const tx = xs[0]; xs[0] = xs[1]; xs[1] = tx; const td = dr[0]; dr[0] = dr[1]; dr[1] = td; } sub[0] = 0; sub[1] = 1; } else { // 行内交点很少:插入排序,比 slice+sort 省掉每行一次的分配 for (let k = 0; k < m; k++) { const v = xs[k]; let j2 = k - 1; while (j2 >= 0 && xs[sub[j2]] > v) { sub[j2 + 1] = sub[j2]; j2--; } sub[j2 + 1] = k; } } let wind = 0, startX = 0; for (let k = 0; k < m; k++) { const kk = sub[k]; const prev = wind; wind += dr[kk]; if (prev === 0 && wind !== 0) startX = xs[kk]; else if (prev !== 0 && wind === 0) { let xa = startX, xb = xs[kk]; if (xb <= 0 || xa >= mw) continue; if (xa < 0) xa = 0; if (xb > mw) xb = mw; const ia = Math.floor(xa), ib = Math.floor(xb); if (ia === ib) { out[row + ia] += (xb - xa) * wgt; continue; } out[row + ia] += (ia + 1 - xa) * wgt; for (let x = ia + 1; x < ib; x++) out[row + x] += wgt; if (ib < mw) out[row + ib] += (xb - ib) * wgt; } } } } } // ---------------- 距离变换(3×4 chamfer,近似欧氏) ---------------- function distanceTransform(bin, w, h) { const INF = 1e9; const d = new Float32Array(w * h); for (let i = 0; i < w * h; i++) d[i] = bin[i] ? INF : 0; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; if (d[i] === 0) continue; let v = d[i]; if (x > 0) v = Math.min(v, d[i - 1] + 3); if (y > 0) v = Math.min(v, d[i - w] + 3); if (x > 0 && y > 0) v = Math.min(v, d[i - w - 1] + 4); if (x + 1 < w && y > 0) v = Math.min(v, d[i - w + 1] + 4); d[i] = v; } } for (let y = h - 1; y >= 0; y--) { for (let x = w - 1; x >= 0; x--) { const i = y * w + x; if (d[i] === 0) continue; let v = d[i]; if (x + 1 < w) v = Math.min(v, d[i + 1] + 3); if (y + 1 < h) v = Math.min(v, d[i + w] + 3); if (x + 1 < w && y + 1 < h) v = Math.min(v, d[i + w + 1] + 4); if (x > 0 && y + 1 < h) v = Math.min(v, d[i + w - 1] + 4); d[i] = v; } } for (let i = 0; i < w * h; i++) d[i] /= 3; return d; } // ---------------- 形态学:膨胀(方形核) ---------------- function dilate(bin, w, h, r) { if (r <= 0) return bin.slice(); // 行列两次一维膨胀(方形核) const tmp = new Uint8Array(w * h), out = new Uint8Array(w * h); for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { let v = 0; for (let k = -r; k <= r && !v; k++) { const xx = x + k; if (xx >= 0 && xx < w && bin[row + xx]) v = 1; } tmp[row + x] = v; } } for (let x = 0; x < w; x++) { for (let y = 0; y < h; y++) { let v = 0; for (let k = -r; k <= r && !v; k++) { const yy = y + k; if (yy >= 0 && yy < h && tmp[yy * w + x]) v = 1; } out[y * w + x] = v; } } return out; } // ---------------- 腐蚀(膨胀的对偶) ---------------- function erode(bin, w, h, r) { const inv = new Uint8Array(w * h); for (let i = 0; i < inv.length; i++) inv[i] = bin[i] ? 0 : 1; const d = dilate(inv, w, h, r); const out = new Uint8Array(w * h); for (let i = 0; i < out.length; i++) out[i] = d[i] ? 0 : 1; return out; } // ---------------- 折线简化(RDP):把"几乎笔直"的中轴拉成真正的直线 ---------------- // 细线的中轴是一像素一像素拐出来的,直接描出来就是坑坑洼洼的(飞机支柱、字母描边)。 function rdpSimplify(pts, n, tol) { if (n < 3) return { pts: Float64Array.from(pts.slice(0, n * 2)), n }; const keep = new Uint8Array(n); keep[0] = 1; keep[n - 1] = 1; const stack = [[0, n - 1]]; const tol2 = tol * tol; while (stack.length) { const seg = stack.pop(); const a = seg[0], b = seg[1]; if (b - a < 2) continue; const ax = pts[a * 2], ay = pts[a * 2 + 1]; const dx = pts[b * 2] - ax, dy = pts[b * 2 + 1] - ay; const L2 = dx * dx + dy * dy; let best = -1, bd = 0; for (let i = a + 1; i < b; i++) { const px = pts[i * 2], py = pts[i * 2 + 1]; let t = L2 > 0 ? ((px - ax) * dx + (py - ay) * dy) / L2 : 0; t = t < 0 ? 0 : (t > 1 ? 1 : t); const ex = px - (ax + dx * t), ey = py - (ay + dy * t); const d2 = ex * ex + ey * ey; if (d2 > bd) { bd = d2; best = i; } } if (bd > tol2 && best > 0) { keep[best] = 1; stack.push([a, best], [best, b]); } } const o = []; for (let i = 0; i < n; i++) if (keep[i]) o.push(pts[i * 2], pts[i * 2 + 1]); return { pts: Float64Array.from(o), n: o.length >> 1 }; } // ---------------- 形态学闭运算(先膨胀再腐蚀):把"被上层盖住的缺口"接上 ---------------- function closeMask(m, w, h, r) { if (r <= 0) return m.slice(); return erode(dilate(m, w, h, r), w, h, r); } // 圆盘核闭运算(八边形近似,走距离变换)。图层"完整形状"必须用它: // 方形核在斜边和拐角处会推出方角小凸起,回放画到那一层时字/飞机边上就会露出 // 几个方块"耳朵"(2026-10-18 用户点出的遗留)。同样的半径下圆盘是方形的子集, // 补出来的"被上层盖住的部分"更贴形状。 function closeMaskDisk(m, w, h, r) { if (r <= 0) return m.slice(); const n = w * h, R3 = 3 * r; // 距离变换是 3-4 通道度量:正交一步 = 3 const inv = new Uint8Array(n), dil = new Uint8Array(n), out = new Uint8Array(n); for (let i = 0; i < n; i++) inv[i] = m[i] ? 0 : 1; const dOut = distanceTransform(inv, w, h); // 到最近"本层像素"的距离 for (let i = 0; i < n; i++) dil[i] = dOut[i] <= R3 ? 1 : 0; const dIn = distanceTransform(dil, w, h); // 到最近"膨胀后仍在外面"的距离 for (let i = 0; i < n; i++) out[i] = dIn[i] > R3 ? 1 : 0; return out; } // ---------------- 连通域(给定标签图,4 连通,只保留"同标签"连通) ---------------- function compsOfLabels(labels, W, H) { const compOf = new Int32Array(W * H).fill(-1); const pix = new Uint32Array(W * H); const comps = []; const q = new Int32Array(W * H); let np = 0; for (let s = 0; s < W * H; s++) { if (compOf[s] >= 0) continue; const lab = labels[s]; const id = comps.length; let head = 0, tail = 0; q[tail++] = s; compOf[s] = id; const start = np; let x0 = W, y0 = H, x1 = -1, y1 = -1; while (head < tail) { const p = q[head++]; pix[np++] = p; const x = p % W, y = (p - x) / W; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; if (x > 0 && compOf[p - 1] < 0 && labels[p - 1] === lab) { compOf[p - 1] = id; q[tail++] = p - 1; } if (x + 1 < W && compOf[p + 1] < 0 && labels[p + 1] === lab) { compOf[p + 1] = id; q[tail++] = p + 1; } if (y > 0 && compOf[p - W] < 0 && labels[p - W] === lab) { compOf[p - W] = id; q[tail++] = p - W; } if (y + 1 < H && compOf[p + W] < 0 && labels[p + W] === lab) { compOf[p + W] = id; q[tail++] = p + W; } } comps.push({ label: lab, start, count: np - start, x0, y0, x1, y1 }); } return { compOf, pix, comps }; } // ---------------- 细化(Zhang-Suen,4/8 连通骨架) ---------------- // 大掩膜上很费时间(黑框的外框就是整幅),所以:① 每轮只扫"还有前景的行区间"; // ② A 的八邻域转折数用内联比较算(原来每个像素分配一个 9 元素数组,GC 就吃掉几十秒)。 function thin(bin, w, h) { const im = bin.slice(); const del = new Int32Array(w * h); let changedTotal = false; let ylo = 1, yhi = h - 2; const t0 = Date.now(); let iters = 0, visits = 0; for (let iter = 0; iter < 100; iter++) { iters++; let changed = false; for (let step = 0; step < 2; step++) { let nd = 0; let nyLo = h, nyHi = 0; for (let y = ylo; y <= yhi; y++) { const row = y * w; let rowHit = 0; visits += w - 2; for (let x = 1; x < w - 1; x++) { const i = row + x; if (!im[i]) continue; const p2 = im[i - w], p3 = im[i - w + 1], p4 = im[i + 1], p5 = im[i + w + 1], p6 = im[i + w], p7 = im[i + w - 1], p8 = im[i - 1], p9 = im[i - w - 1]; const B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; if (B < 2 || B > 6) continue; const A = (p2 === 0 && p3 === 1 ? 1 : 0) + (p3 === 0 && p4 === 1 ? 1 : 0) + (p4 === 0 && p5 === 1 ? 1 : 0) + (p5 === 0 && p6 === 1 ? 1 : 0) + (p6 === 0 && p7 === 1 ? 1 : 0) + (p7 === 0 && p8 === 1 ? 1 : 0) + (p8 === 0 && p9 === 1 ? 1 : 0) + (p9 === 0 && p2 === 1 ? 1 : 0); if (A !== 1) continue; if (step === 0) { if (p2 * p4 * p6 !== 0) continue; if (p4 * p6 * p8 !== 0) continue; } else { if (p2 * p4 * p8 !== 0) continue; if (p2 * p6 * p8 !== 0) continue; } del[nd++] = i; rowHit = 1; } if (rowHit) { if (y < nyLo) nyLo = y; if (y > nyHi) nyHi = y; } } if (nd) { for (let k = 0; k < nd; k++) im[del[k]] = 0; changed = true; } // 下一轮只需要扫"这一轮有删改的行 ±1" if (nyHi >= nyLo) { ylo = Math.max(1, nyLo - 1); yhi = Math.min(h - 2, nyHi + 1); } } if (changed) changedTotal = true; else break; } if (globalThis.__thinStats) { const g = globalThis.__thinStats; g.calls++; g.ms += Date.now() - t0; g.iters += iters; g.visits += visits; const tag = globalThis.__thinTag || '?'; g.by = g.by || {}; const b = g.by[tag] || (g.by[tag] = { calls: 0, ms: 0, visits: 0, iters: 0 }); b.calls++; b.ms += Date.now() - t0; b.visits += visits; b.iters += iters; if (Date.now() - t0 > 300) g.big.push([w, h, iters, visits, Date.now() - t0]); } return { sk: im, changed: changedTotal }; } // ---------------- 骨架最长路径(两次 BFS) ---------------- function longestPath(sk, w, h) { const idx = []; for (let i = 0; i < w * h; i++) if (sk[i]) idx.push(i); if (!idx.length) return null; const dist = new Int32Array(w * h).fill(-1); const par = new Int32Array(w * h).fill(-1); const q = new Int32Array(idx.length); const bfs = (s) => { dist.fill(-1); par.fill(-1); let head = 0, tail = 0, far = s; q[tail++] = s; dist[s] = 0; while (head < tail) { const p = q[head++]; const x = p % w, y = (p - x) / w; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const xx = x + dx, yy = y + dy; if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue; const n = yy * w + xx; if (!sk[n] || dist[n] >= 0) continue; dist[n] = dist[p] + 1; par[n] = p; if (dist[n] > dist[far]) far = n; q[tail++] = n; } } } return far; }; const a = bfs(idx[0]); const b = bfs(a); const path = []; let cur = b; while (cur >= 0) { path.push(cur); cur = par[cur]; } const pts = new Float64Array(path.length * 2); for (let i = 0; i < path.length; i++) { const p = path[path.length - 1 - i]; pts[i * 2] = p % w; pts[i * 2 + 1] = (p - (p % w)) / w; } return { pts, len: path.length }; } // ---------------- 走廊多边形(沿路径左右偏移) ---------------- function ribbon(path, n, halfW, noCap) { // path: flat [x0,y0,...](绝对坐标), 返回 flat 多边形 if (n === 1) { const out = []; for (let k = 0; k < 13; k++) { const a = k / 13 * Math.PI * 2; out.push(path[0] + halfW * Math.cos(a), path[1] + halfW * Math.sin(a)); } return out; } const dir = new Float64Array(n * 2); for (let i = 0; i < n; i++) { // 相邻段拼接处会有重复点:直接用 (i-1,i+1) 求方向会得到 0 向量 ⇒ 那个顶点被掐细, // 光栅出来就是一个三角形缺口(就是"铺色中间那些白三角"的老根)。 let a = i - 1 < 0 ? 0 : i - 1, b = i + 1 > n - 1 ? n - 1 : i + 1; const xi = path[i * 2], yi = path[i * 2 + 1]; while (a > 0 && path[a * 2] === xi && path[a * 2 + 1] === yi) a--; while (b < n - 1 && path[b * 2] === xi && path[b * 2 + 1] === yi) b++; let dx = path[b * 2] - path[a * 2], dy = path[b * 2 + 1] - path[a * 2 + 1]; if (dx === 0 && dy === 0) { dx = 1; dy = 0; } const ln = Math.hypot(dx, dy) || 1; dir[i * 2] = dx / ln; dir[i * 2 + 1] = dy / ln; } const left = [], right = []; for (let i = 0; i < n; i++) { const dx = dir[i * 2], dy = dir[i * 2 + 1]; const nx = -dy, ny = dx; left.push(path[i * 2] + halfW * nx, path[i * 2 + 1] + halfW * ny); right.push(path[i * 2] - halfW * nx, path[i * 2 + 1] - halfW * ny); } const out = []; out.push(...left); if (noCap) { // 取色用的"平头"笔形:不含圆头,免得把两端的邻色平均进来 for (let i = n - 1; i >= 0; i--) out.push(right[i * 2], right[i * 2 + 1]); return out; } // 末端圆头:从"左侧法线"绕过**正前方**扫到"右侧法线"(角度递减)。 // ⚠️ 老代码是 `ea + πk/5`,扫的是**后半侧**——笔形在两端自己折回去, // 非零环绕在那个折角里正负相抵 ⇒ 绕数归零 ⇒ 画出来是个破洞。 // 短笔(笔宽 > 长度)整个就是折角,"铺色中间那些白斑/白三角"的根就在这里。 const ex = path[(n - 1) * 2], ey = path[(n - 1) * 2 + 1]; const ea = Math.atan2(dir[(n - 1) * 2 + 1], dir[(n - 1) * 2]); for (let k = 1; k <= 4; k++) { const a = ea + Math.PI / 2 - Math.PI * k / 5; out.push(ex + halfW * Math.cos(a), ey + halfW * Math.sin(a)); } for (let i = n - 1; i >= 0; i--) out.push(right[i * 2], right[i * 2 + 1]); // 起点圆头:从"右侧法线"绕过**正后方**扫到"左侧法线" const sa = Math.atan2(dir[1], dir[0]); for (let k = 1; k <= 4; k++) { const a = sa - Math.PI / 2 - Math.PI * k / 5; out.push(path[0] + halfW * Math.cos(a), path[1] + halfW * Math.sin(a)); } return out; } // 变宽走廊:halfs[i] 是第 i 个点处的半宽(起笔细→中间粗→收笔细,像有笔压) // noCap:取色用的"平头"笔形(与 ribbon 的同名参数一致,免得把两端邻色平均进来) function ribbonProf(path, n, halfs, noCap) { if (n === 1) return ribbon(path, 1, halfs[0]); const dir = new Float64Array(n * 2); for (let i = 0; i < n; i++) { let a = i - 1 < 0 ? 0 : i - 1, b = i + 1 > n - 1 ? n - 1 : i + 1; const xi = path[i * 2], yi = path[i * 2 + 1]; while (a > 0 && path[a * 2] === xi && path[a * 2 + 1] === yi) a--; while (b < n - 1 && path[b * 2] === xi && path[b * 2 + 1] === yi) b++; let dx = path[b * 2] - path[a * 2], dy = path[b * 2 + 1] - path[a * 2 + 1]; if (dx === 0 && dy === 0) { dx = 1; dy = 0; } const ln = Math.hypot(dx, dy) || 1; dir[i * 2] = dx / ln; dir[i * 2 + 1] = dy / ln; } const left = [], right = []; for (let i = 0; i < n; i++) { const dx = dir[i * 2], dy = dir[i * 2 + 1]; const nx = -dy, ny = dx, hw = halfs[i]; left.push(path[i * 2] + hw * nx, path[i * 2 + 1] + hw * ny); right.push(path[i * 2] - hw * nx, path[i * 2 + 1] - hw * ny); } const out = []; out.push(...left); if (noCap) { // 取色用:不含圆头(平头),与 ribbon(path,n,halfW,true) 同式 for (let i = n - 1; i >= 0; i--) out.push(right[i * 2], right[i * 2 + 1]); return out; } // 两端圆头:与 ribbon 完全同式(end 绕**正前方**、start 绕**正后方**)。 // ⚠️ 老代码是 `ea + πk/5` / `sa - πk/5`:扫的是**侧后方**,端头前半球整块盖不住, // 还在笔身上啃出一个三角缺口——每一支细长层的笔两端各一个,接缝处两个缺口拼成一条 // 带子(S 那一层 11 支笔漏 995px、飞机 16 支漏 3099px,全靠每层自检的矩形补丁兜底, // 回放里就是"没涂满 + 补丁乱飞")。capFix=false 可以退回老行为做对照。 const ex = path[(n - 1) * 2], ey = path[(n - 1) * 2 + 1], hw1 = halfs[n - 1]; const ea = Math.atan2(dir[(n - 1) * 2 + 1], dir[(n - 1) * 2]); for (let k = 1; k <= 4; k++) { const a = CAPFIX ? ea + Math.PI / 2 - Math.PI * k / 5 : ea + Math.PI * k / 5; out.push(ex + hw1 * Math.cos(a), ey + hw1 * Math.sin(a)); } for (let i = n - 1; i >= 0; i--) out.push(right[i * 2], right[i * 2 + 1]); const hw0 = halfs[0]; const sa = Math.atan2(dir[1], dir[0]); for (let k = 1; k <= 4; k++) { const a = CAPFIX ? sa - Math.PI / 2 - Math.PI * k / 5 : sa - Math.PI * k / 5; out.push(path[0] + hw0 * Math.cos(a), path[1] + hw0 * Math.sin(a)); } return out; } // 把同一区块来回的几档"并成一条连续走笔":各档的走廊多边形并成一个非零环绕多边形。 // 做法是"链式钥匙孔桥接"——每个环的入口取离上一个环入口最近的顶点(桥接边很短,就在 // 拐弯那一头),进出各走一遍 ⇒ 环绕数相抵、桥接边不产生面积,填充结果就是各环的并集。 // 两个坑:① 各环方向必须统一——方向相反的两环在重叠处环绕数相加会变成 0(挖洞), // 而同一区块相邻两档本来就重叠 38%;② 桥接只能连"相邻"的环,连到远处的锚点会在 // 区块中间拉出一条 1.4px 的描边线(同色看不出来,但在渐变区块上会成一道痕)。 function mergeLoops(loops) { const m = loops.length; const out = []; if (!m) return out; const rot = []; let sign = 0; for (let i = 0; i < m; i++) { const L = loops[i]; const n = L.length >> 1; if (n < 3) continue; let a2 = 0; for (let k = 0; k < n; k++) { const j = (k + 1) % n; a2 += L[k * 2] * L[j * 2 + 1] - L[j * 2] * L[k * 2 + 1]; } const s = a2 >= 0 ? 1 : -1; if (!sign) sign = s; const flip = s !== sign; const arr = new Float64Array(n * 2); for (let k = 0; k < n; k++) { const src = (flip ? n - 1 - k : k) * 2; arr[k * 2] = L[src]; arr[k * 2 + 1] = L[src + 1]; } rot.push(arr); } if (!rot.length) return out; // 每个环的入口顶点 = 离上一个环入口最近的那个(把顶点数组旋转到从它开始) for (let i = 1; i < rot.length; i++) { const L = rot[i], n = L.length >> 1; const px = rot[i - 1][0], py = rot[i - 1][1]; let bj = 0, bd = Infinity; for (let j = 0; j < n; j++) { const d = (L[j * 2] - px) * (L[j * 2] - px) + (L[j * 2 + 1] - py) * (L[j * 2 + 1] - py); if (d < bd) { bd = d; bj = j; } } if (bj) { const r = new Float64Array(n * 2); for (let j = 0; j < n; j++) { const k = (j + bj) % n; r[j * 2] = L[k * 2]; r[j * 2 + 1] = L[k * 2 + 1]; } rot[i] = r; } } for (let i = 0; i < rot.length; i++) { const L = rot[i]; if (i) out.push(L[0], L[1]); // 桥入:上一个环的入口 → 本环入口 for (let k = 0; k < L.length; k++) out.push(L[k]); out.push(L[0], L[1]); // 显式闭合本环 } for (let i = rot.length - 2; i >= 0; i--) out.push(rot[i][0], rot[i][1]); // 逐级桥回(与桥入正反相抵) return out; } // ---------------- 轻度平滑(仅用于分块:median3 + 两次 box3) ---------------- function med3(src, w, h) { const out = new Uint8Array(src.length); const win = new Uint8Array(9); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let n = 0; for (let dy = -1; dy <= 1; dy++) { const yy = y + dy; if (yy < 0 || yy >= h) continue; const row = yy * w; for (let dx = -1; dx <= 1; dx++) { const xx = x + dx; if (xx < 0 || xx >= w) continue; win[n++] = src[row + xx]; } } // 9 元以内插入排序 for (let i = 1; i < n; i++) { const v = win[i]; let j = i - 1; while (j >= 0 && win[j] > v) { win[j + 1] = win[j]; j--; } win[j + 1] = v; } out[y * w + x] = win[n >> 1]; } } return out; } function box3(src, w, h) { const out = new Uint8Array(src.length); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let acc = 0, cnt = 0; for (let dy = -1; dy <= 1; dy++) { const yy = y + dy; if (yy < 0 || yy >= h) continue; const row = yy * w; for (let dx = -1; dx <= 1; dx++) { const xx = x + dx; if (xx < 0 || xx >= w) continue; acc += src[row + xx]; cnt++; } } out[y * w + x] = (acc / cnt + 0.5) | 0; } } return out; } // ---------------- 1px 柔边(3×3 二项核,模拟画笔落笔边缘) ---------------- function feather(m, w, h) { const out = new Float32Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let acc = 0; for (let dy = -1; dy <= 1; dy++) { const yy = y + dy; if (yy < 0 || yy >= h) continue; const wy = dy === 0 ? 2 : 1; for (let dx = -1; dx <= 1; dx++) { const xx = x + dx; if (xx < 0 || xx >= w) continue; if (m[yy * w + xx]) acc += wy * (dx === 0 ? 2 : 1); } } out[y * w + x] = acc / 16; } } return out; } // ---------------- k-means(Lab 空间) ---------------- function kmeans(samples, K, iters, seed) { let s = seed >>> 0; const rnd = () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; const n = samples.length / 3; const cen = new Float64Array(K * 3); for (let k = 0; k < K; k++) { const i = Math.floor(rnd() * n) * 3; cen[k * 3] = samples[i]; cen[k * 3 + 1] = samples[i + 1]; cen[k * 3 + 2] = samples[i + 2]; } const asg = new Int32Array(n); for (let it = 0; it < iters; it++) { let moved = 0; for (let i = 0, i3 = 0; i < n; i++, i3 += 3) { const L = samples[i3], A = samples[i3 + 1], B = samples[i3 + 2]; let best = 0, bd = Infinity; for (let k = 0, k3 = 0; k < K; k++, k3 += 3) { const dL = L - cen[k3], dA = A - cen[k3 + 1], dB = B - cen[k3 + 2]; const d = dL * dL + dA * dA + dB * dB; if (d < bd) { bd = d; best = k; } } if (asg[i] !== best) { asg[i] = best; moved++; } } const sum = new Float64Array(K * 3), cnt = new Int32Array(K); for (let i = 0; i < n; i++) { const k = asg[i]; sum[k * 3] += samples[i * 3]; sum[k * 3 + 1] += samples[i * 3 + 1]; sum[k * 3 + 2] += samples[i * 3 + 2]; cnt[k]++; } for (let k = 0; k < K; k++) { if (cnt[k] >= 4) { cen[k * 3] = sum[k * 3] / cnt[k]; cen[k * 3 + 1] = sum[k * 3 + 1] / cnt[k]; cen[k * 3 + 2] = sum[k * 3 + 2] / cnt[k]; } } if (moved < n * 0.001) break; } return { cen, asg }; } // ---------------- 主流程 ---------------- const CFG = { // --- 图层栈(一个对象 = 一层)--- objMergeCd: 70, // 相邻且颜色差 < 它 ⇒ 同一层(蓝底的多条色带、黑框的四条边并成一整层) lineTol: 0.9, // 细线中轴的拉直容差(像素):直线段必须一笔到底、笔直 layerCheck: true, // 每一层刷完当场自检(不只底色层):这一层自己有没有漏 sweepSeal: true, // 铺色"一次过":落笔前先算这一层所有档的联合覆盖,缝就地加宽(不新增笔画) sealIter: 3, // 加宽最多迭代几轮 sealMin: 1200, // 层外框小于它就不做(小层的缝交给每层自检,成本更省) lineSeal: true, // 细长层同样"一次过":中轴笔形没盖到的凸起就地加宽(不新增笔画) capFix: true, // ribbonProf 端头扫正前方(见文件头 CAPFIX 注释) thinNoErode: true, // 细长层取色不缩边(否则整层一个均值色,边界那圈刷平) rimPass: true, // 每层画完,把自己那圈 1~2px 边界用**局部色**补一遍(治"边不像") rimLinearOnly: true, // 只对细长层做(大块平涂层的边由残差收拾,做了会多出几千笔) bleedOrder: true, // 出界笔只许压在"已经画过"的对象上(否则那圈颜色留在别人身上) profFlatSample: true, // 细长层取色用平头变宽笔形 occlude: true, objGrowF: 0.55, // 图层"完整形状"往外补的系数(见 closeMaskDisk 的注释;0.8 会伸出圆头小凸起) // 每层补出"被上层盖住的部分"(形状闭合推断);照片类纹理太碎时关掉更干净 colorPass: true, // 同色合并成"一趟":互不遮挡的层之间顺序自由,优先挑"跟上一笔同色、 // 且离笔尖近"的那层——同一支笔一路把同色的东西画完(原来是按面积大小排, // 笔尖在整幅上按"大块→小块"来回跳,一个字一层) colorPassTol: 36, // "算同色"的色差上限(曼哈顿,约 12/通道) colorPassArea: 4, // 同色还不够:面积也要在同一个量级(大/小之比 ≤ 它)才并进这一趟。 // 否则"整片蓝底"会把画面另一头的零星蓝点拉进同一趟,笔尖满屏飞 // (实测这一条同时把残差补笔的落点扰动压小) edgeSnap: false, // 边界按颜色重判(**默认关**):三张示例实测它净亏——照片可见差异 // 3.07% → 2.57%、AIR MAIL 5.23% → 2.46%,只有海报整幅靠"把背景纸纹 // 噪点刷成蓝底"赚回 0.1 个点。它当初是在替"调色板太小"擦屁股 // (色数提上去之后同样的问题不再出现),而它会把真的细描边啃掉一圈。 edgeSnapTol: 6, // 重判的容差(曼哈顿):比本侧更近这么多才改判 edgeSnapIter: 3, // 重判最多剥几层(扫描件的过渡带可能有 2~3px 宽) edgeSnapBad: 40, // 只修"真正的错分":自己的区色离原图色 ≥ 它(曼哈顿)才允许改判。 // 抗锯齿过渡带的区色本来就是混色、已经贴近原图(实测海报上这类像素 // 有 1500+ 个),推成纯色反而把字的边推错——文字区可见差异 2.46% → 5.23% edgeSnapRatio: 0.62, // 相对门槛:邻居的色距必须 ≤ 本侧的 62% 才改判(0 = 关,回到纯绝对容差)。 // 描边外缘那圈"深色/混色"像素离两侧都远,绝对距离常常"蓝底更近"⇒ // 判走之后红描边就被啃掉一圈(实测 AIR MAIL 里 2200 像素被翻) edgeSnapBand: 2, // 边界带膨胀半径(越大越慢;3 会把更多过渡像素拉进重判) edgeRamp: true, // 抗锯齿过渡带按两侧颜色并进去(不让它自己成层被涂成灰条) edgeRampR: 4, // 过渡带判据的搜索半径(像素) maskMajority: 5, // 掩膜边界去锯齿的窗口(5 = 5x5 多数表决;0 = 关): // 照片类靠它把边缘的锯齿抹平(可见差异 17.5% → 16.9%),海报类只改百来个像素 maskMajThr: 17, // 压倒性多数的门槛(25 格里的格数;越大越保守) maxSide: 1024, colors: 32, mergeThr: 9.0, // Lab 距离,小于它合并(对渐变友好) minArea: 120, // 小于它的小碎块并入邻块(越小分区越细,笔数越多) targetSec: 240, // 回放总时长(1× 速度) bleedFirst: 0, // 每个区块第一遍"铺底"允许外扩的像素(0 = 严格裁形,保真优先) resThick: 30, // 补笔忽略"又大又厚"的错误块(那是平涂风格本身的平滑差异)。 // 门槛是**真实厚度**(2×最大内切半径,见 compThick):8 的时候 // 连字母/飞机那圈深色描边(真厚 8~14px)都被当"大片"跳过, // 边就一直差着;30 之后海报可见差异 2.01%→1.45%、明显误差 0.35%→0.04%。 inkLuma: 195, coverOk: 0.86, residual: [30, 24, 18, 14, 11, 9, 7], absorbCd: 60, // 碎块并入邻块的颜色阈值(Lab/RGB 距离) texMax: 1500, // "纹理块"面积上限:小于它 + 弱对比 ⇒ 判为纸纹/噪点,并进邻块且不画 texCd: 130, // "纹理块"的对比上限(调色板距离):超过它才当"真特征"保留 inkMinCd: 100, // 细线要画的对比下限:比它弱的"线"其实是纹理,不写 resHalfA: 0.95, resHalfB: 0.7, resMin: 1.0, // 补笔半宽 = dmed*A + B resSplit: 18, // 补丁按颜色二分的停止阈值(越小补得越准、补丁越多) resPatchMax: 128, // 小于这么多像素的错误块直接并进补丁笔(不走骨架笔): // 补丁笔的掩膜是 1 位硬遮罩、口径比骨架笔更准,而且能合并——实测比走骨架笔 // 笔数少 27%、可见差异还更低 maxStrokes: 26000, // 排定轮次的笔数上限(照片类纹理太碎时保护;尾巴轮另有倍数上限) returnKeys: true, // 生成关键帧(网页端拖动进度用) keyFrames: 5, resStop: 0, // 补笔收益阈值(0 = 不提前收手,把 7 轮补完) resTail: 20, // 排定轮次之后的"自适应续补"轮数(按当前阈值一路补到收敛) // 老版本是被固定档位+预算砍掉的:海报停在 20%、照片停在 29%,其实都没收敛 resTailStep: 0.6, // 续补每轮把阈值再降多少 resTailMin: 3, // 续补的阈值下限(再细下去补的是平涂风格本身,没意义) resTailStop: 0.25, // 续补收益(可见差异百分点)低于它就收手 resTailMax: 3, // 续补最多把笔数预算用到排定轮的几倍 localColor: true, // 主笔触按段取局部纯色(更贴近原图渐变) segF: 3, // 取色分段长 = 笔半宽 × 它(越小越贴局部色,笔数越多) segMin: 30, // 取色分段长下限(像素) segJoinCd: 8, // 相邻两段颜色差 < 它就连成一笔(曼哈顿):越大笔越省、但会把渐变抹平 // 实测 42(≈14/通道)会把长笔跨过的渐变抹平——海报平坦区因此留下 ±8 的误差, // 收到 8(≈3/通道)后主笔多 60%,可见差异直接降 5 个百分点 dilMain: 3, // 主笔允许溢出 3px(自然覆盖,后画的会盖住) dilInk: 0, // 勾线层不向外扩边:它现在画在铺色之后,扩边会留下压不回去的深色描边 // --- "像人在画"的相关参数 --- sketch: false, // 起稿(默认关):勾出来的灰线随即被铺色盖掉,对成品零贡献, // 回放开头却是一段"乱线"——用户 2026-10-18 拍板删掉;要打形观感再打开 sketchMax: 56, // 起稿最多勾多少条轮廓 sketchHalf: 1.5, // 起稿线半宽(乘 strokeScale) sketchMix: 0.45, // 起稿色 = 局部目标色 与 铅笔灰 的混合比例 bleedP: 0.22, // 多少比例的铺色笔允许"画出界",后面再被覆盖修回 bleedR: 2, // 出界的像素半径 featherRaster: false, // 光栅口径:false = 硬边(和网页端一致);true = 羽化(只用于对比实验) organic: true, // 单笔形态人性化:弧线 + 手抖 + 宽窄起伏 holeFill: true, // 收尾补漏:把"还是没画上"的小洞逐个补掉(残差补笔有"太小/太散不补"的门槛) holeT: 28, // 补漏的判定阈值(单通道差) holeWhite: 244, // "看着是白的"门槛(这一类补验:原图明显更深的) holePaper: 254, // "纸面根本没涂上"门槛(三通道都 ≥ 它 = 那像素还是白纸),原图比它深 8 以上就补 holeMax: 420, // 只补这么大的洞(像素数);更大的块属于平涂风格化,补了会花 holeRounds: 3, // 收尾补漏 B 类迭代几轮(每轮只补总误差量最大的那批) // --- 接缝修正(画布上"不该有的台阶":两块各自差 5~7、加起来是一道肉眼可见的缝)--- baseCheck: true, // 底色层刷完当场自检:没盖到的楔形/边角立刻补上(底层一次铺满) seamFix: true, // 开关 seamT: 7, // 画布这一步的台阶达到它就怀疑(单通道) seamSmooth: 6, // 而原图这一步的台阶不超过它(= 原图这里本来是平滑的) seamR: 1, // 沿缝两侧各看几像素 seamE: 3, // 缝带里"差得超过它"的像素才补(补上目标色,台阶就散成原图的渐变) dropDead: true, // 收尾删掉"一笔都没留下"的笔(像素级不影响成品,回放里少一次多余动作) holeSeg: 48, // 补漏一笔画多长(越长笔数越省) holeSplit: 18, // 补漏的一块错误按颜色二分的停止阈值(0 = 不切): // 一块里深浅混着(渐变带、两条细线挨着)时,用一个均值色补会把浅的那半刷深 // (飞机边上那些"深色楔子"),而且下一轮又被判成错、再补一遍 holeBatchMax: 200, // 小于这么大的洞并进"补丁笔"(掩膜互不相交 ⇒ 合并像素等价,笔数省) patchShift: 3, // 补笔合并的颜色分桶位移(3 = 每桶 8 个色阶):同格同桶的散点合成一笔 patchTol: 12, // 自适应色差合并(0 = 关,按固定色桶并):>0 时跟"组均值色"比,曼哈顿上限 // 固定色桶必须卡在可见阈值以内,合并比只有 1.7:1;改成跟组均值色比之后 // 同样的补笔轮次少用 37% 的笔数 texResSkip: 0, // 残差补笔跳过"纹理像素"(1 = 跳过;0 = 连纹理一起补)。 // 主笔仍然不画纸纹(铺色干净),但残差补笔用细笔把颗粒/毛发点回来: // 照片可见差异 25.5% → 19.6%,海报 12.4% → 10.3% resSkipInk: true, // 残差不碰"细线像素"(它们留给最后的细线/题字阶段) resSkipRamp: true, // 残差不碰"抗锯齿斜坡像素" resRampSkip: false, // 抗锯齿斜坡不补:目标色正好夹在相邻两个区块的纯色之间(渲染毛边,不是内容)。 // 补它 = 用一堆点去描毛边(插画 5000 笔里大半是这个),不补 = 干净的硬边 rampTol: 30, // 斜坡判据的容差(到"两色连线"的曼哈顿距离) resFlatTol: 0, // 局部起伏 ≤ 它 = 平滑区(渐变/平色) resFlatErr: 26, // 平滑区里误差 ≤ 它的不补:那是采样台阶,用点去描只会描出一片鬼影 resSkipRampPhoto: false,// 照片类不启用斜坡判据(照片没有"纯色区块",判不准) coverMin: 0.7, // 光栅判"这一笔画上了"所需的覆盖率(网页端半透明毛边按没画上算) coverSubY: 2, // 覆盖率用的亚扫描线数(1 = 只看像素中线,够抓白点且更快) patchCell: 2048, // 补笔合并的空间格子(像素);远大于图幅 = 不分格,只靠 patchShift/patchBox 控制 patchBox: 128, // 合并后一笔的外框上限(像素):外框越大,网页端每笔要清/合的画布越大、内存越费 patchFill: 0, // 外框最小填充率(0 = 关,按"外框边长"卡):>0 时改按密度卡,散点能并得更多 // --- 交界过渡带(实测负收益,默认关;见 README 的坑 5)--- edgeFill: false, // 沿色块交界补"中间色"细笔:边界像素在目标图里大多本来就是纯色,补了反而更差 edgeT: 10, // 交界过渡带的阈值(单通道差) edgeCd: 60, // 只补"两边颜色差得远"的交界(RGB 曼哈顿距离) edgeMax: 3200, // 最多补多少处 edgeSeg: 40, // 过渡带一笔多长 // --- 笔形/笔宽(风格预设会覆盖) --- bwF: 0.16, bwMin: 9, bwMax: 46, coverOv: 0.62, // 笔宽 = 块厚×bwF,限幅;档间重叠 coverOv joinPass: true, // 同一区块来回的几档接成一条连续走笔("像人一笔到底",不是一档一笔) joinGap: 3, // 连笔“接得上”的上限(像素):两段之间跳得比它远就不能并 joinCd: 18, // 连笔的颜色上限(曼哈顿):档间色差超过它就另起一笔。 // 实测 18(约 6 级/通道)是质量拐点:三张示例的成品指标都比不连笔更好; // 24 以上开始把区块内的颜色变化抹平(海报可见差异 21.6% → 22.5%) taperLo: 0.86, taperAmp: 0.20, taperWob: 0.08, // 笔压:两头细、中间粗、再抖一点 // --- 起稿("真起稿"会画得不准) --- sketchGrid: 6, // 起稿路径按这个网格随手简化(越大越不准、越像真起稿) sketchOver: 5, // 起稿两端的出锋(真起稿会画过头一点) // --- 自动/档位 --- autoColors: true, // colors 为 0 时按纹理强度自动选色数(照片 32~36,插画 24) style: 'marker', // 笔触风格:marker(平涂马克笔)/ pencil(铅笔速写)/ oil(油画厚涂) detail: 1, // 细节档:0 快看 / 1 标准 / 2 慢工出细活 }; function regionMaskOf(compOf, W, compId, x0, y0, x1, y1) { const w = x1 - x0 + 1, h = y1 - y0 + 1; const m = new Uint8Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) m[y * w + x] = compOf[(y + y0) * W + (x + x0)] === compId ? 1 : 0; } return { w, h, m }; } async function run(px, W, H, opts, onProgress) { opts = Object.assign({}, CFG, opts || {}); CAPFIX = opts.capFix !== false; PROF_FLAT_SAMPLE = opts.profFlatSample !== false; // 细节档:只影响"补笔要补多细",不影响主笔触的取色逻辑 if (opts.detail === 0) { Object.assign(opts, { residual: [30, 24, 18, 14], holeT: 40, resMin: 1.2, sketchMax: 40, edgeFill: false }); } else if (opts.detail === 2) { Object.assign(opts, { residual: [30, 24, 18, 14, 11, 9, 7, 5.5], holeT: 22, resMin: 0.8, resHalfA: 0.85, resHalfB: 0.55, sketchMax: 76, sketchGrid: 5, edgeT: 8 }); } // 笔触风格:只改"笔形/笔宽/重叠/起稿笔感",覆盖率的规则不动(保真优先) if (opts.style === 'pencil') { Object.assign(opts, { bwF: 0.10, bwMin: 7, bwMax: 30, coverOv: 0.55, taperLo: 0.74, taperAmp: 0.34, taperWob: 0.12, sketchHalf: 1.2, sketchGrid: 9, sketchOver: 8, bleedP: 0.16 }); } else if (opts.style === 'oil') { Object.assign(opts, { bwF: 0.21, bwMin: 12, bwMax: 62, coverOv: 0.72, taperLo: 0.93, taperAmp: 0.11, taperWob: 0.05, sketchHalf: 2.6, sketchGrid: 4, sketchOver: 3, bleedP: 0.3, bleedR: 3 }); } const yieldUI = () => new Promise((r) => setTimeout(r, 0)); const prog = (p, msg) => { if (onProgress) onProgress(p, msg); }; // 分段计时(诊断用):哪一段在吃时间 const T = {}; let tPrev = Date.now(); const lap = (k) => { const n = Date.now(); if (opts.debug) T[k] = (T[k] || 0) + (n - tPrev); tPrev = n; }; // 错误连通块的"真实厚度" = 2×最大内切半径(距离变换)。 // 不能再用 面积÷外框边长:字母/飞机那圈深色描边是细环,按面积÷外框会算出 46px 的假厚度, // 整条描边就被当成"大片平滑渐变"跳过、永远不补(用户看到的"边被啃"里有一条就是这个)。 function compThick(c, pixArr) { const bw2 = c.x1 - c.x0 + 1, bh2 = c.y1 - c.y0 + 1; const m2 = new Uint8Array(bw2 * bh2); for (let k = 0; k < c.count; k++) { const p2 = pixArr[c.start + k]; const x2 = p2 % W, y2 = (p2 - x2) / W; m2[(y2 - c.y0) * bw2 + (x2 - c.x0)] = 1; } const dt2 = distanceTransform(m2, bw2, bh2); let mx2 = 0; for (let i2 = 0; i2 < dt2.length; i2++) if (dt2[i2] > mx2) mx2 = dt2[i2]; return mx2 * 2; } const N = W * H; const R = new Uint8Array(N), G = new Uint8Array(N), B = new Uint8Array(N); for (let i = 0; i < N; i++) { R[i] = px[i * 4]; G[i] = px[i * 4 + 1]; B[i] = px[i * 4 + 2]; } // 分块用的平滑图(取色与差异仍用原图)。 // 先 median3 去噪点;再看"纹理强度"决定要不要再模糊(照片才需要,插画保持线条锐利) let Rs = med3(R, W, H), Gs = med3(G, W, H), Bs = med3(B, W, H); { let diff = 0; for (let i = 0; i < N; i++) { const d = Math.abs(Rs[i] - R[i]) + Math.abs(Gs[i] - G[i]) + Math.abs(Bs[i] - B[i]); if (d > 36) diff++; } const texFrac = diff / N; if (texFrac > 0.06) { Rs = box3(box3(Rs, W, H), W, H); Gs = box3(box3(Gs, W, H), W, H); Bs = box3(box3(Bs, W, H), W, H); } var texFracOut = texFrac; // 照片自动模式:纹理强的图(照片/扫描件)单独一套参数—— // 色数提上去、笔更细、允许更多笔,不然平涂会把纹理糊成一格一格的色块。 if (texFrac > 0.06) { opts.photoMode = true; // 实测(照片样张):40 色比 32 色可见差异 35.03% → 34.63%,笔数还略少;再往上加轮数没收益 if (!opts.colors && opts.autoColors) opts.colors = texFrac > 0.14 ? 44 : 40; if (opts.maxStrokes === CFG.maxStrokes) opts.maxStrokes = 40000; if (opts.minArea === CFG.minArea) opts.minArea = texFrac > 0.14 ? 90 : 110; if (opts.bwF === CFG.bwF) { opts.bwF = 0.12; opts.bwMin = 7; } if (opts.coverOv === CFG.coverOv) opts.coverOv = 0.58; // 细线对比门槛:照片类的"细线"多是毛发/发丝(该画的细节),门槛放低保住它们; // 平涂/海报类的"细线"多是纸纹脏痕,保持高门槛(不写上去) if (opts.inkMinCd === CFG.inkMinCd) opts.inkMinCd = 60; // 照片类的"层"要切得细:合并门槛一大,整片岩石/毛发会被并成一层、用一个平均色去刷, // 远处暗部就整片错(实测 s2 19.3% → 32.6%)。25 是实测拐点。 if (opts.objMergeCd === CFG.objMergeCd) opts.objMergeCd = 25; // 照片类的"又大又厚"门槛要保持低:纹理本来就该留着,让残差去描 8~30px 厚的块 // 只会把纹理糊成色块(实测照片可见差异 2.58% → 2.92%) if (opts.resThick === CFG.resThick) opts.resThick = 8; } } // 自动色数(插画类也走这里:没指定颜色数就按纹理强弱给个合适的) // 非照片类(插画/海报)的自动色数:原来是 24。实测 40 色在三张示例上都更好或持平—— // 海报那种"印刷扫描件"的深色描边/暗红在 24 色里根本没有对应的中心,量化只能把它塞给 // 橙字或蓝底,于是边上一圈被涂成浑浊的棕带("Gets first attention" 可见差异 11.4% → 3.5%)。 if (!opts.colors && opts.autoColors) opts.colors = 40; if (opts.debug) console.log(`[auto] 纹理 ${(texFracOut * 100).toFixed(1)}% → colors=${opts.colors} photo=${!!opts.photoMode} maxStrokes=${opts.maxStrokes} bwF=${opts.bwF}`); prog(0.02, '分析颜色…'); await yieldUI(); // 1) Lab + k-means(隔点采样加速) const step = Math.max(1, Math.round(Math.sqrt(N / 220000))); const nS = Math.ceil(N / step); const samp = new Float32Array(nS * 3); let ns = 0; { const lab = [0, 0, 0]; for (let i = 0; i < N; i += step) { srgb2labInto(Rs[i], Gs[i], Bs[i], lab); samp[ns * 3] = lab[0]; samp[ns * 3 + 1] = lab[1]; samp[ns * 3 + 2] = lab[2]; ns++; } } const { cen } = kmeans(samp.subarray(0, ns * 3), opts.colors, 12, 12345); const cenRaw = Array.from(cen, (v) => Math.round(v)); // 2) 归并相近中心(领袖聚类)→ 调色板(RGB 由样本均值给出) const K = opts.colors; const pai = new Int32Array(K).fill(-1); const pal = []; for (let k = 0; k < K; k++) { let hit = -1; for (let p = 0; p < pal.length; p++) { const d = Math.hypot(cen[k * 3] - pal[p][0], cen[k * 3 + 1] - pal[p][1], cen[k * 3 + 2] - pal[p][2]); if (d < opts.mergeThr) { hit = p; break; } } if (hit < 0) { pal.push([cen[k * 3], cen[k * 3 + 1], cen[k * 3 + 2], 0]); hit = pal.length - 1; } pai[k] = hit; } // 中心 → 代表 RGB(用该中心附近的像素均值),顺带把"每个像素属于哪个调色板色" // 一次算完(labels)。这两件事原来各跑了一遍全图 × K 色的最近中心搜索, // 用的是同一份 cen/pai(中间没有任何改动)⇒ 结果逐位相同,纯白跑一遍。 const labels = new Uint8Array(N); { const accR = new Float64Array(pal.length), accG = new Float64Array(pal.length), accB = new Float64Array(pal.length), cnt = new Float64Array(pal.length); const lab = [0, 0, 0]; for (let i = 0; i < N; i++) { srgb2labInto(Rs[i], Gs[i], Bs[i], lab); const l0 = lab[0], l1 = lab[1], l2 = lab[2]; let best = 0, bd = Infinity; for (let k = 0, k3 = 0; k < K; k++, k3 += 3) { const dL = l0 - cen[k3], dA = l1 - cen[k3 + 1], dB = l2 - cen[k3 + 2]; const d = dL * dL + dA * dA + dB * dB; if (d < bd) { bd = d; best = k; } } const p = pai[best]; labels[i] = p; accR[p] += R[i]; accG[p] += G[i]; accB[p] += B[i]; cnt[p]++; } for (let p = 0; p < pal.length; p++) { if (cnt[p] > 0) { pal[p][0] = accR[p] / cnt[p]; pal[p][1] = accG[p] / cnt[p]; pal[p][2] = accB[p] / cnt[p]; } } } prog(0.16, '分块…'); await yieldUI(); // 3) 全图归类 —— 已在上面的循环里一起算完(labels),这里不再重复搜一遍 // 4) 连通域 let { compOf, pix, comps } = compsOfLabels(labels, W, H); // 5) 碎块吸收(并到颜色最近、面积最大的邻块) const oldComps = comps; // 细线像素(文字笔画、轮廓线)单独记下来:它们并进邻块 ⇒ 铺色时那里会被底色铺满, // 细线自己留到最后再"写"上去(人也是先铺满、最后题字,不是先挖个洞再一点点补) const inkPix = new Uint8Array(N); // 纹理像素(纸纹/噪点):并进邻块后"不画、不参与底色取色、残差也不补"。 // 老海报/扫描件的斑驳就是被当成内容一块块画出来才显得脏,放大后跟原图差得远。 const texPix = new Uint8Array(N); let nThin = 0, nTex = 0; { const parent = new Int32Array(comps.length); for (let i = 0; i < comps.length; i++) parent[i] = i; const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }; let nSmall = 0, nMerged = 0, nNoVote = 0, nFar = 0, nNaN = 0; const thinLim = 9.0 * (W / 1024); // 平均笔画宽度 ≤ 它就算细线(对齐原来 maxr ≤ 6.5*scale 的口径) // 邻块投票的计数用 Int32Array + touched 列表,不用 Map:数字键的 Map 在这 // 是几百万次哈希(3 万块 × 8 邻域 × 2 轮),换数组后同样的算术、同样的 // "首次遇到顺序" ⇒ bestScore 并列时的取舍一致,结果逐位不变。 const vcnt = new Int32Array(comps.length); const touched = []; for (let round = 0; round < 2; round++) { for (let ci = 0; ci < comps.length; ci++) { if (find(ci) !== ci) continue; const c = comps[ci]; const bwW = c.x1 - c.x0 + 1, bwH = c.y1 - c.y0 + 1; const estW = c.count / Math.max(1, Math.max(bwW, bwH)); // 细线只认"长条形"(文字笔画、轮廓):方方正正的小斑不是线,是纸纹 const elong = Math.max(bwW, bwH) / Math.max(1, Math.min(bwW, bwH)); const thin = estW <= thinLim && c.count >= 8 && elong >= 2.2; if (!thin && c.count >= opts.minArea) continue; nSmall++; touched.length = 0; for (let k = 0; k < c.count; k++) { const p = pix[c.start + k]; const x = p % W, y = (p - x) / W; for (let dy = -1; dy <= 1; dy++) { const yy = y + dy; if (yy < 0 || yy >= H) continue; const row = yy * W; for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const xx = x + dx; if (xx < 0 || xx >= W) continue; const cj = find(compOf[row + xx]); if (cj === ci) continue; if (vcnt[cj]++ === 0) touched.push(cj); } } } const pi = pal[c.label]; let bestJ = -1, bestScore = -1; for (let ti = 0; ti < touched.length; ti++) { const cj = touched[ti], v = vcnt[cj]; const pj = pal[comps[cj].label]; const cd = Math.hypot(pj[0] - pi[0], pj[1] - pi[1], pj[2] - pi[2]); const score = v / (1 + cd * 0.5); if (score > bestScore) { bestScore = score; bestJ = cj; } } for (let ti = 0; ti < touched.length; ti++) vcnt[touched[ti]] = 0; if (bestJ < 0) { nNoVote++; continue; } const pj = pal[comps[bestJ].label]; if (!pj || !pi) { nNaN++; continue; } const cd = Math.hypot(pj[0] - pi[0], pj[1] - pi[1], pj[2] - pi[2]); // 纹理判据:小块 + 对比不强 ⇒ 纸纹/噪点(并进邻块、不画) const tex = !thin && c.count < opts.texMax && cd <= opts.texCd; if (thin || c.count < 6 || cd <= opts.absorbCd || tex) { parent[ci] = bestJ; nMerged++; if (thin) { if (cd >= opts.inkMinCd) { nThin++; for (let k = 0; k < c.count; k++) inkPix[pix[c.start + k]] = 1; } else { // 弱对比的"线"其实是纸纹暗纹,不写 nTex++; for (let k = 0; k < c.count; k++) texPix[pix[c.start + k]] = 1; } } else { nTex++; for (let k = 0; k < c.count; k++) texPix[pix[c.start + k]] = 1; } } else nFar++; } } if (opts.debug) console.log(`[absorb] comps=${comps.length} small=${nSmall} merged=${nMerged} thin=${nThin} tex=${nTex} noVote=${nNoVote} far=${nFar} nan=${nNaN} pal=${pal.length}`); // 重贴标签 const rootOf = new Int32Array(comps.length); for (let i = 0; i < comps.length; i++) rootOf[i] = find(i); const L2 = new Uint32Array(N); for (let i = 0; i < N; i++) L2[i] = rootOf[compOf[i]] + 1; const cc = compsOfLabels(L2, W, H); compOf = cc.compOf; pix = cc.pix; comps = cc.comps; for (const c of comps) { const oldIdx = c.label - 1; c.palLabel = oldComps[oldIdx] ? oldComps[oldIdx].label : c.label - 1; } } // 6) 区块统计(面积/重心/PCA/包围盒掩膜) lap('absorb'); prog(0.28, '整理区块…'); await yieldUI(); const regions = []; for (let ci = 0; ci < comps.length; ci++) { const c = comps[ci]; const n = c.count; let sx = 0, sy = 0; for (let k = 0; k < n; k++) { const p = pix[c.start + k]; const x = p % W; sx += x; sy += (p - x) / W; } const cx = sx / n, cy = sy / n; let sxx = 0, sxy = 0, syy = 0; for (let k = 0; k < n; k++) { const p = pix[c.start + k]; const x = p % W, y = (p - x) / W; const dx = x - cx, dy = y - cy; sxx += dx * dx; sxy += dx * dy; syy += dy * dy; } sxx /= n; sxy /= n; syy /= n; const tr = sxx + syy, det = sxx * syy - sxy * sxy; const l1 = tr / 2 + Math.sqrt(Math.max(0, tr * tr / 4 - det)); const l2 = tr / 2 - Math.sqrt(Math.max(0, tr * tr / 4 - det)); const ang = 0.5 * Math.atan2(2 * sxy, sxx - syy); let ax = Math.cos(ang), ay = Math.sin(ang); // 让主轴方向落在"更长"的一侧 let tmin = Infinity, tmax = -Infinity, vmax = 0; const nx = -ay, ny = ax; for (let k = 0; k < n; k++) { const p = pix[c.start + k]; const x = p % W, y = (p - x) / W; const t = (x - cx) * ax + (y - cy) * ay; const v = Math.abs((x - cx) * nx + (y - cy) * ny); if (t < tmin) tmin = t; if (t > tmax) tmax = t; if (v > vmax) vmax = v; } if (tmax - tmin < 2 * vmax) { ax = nx; ay = ny; let tn = Infinity, tx = -Infinity, vm = 0; } // 用最终轴重算范围 { const nx2 = -ay, ny2 = ax; let t2min = Infinity, t2max = -Infinity, v2max = 0, v2s = []; for (let k = 0; k < n; k++) { const p = pix[c.start + k]; const x = p % W, y = (p - x) / W; const t = (x - cx) * ax + (y - cy) * ay; const v = Math.abs((x - cx) * nx2 + (y - cy) * ny2); if (t < t2min) t2min = t; if (t > t2max) t2max = t; if (v > v2max) v2max = v; } tmin = t2min; tmax = t2max; vmax = v2max; } let sr = 0, sg = 0, sb = 0, cn = 0; for (let k = 0; k < n; k++) { const p = pix[c.start + k]; if (inkPix[p] || texPix[p]) continue; // 细线/纹理像素不算进底色(否则会被黑字、纸纹拉暗拉花) sr += R[p]; sg += G[p]; sb += B[p]; cn++; } const color = cn > 0 ? [sr / cn, sg / cn, sb / cn] : [0, 0, 0]; regions.push({ cid: ci, x0: c.x0, y0: c.y0, x1: c.x1, y1: c.y1, area: n, cx, cy, ax, ay, tmin, tmax, hp: Math.max(1, vmax), color: [Math.round(color[0]), Math.round(color[1]), Math.round(color[2])], pix0: c.start, pix1: c.start + n - 1, }); } // 7) 细线层:就是吸收阶段挑出来的细线像素(文字笔画、轮廓线)。 // 它们已经并进邻块 ⇒ 铺色时被底色盖住,留到最后再"写"上去。 const inkBin = inkPix; const isInk = new Uint8Array(regions.length); const dbg = { dark: 0, darkThin: nThin, tex: texFracOut, texPix: nTex, pal: [], cen: cenRaw, smallLeft: regions.filter((r) => r.area < opts.minArea).length, marks: [] }; const scale = W / 1024; // 区块面积分布(笔数来源分析的底数):[<120, 120-600, 600-2400, 2400-1万, ≥1万] const areaBins = [0, 120, 600, 2400, 10000, Infinity]; { const hist = new Array(areaBins.length - 1).fill(0); for (const r of regions) { for (let b = 0; b < hist.length; b++) if (r.area >= areaBins[b] && r.area < areaBins[b + 1]) { hist[b]++; break; } } dbg.areaHist = hist; } lap('regions'); prog(0.38, '提笔勾线…'); await yieldUI(); // 8) 笔触生成 const strokes = []; // {rib,tp,color,pen,ms,c(裁剪组),r(区块),ph(阶段)} const mark = (tag) => dbg.marks.push({ tag, n: strokes.length }); // 笔数分解埋点(dev/stroke_breakdown.mjs 用) const lastPen = { x: 0, y: 0 }; // 笔尖当前位置:铺色顺序按"就近"串起来 // 阶段:0 起稿(轻勾轮廓) 1 铺色(大块优先→收小) 2 收形(勾线+中等补笔) 3 点睛(细补笔) let visCanvas = null; // 调试/测试用:最终纯色光栅结果 let keysRgb = null; // 关键帧(拖动进度用) const clips = []; // {x,y,w,h,mask} const target = { R, G, B }; // 一块错误里"深浅混在一起"时(渐变带、两条细线挨着),用一个均值色补上去会把浅的那半 // 刷深——飞机边上那些"深色楔子"就是这么来的,而且下一轮还会被重新判成错、再补一遍。 // 先按颜色二分(方差最大的通道、按中位数切),每一份单独补(各用各的均值色)。 const splitPixByColor = (pix, cnt, depth, out) => { if (depth >= 4 || cnt < 9) { out.push({ pix, cnt }); return; } let mn0 = 255, mn1 = 255, mn2 = 255, mx0 = 0, mx1 = 0, mx2 = 0; for (let k = 0; k < cnt; k++) { const p = pix[k]; if (R[p] < mn0) mn0 = R[p]; if (R[p] > mx0) mx0 = R[p]; if (G[p] < mn1) mn1 = G[p]; if (G[p] > mx1) mx1 = G[p]; if (B[p] < mn2) mn2 = B[p]; if (B[p] > mx2) mx2 = B[p]; } const s0 = mx0 - mn0, s1 = mx1 - mn1, s2 = mx2 - mn2; const smax = Math.max(s0, s1, s2); if (smax <= (opts.holeSplit || 1e9)) { out.push({ pix, cnt }); return; } const ch = s0 === smax ? 0 : (s1 === smax ? 1 : 2); const vals = new Float64Array(cnt); for (let k = 0; k < cnt; k++) { const p = pix[k]; vals[k] = ch === 0 ? R[p] : (ch === 1 ? G[p] : B[p]); } const sorted = Array.from(vals).sort((a, b) => a - b); const med = sorted[cnt >> 1]; let i = 0, j = cnt - 1; while (i <= j) { if (vals[i] <= med) i++; else { const tp = pix[i]; pix[i] = pix[j]; pix[j] = tp; const tv = vals[i]; vals[i] = vals[j]; vals[j] = tv; j--; } } if (i === 0 || i === cnt) { out.push({ pix, cnt }); return; } splitPixByColor(pix.subarray(0, i), i, depth + 1, out); splitPixByColor(pix.subarray(i), cnt - i, depth + 1, out); }; let curTarget = target; // 当前取色源(底色层会换成"背景色场") const strokeScale = Math.max(0.5, scale); const pushClip = (x, y, w, h, mask) => { clips.push({ x, y, w, h, mask }); return clips.length - 1; }; function bboxOfComp(cc, ci, pad) { const c = cc.comps[ci]; const x = Math.max(0, c.x0 - pad), y = Math.max(0, c.y0 - pad); const x1 = Math.min(W - 1, c.x1 + pad), y1 = Math.min(H - 1, c.y1 + pad); const w = x1 - x + 1, h = y1 - y + 1; const m = new Uint8Array(w * h); for (let k = 0; k < c.count; k++) { const p = cc.pix[c.start + k]; const px0 = p % W, py0 = (p - px0) / W; if (px0 < x || py0 < y || px0 > x1 || py0 > y1) continue; m[(py0 - y) * w + (px0 - x)] = 1; } return { x, y, w, h, m }; } // 采样:某个多边形在某个掩膜内的目标平均色 const segTmp = { w: 0, h: 0, m: null }; function sampleColor(polyFlat, np, clipMask, clipX, clipY, clipW, clipH) { let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; for (let i = 0; i < np; i++) { const x = polyFlat[i * 2], y = polyFlat[i * 2 + 1]; if (x < xmin) xmin = x; if (x > xmax) xmax = x; if (y < ymin) ymin = y; if (y > ymax) ymax = y; } const mw = Math.max(1, Math.ceil(xmax - Math.floor(xmin)) + 1); const mh = Math.max(1, Math.ceil(ymax - Math.floor(ymin)) + 1); if (mw * mh > 4000000) return null; const ox = Math.floor(xmin), oy = Math.floor(ymin); const local = new Uint8Array(mw * mh); const pts = new Float64Array(np * 2); for (let i = 0; i < np; i++) { pts[i * 2] = polyFlat[i * 2] - ox; pts[i * 2 + 1] = polyFlat[i * 2 + 1] - oy; } fillPoly(local, mw, mh, pts); let sr = 0, sg = 0, sb = 0, cnt = 0; for (let y = 0; y < mh; y++) { const gy = oy + y; if (gy < clipY || gy >= clipY + clipH || gy < 0 || gy >= H) continue; for (let x = 0; x < mw; x++) { if (!local[y * mw + x]) continue; const gx = ox + x; if (gx < clipX || gx >= clipX + clipW || gx < 0 || gx >= W) continue; if (!clipMask[(gy - clipY) * clipW + (gx - clipX)]) continue; const p = gy * W + gx; sr += curTarget.R[p]; sg += curTarget.G[p]; sb += curTarget.B[p]; cnt++; } } if (cnt < 4) return null; return [Math.round(sr / cnt), Math.round(sg / cnt), Math.round(sb / cnt)]; } function pathSegments(path, n, segLen) { if (n < 2) return [{ pts: Float64Array.from([path[0], path[1]]), n: 1, len: 0 }]; const cum = new Float64Array(n); for (let i = 1; i < n; i++) { cum[i] = cum[i - 1] + Math.hypot(path[i * 2] - path[(i - 1) * 2], path[i * 2 + 1] - path[(i - 1) * 2 + 1]); } const total = cum[n - 1]; if (total <= 1e-6) return [{ pts: Float64Array.from([path[0], path[1]]), n: 1, len: 0 }]; const at = (t) => { let i = 1; while (i < n - 1 && cum[i] < t) i++; const f = (t - cum[i - 1]) / Math.max(1e-6, cum[i] - cum[i - 1]); return [path[(i - 1) * 2] + (path[i * 2] - path[(i - 1) * 2]) * f, path[(i - 1) * 2 + 1] + (path[i * 2 + 1] - path[(i - 1) * 2 + 1]) * f]; }; const segs = []; const step = Math.max(4, segLen); let s0 = 0; while (s0 < total - 1e-6) { const e = Math.min(total, s0 + step); const pts = []; const p0 = at(s0), p1 = at(e); pts.push(p0[0], p0[1]); for (let k = 1; k < n - 1; k++) { if (cum[k] > s0 + 1e-6 && cum[k] < e - 1e-6) pts.push(path[k * 2], path[k * 2 + 1]); } pts.push(p1[0], p1[1]); segs.push({ pts: Float64Array.from(pts), n: pts.length >> 1, len: e - s0 }); s0 = e; } return segs; } function emitStroke(ribFlat, np, pathFlat, nPath, halfW, color, pen, cIdx, rIdx, ph) { let len = 0; for (let i = 1; i < nPath; i++) { len += Math.hypot(pathFlat[i * 2] - pathFlat[(i - 1) * 2], pathFlat[i * 2 + 1] - pathFlat[(i - 1) * 2 + 1]); } const phase = ph === undefined ? 1 : ph; // 节奏:起稿又快又轻;点睛最慢最小心;其余按长度给时间(短笔也要有起步价) let weight; if (phase === 0) weight = 8 + len * 1.0; else if (phase === 3) weight = 26 + len * 2.6; else if (phase === 2) weight = 18 + len * 2.2; else weight = 12 + len * 1.8; weight *= 0.85 + 0.3 * rand01(len + strokes.length * 0.37, phase + 1.7); // 每笔快慢有点起伏 if (luma(color) > 238) weight *= 0.3; // 白底一笔带过 const rec = { rib: Array.from(ribFlat, (v) => Math.round(v * 10) / 10), tp: Array.from(pathFlat, (v) => Math.round(v * 10) / 10), w: Math.round(halfW * 2 * 10) / 10, color: [color[0], color[1], color[2]], pen: pen ? 1 : 0, ms: weight, // 先存权重,收尾时按目标总时长归一 c: cIdx, r: rIdx, ph: phase, ly: curLayer, }; strokes.push(rec); if (nPath >= 1) { lastPen.x = pathFlat[(nPath - 1) * 2]; lastPen.y = pathFlat[(nPath - 1) * 2 + 1]; } return rec; } // 沿路径取局部色并按"颜色相近的相邻段"合并:返回待落笔的段(不直接落笔,好让调用方连笔) let segLenOverride = 0, joinCdOverride = 0; // 底色层用:分段更短、颜色差得更少才并笔(渐变才不会被抹平) function localParts(path, n, halfW, clipMask, cx, cy, cw, ch, pen, cIdx, rIdx, fallback, ph) { const segs = pathSegments(path, n, segLenOverride || Math.max(opts.segMin * strokeScale, halfW * opts.segF)); const cols = []; for (const sg of segs) { if (sg.n < 2) { cols.push(null); continue; } const rb = ribbon(sg.pts, sg.n, halfW); const rbS = ribbon(sg.pts, sg.n, halfW, true); // 取色用平头:圆头会把两端邻色平均进来 cols.push(sampleColor(rbS, rbS.length / 2, clipMask, cx, cy, cw, ch) || fallback); } const parts = []; let i = 0; while (i < segs.length) { let j = i; while (j + 1 < segs.length && cols[j + 1] && cols[i] && Math.abs(cols[j + 1][0] - cols[i][0]) + Math.abs(cols[j + 1][1] - cols[i][1]) + Math.abs(cols[j + 1][2] - cols[i][2]) < (joinCdOverride || opts.segJoinCd)) j++; const pts = []; for (let k = i; k <= j; k++) { const P = segs[k].pts; for (let m = 0; m < P.length; m++) pts.push(P[m]); } const arr = Float64Array.from(pts); const np = arr.length / 2; if (np >= 2) { const rib = ribbon(arr, np, halfW); const col = cols[i] || fallback; const pn = pen < 0 ? (luma(col) < opts.inkLuma ? 1 : 0) : pen; // -1:按色深自动 parts.push({ rib, path: arr, col, pen: pn, cIdx, rIdx, ph }); } i = j + 1; } return parts; } // 变宽版 localParts:半宽按"路径上这一点的厚度"来(细长层用:笔要跟着形状的宽窄走) function localPartsProf(path, n, halfAt, sampleM, cIdx, rIdx, fallback, ph, pen, sampleHalfAt) { const segs = pathSegments(path, n, Math.max(opts.segMin * strokeScale, 26)); const ribs = [], cols = []; for (const sg of segs) { if (sg.n < 2) { ribs.push(null); cols.push(null); continue; } const halfs = new Float64Array(sg.n); for (let i = 0; i < sg.n; i++) halfs[i] = halfAt(sg.pts[i * 2], sg.pts[i * 2 + 1]); const rb = ribbonProf(sg.pts, sg.n, halfs); ribs.push(rb); // 取色可以另用一套"窄笔形":笔形为了盖满形状被就地加宽之后, // 取色还按加宽后的范围算,就会把旁边的深色描边也平均进去(实测会画出灰条) let sPoly = ribbon(sg.pts, sg.n, halfs[0], true); if (sampleHalfAt) { const sh = new Float64Array(sg.n); for (let i = 0; i < sg.n; i++) sh[i] = sampleHalfAt(sg.pts[i * 2], sg.pts[i * 2 + 1]); // 取色笔形必须**平头**(noCap):带圆头会把两端邻色平均进来, // 长笔在拐弯处采样到隔壁颜色就整段刷错色(与 ribbon(...) 的 noCap 同一口径) sPoly = ribbonProf(sg.pts, sg.n, sh, PROF_FLAT_SAMPLE); } cols.push(sampleColor(sPoly, sPoly.length / 2, sampleM.m, sampleM.x, sampleM.y, sampleM.w, sampleM.h) || fallback); } const parts = []; let i = 0; while (i < segs.length) { let j = i; while (j + 1 < segs.length && cols[j + 1] && cols[i] && Math.abs(cols[j + 1][0] - cols[i][0]) + Math.abs(cols[j + 1][1] - cols[i][1]) + Math.abs(cols[j + 1][2] - cols[i][2]) < opts.segJoinCd) j++; const pts = []; for (let k = i; k <= j; k++) { const P = segs[k].pts; for (let m = 0; m < P.length; m++) pts.push(P[m]); } const arr = Float64Array.from(pts); if (arr.length >= 4) { const rib = (j > i) ? mergeLoops(ribs.slice(i, j + 1)) : ribs[i]; parts.push({ rib, path: arr, col: cols[i] || fallback, pen: 1, cIdx, rIdx, ph }); } i = j + 1; } return parts; } function emitLocal(path, n, halfW, clipMask, cx, cy, cw, ch, pen, cIdx, rIdx, fallback, ph, onRec) { for (const p of localParts(path, n, halfW, clipMask, cx, cy, cw, ch, pen, cIdx, rIdx, fallback, ph)) { const rec = emitStroke(p.rib, p.rib.length / 2, p.path, p.path.length / 2, halfW, p.col, p.pen, p.cIdx, p.rIdx, p.ph); if (onRec) onRec(rec); } } // ---- 7.5) 起稿:沿"主要轮廓"轻轻勾一遍(细、快、轻;后面铺色会盖住它,像真起稿) ---- prog(0.42, '起稿勾形…'); await yieldUI(); if (opts.sketch) { let maxCid = 0; for (const rg of regions) if (rg.cid > maxCid) maxCid = rg.cid; const areaOf = new Int32Array(maxCid + 1); for (const rg of regions) areaOf[rg.cid] = rg.area; const minSide = Math.max(200, Math.round(240 * scale * scale)); // 只勾大体轮廓,不勾碎屑 const edge = new Uint8Array(N); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const p = y * W + x; const c0 = compOf[p], a0 = areaOf[c0] || 0; let on = 0; if (x + 1 < W) { const c1 = compOf[p + 1]; if (c1 !== c0 && (a0 >= minSide || (areaOf[c1] || 0) >= minSide)) on = 1; } if (!on && y + 1 < H) { const c2 = compOf[p + W]; if (c2 !== c0 && (a0 >= minSide || (areaOf[c2] || 0) >= minSide)) on = 1; } if (on) edge[p] = 1; } } const ecc = compsOfLabels(edge, W, H); const cand = []; for (let ci = 0; ci < ecc.comps.length; ci++) { if (ecc.comps[ci].label === 1 && ecc.comps[ci].count >= 12) cand.push(ci); } cand.sort((a, b) => ecc.comps[b].count - ecc.comps[a].count); const items = cand.slice(0, opts.sketchMax).map((ci) => ({ ci, cx: (ecc.comps[ci].x0 + ecc.comps[ci].x1) / 2, cy: (ecc.comps[ci].y0 + ecc.comps[ci].y1) / 2, })); const seq = chainNN(items, (it) => [it.cx, it.cy], 0, 0); const halfS = Math.max(1.1, opts.sketchHalf * strokeScale); const clipPad = Math.max(1, Math.round(1.2 * strokeScale)); const mix = opts.sketchMix; // 起稿色:局部目标色往铅笔灰里压一点——有"铅笔线"的味道,又不会留下大片深色残迹 const pencil = (x, y) => { const xi = clamp(Math.round(x), 0, W - 1), yi = clamp(Math.round(y), 0, H - 1); const p0 = yi * W + xi; return [Math.round(R[p0] * (1 - mix) + 74 * mix), Math.round(G[p0] * (1 - mix) + 72 * mix), Math.round(B[p0] * (1 - mix) + 76 * mix)]; }; const grid = Math.max(0, Math.round(opts.sketchGrid * strokeScale)); const over = Math.max(0, opts.sketchOver * strokeScale); for (const it of seq) { const bw = bboxOfComp(ecc, it.ci, clipPad + grid); const { sk } = thin(bw.m, bw.w, bw.h); const lp = longestPath(sk, bw.w, bw.h); if (!lp || lp.len < 4) continue; const absPath = new Float64Array(lp.len * 2); for (let i = 0; i < lp.len; i++) { absPath[i * 2] = lp.pts[i * 2] + bw.x; absPath[i * 2 + 1] = lp.pts[i * 2 + 1] + bw.y; } const mi = lp.len >> 1; const col = pencil(absPath[mi * 2], absPath[mi * 2 + 1]); // 真起稿画不准、也不收锋:路径按网格"随手"简化,两端再画过头一点 let pth = absPath, pn = lp.len; if (grid > 1 && lp.len >= 6) { const simp = []; const push = (x, y) => { if (simp.length >= 2) { const lx = simp[simp.length - 2], ly = simp[simp.length - 1]; if (Math.max(Math.abs(x - lx), Math.abs(y - ly)) < grid) return; } simp.push(x, y); }; const d0x = absPath[0] - absPath[2], d0y = absPath[1] - absPath[3]; const l0 = Math.hypot(d0x, d0y) || 1; push(absPath[0] + d0x / l0 * over, absPath[1] + d0y / l0 * over); for (let i = 0; i < lp.len; i++) push(absPath[i * 2], absPath[i * 2 + 1]); const e2 = (lp.len - 1) * 2; const d1x = absPath[e2] - absPath[e2 - 2], d1y = absPath[e2 + 1] - absPath[e2 - 1]; const l1 = Math.hypot(d1x, d1y) || 1; push(absPath[e2] + d1x / l1 * over, absPath[e2 + 1] + d1y / l1 * over); if (simp.length >= 4) { pth = Float64Array.from(simp); pn = simp.length >> 1; } } // 粗线会偏出原始轮廓,掩膜相应放宽,免得被切成虚线 const cIdx = pushClip(bw.x, bw.y, bw.w, bw.h, dilate(bw.m, bw.w, bw.h, clipPad + grid)); const rib = ribbon(pth, pn, halfS); emitStroke(rib, rib.length / 2, pth, pn, halfS, col, 1, cIdx, -1, 0); } } mark('起稿'); // ---- 8a) 细线层(轮廓线 + 文字):分两拨画 ---- // 属于某个对象的细线(猫嘴、胡须、字的描边)在该对象铺完色后立刻画; // 落在背景/纸底上的(题字、落款)留到最后,按行写。 // 组划分(连通域、行序、归属)在铺色前统一做,这里只负责"画给定的组"。 const inkClips = []; let inkIcc = null; // 细线连通域(两拨共用) let inkLastSeq = []; // 留到最后的细线组(按行序) const inkOfObj = new Map(); // 对象序号 → [细线组 ci, ...](按行序) const drawInkSeq = (seq, rasterFn, ph = 2) => { if (!inkIcc) return; const icc = inkIcc; for (const ci of seq) { const bw = bboxOfComp(icc, ci, 0); // 严格掩膜(取色用) const clipM = opts.dilInk > 0 ? dilate(bw.m, bw.w, bw.h, opts.dilInk) : bw.m; const full = new Uint8Array(W * H); for (let k = 0; k < icc.comps[ci].count; k++) full[icc.pix[icc.comps[ci].start + k]] = 1; const cIdx = pushClip(bw.x, bw.y, bw.w, bw.h, clipM); let inkFallback = [46, 46, 52]; { let sr0 = 0, sg0 = 0, sb0 = 0, n0 = 0; for (let k = 0; k < icc.comps[ci].count; k++) { const pp0 = icc.pix[icc.comps[ci].start + k]; sr0 += R[pp0]; sg0 += G[pp0]; sb0 += B[pp0]; n0++; } if (n0) inkFallback = [Math.round(sr0 / n0), Math.round(sg0 / n0), Math.round(sb0 / n0)]; } const bin = new Uint8Array(bw.w * bw.h); for (let y = 0; y < bw.h; y++) { for (let x = 0; x < bw.w; x++) bin[y * bw.w + x] = full[(y + bw.y) * W + (x + bw.x)]; } const dt = distanceTransform(bin, bw.w, bw.h); let dmax = 0; for (let i = 0; i < dt.length; i++) if (dt[i] > dmax) dmax = dt[i]; const half = clamp(dmax + 1.4 * strokeScale, 2.0, 12 * strokeScale); // 分支队列:每画一条最长骨架路径,就把剩下没盖住的连通块继续排队, // 直到整层线稿都被笔画覆盖(复杂线稿会有很多分支)。 // 剩下的块只要还有点面积就继续排——门槛太高会留下没人画的白点。 const queue = [bin.slice()]; let guard = 0; while (queue.length && guard++ < 200) { const m0 = queue.shift(); globalThis.__thinTag = 'ink'; const { sk } = thin(m0, bw.w, bw.h); const lp = longestPath(sk, bw.w, bw.h); if (lp && lp.len >= 2) { const raw = new Float64Array(lp.len * 2); for (let i = 0; i < lp.len; i++) { raw[i * 2] = lp.pts[i * 2] + bw.x; raw[i * 2 + 1] = lp.pts[i * 2 + 1] + bw.y; } // 中轴拉直:直线的骨架是"一像素一像素拐"出来的,照描就是坑坑洼洼(用户点名的毛病) const sp = rdpSimplify(raw, lp.len, opts.lineTol); const absPath = sp.pts, nPath = sp.n; if (opts.localColor) { emitLocal(absPath, nPath, half, bw.m, bw.x, bw.y, bw.w, bw.h, -1, cIdx, -1, inkFallback, ph, rasterFn); } else { const rib = ribbon(absPath, nPath, half); const rec = emitStroke(rib, rib.length / 2, absPath, nPath, half, inkFallback, 1, cIdx, -1, ph); if (rasterFn) rasterFn(rec); } const cover = new Uint8Array(bw.w * bw.h); fillPoly(cover, bw.w, bw.h, (() => { const r = ribbon(absPath, nPath, half); const out = new Float64Array(r.length); for (let i = 0; i < r.length; i += 2) { out[i] = r[i] - bw.x; out[i + 1] = r[i + 1] - bw.y; } return out; })()); for (let i = 0; i < m0.length; i++) if (m0[i] && cover[i]) m0[i] = 0; } // 剩下的连通块继续排队 const rc = compsOfLabels(m0, bw.w, bw.h); for (let j = 0; j < rc.comps.length; j++) { if (rc.comps[j].label !== 1 || rc.comps[j].count < Math.max(6, half * half)) continue; const nm = new Uint8Array(bw.w * bw.h); for (let k = 0; k < rc.comps[j].count; k++) nm[rc.pix[rc.comps[j].start + k]] = 1; queue.push(nm); } } inkClips.push(cIdx); } }; lap('ink'); prog(0.52, '铺色块…'); await yieldUI(); // ---- 8b) 非线稿区块:毛笔刷(沿主轴一档档来回涂) ---- const regionOrder = []; let sweepRegions = 0, passTotal = 0, passMax = 0; // 埋点:铺色区块数 / 总档数 / 单块最多档数 const objOfComp = new Int32Array(regions.length); // 区块 → 对象序号(残差/补漏也按这个顺序走) // ================= 图层栈:一个对象 = 一层 ================= // 老版本是"一个层核一个区块" ⇒ 蓝底被色带切成几十条各画各的(用户截图里的竖条带)、 // 黑框四条边也各画各的、每层还都只画自己"看得见"的那块。这里改成: // ① 相邻 + 颜色相近的层核并成同一个对象(一整片蓝底是一层、一整圈黑框是一层); // ② 每层算出"完整形状"——被上层盖住的部分也要画(形状闭合推断,只补"上层"不补"下层"); // ③ 一层画完当场自检这一层有没有漏,漏了立刻补,不拖到最后。 let baseObj = -1; // 底色层(最下面那层非纸底) let curLayer = -1; // 当前正在画的层号(记进每一笔的 ly,收尾判"该不该删"用) let objLab = null; // 像素 → 对象序号 const objRegions = []; // 每层包含哪些区块 const objBox = []; // 每层:可见部分的 bbox + 掩膜 const objFull = []; // 每层:完整形状(含被上层盖住的部分) const objStat = []; // 每层:面积/主轴/半宽/厚度/颜色/是否细长/是否纸底 const objAdj = []; // 每层的邻接表(贴着哪些层) let nObj = 0; let bgMap = null; // 底色层的"背景色场":被上层盖住的地方按周围推出来 { const imp = (rg) => Math.hypot(rg.color[0] - 255, rg.color[1] - 255, rg.color[2] - 255); const isBg = (rg) => luma(rg.color) > 240 && imp(rg) < 24; const list = []; for (let ri = 0; ri < regions.length; ri++) if (!isInk[ri]) list.push(ri); // --- 并查集:**挨着 + 颜色相近** 两个条件同时满足才并成一层 --- // 只按"挨着"会把字并进蓝底(整幅变一层);只按"颜色近"会把画面两头同色的东西并成一层 // (笔尖满屏飞)。两个一起用,分出来的才是"一个东西":一整片蓝底、一整圈黑框、一个字母。 const uf = new Int32Array(regions.length); for (let i = 0; i < regions.length; i++) uf[i] = i; const find = (a) => { while (uf[a] !== a) { uf[a] = uf[uf[a]]; a = uf[a]; } return a; }; const bgReg = new Uint8Array(regions.length); for (let ri = 0; ri < regions.length; ri++) if (isBg(regions[ri])) bgReg[ri] = 1; const cdist = (a, b) => Math.abs(regions[a].color[0] - regions[b].color[0]) + Math.abs(regions[a].color[1] - regions[b].color[1]) + Math.abs(regions[a].color[2] - regions[b].color[2]); { const cdMax = opts.objMergeCd; for (let y = 0; y < H; y++) { const row = y * W; for (let x = 0; x < W; x++) { const p = row + x, c0 = compOf[p]; if (isInk[c0]) continue; const k0 = bgReg[c0]; if (x + 1 < W) { const c1 = compOf[p + 1]; if (c1 !== c0 && !isInk[c1] && bgReg[c1] === k0 && cdist(c0, c1) <= cdMax) { const ra = find(c0), rb = find(c1); if (ra !== rb) uf[ra] = rb; } } if (y + 1 < H) { const c1 = compOf[p + W]; if (c1 !== c0 && !isInk[c1] && bgReg[c1] === k0 && cdist(c0, c1) <= cdMax) { const ra = find(c0), rb = find(c1); if (ra !== rb) uf[ra] = rb; } } } } } // --- 分组 → 层 --- const gmap = new Map(); for (const ri of list) { const r = find(ri); let g = gmap.get(r); if (!g) { g = { regs: [], area: 0, isPaper: bgReg[ri] === 1 }; gmap.set(r, g); } g.regs.push(ri); g.area += regions[ri].area; if (!bgReg[ri]) g.isPaper = false; } let groups = Array.from(gmap.values()); // 太小的组(零星几像素的碎屑)并进"挨着它、颜色最近"的那个组,不单独成层 { const MINLAY = 300; const bigs = groups.filter((g) => g.isPaper || g.area >= MINLAY); const smalls = groups.filter((g) => !g.isPaper && g.area < MINLAY); if (smalls.length) { const gIdxOf = new Int32Array(regions.length).fill(-1); for (let gi = 0; gi < groups.length; gi++) for (const ri of groups[gi].regs) gIdxOf[ri] = gi; const glab = new Int32Array(N); for (let p = 0; p < N; p++) glab[p] = gIdxOf[compOf[p]]; const pairs = new Set(); for (let y = 0; y < H; y++) { const row = y * W; for (let x = 0; x < W; x++) { const p = row + x, a = glab[p]; if (a < 0) continue; if (x + 1 < W) { const b = glab[p + 1]; if (b >= 0 && b !== a) { const lo = Math.min(a, b), hi = Math.max(a, b); pairs.add(lo * 4096 + hi); } } if (y + 1 < H) { const b = glab[p + W]; if (b >= 0 && b !== a) { const lo = Math.min(a, b), hi = Math.max(a, b); pairs.add(lo * 4096 + hi); } } } } const nbOf = new Map(); for (const k of pairs) { const a = Math.floor(k / 4096), b = k % 4096; if (!nbOf.has(a)) nbOf.set(a, []); if (!nbOf.has(b)) nbOf.set(b, []); nbOf.get(a).push(b); nbOf.get(b).push(a); } const gcol = groups.map((g) => { let cr = 0, cg = 0, cb = 0; for (const ri of g.regs) { cr += regions[ri].color[0] * regions[ri].area; cg += regions[ri].color[1] * regions[ri].area; cb += regions[ri].color[2] * regions[ri].area; } const n = Math.max(1, g.area); return [cr / n, cg / n, cb / n]; }); const bigSet = new Set(bigs); for (const g of smalls) { const gi = groups.indexOf(g); let best = -1, bd = Infinity; for (const gj of (nbOf.get(gi) || [])) { const cand = groups[gj]; if (!bigSet.has(cand)) continue; const d = Math.abs(gcol[gi][0] - gcol[gj][0]) + Math.abs(gcol[gi][1] - gcol[gj][1]) + Math.abs(gcol[gi][2] - gcol[gj][2]); if (d < bd) { bd = d; best = gj; } } // 只有"颜色也对得上"才并(否则一片橙色的碎块会被并进蓝底,底色色场就被染花了); // 对不上的碎块自己留一层——小是小,但颜色是对的 if (best >= 0 && bd <= 90) { groups[best].regs.push(...g.regs); groups[best].area += g.area; } else bigs.push(g); } groups = bigs; } } groups.sort((a, b) => (a.isPaper === b.isPaper ? b.area - a.area : (a.isPaper ? -1 : 1))); nObj = groups.length; for (let oi = 0; oi < nObj; oi++) objRegions.push(groups[oi].regs); for (let oi = 0; oi < nObj; oi++) for (const ri of objRegions[oi]) objOfComp[ri] = oi; dbg.groups = groups.length; dbg.loose = 0; dbg.cores = groups.length; dbg.objs = nObj; lap('sweep:uf'); // --- 像素索引图 + 逐层统计 --- objLab = new Int32Array(N); for (let p = 0; p < N; p++) objLab[p] = objOfComp[compOf[p]]; const oA = new Float64Array(nObj), oSx = new Float64Array(nObj), oSy = new Float64Array(nObj); for (let p = 0; p < N; p++) { const oi = objLab[p], x = p % W; oA[oi]++; oSx[oi] += x; oSy[oi] += (p - x) / W; } const oXx = new Float64Array(nObj), oXy = new Float64Array(nObj), oYy = new Float64Array(nObj); const oCx = new Float64Array(nObj), oCy = new Float64Array(nObj); for (let oi = 0; oi < nObj; oi++) { oCx[oi] = oA[oi] ? oSx[oi] / oA[oi] : 0; oCy[oi] = oA[oi] ? oSy[oi] / oA[oi] : 0; } for (let p = 0; p < N; p++) { const oi = objLab[p], x = p % W, y = (p - x) / W; const dx = x - oCx[oi], dy = y - oCy[oi]; oXx[oi] += dx * dx; oXy[oi] += dx * dy; oYy[oi] += dy * dy; } for (let oi = 0; oi < nObj; oi++) objAdj.push(new Set()); for (let y = 0; y < H; y++) { const row = y * W; for (let x = 0; x < W; x++) { const p = row + x, a = objLab[p]; if (x + 1 < W) { const b = objLab[p + 1]; if (b !== a) { objAdj[a].add(b); objAdj[b].add(a); } } if (y + 1 < H) { const b = objLab[p + W]; if (b !== a) { objAdj[a].add(b); objAdj[b].add(a); } } } } for (let oi = 0; oi < nObj; oi++) { const rl = objRegions[oi]; let x0 = W, y0 = H, x1 = -1, y1 = -1, cr = 0, cg = 0, cb = 0, isPaper = true; for (const ri of rl) { const rg = regions[ri]; if (rg.x0 < x0) x0 = rg.x0; if (rg.y0 < y0) y0 = rg.y0; if (rg.x1 > x1) x1 = rg.x1; if (rg.y1 > y1) y1 = rg.y1; cr += rg.color[0] * rg.area; cg += rg.color[1] * rg.area; cb += rg.color[2] * rg.area; if (!isBg(rg)) isPaper = false; } if (x1 < 0) { x0 = y0 = 0; x1 = y1 = 0; } const n = Math.max(1, oA[oi]); const sxx = oXx[oi] / n, sxy = oXy[oi] / n, syy = oYy[oi] / n; const tr = sxx + syy, det = sxx * syy - sxy * sxy; const l1 = tr / 2 + Math.sqrt(Math.max(0, tr * tr / 4 - det)); const l2 = tr / 2 - Math.sqrt(Math.max(0, tr * tr / 4 - det)); const ang = 0.5 * Math.atan2(2 * sxy, sxx - syy); let ax = Math.cos(ang), ay = Math.sin(ang); // 主轴要落在"更长"的那一侧(否则一档档刷的方向是横着的,档数暴涨) if (l1 < l2) { const t = ax; ax = -ay; ay = t; } objStat.push({ oi, area: oA[oi], x0, y0, x1, y1, cx: oCx[oi], cy: oCy[oi], ax, ay, l1: Math.sqrt(Math.max(0, l1)), l2: Math.sqrt(Math.max(0, l2)), tmin: Infinity, tmax: -Infinity, vmax: 0, hp: 1, color: [Math.round(cr / n), Math.round(cg / n), Math.round(cb / n)], isPaper, linear: false, from: 0, }); } for (let p = 0; p < N; p++) { const oi = objLab[p], st = objStat[oi]; const x = p % W, y = (p - x) / W; const t = (x - st.cx) * st.ax + (y - st.cy) * st.ay; const v = Math.abs(-(x - st.cx) * st.ay + (y - st.cy) * st.ax); if (t < st.tmin) st.tmin = t; if (t > st.tmax) st.tmax = t; if (v > st.vmax) st.vmax = v; } for (const st of objStat) { if (st.tmin > st.tmax) { st.tmin = st.tmax = 0; } st.hp = Math.max(1, st.vmax); } lap('sweep:stat'); // 底色层:第一个非纸底的层 baseObj = -1; for (let oi = 0; oi < nObj; oi++) if (!objStat[oi].isPaper && objStat[oi].area >= 200) { baseObj = oi; break; } if (baseObj < 0) baseObj = 0; dbg.base = baseObj; // --- 边界按颜色重判(抗锯齿过渡带)--- // 目标图的边缘有一圈 1~2px 的混色像素;量化+合并把它们随机分给两侧的对象, // 于是"本来是直的边"看起来像被啃过(用户 2026-10-18 反馈的飞机边缘)。 // 规则:贴着异类的像素,归给"颜色离它更近的那个邻居"——边界就被钉在 50% 混色处, // 直边变直、圆边变顺(平涂画法本来也不画毛边,只画两侧的纯色)。 // 边界带(离异类 ≤2px)的像素清单:下面三个"边界重判"块都只扫这些像素—— // 整幅扫描一个像素一个像素地查 Map,光这一项就要 7 秒 const bandList = (() => { const band = new Uint8Array(N); for (let y = 0; y < H; y++) { const row = y * W; for (let x = 0; x < W; x++) { const p = row + x, a = objLab[p]; if ((x + 1 < W && objLab[p + 1] !== a) || (y + 1 < H && objLab[p + W] !== a) || (x > 0 && objLab[p - 1] !== a) || (y > 0 && objLab[p - W] !== a)) band[p] = 1; } } const bd = dilate(band, W, H, 2); const list = []; for (let p = 0; p < N; p++) if (bd[p]) list.push(p); return Int32Array.from(list); })(); if (opts.edgeSnap) { let maxCid = 0; for (let p = 0; p < N; p++) if (compOf[p] > maxCid) maxCid = compOf[p]; const cR = new Int32Array(maxCid + 1).fill(-1), cG = new Int32Array(maxCid + 1), cB = new Int32Array(maxCid + 1); for (const rg of regions) { cR[rg.cid] = rg.color[0]; cG[rg.cid] = rg.color[1]; cB[rg.cid] = rg.color[2]; } const colOfC = { get: (cid) => (cid >= 0 && cR[cid] >= 0 ? [cR[cid], cG[cid], cB[cid]] : null) }; const DX4 = [1, -1, 0, 0], DY4 = [0, 0, 1, -1]; let snapped = 0; // 过渡带可能有 2~3px 宽(扫描件的边缘不锐):一轮只剥一层,所以走几轮 for (let it = 0; it < (opts.edgeSnapIter | 0); it++) { const chP = [], chO = []; for (let bi = 0; bi < bandList.length; bi++) { { const p = bandList[bi], x = p % W, y = (p - x) / W, a = objLab[p]; if (objStat[a].isPaper) continue; const ca = colOfC.get(compOf[p]); if (!ca) continue; const da = Math.abs(R[p] - ca[0]) + Math.abs(G[p] - ca[1]) + Math.abs(B[p] - ca[2]); // 自己的区色已经贴近原图 ⇒ 这多半是抗锯齿过渡带的混色(本来就该是混色), // 硬推成邻居的纯色只会把边推错 1px。只有"区色明显不像原图"才是真错分。 if (da < opts.edgeSnapBad) continue; const cap = opts.edgeSnapRatio > 0 ? da * opts.edgeSnapRatio : Infinity; let best = -1, bd = Math.min(da - opts.edgeSnapTol, cap); for (let d = 0; d < 4; d++) { const xx = x + DX4[d], yy = y + DY4[d]; if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue; const q = yy * W + xx, b = objLab[q]; if (b === a || objStat[b].isPaper) continue; const cb = colOfC.get(compOf[q]); if (!cb) continue; const d2 = Math.abs(R[p] - cb[0]) + Math.abs(G[p] - cb[1]) + Math.abs(B[p] - cb[2]); if (d2 < bd) { bd = d2; best = b; } } if (best >= 0) { chP.push(p); chO.push(best); } } } if (!chP.length) break; for (let i = 0; i < chP.length; i++) objLab[chP[i]] = chO[i]; snapped += chP.length; } dbg.edgeSnap = snapped; } lap('sweep:rejudge'); // --- 过渡带(抗锯齿斜坡)不能单独成层 --- // 目标图的边上有 1~3px 的混色带;量化会把它切成"一条细长的区块", // 于是它自己成了一层、被涂成中间那个灰紫色(飞机边上的灰条就是这么来的)。 // 判据:在两个不同方向各找一个"别的层",自己的颜色正好是那两层纯色的混色 ⇒ // 按"离哪边近"并进去(平涂画法里这条边就是两层颜色的硬交界)。 if (opts.edgeRamp) { const colOfC = new Map(); for (const rg of regions) colOfC.set(rg.cid, rg.color); const DX4 = [1, -1, 0, 0], DY4 = [0, 0, 1, -1]; const R4 = opts.edgeRampR | 0; let rampN = 0; for (let it = 0; it < 3; it++) { const chP = [], chO = []; for (let bi = 0; bi < bandList.length; bi++) { { const p = bandList[bi], x = p % W, y = (p - x) / W, a = objLab[p]; if (objStat[a].isPaper) continue; const sides = []; // 四个方向各自最近的"别的层" for (let d = 0; d < 4; d++) { for (let s = 1; s <= R4; s++) { const xx = x + DX4[d] * s, yy = y + DY4[d] * s; if (xx < 0 || xx >= W || yy < 0 || yy >= H) break; const q = yy * W + xx, b = objLab[q]; if (b === a) continue; if (objStat[b].isPaper) break; const cb = colOfC.get(compOf[q]); if (!cb) break; sides.push({ b, cb, d2: Math.abs(R[p] - cb[0]) + Math.abs(G[p] - cb[1]) + Math.abs(B[p] - cb[2]) }); break; } } if (sides.length < 2) continue; // 挑"两个不同层"里离得最近的一对 let s1 = -1, s2 = -1, sBest = Infinity; for (let i = 0; i < sides.length; i++) { for (let j = i + 1; j < sides.length; j++) { if (sides[i].b === sides[j].b) continue; const sum = sides[i].d2 + sides[j].d2; if (sum < sBest) { sBest = sum; s1 = i; s2 = j; } } } if (s1 < 0) continue; const A = sides[s1].cb, Bv = sides[s2].cb; const vx = Bv[0] - A[0], vy = Bv[1] - A[1], vz = Bv[2] - A[2]; const vv = vx * vx + vy * vy + vz * vz; if (vv < 400) continue; // 两层颜色太近:无所谓 const px2 = R[p] - A[0], py2 = G[p] - A[1], pz2 = B[p] - A[2]; const t = (px2 * vx + py2 * vy + pz2 * vz) / vv; if (t <= 0.15 || t >= 0.85) continue; // 更靠某一侧 ⇒ 不是混色带 const dx2 = px2 - t * vx, dy2 = py2 - t * vy, dz2 = pz2 - t * vz; if (Math.abs(dx2) + Math.abs(dy2) + Math.abs(dz2) > opts.rampTol) continue; chP.push(p); chO.push(sides[s1].d2 <= sides[s2].d2 ? sides[s1].b : sides[s2].b); } } if (!chP.length) break; for (let i = 0; i < chP.length; i++) objLab[chP[i]] = chO[i]; rampN += chP.length; } dbg.edgeRamp = rampN; } // 扫描件的网点 + 抗锯齿会把直边搅成 2px 上下的锯齿(用户说的"本来是直的却像被啃过")。 lap('sweep:ramp'); // --- 掩膜边界"去锯齿"(多数表决)--- // 扫描件的网点 + 抗锯齿会把直边搅成 2px 上下的锯齿(用户说的"本来是直的却像被啃过")。 // 在 5x5 窗口里数各层占了多少格,只有"压倒性多数"(≥ maskMajThr)才改判: // 3px 宽的细杆/笔画在 5x5 里最多占 15 格,达不到压倒多数 ⇒ 不会被吃掉。 if (opts.maskMajority) { const R2 = (opts.maskMajority | 0) >> 1; const cnts = new Int32Array(nObj), touched = []; for (let it = 0; it < 2; it++) { const chP = [], chO = []; for (let bi = 0; bi < bandList.length; bi++) { { const p = bandList[bi], x = p % W, y = (p - x) / W, a = objLab[p]; if (objStat[a].isPaper) continue; touched.length = 0; const y0 = Math.max(0, y - R2), y1 = Math.min(H - 1, y + R2); const x0 = Math.max(0, x - R2), x1 = Math.min(W - 1, x + R2); for (let yy = y0; yy <= y1; yy++) { const row = yy * W; for (let xx = x0; xx <= x1; xx++) { const b = objLab[row + xx]; if (cnts[b]++ === 0) touched.push(b); } } let best = -1, bc = 0; for (const b of touched) { if (b === a || objStat[b].isPaper) continue; if (cnts[b] > bc) { bc = cnts[b]; best = b; } } const ca2 = cnts[a]; for (const b of touched) cnts[b] = 0; if (best >= 0 && bc >= opts.maskMajThr && bc > ca2) { chP.push(p); chO.push(best); } } } if (!chP.length) break; for (let i = 0; i < chP.length; i++) objLab[chP[i]] = chO[i]; dbg.maskMaj = (dbg.maskMaj || 0) + chP.length; } } lap('sweep:maj'); // --- 可见部分的掩膜 + 每层的"厚度/细长"判定 --- for (let oi = 0; oi < nObj; oi++) { const st = objStat[oi]; const ownM = regionMaskOf(objLab, W, oi, st.x0, st.y0, st.x1, st.y1); const own = { x0: st.x0, y0: st.y0, w: ownM.w, h: ownM.h, m: ownM.m }; objBox.push(own); if (st.isPaper || st.area < 160 || oi === baseObj) continue; const dt = distanceTransform(own.m, own.w, own.h); const vals = []; for (let i = 0; i < dt.length; i++) if (own.m[i]) vals.push(dt[i]); vals.sort((a, b) => a - b); const med = vals.length ? vals[vals.length >> 1] : 0; st.thick = med; // 细长(黑框、支柱、字的笔画):这一层"能画几个自己的厚度那么长"。 // 用中轴厚度而不是外框短边:黑框是个大圈(外框=整幅),但它处处只有十几像素厚。 const elong = (st.tmax - st.tmin) / Math.max(1, 2 * med); st.elong = elong; // 两个条件都要满足:够长(能画 9 个自己的厚度)+ 真的薄(绝对厚度有上限, // 否则照片里一大片细长的区域也会被判成"线",几千笔去描它) st.linear = elong >= 9 && med * 2 <= Math.max(12, 24 * strokeScale); } lap('sweep:vis'); // --- 完整形状:底色层 = 整幅(除了纸底);其它层 = 自己可见的部分 ∪ 被上层盖住的部分 --- // "被上层盖住的部分"用形状闭合推断:把本层形状按"贴着它的上层有多厚"闭一下, // 闭出来的区域里属于上层的像素就是"本来也是这一层、只是被盖住了"。 // 判据只认"上层",所以飞机两翼之间那片天(下层)不会被补进飞机里。 const paperPix = new Uint8Array(N); for (let p = 0; p < N; p++) if (objStat[objLab[p]].isPaper) paperPix[p] = 1; for (let oi = 0; oi < nObj; oi++) { const st = objStat[oi]; const own = objBox[oi]; if (oi === baseObj) { const m = new Uint8Array(N); for (let p = 0; p < N; p++) if (!paperPix[p]) m[p] = 1; objFull.push({ x0: 0, y0: 0, w: W, h: H, m }); continue; } if (st.isPaper || !opts.occlude) { objFull.push(own); continue; } // own 现在带 x0/y0 let r = 0; for (const ob of objAdj[oi]) if (ob > oi && !objStat[ob].isPaper) r = Math.max(r, objStat[ob].hp * 2); // 外扩量 = 贴着它的上层有多厚 × 系数(objGrowF)。补出来的"完整形状"只要盖到 // 上层的一半就够,再往外就是上层自己的地盘 —— 系数偏大时,字/飞机那些凸出的 // 部位会伸出一圈圆头小凸起(回放画到那一层时看得见)。 r = clamp(Math.round(r * opts.objGrowF), 3, 26); if (r <= 3) { objFull.push(own); continue; } const bx0 = Math.max(0, st.x0 - r), by0 = Math.max(0, st.y0 - r); const bx1 = Math.min(W - 1, st.x1 + r), by1 = Math.min(H - 1, st.y1 + r); const bw = bx1 - bx0 + 1, bh = by1 - by0 + 1; const sub = new Uint8Array(bw * bh), above = new Uint8Array(bw * bh); let any = false; for (let y = 0; y < bh; y++) { const row = (y + by0) * W; for (let x = 0; x < bw; x++) { const o = objLab[row + bx0 + x]; if (o === oi) sub[y * bw + x] = 1; else if (o > oi && !objStat[o].isPaper) { above[y * bw + x] = 1; any = true; } } } if (!any) { objFull.push(own); continue; } const cl = closeMaskDisk(sub, bw, bh, r); const m = new Uint8Array(bw * bh); let added = 0; for (let i = 0; i < m.length; i++) { if (sub[i]) m[i] = 1; else if (cl[i] && above[i]) { m[i] = 1; added++; } } if (!added) { objFull.push(own); continue; } st.grown = added; objFull.push({ x0: bx0, y0: by0, w: bw, h: bh, m }); } lap('sweep:full'); // --- 底色层的"背景色场":被字/飞机盖住的地方看不见,按周围推出来 --- // 已知像素 = 底色层自己(往里缩 2px 避开边界混色),颜色取平滑图;未知像素按"最近的 // 已知像素"向里灌(chamfer 两趟),再平滑几遍 —— 整幅任何位置都能取到底色。 if (baseObj >= 0 && objBox[baseObj]) { const bm = objBox[baseObj]; const bin = new Uint8Array(N); for (let y = 0; y < bm.h; y++) { const row = (y + objStat[baseObj].y0) * W; for (let x = 0; x < bm.w; x++) if (bm.m[y * bm.w + x]) bin[row + objStat[baseObj].x0 + x] = 1; } const core = erode(bin, W, H, 3); lap('bg:core'); const smoothish = (p2) => { // 平滑图在这里可不可信(跟区块纯色对得上 = 没被隔壁糊过来) const rc2 = regions[compOf[p2]].color; return Math.abs(rc2[0] - Rs[p2]) + Math.abs(rc2[1] - Gs[p2]) + Math.abs(rc2[2] - Bs[p2]) <= 36; }; const cr = new Float32Array(N), cg = new Float32Array(N), cb = new Float32Array(N); const known = new Uint8Array(N); let nKnown = 0; if (opts.photoMode) { // 照片:底色层就是"整幅的底色"——用平滑图(明暗关系保住),别用"最近的底色像素"往外灌 // (灌出来的色场在远处的暗部会整片错,实测差 15 个点,而残差补笔又嫌那些块"又大又厚"不补) for (let p = 0; p < N; p++) { const rc = regions[compOf[p]].color; if (Math.abs(rc[0] - Rs[p]) + Math.abs(rc[1] - Gs[p]) + Math.abs(rc[2] - Bs[p]) <= 36) { cr[p] = Rs[p]; cg[p] = Gs[p]; cb[p] = Bs[p]; } else { cr[p] = rc[0]; cg[p] = rc[1]; cb[p] = rc[2]; } } bgMap = { R: cr, G: cg, B: cb }; dbg.bgFill = 0; if (opts.dumpMasks) { const bm2 = new Uint8Array(N * 3); for (let pp = 0; pp < N; pp++) { bm2[pp*3] = cr[pp]; bm2[pp*3+1] = cg[pp]; bm2[pp*3+2] = cb[pp]; } dbg.bgMapImg = bm2; } } else { for (let p = 0; p < N; p++) { if (!core[p] || inkPix[p] || texPix[p]) continue; // 细线/纹理像素被吸收进底色,但不是底色的颜色 // 颜色取"平滑图",但只在这个像素的平滑色跟它所属区块的纯色对得上时—— // 对不上说明平滑把隔壁(字/飞机)的颜色糊过来了,这时用区块自己的纯色(干净) const rc = regions[compOf[p]].color; if (Math.abs(rc[0] - Rs[p]) + Math.abs(rc[1] - Gs[p]) + Math.abs(rc[2] - Bs[p]) <= 36) { cr[p] = Rs[p]; cg[p] = Gs[p]; cb[p] = Bs[p]; } else { cr[p] = rc[0]; cg[p] = rc[1]; cb[p] = rc[2]; } known[p] = 1; nKnown++; } lap('bg:known'); const INF = 1e9; const dist = new Float32Array(N); for (let p = 0; p < N; p++) dist[p] = known[p] ? 0 : INF; const prop = (p, q, w2) => { const d = dist[q] + w2; if (d < dist[p]) { dist[p] = d; cr[p] = cr[q]; cg[p] = cg[q]; cb[p] = cb[q]; } }; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const p = y * W + x; if (dist[p] === 0) continue; if (x > 0) prop(p, p - 1, 3); if (y > 0) prop(p, p - W, 3); if (x > 0 && y > 0) prop(p, p - W - 1, 4); if (x + 1 < W && y > 0) prop(p, p - W + 1, 4); } } for (let y = H - 1; y >= 0; y--) { for (let x = W - 1; x >= 0; x--) { const p = y * W + x; if (dist[p] === 0) continue; if (x + 1 < W) prop(p, p + 1, 3); if (y + 1 < H) prop(p, p + W, 3); if (x + 1 < W && y + 1 < H) prop(p, p + W + 1, 4); if (x > 0 && y + 1 < H) prop(p, p + W - 1, 4); } } lap('bg:chamfer'); // 平滑 4 遍(3×3 盒式模糊):灌出来的硬边、区块之间的色阶差都抹平。 // 提速两处,都不改结果: // ① 内点(1..H-2 行 × 1..W-2 列)九邻域齐全 ⇒ 去掉全部边界判断和 yy*W 乘法, // 计数恒为 9;边界像素只有 O(W+H) 个,照旧走通用写法。 // ② 双缓冲,省掉每遍 new 三个 4MB 数组 + cr.set(nr) 的两趟拷贝。 // 为什么"展开"不改结果:输入是 float32(24 位尾数),9 个数相加最多 28 位 // < float64 的 53 位 ⇒ 求和本来就是精确的、与加法顺序无关。 let curR = cr, curG = cg, curB = cb; let nxtR = new Float32Array(N), nxtG = new Float32Array(N), nxtB = new Float32Array(N); const blurGeneric = (x, y) => { const p = y * W + x; let sr = 0, sg = 0, sb = 0, cnt = 0; for (let dy = -1; dy <= 1; dy++) { const yy = y + dy; if (yy < 0 || yy >= H) continue; for (let dx = -1; dx <= 1; dx++) { const xx = x + dx; if (xx < 0 || xx >= W) continue; const q = yy * W + xx; sr += curR[q]; sg += curG[q]; sb += curB[q]; cnt++; } } nxtR[p] = sr / cnt; nxtG[p] = sg / cnt; nxtB[p] = sb / cnt; }; for (let it = 0; it < 4; it++) { if (W >= 3 && H >= 3) { for (let y = 1; y < H - 1; y++) { const yc = y * W; for (let x = 1; x < W - 1; x++) { const p = yc + x; let sr = 0, sg = 0, sb = 0; for (let dy = -1; dy <= 1; dy++) { const row = yc + dy * W; for (let dx = -1; dx <= 1; dx++) { const q = row + x + dx; sr += curR[q]; sg += curG[q]; sb += curB[q]; } } nxtR[p] = sr / 9; nxtG[p] = sg / 9; nxtB[p] = sb / 9; } } for (let x = 0; x < W; x++) { blurGeneric(x, 0); blurGeneric(x, H - 1); } for (let y = 1; y < H - 1; y++) { blurGeneric(0, y); blurGeneric(W - 1, y); } } else { for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) blurGeneric(x, y); } { const t = curR; curR = nxtR; nxtR = t; } { const t = curG; curG = nxtG; nxtG = t; } { const t = curB; curB = nxtB; nxtB = t; } } if (curR !== cr) { cr.set(curR); cg.set(curG); cb.set(curB); } lap('bg:blur'); bgMap = { R: cr, G: cg, B: cb }; dbg.bgFill = nKnown; } if (opts.probePts) { dbg.probeInfo = opts.probePts.map(([x, y]) => { const p0 = y * W + x, ci = compOf[p0]; return { x, y, compOf: ci, regColor: regions[ci] ? regions[ci].color : null, obj: objOfComp[ci], isBase: objOfComp[ci] === baseObj, inBaseMask: bin[p0] ? 1 : 0, inCore: core[p0] ? 1 : 0, ink: inkPix[p0] ? 1 : 0, tex: texPix[p0] ? 1 : 0, Rs: [Rs[p0], Gs[p0], Bs[p0]], known: known[p0] ? 1 : 0, bg: [Math.round(cr[p0]), Math.round(cg[p0]), Math.round(cb[p0])] }; }); } if (opts.dumpMasks) { const bm2 = new Uint8Array(N * 3); for (let pp = 0; pp < N; pp++) { bm2[pp*3] = cr[pp]; bm2[pp*3+1] = cg[pp]; bm2[pp*3+2] = cb[pp]; } dbg.bgMapImg = bm2; } } lap('sweep:obj'); // ---- 细线组划分 + 归属("两拨画法"的分派表)---- // 算一次连通域,按"行"排好(文字和轮廓都是成行成列的),再看每组贴着谁: // 贴着"某个对象"的 ⇒ 跟那个对象一起画(猫嘴跟着猫脸,字的描边走完字就画上); // 只贴着底色/纸底的(题字、落款、印在底上的小字)⇒ 留到最后写。 { const icc = compsOfLabels(inkBin, W, H); const groups = []; for (let ci = 0; ci < icc.comps.length; ci++) { if (icc.comps[ci].label === 1 && icc.comps[ci].count >= 6) groups.push(ci); } groups.sort((a, b) => icc.comps[b].count - icc.comps[a].count); const items = groups.slice(0, 3000).map((ci) => ({ ci, cx: (icc.comps[ci].x0 + icc.comps[ci].x1) / 2, cy: (icc.comps[ci].y0 + icc.comps[ci].y1) / 2, h: icc.comps[ci].y1 - icc.comps[ci].y0 + 1, })); items.sort((a, b) => a.cy - b.cy || a.cx - b.cx); const rows = []; for (const it of items) { const cur = rows[rows.length - 1]; if (!cur || it.cy - cur.cy > Math.max(9 * strokeScale, cur.h * 0.8)) { rows.push({ cy: it.cy, h: it.h, items: [it] }); } else { cur.items.push(it); if (it.h > cur.h) cur.h = it.h; } } const gSeq = []; for (const r of rows) { r.items.sort((a, b) => a.cx - b.cx); // 一行内从左到右 for (const it of r.items) gSeq.push(it.ci); } // "背景侧"对象:纸底和底色层——只贴着它们的细线留到最后写 const skipObjs = new Set(); for (let oi = 0; oi < nObj; oi++) if (objStat[oi].isPaper) skipObjs.add(oi); if (baseObj >= 0) skipObjs.add(baseObj); let nToObj = 0, nLast = 0; for (const ci of gSeq) { const bw = bboxOfComp(icc, ci, 3); const m = dilate(bw.m, bw.w, bw.h, 3); const cnt = new Map(); for (let y = 0; y < bw.h; y++) { const row = (y + bw.y) * W + bw.x; for (let x = 0; x < bw.w; x++) { if (!m[y * bw.w + x]) continue; const o = objOfComp[compOf[row + x]]; cnt.set(o, (cnt.get(o) || 0) + 1); } } let bestO = -1, bestC = 0; for (const [o, c] of cnt) { if (skipObjs.has(o)) continue; if (c > bestC) { bestC = c; bestO = o; } } if (bestO >= 0 && bestC >= 8) { if (!inkOfObj.has(bestO)) inkOfObj.set(bestO, []); inkOfObj.get(bestO).push(ci); nToObj++; } else { inkLastSeq.push(ci); nLast++; } } inkIcc = icc; dbg.inkToObj = nToObj; dbg.inkLast = nLast; } const bleedRad = Math.max(1, Math.round(opts.bleedR * strokeScale)); // 一个对象画完,立刻把它身上的细线补上(猫嘴/描边不拖到最后,最后只留题字) let curInkObj = -1; const drawInkOfObj = (oi) => { const l = inkOfObj.get(oi); if (l) drawInkSeq(l, null, 1); // ph=1:算"铺色阶段"的笔(跟着对象走) }; // 一层"完整形状"沿某个方向的真实范围(一档档刷要按它来,刷不到就是白边) const layerSpan = (full, ax, ay, cx, cy) => { let tmin = Infinity, tmax = -Infinity, vmax = 0; for (let y = 0; y < full.h; y++) { for (let x = 0; x < full.w; x++) { if (!full.m[y * full.w + x]) continue; const gx = x + full.x0, gy = y + full.y0; const t = (gx - cx) * ax + (gy - cy) * ay; const v = Math.abs(-(gx - cx) * ay + (gy - cy) * ax); if (t < tmin) tmin = t; if (t > tmax) tmax = t; if (v > vmax) vmax = v; } } if (tmin > tmax) { tmin = tmax = 0; } return { tmin, tmax, vmax }; }; // ---- 细长的一层:中轴 + 跟着形状的宽度(直线一笔到底,不切段、不手抖)---- const paintAsLines = (oi, full, sampleM) => { const st = objStat[oi]; const cIdx = pushClip(full.x0, full.y0, full.w, full.h, full.m); const halfMin = 1.3 * strokeScale, halfMax = 16 * strokeScale; const queue = [{ m: full.m.slice(), x: 0, y: 0, w: full.w, h: full.h }]; let guard = 0; while (queue.length && guard++ < 400) { const it = queue.shift(); if (it.w >= 2 && it.h >= 2) { const dt = distanceTransform(it.m, it.w, it.h); globalThis.__thinTag = 'line' + (it.w >= full.w ? 'full' : 'sub'); const { sk } = thin(it.m, it.w, it.h); tick('thin'); const lp = longestPath(sk, it.w, it.h); if (lp && lp.len >= 2) { const abs = new Float64Array(lp.len * 2); for (let i = 0; i < lp.len; i++) { abs[i * 2] = lp.pts[i * 2] + it.x + full.x0; abs[i * 2 + 1] = lp.pts[i * 2 + 1] + it.y + full.y0; } // 直线段拉直:中轴是一像素一像素拐出来的,不简化就是坑坑洼洼 const sp = rdpSimplify(abs, lp.len, opts.lineTol); const halfAt = (x, y) => { const lx = Math.round(x - full.x0 - it.x), ly = Math.round(y - full.y0 - it.y); let d = 0; if (lx >= 0 && lx < it.w && ly >= 0 && ly < it.h) d = dt[ly * it.w + lx]; return clamp(d + 0.8 * strokeScale, halfMin, halfMax); }; // 每点半宽(拉直后的顶点上取厚度) const halfs = new Float64Array(sp.n); for (let i = 0; i < sp.n; i++) halfs[i] = halfAt(sp.pts[i * 2], sp.pts[i * 2 + 1]); // 落笔前先算"这支笔盖到哪":中轴被拉直之后,笔形会切掉形状上的凸起/拐角, // 那些没盖到的像素以前是留给"每层自检"用方框补丁补的——边缘就成了坑坑洼洼 // (用户 2026-10-18 反馈的"本来是直的却像被啃过")。 // 这里改成**就地加宽**:把离缺口最近的那个顶点加宽到能盖住它(不新增笔画)。 if (opts.lineSeal && sp.n >= 2) { for (let sit = 0; sit < (opts.sealIter | 0); sit++) { const r0 = ribbonProf(sp.pts, sp.n, halfs); const loc0 = new Float64Array(r0.length); for (let i = 0; i < r0.length; i += 2) { loc0[i] = r0[i] - full.x0 - it.x; loc0[i + 1] = r0[i + 1] - full.y0 - it.y; } const cov0 = new Uint8Array(it.w * it.h); fillPoly(cov0, it.w, it.h, loc0); const gaps = []; for (let i = 0; i < it.m.length; i++) if (it.m[i] && !cov0[i]) gaps.push(i); if (!gaps.length) break; let nW = 0; for (const gi of gaps) { const gx = (gi % it.w) + full.x0 + it.x, gy = ((gi / it.w) | 0) + full.y0 + it.y; let bi = 0, bd = Infinity; for (let i = 0; i < sp.n; i++) { const dx = sp.pts[i * 2] - gx, dy = sp.pts[i * 2 + 1] - gy; const d = dx * dx + dy * dy; if (d < bd) { bd = d; bi = i; } } const need = Math.sqrt(bd) + 0.9 * strokeScale; if (need > halfs[bi]) { halfs[bi] = need; nW++; } } if (!nW) break; } } const halfAt2 = (x, y) => { let bi = 0, bd = Infinity; for (let i = 0; i < sp.n; i++) { const dx = sp.pts[i * 2] - x, dy = sp.pts[i * 2 + 1] - y; const d = dx * dx + dy * dy; if (d < bd) { bd = d; bi = i; } } return Math.max(halfAt(x, y), halfs[bi]); }; for (const p2 of localPartsProf(sp.pts, sp.n, halfAt2, sampleM, cIdx, oi, st.color, 1, 1, halfAt)) { emitStroke(p2.rib, p2.rib.length / 2, p2.path, p2.path.length / 2, Math.max(halfMin, st.thick || 2), p2.col, p2.pen, cIdx, oi, 1); } // 扣掉已经盖住的部分,剩下的连通块继续排队 const r = ribbonProf(sp.pts, sp.n, halfs); const loc = new Float64Array(r.length); for (let i = 0; i < r.length; i += 2) { loc[i] = r[i] - full.x0 - it.x; loc[i + 1] = r[i + 1] - full.y0 - it.y; } const cov = new Uint8Array(it.w * it.h); fillPoly(cov, it.w, it.h, loc); let nMask = 0, nCov = 0, nAfter = 0; for (let i = 0; i < it.m.length; i++) { if (it.m[i]) { nMask++; if (cov[i]) nCov++; } } for (let i = 0; i < it.m.length; i++) if (it.m[i] && cov[i]) it.m[i] = 0; for (let i = 0; i < it.m.length; i++) if (it.m[i]) nAfter++; if (opts.debug) (dbg.lineInfo || (dbg.lineInfo = [])).push({ oi, w: it.w, h: it.h, mask: nMask, cov: nCov, left: nAfter, pts: sp.n, raw: lp.len }); } } // 剩下的连通块:各自的外框里继续排队(不整块重跑——细长层的外框可能是整幅) const rc = compsOfLabels(it.m, it.w, it.h); for (let j = 0; j < rc.comps.length; j++) { const c = rc.comps[j]; if (c.label !== 1 || c.count < 6) continue; const mw = c.x1 - c.x0 + 1, mh = c.y1 - c.y0 + 1; const nm = new Uint8Array(mw * mh); for (let k = 0; k < c.count; k++) { const p3 = rc.pix[c.start + k], x = p3 % it.w, y = (p3 - x) / it.w; nm[(y - c.y0) * mw + (x - c.x0)] = 1; } queue.push({ m: nm, x: it.x + c.x0, y: it.y + c.y0, w: mw, h: mh }); } } }; // ---- 一层一整片刷(原来的毛笔刷:沿主轴一档档来回涂)---- const paintSweep = (oi, full, sampleM) => { const st = objStat[oi]; const isBase = oi === baseObj; const ax = isBase ? (W >= H ? 1 : 0) : st.ax; const ay = isBase ? (W >= H ? 0 : 1) : st.ay; const rcx = isBase ? W * 0.5 : st.cx, rcy = isBase ? H * 0.5 : st.cy; const span = layerSpan(full, ax, ay, rcx, rcy); const rtmin = span.tmin, rtmax = span.tmax; const vHalf = Math.max(1, span.vmax); const cIdx = pushClip(full.x0, full.y0, full.w, full.h, full.m); // 一小部分笔允许"画出界":像人一样压到轮廓外面,后面被别的颜色覆盖回来 const allowBleed = opts.bleedP > 0 && st.area >= 1200 && !isBase && bleedOK[oi]; const cBleed = allowBleed ? pushClip(full.x0, full.y0, full.w, full.h, dilate(full.m, full.w, full.h, bleedRad)) : cIdx; const ext0 = vHalf * 2; let bwid = clamp(ext0 * opts.bwF, opts.bwMin * strokeScale, opts.bwMax * strokeScale); // 窄的一层不抖:宽度本来就窄,抖一点边就花了(黑框/细条带就是这么变坑洼的) const thinF = clamp(vHalf / 22, 0.25, 1); const wobA = (opts.organic ? Math.min(2.8, bwid * 0.13) : Math.min(2.6, bwid * 0.1)) * thinF; const bowA = (opts.organic ? Math.min(2.4, bwid * 0.11) : 0) * thinF; const dev = wobA + bowA; const ext = ext0 + 2 * dev; let nP = Math.max(1, Math.ceil(ext / (bwid * opts.coverOv))); if (nP > 24) { nP = 24; bwid = Math.max(bwid, ext / (nP * opts.coverOv)); } sweepRegions++; passTotal += nP; if (nP > passMax) passMax = nP; const px2 = -ay, py2 = ax; const wobPh = rand01(oi * 7919 + 13, 1) * 6.283; const wobW = 2.2 + 2.6 * rand01(oi * 7919 + 41, 9); const bowDir = rand01(oi * 7919 + 29, 3) > 0.5 ? 1 : -1; const tPen = (lastPen.x - rcx) * ax + (lastPen.y - rcy) * ay; const fromPlus = tPen > 0; const rRep = objRegions[oi].length ? objRegions[oi][0] : -1; const phL = isBase ? 0 : 1; // 底色层 = "铺底色"这一步,其余层 = "铺色" const run = { parts: [], col: null, cIdx: -1, ex: 0, ey: 0 }; const flushRun = () => { const ps = run.parts; if (!ps.length) return; if (ps.length === 1) { const p = ps[0]; emitStroke(p.rib, p.rib.length / 2, p.path, p.path.length / 2, p.halfW, p.col, p.pen, p.cIdx, rRep, p.ph); } else { const rib = mergeLoops(ps.map((p) => p.rib)); let tot = 0; for (const p of ps) tot += p.path.length; const arr = new Float64Array(tot); let o = 0; for (const p of ps) { arr.set(p.path, o); o += p.path.length; } // 并成一笔之后只有一个颜色:取各段的"按长度加权均值",不能拿第一段的颜色—— // 一条长笔跨过渐变时,末端会整段刷成起点色(插画粉色圆里那块方块状鬼影就是这么来的) let cr2 = 0, cg2 = 0, cb2 = 0, cw2 = 0; for (const p of ps) { const w2 = p.path.length; cr2 += p.col[0] * w2; cg2 += p.col[1] * w2; cb2 += p.col[2] * w2; cw2 += w2; } const col2 = cw2 ? [Math.round(cr2 / cw2), Math.round(cg2 / cw2), Math.round(cb2 / cw2)] : ps[0].col; emitStroke(rib, rib.length / 2, arr, arr.length / 2, ps[0].halfW, col2, ps[0].pen, ps[0].cIdx, rRep, ps[0].ph); } run.parts = []; run.col = null; run.cIdx = -1; run.ex = 0; run.ey = 0; }; const pushPart = (p, halfW) => { p.halfW = halfW; if (!opts.joinPass) { run.parts.push(p); flushRun(); return; } if (run.parts.length) { const cd = Math.abs(p.col[0] - run.col[0]) + Math.abs(p.col[1] - run.col[1]) + Math.abs(p.col[2] - run.col[2]); // 只能并"接得上"的段:跨档并笔时路径会在两档之间跳过去, // 笔形跟着自交、绕数归零,画出来就是个破洞(回放里那些白斑就是它) const gap = Math.hypot(p.path[0] - run.ex, p.path[1] - run.ey); // 并笔的色差门槛跟"分段并笔"用同一个值(底色层是 6):用默认的 18 太松, // 一条长笔能跨 18 个色阶的渐变,末端就整段刷成起点色(鬼影的另一个来源) if (run.cIdx !== p.cIdx || cd >= (joinCdOverride || opts.joinCd) || gap > opts.joinGap) flushRun(); } if (!run.parts.length) { run.col = p.col; run.cIdx = p.cIdx; } run.parts.push(p); run.ex = p.path[p.path.length - 2]; run.ey = p.path[p.path.length - 1]; }; // 1) 先把这一层所有"档"的几何排好(路径 + 笔宽),先不落笔—— // 这样才有机会在落笔前把"档与档之间的缝"算出来(见下面的联合覆盖自检) const passes = []; for (let k = 0; k < nP; k++) { const pi = fromPlus ? (nP - 1 - k) : k; const dir = ((k % 2 === 0) !== fromPlus) ? 1 : -1; const seed = oi * 7919 + pi * 104729 + k * 15485863; const v = -vHalf - dev + (pi + 0.5) * (ext / nP); const bwidP = opts.organic ? bwid * (0.9 + 0.2 * rand01(seed, 11)) : bwid; const wobPhP = wobPh + (rand01(seed, 1) - 0.5) * 0.7; const bowP = opts.organic ? bowA * bowDir * (0.7 + 0.6 * rand01(seed, 3)) : 0; const e0 = opts.organic ? (rand01(seed, 5) - 0.35) * 5 : 3; const e1 = opts.organic ? (rand01(seed, 7) - 0.35) * 5 : 3; const tA = dir > 0 ? rtmin - 2 - e0 : rtmax + 2 + e1; const tB = dir > 0 ? rtmax + 2 + e1 : rtmin - 2 - e0; const nseg = opts.organic ? 6 : 5; const path = new Float64Array(nseg * 2); const halfs = new Float64Array(nseg); for (let s = 0; s < nseg; s++) { const f = nseg > 1 ? s / (nseg - 1) : 0; const tt = tA + (tB - tA) * f; const off = v + wobA * Math.sin(wobPhP + f * wobW) + bowP * Math.sin(Math.PI * f); path[s * 2] = rcx + ax * tt + px2 * off; path[s * 2 + 1] = rcy + ay * tt + py2 * off; halfs[s] = (bwidP / 2) * (opts.taperLo + opts.taperAmp * Math.sin(Math.PI * f) + opts.taperWob * Math.sin(wobPhP + f * 3.1)); } const useClip = (opts.bleedP > 0 && rand01(seed, 13) < opts.bleedP) ? cBleed : cIdx; passes.push({ path, halfs, n: nseg, half: bwidP / 2, useClip }); } // 2) 铺色"一次过":把这一层所有档的笔形按网页端口径(覆盖率 ≥ coverMin 才算画上) // 合起来光栅化一遍,掩膜里没盖到的像素**就地加宽离它最近的那一档**(不新增笔画)。 // 白缝在铺的时候就补平,不用等每层自检事后补——用户看到的"铺色中间有空洞、 // 最右边没铺到、后来又回来补一次"就是这么来的。 let sealed = false; if (opts.sweepSeal && full.w * full.h >= opts.sealMin) { const cov = new Uint8Array(full.w * full.h); const raster = (ps) => { const rib = ribbon(ps.path, ps.n, ps.half); let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; for (let i = 0; i < rib.length; i += 2) { const x = rib[i], y = rib[i + 1]; if (x < xmin) xmin = x; if (x > xmax) xmax = x; if (y < ymin) ymin = y; if (y > ymax) ymax = y; } const bx = Math.max(full.x0, Math.floor(xmin)), by = Math.max(full.y0, Math.floor(ymin)); const ex = Math.min(full.x0 + full.w - 1, Math.ceil(xmax)), ey = Math.min(full.y0 + full.h - 1, Math.ceil(ymax)); if (ex < bx || ey < by) return; const w2 = ex - bx + 1, h2 = ey - by + 1; const cv = new Float32Array(w2 * h2); const p2 = new Float64Array(rib.length); for (let i = 0; i < rib.length; i += 2) { p2[i] = rib[i] - bx; p2[i + 1] = rib[i + 1] - by; } fillPolyCov(cv, w2, h2, p2, opts.coverSubY || 1); const cl = clips[ps.useClip]; for (let y = 0; y < h2; y++) { const gy = by + y; if (gy < cl.y || gy >= cl.y + cl.h || gy < full.y0 || gy >= full.y0 + full.h) continue; const rowC = (gy - cl.y) * cl.w, rowF = (gy - full.y0) * full.w; for (let x = 0; x < w2; x++) { if (cv[y * w2 + x] < opts.coverMin) continue; const gx = bx + x; if (gx < cl.x || gx >= cl.x + cl.w) continue; if (!cl.mask[rowC + (gx - cl.x)]) continue; cov[rowF + (gx - full.x0)] = 1; } } }; for (const ps of passes) raster(ps); let left = 0; for (let it = 0; it < (opts.sealIter | 0); it++) { const gaps = []; for (let y = 0; y < full.h; y++) { const rowF = y * full.w; for (let x = 0; x < full.w; x++) if (full.m[rowF + x] && !cov[rowF + x]) gaps.push(full.x0 + x, full.y0 + y); } left = gaps.length >> 1; if (opts.debug) { const S2 = dbg.seal || (dbg.seal = { layer: 0, gap: 0, left: 0 }); if (it === 0) S2.gap += left; S2.left += 0; S2.last = left; } if (!left) break; const dirty = new Set(); for (let gi = 0; gi < gaps.length; gi += 2) { const gx = gaps[gi], gy = gaps[gi + 1]; let best = -1, bd = Infinity, bi = 0, bpx = 0, bpy = 0; for (let pi = 0; pi < passes.length; pi++) { const ps = passes[pi]; for (let i = 0; i < ps.n; i++) { const dx = ps.path[i * 2] - gx, dy = ps.path[i * 2 + 1] - gy; const d = dx * dx + dy * dy; if (d < bd) { bd = d; best = pi; bi = i; bpx = ps.path[i * 2]; bpy = ps.path[i * 2 + 1]; } } } if (best < 0) continue; const ps = passes[best]; const need = Math.hypot(bpx - gx, bpy - gy) + 0.9 * strokeScale; if (need > ps.half) { ps.half = need; dirty.add(best); } } if (!dirty.size) break; for (const pi of dirty) raster(passes[pi]); } sealed = !left; dbg.seal = dbg.seal || { layer: 0, gap: 0, left: 0 }; dbg.seal.layer++; } // 3) 落笔(颜色照旧:按段取局部色,相邻同色段并成一笔) for (const ps of passes) { if (opts.localColor) { for (const p of localParts(ps.path, ps.n, ps.half, sampleM.m, sampleM.x, sampleM.y, sampleM.w, sampleM.h, 0, ps.useClip, oi, st.color, phL)) { pushPart(p, ps.half); } } else { const rib = ribbon(ps.path, ps.n, ps.half); pushPart({ rib, path: ps.path, col: st.color, pen: 0, cIdx: ps.useClip, rIdx: oi, ph: phL }, ps.half); } } flushRun(); return sealed; }; // ---- 每层当场自检:这一层自己有没有没刷到的地方(不只底色层)---- // 把这一层刚落的笔按"网页端口径"(覆盖率 ≥ coverMin 才算画上)光栅化一遍, // 掩膜里没盖到的连通块立刻补掉——底层一次铺满,不留白斑等最后的补漏收拾。 const layerCheck = (oi, full, from) => { if (!opts.layerCheck) return; const cov = new Uint8Array(full.w * full.h); for (let si = from; si < strokes.length; si++) { const st0 = strokes[si]; const cl = clips[st0.c]; const rib = st0.rib; const np2 = rib.length >> 1; let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; for (let i = 0; i < np2; i++) { const x = rib[i * 2], y = rib[i * 2 + 1]; if (x < xmin) xmin = x; if (x > xmax) xmax = x; if (y < ymin) ymin = y; if (y > ymax) ymax = y; } xmin = Math.max(cl.x, Math.floor(xmin)); ymin = Math.max(cl.y, Math.floor(ymin)); xmax = Math.min(cl.x + cl.w - 1, Math.ceil(xmax)); ymax = Math.min(cl.y + cl.h - 1, Math.ceil(ymax)); if (xmax < xmin || ymax < ymin) continue; const mw = xmax - xmin + 1, mh = ymax - ymin + 1; const pts2 = new Float64Array(np2 * 2); for (let i = 0; i < np2; i++) { pts2[i * 2] = rib[i * 2] - xmin; pts2[i * 2 + 1] = rib[i * 2 + 1] - ymin; } const cv2 = new Float32Array(mw * mh); fillPolyCov(cv2, mw, mh, pts2, opts.coverSubY || 1); for (let y = 0; y < mh; y++) { const rowC = (ymin + y - cl.y) * cl.w, row = (ymin + y); for (let x = 0; x < mw; x++) { if (cv2[y * mw + x] < opts.coverMin) continue; const xx = xmin + x; if (!cl.mask[rowC + (xx - cl.x)]) continue; if (xx < full.x0 || xx >= full.x0 + full.w || row < full.y0 || row >= full.y0 + full.h) continue; cov[(row - full.y0) * full.w + (xx - full.x0)] = 1; } } } const gap = new Uint8Array(full.w * full.h); let nGap = 0; for (let i = 0; i < gap.length; i++) if (full.m[i] && !cov[i]) { gap[i] = 1; nGap++; } // 边界带**主动**补一遍:本层自己那一圈 1~2px 的边界(掩膜减"缩 1px 的掩膜")用局部色刷。 // 为什么:主笔是"整层一个色"(细长层尤其),而边界那圈在原图里是混色/深描边—— // 海报字母那圈深红描边、照片发丝边的灰过渡,平涂上去必然差一截。 // 老版本是"笔形端头缺半个圆 ⇒ 自检发现洞 ⇒ 补丁按局部色补"歪打正着把边刷准的; // 端头修好之后没有洞了,这件事就得明着做(用户要的"和原图 1:1"主要在边上)。 if (opts.rimPass && oi !== baseObj && (opts.rimLinearOnly ? objStat[oi].linear : true)) { const own2 = objBox[oi]; const core = erode(own2.m, own2.w, own2.h, 1); for (let y = 0; y < own2.h; y++) { const fy = y + own2.y0 - full.y0; if (fy < 0 || fy >= full.h) continue; for (let x = 0; x < own2.w; x++) { const i2 = y * own2.w + x; if (!own2.m[i2] || core[i2]) continue; const fx = x + own2.x0 - full.x0; if (fx < 0 || fx >= full.w) continue; const k = fy * full.w + fx; if (!gap[k]) { gap[k] = 1; nGap++; } } } } if (!nGap) return; if (!dbg.gapAt) dbg.gapAt = {}; dbg.gapAt[oi] = strokes.length; const gc = compsOfLabels(gap, full.w, full.h); const order = []; for (let ci = 0; ci < gc.comps.length; ci++) { if (gc.comps[ci].label !== 1) continue; const c = gc.comps[ci]; order.push({ ci, h: hilbertIndex((c.x0 + c.x1) >> 1, (c.y0 + c.y1) >> 1, 10) }); } order.sort((a, b) => a.h - b.h); // 沿希尔伯特序走,笔尖一片一片挪 for (const o of order) { const c = gc.comps[o.ci]; // 一个缺口里"深浅混在一起"时(缺口压在深色描边上、或者跨着渐变), // 用整块的均值色补上去就是一道灰条(实测飞机边上那块 15x20 的灰斑)。 // 先按颜色二分,每一份单独补(各用各的均值色)。 const parts = []; { const px2 = new Int32Array(c.count); for (let k = 0; k < c.count; k++) { const p = gc.pix[c.start + k]; const x = p % full.w, y = (p - x) / full.w; px2[k] = (y + full.y0) * W + (x + full.x0); // 整幅坐标 } splitPixByColor(px2, c.count, 0, parts); } for (const pt of parts) { let px0 = W, py0 = H, px1 = -1, py1 = -1, cr = 0, cg = 0, cb = 0; for (let k = 0; k < pt.cnt; k++) { const gp = pt.pix[k]; const x = gp % W, y = (gp - x) / W; if (x < px0) px0 = x; if (x > px1) px1 = x; if (y < py0) py0 = y; if (y > py1) py1 = y; cr += R[gp]; cg += G[gp]; cb += B[gp]; } const x0 = px0, y0 = py0, x1 = px1, y1 = py1; const mw = x1 - x0 + 1, mh = y1 - y0 + 1; const bin = new Uint8Array(mw * mh); for (let k = 0; k < pt.cnt; k++) { const gp = pt.pix[k]; const x = gp % W, y = (gp - x) / W; bin[(y - y0) * mw + (x - x0)] = 1; // 组内局部坐标(不是整幅坐标) } // 补缝的颜色:底色层用"背景色场"(bgMap)——用原图局部均值的话, // 这块正好压在字上就会补成"字和底的混色"(紫灰块,实测整片花掉) let col; if (oi === baseObj && bgMap) { let sr2 = 0, sg2 = 0, sb2 = 0; for (let k = 0; k < pt.cnt; k++) { const gp = pt.pix[k]; sr2 += bgMap.R[gp]; sg2 += bgMap.G[gp]; sb2 += bgMap.B[gp]; } col = [Math.round(sr2 / pt.cnt), Math.round(sg2 / pt.cnt), Math.round(sb2 / pt.cnt)]; } else { col = [Math.round(cr / pt.cnt), Math.round(cg / pt.cnt), Math.round(cb / pt.cnt)]; } const cIdx = pushClip(x0, y0, mw, mh, bin); // 外扩 1px 的方框笔形 ∩ 这个 1 位掩膜 ⇒ 严格只覆盖"确实没刷到"的像素 const ex0 = x0 - 1, ey0 = y0 - 1, ex1 = x1 + 2, ey1 = y1 + 2; const rect = [ex0, ey0, ex1, ey0, ex1, ey1, ex0, ey1]; emitStroke(rect, 4, [ex0, (ey0 + ey1) / 2, ex1, (ey0 + ey1) / 2], 2, Math.max(1.6, mh / 2 + 1), col, 0, cIdx, objRegions[oi].length ? objRegions[oi][0] : -1, 1); } } dbg.layerGap = (dbg.layerGap || 0) + nGap; // 补完再验一次:还剩多少没盖到(只在诊断时跑:它会把这一层的笔再光栅化一遍,很贵) if (opts.debug) { const cov2 = new Uint8Array(full.w * full.h); for (let si = from; si < strokes.length; si++) { const st0 = strokes[si]; const cl = clips[st0.c]; const rib = st0.rib; const np2 = rib.length >> 1; let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; for (let i = 0; i < np2; i++) { const x = rib[i * 2], y = rib[i * 2 + 1]; if (x < xmin) xmin = x; if (x > xmax) xmax = x; if (y < ymin) ymin = y; if (y > ymax) ymax = y; } xmin = Math.max(cl.x, Math.floor(xmin)); ymin = Math.max(cl.y, Math.floor(ymin)); xmax = Math.min(cl.x + cl.w - 1, Math.ceil(xmax)); ymax = Math.min(cl.y + cl.h - 1, Math.ceil(ymax)); if (xmax < xmin || ymax < ymin) continue; const mw = xmax - xmin + 1, mh = ymax - ymin + 1; const pts2 = new Float64Array(np2 * 2); for (let i = 0; i < np2; i++) { pts2[i * 2] = rib[i * 2] - xmin; pts2[i * 2 + 1] = rib[i * 2 + 1] - ymin; } const cv2 = new Float32Array(mw * mh); fillPolyCov(cv2, mw, mh, pts2, opts.coverSubY || 1); for (let y = 0; y < mh; y++) { const rowC = (ymin + y - cl.y) * cl.w, row = (ymin + y); for (let x = 0; x < mw; x++) { if (cv2[y * mw + x] < opts.coverMin) continue; const xx = xmin + x; if (!cl.mask[rowC + (xx - cl.x)]) continue; if (xx < full.x0 || xx >= full.x0 + full.w || row < full.y0 || row >= full.y0 + full.h) continue; cov2[(row - full.y0) * full.w + (xx - full.x0)] = 1; } } } let left2 = 0; for (let i = 0; i < gap.length; i++) if (full.m[i] && !cov2[i]) left2++; dbg.layerGapLeft = (dbg.layerGapLeft || 0) + left2; } }; lap('sweep:layerprep'); // ---- 绘制顺序:同色合并成"一趟" ---- // 层与层之间只有"谁盖谁"这一条硬约束:A 的完整形状盖到了 B 的可见像素 ⇒ B 必须先画。 // 其余两两之间顺序完全自由 —— 那就优先挑"跟上一笔同色、又离笔尖近"的那层, // 于是同一支笔会一路把同色的东西画完(一整排橙字连着写完),而不是按面积大小来回跳。 const paintOrder = (() => { const ord = new Int32Array(nObj); for (let i = 0; i < nObj; i++) ord[i] = i; if (!opts.colorPass || nObj < 2) return ord; const need = new Map(); // a → Set(必须先画的层) const rel = new Map(); // b → Set(被 b 挡住的层) const addEdge = (b, a) => { let s = need.get(a); if (!s) { s = new Set(); need.set(a, s); } if (s.has(b)) return; s.add(b); let t = rel.get(b); if (!t) { t = new Set(); rel.set(b, t); } t.add(a); }; // 判据:**两张完整形状只要相交,先后就不能换**(谁先谁后是按面积定的,换了就有一方 // 会盖到对方身上)。先按外框粗筛,再逐像素确认——只用"可见掩膜相交"是不够的: // 两个层各自的完整形状会互相长进对方那边,只看可见部分会漏掉这类约束。 const inter = (A, B) => { const y0 = Math.max(A.y0, B.y0), y1 = Math.min(A.y0 + A.h - 1, B.y0 + B.h - 1); const x0 = Math.max(A.x0, B.x0), x1 = Math.min(A.x0 + A.w - 1, B.x0 + B.w - 1); for (let y = y0; y <= y1; y++) { const rowA = (y - A.y0) * A.w, rowB = (y - B.y0) * B.w; for (let x = x0; x <= x1; x++) if (A.m[rowA + (x - A.x0)] && B.m[rowB + (x - B.x0)]) return true; } return false; }; for (let a = 1; a < nObj; a++) { const F = objFull[a]; for (let b = 0; b < a; b++) { const G = objFull[b]; if (F.x0 > G.x0 + G.w - 1 || G.x0 > F.x0 + F.w - 1 || F.y0 > G.y0 + G.h - 1 || G.y0 > F.y0 + F.h - 1) continue; // 外框不相交 if (inter(F, G)) addEdge(b, a); } } const indeg = new Int32Array(nObj); for (const [a, s] of need) indeg[a] = s.size; const done = new Uint8Array(nObj); const out = []; let pc = null, px0 = 0, py0 = 0, pa = 0; for (let k = 0; k < nObj; k++) { let bestSame = -1, bdSame = Infinity, bestAny = -1, baAny = -1; for (let i = 0; i < nObj; i++) { if (done[i] || indeg[i] > 0) continue; const st = objStat[i]; if (pc) { const cd = Math.abs(st.color[0] - pc[0]) + Math.abs(st.color[1] - pc[1]) + Math.abs(st.color[2] - pc[2]); const ar = Math.max(st.area, pa) / Math.max(1, Math.min(st.area, pa)); if (cd <= opts.colorPassTol && ar <= opts.colorPassArea) { const d = Math.hypot(st.cx - px0, st.cy - py0); if (d < bdSame) { bdSame = d; bestSame = i; } continue; } } if (st.area > baAny) { baAny = st.area; bestAny = i; } } let pick = bestSame >= 0 ? bestSame : bestAny; if (pick < 0) { for (let i = 0; i < nObj; i++) if (!done[i]) { pick = i; break; } } // 有环:按原序打破 done[pick] = 1; out.push(pick); pc = objStat[pick].color; px0 = objStat[pick].cx; py0 = objStat[pick].cy; pa = objStat[pick].area; const t = rel.get(pick); if (t) for (const a of t) indeg[a]--; } dbg.colorPasses = (() => { // 埋点:同色的层连着画算一趟,共几趟 let n = 1; for (let i = 1; i < out.length; i++) { const c0 = objStat[out[i - 1]].color, c1 = objStat[out[i]].color; if (Math.abs(c0[0] - c1[0]) + Math.abs(c0[1] - c1[1]) + Math.abs(c0[2] - c1[2]) > opts.colorPassTol) n++; } return n; })(); return Int32Array.from(out); })(); // ---- 出界笔的顺序约束 ---- // 铺色时允许一小部分笔"画出界"(像人一样压到轮廓外),但只有当出界那一圈**只压在 // 已经画过的对象上**才允许:否则后画的邻居不会再来盖它,那圈颜色就留在别人身上 // (实测:蓝层 #30 的两支出界笔把飞机 95 个米色像素盖成蓝色,飞机就"被啃"了)。 const bleedOK = new Uint8Array(nObj); if (opts.bleedP > 0) { const posOf = new Int32Array(nObj).fill(-1); for (let k = 0; k < paintOrder.length; k++) posOf[paintOrder[k]] = k; const br = Math.max(1, opts.bleedR | 0); let nOK = 0, nNo = 0; for (const a of paintOrder) { const st = objStat[a]; if (st.area < 1200 || a === baseObj) continue; const F = objFull[a]; const x0 = Math.max(0, F.x0 - br), y0 = Math.max(0, F.y0 - br); const x1 = Math.min(W - 1, F.x0 + F.w - 1 + br), y1 = Math.min(H - 1, F.y0 + F.h - 1 + br); const w = x1 - x0 + 1, h = y1 - y0 + 1; const m = new Uint8Array(w * h); for (let y = 0; y < F.h; y++) { const row = (y + F.y0 - y0) * w; for (let x = 0; x < F.w; x++) if (F.m[y * F.w + x]) m[row + (x + F.x0 - x0)] = 1; } const d = dilate(m, w, h, br); let bad = false; for (const b of paintOrder) { if (b === a || posOf[b] > posOf[a]) continue; // 排在后面的对象会把它盖回来,不用管 const G = objFull[b]; if (G.x0 > x1 || x0 > G.x0 + G.w - 1 || G.y0 > y1 || y0 > G.y0 + G.h - 1) continue; const gx0 = Math.max(x0, G.x0), gx1 = Math.min(x1, G.x0 + G.w - 1); const gy0 = Math.max(y0, G.y0), gy1 = Math.min(y1, G.y0 + G.h - 1); let hit = false; for (let y = gy0; y <= gy1 && !hit; y++) { const rowD = (y - y0) * w, rowF = (y - F.y0) * F.w, rowG = (y - G.y0) * G.w; for (let x = gx0; x <= gx1; x++) { if (!d[rowD + (x - x0)]) continue; if (F.m[rowF + (x - F.x0)]) continue; // 只算"出界那一圈" if (G.m[rowG + (x - G.x0)]) { hit = true; break; } } } if (hit) { bad = true; break; } } if (bad) nNo++; else { bleedOK[a] = 1; nOK++; } } dbg.bleedOK = { ok: nOK, off: nNo }; } else { bleedOK.fill(1); } lap('sweep:order'); // ---- 逐层画 ---- const TT = {}; let tMark = Date.now(); const tick = (k) => { const n = Date.now(); TT[k] = (TT[k] || 0) + (n - tMark); tMark = n; }; for (const oi of paintOrder) { curLayer = oi; if (oi !== curInkObj) { if (curInkObj >= 0) drawInkOfObj(curInkObj); curInkObj = oi; } const st = objStat[oi]; if (!st.area) continue; const full = objFull[oi], own = objBox[oi]; st.from = strokes.length; // 取色源:本层自己"看得见"的像素(往内缩 1px 避开边界混色;细线/纹理像素不算) let sampleM = own; // 细长层不缩边:它本来就只有 1~3px 厚,"缩 1px 避开边界混色"之后几乎没剩几个像素, // 取色就退化成"整层一个均值色",边界那一圈(照片的发丝/海报的深色描边)全刷成平的。 if (st.area > 600 && !(opts.thinNoErode && st.linear)) { sampleM = { x: own.x, y: own.y, w: own.w, h: own.h, m: erode(own.m, own.w, own.h, 1) }; } { // 取色时排除"细线/纹理像素"(那里是黑字、纸纹,采进去会让铺色发黑发花)—— // 但如果排除之后剩得太少(照片类几乎整幅都是"纹理像素"),就别排除了, // 否则这一层没地方取色,只能整层用一个平均色,实测照片因此差 15 个点。 let nTex = 0, nTot = 0; for (let y = 0; y < sampleM.h; y++) { const row = (y + sampleM.y) * W + sampleM.x; for (let x = 0; x < sampleM.w; x++) { if (!sampleM.m[y * sampleM.w + x]) continue; nTot++; if (inkPix[row + x] || texPix[row + x]) nTex++; } } if (nTex > 0 && nTex < 0.7 * nTot) { const sm = new Uint8Array(sampleM.m); for (let y = 0; y < sampleM.h; y++) { const row = (y + sampleM.y) * W + sampleM.x; for (let x = 0; x < sampleM.w; x++) if (inkPix[row + x] || texPix[row + x]) sm[y * sampleM.w + x] = 0; } sampleM = { x: sampleM.x, y: sampleM.y, w: sampleM.w, h: sampleM.h, m: sm }; } } let sealed = false; if (oi === baseObj && bgMap) { // 底色层:裁剪范围是整幅(上面要画的东西也照刷,一个洞都不留),取色用"背景色场" // (看得见的地方 = 平滑图,看不见的地方 = 按周围推出来的);分段短、并笔卡得紧, // 免得一条长笔跨过渐变把颜色抹平(底色的暗角/渐变就是这么丢的) curTarget = bgMap; segLenOverride = Math.max(26, Math.round(56 * strokeScale)); joinCdOverride = 6; sealed = paintSweep(oi, full, { x: 0, y: 0, w: W, h: H, m: full.m }); segLenOverride = 0; joinCdOverride = 0; curTarget = target; } else if (st.linear) { paintAsLines(oi, full, own, sampleM); } else { sealed = paintSweep(oi, full, sampleM); } tick(st.linear ? 'lines' : 'sweep'); // 落笔前已经做过联合覆盖自检、缝也补平了 ⇒ 这里的"事后自检"就没活干了, // 直接跳过(它要把这一层的笔再光栅化一遍,很贵) if (!sealed) layerCheck(oi, full, st.from); tick('check'); st.to = strokes.length; if (opts.debug) (dbg.paintTrace || (dbg.paintTrace = [])).push({ oi, from: st.from, to: st.to, area: st.area, linear: !!st.linear, ly0: st.from < st.to ? strokes[st.from].ly : -1, }); } if (curInkObj >= 0) drawInkOfObj(curInkObj); curLayer = -1; // 后面的残差/补漏/题字都不是"某一层"了 tick('lines'); dbg.paint = TT; dbg.layerEnds = objStat.map((s2) => s2.to || 0); if (opts.dumpMasks) { const pal2 = [[255,255,255],[0,0,0],[255,0,0],[0,160,0],[0,0,255],[255,160,0],[160,0,255],[0,200,200], [128,128,0],[255,0,255],[0,100,0],[128,0,0],[0,0,128],[200,200,0],[100,100,100],[255,100,100]]; const lm = new Uint8Array(N * 3); for (let pp = 0; pp < N; pp++) { const c = pal2[objLab[pp] % pal2.length]; lm[pp*3] = c[0]; lm[pp*3+1] = c[1]; lm[pp*3+2] = c[2]; } dbg.layerMap = lm; } dbg.dumpMasks = opts.dumpMasks ? objStat.map((s2, i) => { const f = objFull[i], o = objBox[i]; return { oi: s2.oi, color: s2.color, area: s2.area, linear: !!s2.linear, thick: s2.thick || 0, grown: s2.grown || 0, full: { x0: f.x0, y0: f.y0, w: f.w, h: f.h, m: Array.from(f.m) }, own: { x0: o.x0, y0: o.y0, w: o.w, h: o.h, m: Array.from(o.m) } }; }) : undefined; dbg.layers = objStat.map((s2) => ({ oi: s2.oi, area: s2.area, paper: !!s2.isPaper, linear: !!s2.linear, thick: s2.thick ? +s2.thick.toFixed(1) : 0, grown: s2.grown || 0, strokes: (s2.to || 0) - s2.from, from: s2.from || 0, to: s2.to || 0, bbox: [s2.x0, s2.y0, s2.x1, s2.y1], })); } mark('铺色'); lap('sweep'); prog(0.78, '补笔修形…'); await yieldUI(); dbg.mainStrokes = strokes.length; // ---- 8c) 残差补笔:哪块不像就在哪块补细笔(按局部小块,允许覆盖) ---- // "边缘带"(区块交界附近 2px):纹理像素在这里不跳过残差—— // 边缘上的过渡像素如果也被 texPix 跳过,笔触的半覆盖边就没人补实, // 网页端抗锯齿渲染下会留下 1~2px 的白缝(实测插画 22px)。 // 照片类不启用:它的边缘本来就是渐变纹理,硬补反而把可见差异补高(29.2% → 31.2%)。 const edgeNear = (() => { if (opts.photoMode) return new Uint8Array(N); // 照片类不启用:全 0 = 没有边缘带豁免 const band = new Uint8Array(N); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const p = y * W + x, c0 = compOf[p]; if ((x + 1 < W && compOf[p + 1] !== c0) || (y + 1 < H && compOf[p + W] !== c0)) band[p] = 1; } } return dilate(band, W, H, 2); })(); // 局部起伏(3x3 窗口内每通道极差的最大值):很小 = 这一带是平滑渐变/平色, // 不是"有结构的地方"——渐变上的采样台阶用点去描,只会描出一片鬼影(插画那个粉色圆里 // 那块方块状淡影就是 5000 笔描出来的),而且描完还是不匀。 const flatRng = (() => { const out = new Uint8Array(N); if (!(opts.resFlatTol > 0)) return out; // 判据关着就别算(3x3 极差是整幅扫描) for (let y = 0; y < H; y++) { const y0 = y > 0 ? y - 1 : 0, y1 = y < H - 1 ? y + 1 : H - 1; for (let x = 0; x < W; x++) { const x0 = x > 0 ? x - 1 : 0, x1 = x < W - 1 ? x + 1 : W - 1; let r0 = 255, r1 = 0, g0 = 255, g1 = 0, b0 = 255, b1 = 0; for (let yy = y0; yy <= y1; yy++) { for (let xx = x0; xx <= x1; xx++) { const p = yy * W + xx; if (R[p] < r0) r0 = R[p]; if (R[p] > r1) r1 = R[p]; if (G[p] < g0) g0 = G[p]; if (G[p] > g1) g1 = G[p]; if (B[p] < b0) b0 = B[p]; if (B[p] > b1) b1 = B[p]; } } const rr = r1 - r0, gg = g1 - g0, bb = b1 - b0; out[y * W + x] = rr > gg ? (rr > bb ? rr : bb) : (gg > bb ? gg : bb); } } return out; })(); // 抗锯齿斜坡:目标色正好夹在"相邻两个区块的纯色"之间 ⇒ 这是渲染毛边,不是内容。 // 补它 = 用一堆点去描一条毛边(笔数成千上万,成品还是毛的),不补 = 干净的硬边(人才这么画)。 const rampPix = (() => { const m = new Uint8Array(N); if (!opts.resRampSkip || (opts.photoMode && !opts.resSkipRampPhoto)) return m; const colOf = new Map(); for (const rg of regions) colOf.set(rg.cid, rg.color); const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1]; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const p = y * W + x, c0 = compOf[p]; let a = -1, b = -1; for (let d = 0; d < 4; d++) { for (let s = 1; s <= 3; s++) { const xx = x + DX[d] * s, yy = y + DY[d] * s; if (xx < 0 || xx >= W || yy < 0 || yy >= H) break; const cq = compOf[yy * W + xx]; if (cq === c0) continue; if (a < 0) a = cq; else if (cq !== a && b < 0) b = cq; break; } } if (a < 0 || b < 0) continue; const A = colOf.get(a), Bv = colOf.get(b); if (!A || !Bv) continue; const vx = Bv[0] - A[0], vy = Bv[1] - A[1], vz = Bv[2] - A[2]; const vv = vx * vx + vy * vy + vz * vz; if (vv < 400) continue; // 两边颜色太近:无所谓补不补 const px2 = R[p] - A[0], py2 = G[p] - A[1], pz2 = B[p] - A[2]; const t = (px2 * vx + py2 * vy + pz2 * vz) / vv; if (t <= 0.12 || t >= 0.88) continue; // 不在两色之间(更靠某一侧)⇒ 不是毛边 const dx2 = px2 - t * vx, dy2 = py2 - t * vy, dz2 = pz2 - t * vz; if (Math.abs(dx2) + Math.abs(dy2) + Math.abs(dz2) <= opts.rampTol) m[p] = 1; } } return m; })(); let inkDrawn = false; let deadScan = null; // 像素 → 最后画上它的笔号(回放/补笔时顺手记的,收尾删"零贡献笔"用) let curStroke = -1; // 当前正在落色的笔号(给 deadScan 用) if ((opts.residual && opts.residual.length) || opts.returnCanvas || opts.returnKeys) { const canvas = new Uint8Array(N * 3); canvas.fill(255); if (opts.dropDead || opts.debug) deadScan = new Int32Array(N).fill(-1); const raster = (ribArr, color, clipIdx) => { const cl = clips[clipIdx]; let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; for (let i = 0; i + 1 < ribArr.length; i += 2) { const x = ribArr[i], y = ribArr[i + 1]; if (x < xmin) xmin = x; if (x > xmax) xmax = x; if (y < ymin) ymin = y; if (y > ymax) ymax = y; } xmin = Math.max(cl.x, Math.floor(xmin)); ymin = Math.max(cl.y, Math.floor(ymin)); xmax = Math.min(cl.x + cl.w - 1, Math.ceil(xmax)); ymax = Math.min(cl.y + cl.h - 1, Math.ceil(ymax)); if (xmax < xmin || ymax < ymin) return; const mw = xmax - xmin + 1, mh = ymax - ymin + 1; const np2 = ribArr.length >> 1; const pts = new Float64Array(np2 * 2); for (let i = 0; i < np2; i++) { pts[i * 2] = ribArr[i * 2] - xmin; pts[i * 2 + 1] = ribArr[i * 2 + 1] - ymin; } // 光栅口径(老坑,见 README):网页端是"画布多边形抗锯齿 ∩ 1 位硬遮罩", // 所以这里也按覆盖率算——盖不到 coverMin 的像素就当"没画上"(保守), // 宁可让下一轮补笔/补漏把它补实,也不能让网页端留个半白的点。 const cov = new Float32Array(mw * mh); fillPolyCov(cov, mw, mh, pts, opts.coverSubY || 1); for (let y = 0; y < mh; y++) { const yy = ymin + y; const rowC = (yy - cl.y) * cl.w; for (let x = 0; x < mw; x++) { let al = cov[y * mw + x]; if (al <= 0) continue; if (!opts.featherRaster) al = al >= opts.coverMin ? 1 : 0; if (al <= 0) continue; const xx = xmin + x; if (!cl.mask[rowC + (xx - cl.x)]) continue; const pp = yy * W + xx; if (deadScan && curStroke >= 0) { // 一笔"在最终画布上零贡献"才允许删——但它要是**被别的图层**盖住的(不是被本层 // 后来的笔盖住的),删掉就会在"那一层刚画完"那一帧留一个洞:底色层实测有 19 笔 // 是被后面的图层盖住的,删完底色帧上就是 7623 个白点(0.92%)——用户看到的 // "底色没涂满"。凡是"曾经被别的层盖过"的笔,一律不删。 // 一笔"在最终画布上零贡献"才允许删——但它要是**被别的图层**盖住的(不是被本层 // 后来的笔盖住的),删掉就会在"那一层刚画完"那一帧留一个洞:底色层实测有 19 笔 // 是被后面的图层盖住的,删完底色帧上就是 7623 个白点(0.92%)——用户看到的 // "底色没涂满"。凡是"曾经被别的层盖过"的笔,一律不删。 const pv = deadScan[pp]; if (pv >= 0 && pv !== curStroke) { const rs = strokes[pv]; if (rs && rs.ly !== curLayer) rs.prot = 1; } deadScan[pp] = curStroke; // 最后画上这个像素的笔 } if (al >= 0.995) { canvas[pp * 3] = color[0]; canvas[pp * 3 + 1] = color[1]; canvas[pp * 3 + 2] = color[2]; } else { canvas[pp * 3] = canvas[pp * 3] * (1 - al) + color[0] * al; canvas[pp * 3 + 1] = canvas[pp * 3 + 1] * (1 - al) + color[1] * al; canvas[pp * 3 + 2] = canvas[pp * 3 + 2] * (1 - al) + color[2] * al; } } } }; // 关键帧:主笔过程中每 1/K 张快照,方便网页端拖动进度(当然也在跑完时快照一张) // 顺带(免费)记下"每个像素最后是被哪一笔画上的"(见 raster 里的 deadScan):收尾用它删零贡献笔 const keysOut = []; const kEvery = Math.max(1, Math.ceil(strokes.length / (opts.keyFrames || 5))); for (let i0 = 0; i0 < strokes.length; i0++) { const st0 = strokes[i0]; curStroke = i0; raster(st0.rib, st0.color, st0.c); if ((i0 + 1) % kEvery === 0) keysOut.push({ i: i0 + 1, rgb: canvas.slice() }); } if (!keysOut.length || keysOut[keysOut.length - 1].i !== strokes.length) { keysOut.push({ i: strokes.length, rgb: canvas.slice() }); } // 按"产出站点"计数(诊断用:哪一段在刷笔数) const SRC = { batch: 0, resRibbon: 0, holeRibbon: 0, holeDab: 0, holeRect: 0 }; dbg.rounds = []; dbg.src = SRC; // 外框笔形一定盖满自己的掩膜 ⇒ 直接按掩膜落色(等价,省掉在一整块大外框上跑覆盖光栅) const rasterMask = (clipIdx, color) => { const cl = clips[clipIdx]; for (let y = 0; y < cl.h; y++) { const rowC = y * cl.w, row = (cl.y + y) * W; for (let x = 0; x < cl.w; x++) { if (!cl.mask[rowC + x]) continue; const pp = row + cl.x + x; if (deadScan && curStroke >= 0) { const pv = deadScan[pp]; if (pv >= 0 && pv !== curStroke) { const rs = strokes[pv]; if (rs && rs.ly !== curLayer) rs.prot = 1; } deadScan[pp] = curStroke; } canvas[pp * 3] = color[0]; canvas[pp * 3 + 1] = color[1]; canvas[pp * 3 + 2] = color[2]; } } }; const emitRaster = (rib, pathPts, nPath, halfW, col, pen, cIdx, ph, maskOnly) => { const rec = emitStroke(rib, rib.length / 2, pathPts, nPath, halfW, col, pen, cIdx, -1, ph); rec.src = dbg.srcSite || 'holeDab'; // 诊断:这一笔是哪一段产出的(行程/笔数分解用) dbg.src[dbg.srcSite || 'holeDab']++; curStroke = strokes.length - 1; if (maskOnly) rasterMask(rec.c, rec.color); else raster(rec.rib, rec.color, rec.c); return rec; }; const TILE = 72; // 瓦片按"所属对象"排序:残差补笔也走"一个对象补完再补下一个", // 不再全画面从上到下扫(那样笔尖会一直跨对象来回跳) const tileList = []; for (let ty = 0; ty < H; ty += TILE) { for (let tx = 0; tx < W; tx += TILE) { const mx = Math.min(W - 1, tx + (TILE >> 1)), my = Math.min(H - 1, ty + (TILE >> 1)); tileList.push({ tx, ty, o: objOfComp[compOf[my * W + mx]] }); } } tileList.sort((a, b) => a.o - b.o || a.ty - b.ty || a.tx - b.tx); // ---- 补丁合并池(残差补笔、收尾补漏共用):笔数从几万降到几千的主要手段 ---- // 补笔里绝大多数是 5~16px 的"小点",一拍一笔,照片类能拍出两万个单点笔。 // 但每一笔的笔形最终都会被它自己的 1 位裁剪掩膜裁掉多余部分 —— 也就是 // "笔形只要盖住自己的掩膜"就够。于是把"同一格、颜色同一个桶"的补丁并成一笔: // 掩膜取并集、笔形取并集外框(再外扩 0.5px 保证盖住每个像素中心)、颜色取桶内加权均值。 // 各补丁的掩膜互不相交 ⇒ 谁也没盖住谁 ⇒ 合并只带来"每通道 ≤ 半个桶宽"的取色偏差, // 换来笔数少一个数量级(桶宽可调:patchShift;空间粒度:patchCell)。 const CEL = Math.max(16, opts.patchCell | 0); const makePool = () => { const pool = new Map(); let nOut = 0, nIn = 0, maxG = 0; const flushGroup = (g, ph) => { const mw = g.x1 - g.x0 + 1, mh = g.y1 - g.y0 + 1; const m = new Uint8Array(mw * mh); for (const p of g.parts) { for (let y = 0; y < p.h; y++) { const src = y * p.w, dst = (p.y - g.y0 + y) * mw + (p.x - g.x0); for (let x = 0; x < p.w; x++) if (p.m[src + x]) m[dst + x] = 1; } } const cIdx = pushClip(g.x0, g.y0, mw, mh, m); const ex0 = g.x0 - 0.5, ey0 = g.y0 - 0.5, ex1 = g.x1 + 1.5, ey1 = g.y1 + 1.5; const rect = [ex0, ey0, ex1, ey0, ex1, ey1, ex0, ey1]; // 笔尖轨迹:按"就近串"把这一组点连起来,回放时看着像手在一个地方点点 const n = g.px.length; const ord = n >= 3 ? chainNN(g.px.map((_, i) => i), (i) => [g.px[i], g.py[i]], g.px[0], g.py[0]) : null; const tp = []; for (let k = 0; k < n; k++) { const i = ord ? ord[k] : k; tp.push(g.px[i], g.py[i]); } const col = [Math.round(g.cr / g.cn), Math.round(g.cg / g.cn), Math.round(g.cb / g.cn)]; dbg.srcSite = 'batch'; const rec = emitRaster(rect, tp, n, 4.2, col, 0, cIdx, ph, true); rec.g = n; // 这一笔合了几个补丁(诊断用) nOut++; }; return { add(col, x0, y0, x1, y1, mw, mh, mask, cx, cy, cnt) { // 自适应色差(patchTol > 0):不再只认"固定色桶",而是跟"这一组当前的均值色"比。 // 固定色桶的毛病是桶宽必须卡在可见阈值以内(否则补上去还是错),于是同一个地方 // 颜色略差一点的补丁就各成一笔,合并比只有 1.7:1;改成跟均值色比之后, // 只要彼此差得开(≤ patchTol)就能并进同一笔,均值色本身仍贴着每个补丁。 // 开着自适应色差时色桶反而要**放粗**(候选多、再由均值色判据收紧), // 否则色桶先把自己卡死,自适应就白搭了 const sh = opts.patchTol > 0 ? Math.min(7, opts.patchShift + 1) : opts.patchShift; const key = (((y0 / CEL) | 0) * 4096 + ((x0 / CEL) | 0)) + '|' + (col[0] >> sh) + ',' + (col[1] >> sh) + ',' + (col[2] >> sh); if (opts.debug) { // 诊断:补丁到底散落在多少个色阶桶里(桶数 ≈ 合并下限) const bk = (col[0] >> sh) + ',' + (col[1] >> sh) + ',' + (col[2] >> sh); (dbg.bk || (dbg.bk = new Set())).add(bk); } let list = pool.get(key); if (!list) { list = []; pool.set(key, list); } // 同一格同一色桶可以有好几组:谁能"装下"这个补丁就并进谁。 // 两个门槛:外框不得超过 patchBox(网页端每一笔要清/合的画布、掩膜内存都看它), // 外框面积还不得超过 patchFill 允许的"稀疏度"(外框涨得比补丁多 → 掩膜全是被浪费的空白, // 而散点补丁本来就该并成一大笔:密度判据比"外框边长"省内存得多)。 const BX = opts.patchBox; let g = null, gArea = Infinity; let nCand = 0, nBox = 0, nTol = 0; for (let li = 0; li < list.length; li++) { const gg = list[li]; const bw = Math.max(gg.x1, x1) - Math.min(gg.x0, x0) + 1; const bh = Math.max(gg.y1, y1) - Math.min(gg.y0, y0) + 1; nCand++; if (bw > BX || bh > BX) { nBox++; continue; } if (opts.patchTol > 0) { // 与组均值色的曼哈顿距离上限(超了就不许并) const d = Math.abs(col[0] - gg.cr / gg.cn) + Math.abs(col[1] - gg.cg / gg.cn) + Math.abs(col[2] - gg.cb / gg.cn); if (d > opts.patchTol) { nTol++; continue; } } const area = bw * bh; if (area < gArea) { g = gg; gArea = area; } // 挑"并进去之后外框最小"的一组,装得最满 } if (opts.debug) { // 诊断:补丁合并不上的原因(外框超限 / 色差超限 / 同键里根本没别的组) const R2 = dbg.poolRej || (dbg.poolRej = { key: 0, box: 0, tol: 0, ok: 0, cand: 0 }); R2.cand += nCand; if (!list.length) R2.key++; else if (!nCand) R2.key++; else if (!g && nBox && !nTol) R2.box++; else if (!g && nTol) R2.tol++; else if (g) R2.ok++; } if (!g) { g = { cr: 0, cg: 0, cb: 0, cn: 0, pa: 0, x0, y0, x1, y1, parts: [], px: [], py: [] }; list.push(g); } if (x0 < g.x0) g.x0 = x0; if (y0 < g.y0) g.y0 = y0; if (x1 > g.x1) g.x1 = x1; if (y1 > g.y1) g.y1 = y1; g.cr += col[0] * cnt; g.cg += col[1] * cnt; g.cb += col[2] * cnt; g.cn += cnt; g.pa += cnt; g.parts.push({ x: x0, y: y0, w: mw, h: mh, m: mask }); g.px.push(cx); g.py.push(cy); if (g.parts.length > maxG) maxG = g.parts.length; nIn++; // 一组别太大:回放更细、就近串也不至于变慢 if (g.parts.length >= 360) { flushGroup(g, this._ph); list.splice(list.indexOf(g), 1); } }, flush(ph) { this._ph = ph; // 落盘顺序按"就近串"走:回放时笔尖是在一片地方点点,不是全画面乱跳。 // 只改顺序,不改任何像素结果——各组掩膜互不相交,谁也没盖住谁。 const all = []; for (const list of pool.values()) for (const g of list) if (g.parts.length) all.push(g); const HB = 10; // 最大边 1024 ⇒ 10 bit 够(更长的边会被夹到边界,仍然有序) if (all.length > 400) { // 沿希尔伯特序走:笔尖一片一片挪过去,不再按 (y,x) 横扫整幅 for (const g of all) g.h = hilbertIndex((g.x0 + g.x1) >> 1, (g.y0 + g.y1) >> 1, HB); all.sort((a, b) => a.h - b.h); for (const g of all) flushGroup(g, ph); } else if (all.length > 1) { const used = new Uint8Array(all.length); let cx = lastPen.x, cy = lastPen.y; for (let k = 0; k < all.length; k++) { let best = -1, bd = Infinity; for (let i = 0; i < all.length; i++) { if (used[i]) continue; const g = all[i]; const dx = (g.x0 + g.x1) * 0.5 - cx, dy = (g.y0 + g.y1) * 0.5 - cy; const d = dx * dx + dy * dy; if (d < bd) { bd = d; best = i; } } used[best] = 1; const g = all[best]; flushGroup(g, ph); cx = (g.x0 + g.x1) * 0.5; cy = (g.y0 + g.y1) * 0.5; } } else if (all.length) { flushGroup(all[0], ph); } pool.clear(); return nOut; }, stat() { return { out: nOut, in: nIn, maxG }; }, }; }; let prevPct = 999; let nPieces = 0; // 轮次预算按"补笔个数"算(从 0 起算:主笔已经花掉的预算不该算在补笔头上, // 否则主笔一多,补笔就被预算砍在半路——实测 s2 只补了 5 轮就停了,差 15 个点) // 合并成笔后笔数会变少,若按笔数算,合并本身会让补笔多跑一轮, // 质量变好但耗时变长——那属于另一件事,不该被"省笔"顺手带出来 const nSched = opts.residual.length; const nRounds = nSched + (opts.resTail | 0); let dArr = null; // 轮末存下的"每像素最大通道差",供下一轮的 skip 复用(见下面的注释) for (let round = 0; round < nRounds; round++) { // 预算闸门:排定轮次(粗→细的固定档)用完就停,但**尾巴轮**(自适应续补)只要还有收益就走, // 否则"还没收敛就被预算砍掉"——那正是老版本海报停在 20%、照片停在 29% 的原因。 const inTail = round >= nSched; if (!inTail && nPieces > opts.maxStrokes) break; if (inTail && nPieces > opts.maxStrokes * opts.resTailMax) break; // 前两轮是"收形"(把轮廓/边缘修准),后面几轮是"点睛"(抠细节,手更慢更细) const phR = round < 2 ? 2 : 3; const thr = inTail ? Math.max(opts.resTailMin, opts.residual[nSched - 1] - (round - nSched + 1) * opts.resTailStep) : opts.residual[round]; let added = 0; const pool = makePool(); const addPatch = pool.add; const flushBatch = () => { pool.flush(phR); }; const nBatch = () => pool.stat().out; // 全图错误掩膜:把"又大又厚"的错误块(大片平滑渐变差异)排除在补笔之外, // 否则会按 72px 小格补出块状拼贴 lap('r:begin'); const skip = new Uint8Array(N); { // "最大通道差"整幅重算一遍是这里最贵的一步(百万像素 × 3 通道)。 // 轮末算"可见差异"时反正要算一次,顺手存进 dArr 留给下一轮用 —— // 两处看的是同一份画布(中间只有 prog/yieldUI,不改像素), // 所以这里只做一次比较(dArr > thr),结果完全一样。 if (!dArr) { dArr = new Uint8Array(N); for (let i2 = 0; i2 < N; i2++) { dArr[i2] = Math.max(Math.abs(canvas[i2 * 3] - R[i2]), Math.abs(canvas[i2 * 3 + 1] - G[i2]), Math.abs(canvas[i2 * 3 + 2] - B[i2])); } } const wrongAll = new Uint8Array(N); for (let i2 = 0; i2 < N; i2++) if (dArr[i2] > thr) wrongAll[i2] = 1; const wcc = compsOfLabels(wrongAll, W, H); for (let ci = 0; ci < wcc.comps.length; ci++) { const c = wcc.comps[ci]; if (c.label !== 1) continue; // "厚度"要用距离变换算**真实厚度**(2×最大内切半径),不能用 面积÷外框边长: // 字母/飞机那圈深色描边是"细环",按面积÷外框能算出 46px 的假厚度 ⇒ 整条描边被 // 当成"大片平滑渐变"跳过,永远不补(用户看到的"边被啃"里就有一条是这个)。 const thick = compThick(c, wcc.pix); if (c.count > 2500 && thick > opts.resThick * strokeScale) { for (let k = 0; k < c.count; k++) skip[wcc.pix[c.start + k]] = 1; } } } lap('r:skip'); for (let ti = 0; ti < tileList.length && added < 6000; ti++) { const tx = tileList[ti].tx, ty = tileList[ti].ty; { const tw = Math.min(TILE, W - tx), th = Math.min(TILE, H - ty); const wrong = new Uint8Array(tw * th); let any = 0; for (let y = 0; y < th; y++) { for (let x = 0; x < tw; x++) { const pp = (ty + y) * W + (tx + x); const d = Math.max(Math.abs(canvas[pp * 3] - R[pp]), Math.abs(canvas[pp * 3 + 1] - G[pp]), Math.abs(canvas[pp * 3 + 2] - B[pp])); // 细线像素跳过:它们留到最后由细线层"写"上去,残差补笔不用在这里重复劳动 // 斜坡像素(抗锯齿毛边)和"平滑区的小误差"也跳过:那不是内容,描它只会描出鬼影 if (d > thr && !skip[pp] && !(opts.resSkipInk && inkPix[pp]) && !(opts.resSkipRamp && rampPix[pp]) && !(opts.resFlatTol > 0 && flatRng[pp] <= opts.resFlatTol && d <= opts.resFlatErr) && !(opts.texResSkip && texPix[pp] && !edgeNear[pp])) { wrong[y * tw + x] = 1; any++; } } } if (any < 5) continue; const cc = compsOfLabels(wrong, tw, th); const toX = (lp) => tx + (lp % tw); const toY = (lp) => ty + ((lp / tw) | 0); for (let ci = 0; ci < cc.comps.length; ci++) { const c = cc.comps[ci]; if (c.label !== 1 || c.count < 5) continue; // 按颜色分裂(方差最大通道二分,最多 3 层) const parts = []; (function split(st, cnt, depth) { if (depth >= 4 || cnt < 9) { parts.push([st, cnt]); return; } let mn0 = 255, mn1 = 255, mn2 = 255, mx0 = 0, mx1 = 0, mx2 = 0; for (let k = 0; k < cnt; k++) { const pp = toY(cc.pix[st + k]) * W + toX(cc.pix[st + k]); if (R[pp] < mn0) mn0 = R[pp]; if (R[pp] > mx0) mx0 = R[pp]; if (G[pp] < mn1) mn1 = G[pp]; if (G[pp] > mx1) mx1 = G[pp]; if (B[pp] < mn2) mn2 = B[pp]; if (B[pp] > mx2) mx2 = B[pp]; } const s0 = mx0 - mn0, s1 = mx1 - mn1, s2 = mx2 - mn2; const smax = Math.max(s0, s1, s2); // 颜色散布不超过它就不切了:补上去用的是这一份的均值色, // 所以这个数直接决定"最后一公里"能压到多少(18 ⇒ 残差 ~9,正好卡在可见阈值上; // 降到 8 ⇒ 残差 ~4,代价是补丁数变多) if (smax <= opts.resSplit) { parts.push([st, cnt]); return; } const ch = s0 === smax ? 0 : (s1 === smax ? 1 : 2); const vals = new Float64Array(cnt); for (let k = 0; k < cnt; k++) { const pp = toY(cc.pix[st + k]) * W + toX(cc.pix[st + k]); vals[k] = ch === 0 ? R[pp] : (ch === 1 ? G[pp] : B[pp]); } const sorted = Array.from(vals).sort((a, b) => a - b); const med = sorted[cnt >> 1]; let i = 0, j = cnt - 1; while (i <= j) { if (vals[i] <= med) i++; else { const ti = cc.pix[st + i]; cc.pix[st + i] = cc.pix[st + j]; cc.pix[st + j] = ti; const tv = vals[i]; vals[i] = vals[j]; vals[j] = tv; j--; } } if (i === 0 || i === cnt) { parts.push([st, cnt]); return; } split(st, i, depth + 1); split(st + i, cnt - i, depth + 1); })(c.start, c.count, 0); // 颜色分裂后的每一份再按连通切开(避免"散点连成一条长笔")。 // 老实现是**对整张 72×72 瓦片**做一次连通(pm 每次新建 tw×th),一份就一次; // 照片类一轮几千份,这里就是最大的耗时点。改成只在"这一份自己的外框"里做连通。 const pieces = []; // 每项 = 一份的瓦片内像素下标 for (const part of parts) { const st = part[0], cnt = part[1]; if (cnt < 5) continue; let px0 = tw, py0 = th, px1 = -1, py1 = -1; for (let k = 0; k < cnt; k++) { const lp = cc.pix[st + k], xx = lp % tw, yy = (lp / tw) | 0; if (xx < px0) px0 = xx; if (xx > px1) px1 = xx; if (yy < py0) py0 = yy; if (yy > py1) py1 = yy; } const bw = px1 - px0 + 1, bh = py1 - py0 + 1; // 本来就小、或者本来就紧凑(填充率高)的一份,不必再切连通 if (cnt < 40 || cnt / (bw * bh) > 0.4) { const arr = new Int32Array(cnt); for (let k = 0; k < cnt; k++) arr[k] = cc.pix[st + k]; pieces.push(arr); continue; } const pm = new Uint8Array(bw * bh); for (let k = 0; k < cnt; k++) { const lp = cc.pix[st + k]; pm[(((lp / tw) | 0) - py0) * bw + ((lp % tw) - px0)] = 1; } const pc = compsOfLabels(pm, bw, bh); for (let j = 0; j < pc.comps.length; j++) { const c2 = pc.comps[j]; if (c2.label !== 1 || c2.count < 5) continue; const arr = new Int32Array(c2.count); for (let k = 0; k < c2.count; k++) { const lp = pc.pix[c2.start + k]; arr[k] = (py0 + ((lp / bw) | 0)) * tw + (px0 + (lp % bw)); } pieces.push(arr); } } for (const idxArr of pieces) { const cnt = idxArr.length; if (cnt < 5) continue; let cr = 0, cg = 0, cb = 0; let x0 = W, y0 = H, x1 = -1, y1 = -1; for (let k = 0; k < cnt; k++) { const lp = idxArr[k]; const xx = toX(lp), yy = toY(lp); const pp = yy * W + xx; cr += R[pp]; cg += G[pp]; cb += B[pp]; if (xx < x0) x0 = xx; if (xx > x1) x1 = xx; if (yy < y0) y0 = yy; if (yy > y1) y1 = yy; } const col = [Math.round(cr / cnt), Math.round(cg / cnt), Math.round(cb / cnt)]; const pad = 0; const bx = Math.max(0, x0 - pad), by = Math.max(0, y0 - pad); const bx1 = Math.min(W - 1, x1 + pad), by1 = Math.min(H - 1, y1 + pad); const mw = bx1 - bx + 1, mh = by1 - by + 1; const bin = new Uint8Array(mw * mh); for (let k = 0; k < cnt; k++) { const lp = idxArr[k]; bin[(toY(lp) - by) * mw + (toX(lp) - bx)] = 1; } const clipMask = dilate(bin, mw, mh, pad); const cx0 = (x0 + x1) / 2, cy0 = (y0 + y1) / 2; if (cnt <= opts.resPatchMax) { // 小点:并进"同色同格"的补丁笔(见 addPatch) addPatch(col, bx, by, bx1, by1, mw, mh, bin, cx0, cy0, cnt); added++; continue; } const cIdx = pushClip(bx, by, mw, mh, clipMask); const dt = distanceTransform(bin, mw, mh); const dvals = Array.from(dt).sort((a, b) => a - b); const dmed = dvals[dvals.length >> 1]; const half = clamp(dmed * opts.resHalfA + opts.resHalfB, opts.resMin * strokeScale, 8 * strokeScale); const sk2 = thin(bin, mw, mh).sk; const lp = longestPath(sk2, mw, mh); if (lp && lp.len >= 2) { const absPath = new Float64Array(lp.len * 2); for (let i = 0; i < lp.len; i++) { absPath[i * 2] = lp.pts[i * 2] + bx; absPath[i * 2 + 1] = lp.pts[i * 2 + 1] + by; } { const segs = pathSegments(absPath, lp.len, Math.max(18, half * opts.segF)); const cols = []; for (const sg of segs) { if (sg.n < 2) { cols.push(null); continue; } cols.push(sampleColor(ribbon(sg.pts, sg.n, half), sg.pts.length, bin, bx, by, mw, mh) || col); } let i2 = 0; while (i2 < segs.length) { let j2 = i2; while (j2 + 1 < segs.length && cols[j2 + 1] && cols[i2] && Math.abs(cols[j2 + 1][0] - cols[i2][0]) + Math.abs(cols[j2 + 1][1] - cols[i2][1]) + Math.abs(cols[j2 + 1][2] - cols[i2][2]) < opts.segJoinCd) j2++; const pts2 = []; for (let k2 = i2; k2 <= j2; k2++) { const P2 = segs[k2].pts; for (let m2 = 0; m2 < P2.length; m2++) pts2.push(P2[m2]); } const arr2 = Float64Array.from(pts2); if (arr2.length >= 4) { const rb = ribbon(arr2, arr2.length / 2, half); dbg.srcSite = 'resRibbon'; emitRaster(rb, arr2, arr2.length / 2, half, cols[i2] || col, 0, cIdx, phR); added++; } i2 = j2 + 1; } } } else { // 没有骨架(奇形怪状的小块):也并进补丁笔 addPatch(col, bx, by, bx1, by1, mw, mh, clipMask, cx0, cy0, cnt); added++; } } } } } lap('r:tiles'); flushBatch(); // 本轮攒下的补丁笔在这里落盘(像素结果与逐个补一致,只差半个色阶) lap('r:flush'); nPieces += added; mark('残差' + (round + 1)); // 一次算完"每像素最大通道差"和"可见差异计数":dArr 留给下一轮的 skip 用 let bad = 0; for (let i = 0; i < N; i++) { const d = Math.max(Math.abs(canvas[i * 3] - R[i]), Math.abs(canvas[i * 3 + 1] - G[i]), Math.abs(canvas[i * 3 + 2] - B[i])); dArr[i] = d; if (d > 8) bad++; } const pct = bad / N * 100; if (dbg.rounds) dbg.rounds.push({ r: round, thr, 补丁: added, 笔: nBatch(), 差异: +pct.toFixed(2), nPieces, max: opts.maxStrokes, inTail: inTail ? 1 : 0 }); prog(0.78 + 0.2 * (round + 1) / opts.residual.length, (phR === 2 ? "收形补笔…" : "点睛补细节…") + " 第 " + (round + 1) + " 轮(" + nBatch() + " 笔,可见差异 " + pct.toFixed(2) + "%)"); if (pct < 1.0) break; if (opts.resStop > 0 && prevPct - pct < opts.resStop && round >= 1) break; // 收益变小就收手 if (inTail && prevPct - pct < opts.resTailStop) break; // 尾巴轮:收益掉到阈值以下就收手 prevPct = pct; await yieldUI(); } // ---- 8d) 题字落款(最后一拨细线):贴在对象上的细线(猫嘴、字的描边) // 已经在铺色时跟着各自的对象画完了;这里只写"落在底上的"—— // 题字、落款、印在底色上的小字,按行、按块一个个写出来。 lap('residual'); prog(0.985, '题字落款…'); await yieldUI(); drawInkSeq(inkLastSeq, (rec) => { curStroke = strokes.length - 1; raster(rec.rib, rec.color, rec.c); }); inkDrawn = true; mark('细线'); // ---- 收尾补漏:残差补笔为了不把平滑渐变补成拼贴,设了"太小不补、太散不补"的门槛, // 贴着大片差异的小洞也会被连带跳过。这里分两类补,保证画面不留白点、白线: // A) 真空白——画布上这块还是白的(什么都没画到),而原图不是白的;不论大小都补 // B) 剩余的"小块/细条"错误——用厚度判定,大而厚的块仍跳过(那是平涂风格化本身) if (opts.holeFill) { const errAt = (i) => Math.max(Math.abs(canvas[i * 3] - R[i]), Math.abs(canvas[i * 3 + 1] - G[i]), Math.abs(canvas[i * 3 + 2] - B[i])); const hp = makePool(); // 补一个连通块:大洞贴着它的形状走一条(或几条)笔,小洞并进补丁笔(见 hp.add) const fillComp = (comp, comps2) => { const parts = []; splitPixByColor(comps2.pix.subarray(comp.start, comp.start + comp.count), comp.count, 0, parts); for (const pt of parts) fillPart(pt.pix, pt.cnt); }; const fillPart = (pix, cnt) => { let x0 = W, y0 = H, x1 = -1, y1 = -1; let cr = 0, cg = 0, cb = 0; for (let k = 0; k < cnt; k++) { const p = pix[k]; const x = p % W, y = (p - x) / W; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; cr += R[p]; cg += G[p]; cb += B[p]; } const mw = x1 - x0 + 1, mh = y1 - y0 + 1; const bin = new Uint8Array(mw * mh); for (let k = 0; k < cnt; k++) { const p = pix[k]; const x = p % W, y = (p - x) / W; bin[(y - y0) * mw + (x - x0)] = 1; } const col = [Math.round(cr / cnt), Math.round(cg / cnt), Math.round(cb / cnt)]; // 小洞并进补丁笔:掩膜取并集、笔形取并集外框(外扩 0.5px)—— 一定盖满自己的掩膜, // 所以比"沿骨架走一笔"更稳(骨架笔在细缝/拐角会盖不到像素中心,那正是白点的老坑)。 if (cnt <= opts.holeBatchMax) { hp.add(col, x0, y0, x1, y1, mw, mh, bin, (x0 + x1) / 2, (y0 + y1) / 2, cnt); return; } // 严格只覆盖"确实画错/没画"的那些像素,不外扩——否则会把周围本来正确的像素也刷成均值色 const cIdx = pushClip(x0, y0, mw, mh, bin); const dt = distanceTransform(bin, mw, mh); const dvals = Array.from(dt).sort((a, b) => a - b); const dmed = dvals[dvals.length >> 1]; const half = clamp(dmed + 1.2 * strokeScale, 1.6 * strokeScale, 9 * strokeScale); const sk3 = thin(bin, mw, mh).sk; const lp = longestPath(sk3, mw, mh); if (lp && lp.len >= 2) { const absPath = new Float64Array(lp.len * 2); for (let i = 0; i < lp.len; i++) { absPath[i * 2] = lp.pts[i * 2] + x0; absPath[i * 2 + 1] = lp.pts[i * 2 + 1] + y0; } // 一笔画长一点(笔数省),颜色按段取局部目标色(长条上颜色是渐变的,用一个均值色会不准) const segLen = Math.max(24, opts.holeSeg * strokeScale); const segs = pathSegments(absPath, lp.len, segLen); for (const sg of segs) { if (sg.n < 2) continue; const rb = ribbon(sg.pts, sg.n, half); const cSeg = sampleColor(rb, rb.length / 2, bin, x0, y0, mw, mh) || col; dbg.srcSite = 'holeRibbon'; emitRaster(rb, sg.pts, sg.n, half, cSeg, cSeg[0] < 120 ? 1 : 0, cIdx, 3); } } else { const cx0 = (x0 + x1) / 2, cy0 = (y0 + y1) / 2; const rad = clamp(Math.hypot(mw, mh) / 2 + 1.2 * strokeScale, 2.2, 10); const poly = []; for (let k = 0; k < 13; k++) { const a = k / 13 * Math.PI * 2; poly.push(cx0 + rad * Math.cos(a), cy0 + rad * Math.sin(a)); } dbg.srcSite = 'holeDab'; emitRaster(poly, [cx0, cy0], 1, rad, col, 0, cIdx, 3); } // 自检:沿骨架走的笔形在细缝/拐角/极扁的形状上可能盖不到像素中心(画了等于没画)。 // 对"自检后仍然是白的"那些像素,用方点笔形(严格掩膜)兜底——方框一定包含像素中心。 const left = []; for (let k = 0; k < cnt; k++) { const p = pix[k]; const mn = Math.min(canvas[p * 3], canvas[p * 3 + 1], canvas[p * 3 + 2]); if (mn >= opts.holeWhite) left.push(p); } if (left.length) { let lx0 = W, ly0 = H, lx1 = -1, ly1 = -1, lr = 0, lg = 0, lb = 0; for (const p of left) { const x = p % W, y = (p - x) / W; if (x < lx0) lx0 = x; if (x > lx1) lx1 = x; if (y < ly0) ly0 = y; if (y > ly1) ly1 = y; lr += R[p]; lg += G[p]; lb += B[p]; } const lcol = [Math.round(lr / left.length), Math.round(lg / left.length), Math.round(lb / left.length)]; const lw = lx1 - lx0 + 1, lh = ly1 - ly0 + 1; const b2 = new Uint8Array(lw * lh); for (const p of left) { const x = p % W, y = (p - x) / W; b2[(y - ly0) * lw + (x - lx0)] = 1; } const cIdx2 = pushClip(lx0, ly0, lw, lh, b2); const gx0 = lx0 - 1, gy0 = ly0 - 1, gx1 = lx1 + 2, gy1 = ly1 + 2; // 外扩 1px,保证像素中心在框内 const rect = [gx0, gy0, gx1, gy0, gx1, gy1, gx0, gy1]; dbg.srcSite = 'holeRect'; emitRaster(rect, [gx0, (gy0 + gy1) / 2, gx1, (gy0 + gy1) / 2], 2, Math.max(1.6, lh / 2 + 1), lcol, 0, cIdx2, 3); } }; const fillMask = (mask, limit, maxAdd, errFn) => { const base = strokes.length; const cc2 = compsOfLabels(mask, W, H); const list = []; for (let ci = 0; ci < cc2.comps.length; ci++) if (cc2.comps[ci].label === 1) list.push(ci); if (errFn) { // 给了误差函数就按"总误差量"排(面积 × 平均误差),而不是只按面积: // 只按面积排的话,"小而刺眼"的漏笔(30px 的深色条)会被几千个大块挤到预算外—— // 收尾自检里剩下的白点/亮点就是这么漏掉的 const score = new Float64Array(cc2.comps.length); for (const ci of list) { const c = cc2.comps[ci]; let sc = 0; for (let k = 0; k < c.count; k++) sc += errFn(cc2.pix[c.start + k]); score[ci] = sc; } list.sort((a, b) => score[b] - score[a]); } else { list.sort((a, b) => cc2.comps[b].count - cc2.comps[a].count); // 先补大块(同预算下总误差降得多) } // 选中之后改成沿希尔伯特序落笔:补谁不变,只是笔尖不再在全画面乱跳 const sel = list.slice(0, limit); sel.sort((a, b) => { const ca = cc2.comps[a], cb = cc2.comps[b]; return hilbertIndex((ca.x0 + ca.x1) >> 1, (ca.y0 + ca.y1) >> 1, 10) - hilbertIndex((cb.x0 + cb.x1) >> 1, (cb.y0 + cb.y1) >> 1, 10); }); let n = 0; for (const ci of sel) { if (maxAdd && strokes.length - base >= maxAdd) break; fillComp(cc2.comps[ci], cc2); n++; } return n; }; // A0) 接缝修正:画布上"不该有的台阶"。 // 逐像素差查不出它——台阶两侧各自只差 5~7,都没到可见阈值 8; // 可两块交界处画布是"一级台阶"、原图是平滑过渡,肉眼看就是一道弧线/一条缝 // (用户说的"贴纸式缝隙"就是这一类)。判据:画布这一步的台阶 >> 原图这一步的台阶。 if (opts.seamFix) { const T3 = [R, G, B]; const band = new Uint8Array(N); let anySeam = 0; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const p = y * W + x; let sc = 0, st = 0; for (let d = 0; d < 4; d++) { const q = d === 0 ? (x + 1 < W ? p + 1 : -1) : d === 1 ? (x > 0 ? p - 1 : -1) : d === 2 ? (y + 1 < H ? p + W : -1) : (y > 0 ? p - W : -1); if (q < 0) continue; for (let k = 0; k < 3; k++) { const dc = Math.abs(canvas[p * 3 + k] - canvas[q * 3 + k]); const dt = Math.abs(T3[k][p] - T3[k][q]); if (dc > sc) sc = dc; if (dt > st) st = dt; } } // 原图这一步本来就平滑(st 很小)而画布是硬台阶 ⇒ 这是我们自己画出来的缝 if (sc >= opts.seamT && st <= opts.seamSmooth) { band[p] = 1; anySeam++; } } } let nS = 0; if (anySeam) { const bandD = dilate(band, W, H, opts.seamR); const m = new Uint8Array(N); for (let p = 0; p < N; p++) { if (bandD[p] && errAt(p) > opts.seamE) { m[p] = 1; nS++; } } if (nS) { fillMask(m, 4000, 6000); hp.flush(3); } } dbg.seam = { band: anySeam, fix: nS }; } // A) 真白点:**画布上这块还是白的**(没人画上去),而原图明显不是白的。 // 以前按"几何上有没有笔盖到"判定,问题是照片里"没盖到"的像素成千上万, // 而补漏是按连通块从大到小取的,真正刺眼的 1~2px 白点排在最后,被数量门槛丢掉。 // 改成"画布看着白 + 原图不该白"(肉眼口径),并迭代到收敛。 let nA = 0; for (let roundA = 0; roundA < 3; roundA++) { const m = new Uint8Array(N); let any = 0; for (let i = 0; i < N; i++) { const cmn = Math.min(canvas[i * 3], canvas[i * 3 + 1], canvas[i * 3 + 2]); const tmn = Math.min(R[i], G[i], B[i]); // 两类白点: // ① 纸面根本没被涂上(三通道都 ≥ holePaper)——只要原图不是纯白就要补, // 这样"全满的图"成品上就不会再有没有上色的地方(哪怕原图那里很浅); // ② 只被抗锯齿毛边蹭到 / 被浅色笔盖错(≥ holeWhite 而原图明显更深)。 // 门槛必须满足"原图 ≤ 画布",否则补上目标色后仍然算白,会反复补、补不掉。 if (!((cmn >= opts.holeWhite && tmn < opts.holeWhite - 16) || // 半覆盖/被浅色盖错 (cmn >= opts.holePaper && tmn < opts.holePaper - 8))) continue; // "整块白纸"才算这一条 m[i] = 1; any++; } if (!any) break; const got = fillMask(m, 20000, 12000); nA += got; hp.flush(3); // 本轮并起来的补丁笔在这里落笔(下一轮重扫白点前必须落完) if (!got) break; await yieldUI(); } // B) 剩余的小块/细条错误(A 补完后重新算,已经补上的不再重复)。 // 这里的阈值放宽一倍:照片这类纹理区里,一小块错误用的是一个均值色, // 补上去对块内偏暗的一半反而更差,所以只补"明显不对"的那部分。 let nB = 0; // 迭代几轮:每轮只补"总误差量"最大的那批,补掉之后再重算。 // 只跑一轮的话,几十像素的深色漏笔永远排在几千个大块后面,预算一满就被丢掉 // (收尾自检里剩下的亮点就是这么漏的)。 for (let roundB = 0; roundB < (opts.holeRounds | 0); roundB++) { const thrB = clamp(opts.holeT * 2 * strokeScale, 32, 120); const m = new Uint8Array(N); let anyB = 0; for (let i = 0; i < N; i++) { const d2 = errAt(i); if (d2 <= thrB) continue; if (rampPix[i]) continue; // 抗锯齿毛边:不描 if (opts.resFlatTol > 0 && flatRng[i] <= opts.resFlatTol && d2 <= opts.resFlatErr) continue; // 平滑区的小台阶:不描 m[i] = 1; anyB++; } if (!anyB) break; const cc2 = compsOfLabels(m, W, H); for (let ci = 0; ci < cc2.comps.length; ci++) { const c = cc2.comps[ci]; if (c.label !== 1) continue; const thick = compThick(c, cc2.pix); // 真实厚度(细环不会被误判成"厚") if (c.count > opts.holeMax && thick > 4) { // 又大又厚:平涂风格化,不补 for (let k = 0; k < c.count; k++) m[cc2.pix[c.start + k]] = 0; } } const got = fillMask(m, 2500, 4200, errAt); nB += got; hp.flush(3); // 本轮并起来的补丁笔在这里落笔(下一轮重算前必须落完) if (!got) break; } // C) 交界过渡带:两个色块硬碰硬的地方,目标图那一两像素是混色, // 我们画的是纯色,差值就是"可见差异"的主要来源——沿交界补上目标色本身 let nC = 0; if (opts.edgeFill) { // 只在"两边颜色差得远"的交界上补:差得近的话混色像素本来就没多少误差, // 补上去只是把已经对的像素刷成中间色(实测会把平均差抬高) const colOf = new Map(); for (const rg of regions) colOf.set(rg.cid, rg.color); const bigEdge = new Uint8Array(N); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const p = y * W + x, c0 = compOf[p]; let c1 = -1; if (x + 1 < W && compOf[p + 1] !== c0) c1 = compOf[p + 1]; else if (y + 1 < H && compOf[p + W] !== c0) c1 = compOf[p + W]; else if (x > 0 && compOf[p - 1] !== c0) c1 = compOf[p - 1]; else if (y > 0 && compOf[p - W] !== c0) c1 = compOf[p - W]; if (c1 < 0) continue; const A = colOf.get(c0), Bv = colOf.get(c1); if (!A || !Bv) continue; if (Math.abs(A[0] - Bv[0]) + Math.abs(A[1] - Bv[1]) + Math.abs(A[2] - Bv[2]) < opts.edgeCd) continue; bigEdge[p] = 1; } } const band = dilate(bigEdge, W, H, 1); const thrC = clamp(opts.edgeT * strokeScale, 6, 20); const m = new Uint8Array(N); let any = 0; for (let i = 0; i < N; i++) if (band[i] && errAt(i) > thrC) { m[i] = 1; any++; } if (any) { nC = fillMask(m, opts.edgeMax, 4200); hp.flush(3); } } if (nA || nB || nC) { let bad2 = 0; for (let i = 0; i < N; i++) if (errAt(i) > 8) bad2++; prog(0.995, `收尾补漏… 空白 ${nA} 处、碎块 ${nB} 处、交界 ${nC} 处(可见差异 ${(bad2 / N * 100).toFixed(2)}%)`); await yieldUI(); } } lap('holes'); keysOut.push({ i: strokes.length, rgb: canvas.slice() }); visCanvas = canvas; keysRgb = keysOut; } // 兜底:上面那段没跑(既不要 canvas 也不要关键帧)时,题字层也得画上 if (!inkDrawn) { prog(0.985, '题字落款…'); await yieldUI(); drawInkSeq(inkLastSeq, null); mark('细线'); } mark('补漏'); // ---- 收尾:删掉"一笔都没留下"的笔 ---- // 一笔画上去的像素如果全被后面的笔盖住了,删掉它成品**像素级不变**, // 但回放里就少一次"在同一个地方又点一下"的多余动作(用户说的"多余不用的笔画")。 // 判据来自上面关键帧回放时顺手记的"最后画上每个像素的笔号"(跟网页端同一套光栅口径)。 // ⚠️ 必须同时看 opts.dropDead:deadScan 现在 debug 下也会分配(诊断要用), // 但"删笔+重排笔号"只能 dropDead 打开时做——否则诊断跑里 from/to/gapAt/layerEnds // 这些按笔号的记账会全部错位(踩过一次:覆盖率读数整个失真)。 if (deadScan && opts.dropDead) { const alive = new Uint8Array(strokes.length); let nAlive = 0; for (let p = 0; p < N; p++) { const i2 = deadScan[p]; if (i2 >= 0 && !alive[i2]) { alive[i2] = 1; nAlive++; } } // 判据升级(2026-10-18):光"在最终画布上零贡献"不够——一笔可能"落笔时画上了、后来被 // 别的图层盖掉"(它在回放里真的画过一笔),删了就在那一层刚画完时留个洞(底色层实测 // 19 笔、7623 个白点 = 用户看到的"底色没涂满")。凡是曾被别的层盖过的笔(raster 里打 // 了 prot)一律保留;被本层后来的笔随手压过去的照删,省笔的收益保住。 for (let i2 = 0; i2 < strokes.length; i2++) if (!alive[i2] && strokes[i2].prot) { alive[i2] = 1; nAlive++; } const nDrop = strokes.length - nAlive; dbg.deadDrop = nDrop; dbg.deadProt = strokes.filter((s2) => s2.prot).length; if (nDrop > 0) { // 关键帧索引重映射:新序号 = 它前面还剩几笔 const pre = new Int32Array(strokes.length + 1); for (let i2 = 0; i2 < strokes.length; i2++) pre[i2 + 1] = pre[i2] + alive[i2]; if (keysRgb) for (const k1 of keysRgb) k1.i = pre[k1.i]; if (dbg.marks) for (const m1 of dbg.marks) m1.n = pre[m1.n]; // 裁剪掩膜去重压缩:没有笔再引用的掩膜一并丢掉(省网页端内存) const cMap = new Int32Array(clips.length).fill(-1); const clips2 = []; let w2 = 0; for (let i2 = 0; i2 < strokes.length; i2++) { if (!alive[i2]) continue; const st2 = strokes[i2]; if (cMap[st2.c] < 0) { cMap[st2.c] = clips2.length; clips2.push(clips[st2.c]); } st2.c = cMap[st2.c]; strokes[w2++] = st2; } strokes.length = w2; clips.length = 0; for (const c2 of clips2) clips.push(c2); dbg.dropped = nDrop; dbg.clipsBefore = clips2.length; } } // 笔数分解收尾:铺色笔按"区块面积"归类(看小碎块到底贡献了多少笔) { const cnt = new Array(areaBins.length - 1).fill(0); for (const s2 of strokes) { if (s2.r < 0) continue; const a = regions[s2.r] ? regions[s2.r].area : 0; for (let b = 0; b < cnt.length; b++) if (a >= areaBins[b] && a < areaBins[b + 1]) { cnt[b]++; break; } } dbg.timing = T; dbg.sweepByArea = cnt; dbg.sweep = { regions: sweepRegions, passes: passTotal, maxPass: passMax }; } // 时长归一:先按 targetSec 分配相对权重(网页端还会把"抬笔移动"的时间算进来再分一次) { let tot = 0; for (const st1 of strokes) tot += st1.ms; const k = tot > 0 ? (opts.targetSec * 1000) / tot : 1; for (const st1 of strokes) st1.ms = Math.round(Math.max(6, st1.ms * k) * 10) / 10; } prog(1.0, '完成'); if (opts.debug && deadScan) dbg.deadScan = deadScan; // 诊断用:像素 → 最后画上它的笔号(管线自己的口径) return { W, H, strokes, clips, canvasRgb: opts.returnCanvas ? visCanvas : undefined, keys: opts.returnKeys ? keysRgb : undefined, palette: opts.debug ? pal.map((c) => [Math.round(c[0]), Math.round(c[1]), Math.round(c[2])]) : undefined, dbg: opts.debug ? dbg : undefined, probe: opts.probe ? (() => { const p0 = opts.probe.y * W + opts.probe.x; const ci = compOf[p0]; const ri = regions.findIndex((r) => r.cid === ci); const rg = regions[ri]; return rg ? { cid: ci, ri, area: rg.area, bbox: [rg.x0, rg.y0, rg.x1, rg.y1], hp: Math.round(rg.hp), color: rg.color, isLine: isInk[ri], strokesOfRegion: strokes.filter((st) => st.r === ri).length } : { cid: ci, missing: true }; })() : undefined, stats: { regions: regions.length, inkGroups: inkClips.length, strokes: strokes.length, totalMs: strokes.reduce((a, s) => a + s.ms, 0), phases: [0, 1, 2, 3].map((ph) => strokes.filter((s) => s.ph === ph).length), colors: opts.colors, photo: !!opts.photoMode, style: opts.style, detail: opts.detail, }, }; } root.ISP_PIPELINE = { run, CFG, _u: { ribbon, ribbonProf, fillPoly, fillPolyCov, distanceTransform, thin, longestPath, compsOfLabels, dilate, erode, closeMask, closeMaskDisk, rdpSimplify, mergeLoops } };})(typeof self !== 'undefined' ? self : globalThis);