Skip to content

Commit 1c156d0

Browse files
committed
improve: 色块风格质量与细腻程度联动
两个问题:buildPosterize 从头到尾没读 opts.scale,滑动条对色块完全无效; 且直接拿 k-means 原始标签图描边,边缘是像素台阶,夹杂大量碎块。 - 细腻程度真正接入:控制小区域合并阈值、轮廓简化容差与平滑遍数, 越小越贴原图(约 500 块),越大越概括(约 180 块) - 标注图预处理:3x3 众数滤波抹掉锯齿,再把低于阈值的连通小块并入 接触最多的邻层,消掉散落色斑 - 轮廓后处理:平滑(Chaikin 松弛)→ 半像素外扩补缝 → RDP 简化 - 首层先铺一块满幅底色,避免边缘和碎块区域露底形成空洞 - 界面按风格切换「细腻程度」的说明文案
1 parent fb52c29 commit 1c156d0

3 files changed

Lines changed: 212 additions & 27 deletions

File tree

‎build.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
* 解决办法:所有脚本引用都带上 ?v=BUILD 查询串,每次发布改 BUILD 值即可
88
* 让浏览器当成全新 URL 去取。这里集中管理,避免各处漏改。
99
*/
10-
window.OIL_BUILD = '20261111-4style-b';
10+
window.OIL_BUILD = '20261111-posterize2';

‎core.js‎

Lines changed: 202 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,9 @@
263263
* 输出每个连通区域的闭合轮廓(图像坐标),供「色块」风格平涂。
264264
* 轮廓方向不规范化,渲染端统一用 evenodd 填充,孔洞自然正确。
265265
*/
266-
function layerContours(A, layer, minArea) {
267-
const { labels, AW, AH, sxa, sya } = A;
266+
function layerContours(A, layer, minArea, labelSrc) {
267+
const labels = labelSrc || A.labels;
268+
const { AW, AH, sxa, sya } = A;
268269
const at = (x, y) => (x < 0 || y < 0 || x >= AW || y >= AH) ? 0 : (labels[y * AW + x] === layer ? 1 : 0);
269270
const AX = [], AY = [], BX = [], BY = [];
270271
const seg = (ax, ay, bx, by) => { AX.push(ax); AY.push(ay); BX.push(bx); BY.push(by); };
@@ -354,6 +355,56 @@
354355
return s;
355356
}
356357

358+
/* 闭合轮廓的曲线平滑:对每个点用左右邻居做加权平均(Chaikin 式松弛),
359+
* 把 marching squares 的台阶磨圆。迭代次数越多越圆滑。 */
360+
function smoothLoop(pts, iters) {
361+
let cur = Array.from(pts);
362+
for (let it = 0; it < iters; it++) {
363+
const n = cur.length / 2;
364+
if (n < 4) break;
365+
const out = new Array(n * 2);
366+
for (let i = 0; i < n; i++) {
367+
const ip = (i - 1 + n) % n, inx = (i + 1) % n;
368+
// 0.25 / 0.5 / 0.25 加权,既平滑又不会明显收缩
369+
out[i * 2] = cur[ip * 2] * 0.25 + cur[i * 2] * 0.5 + cur[inx * 2] * 0.25;
370+
out[i * 2 + 1] = cur[ip * 2 + 1] * 0.25 + cur[i * 2 + 1] * 0.5 + cur[inx * 2 + 1] * 0.25;
371+
}
372+
cur = out;
373+
}
374+
return cur;
375+
}
376+
377+
/* 沿法线把闭合轮廓向外推开 d 个像素。
378+
* 相邻色块各自描边后,两次抗锯齿会在交界处留下一道底色细缝,
379+
* 每块向外膨胀一点点就能互相压住,缝就没了。 */
380+
function inflateLoop(pts, d) {
381+
const n = pts.length / 2;
382+
if (n < 3 || d <= 0) return Array.from(pts);
383+
// 先判方向,保证「向外」一致
384+
let area2 = 0;
385+
for (let i = 0; i < n; i++) {
386+
const j = (i + 1) % n;
387+
area2 += pts[i * 2] * pts[j * 2 + 1] - pts[j * 2] * pts[i * 2 + 1];
388+
}
389+
const sign = area2 >= 0 ? 1 : -1;
390+
const out = new Array(n * 2);
391+
for (let i = 0; i < n; i++) {
392+
const ip = (i - 1 + n) % n, inx = (i + 1) % n;
393+
// 前后两条边的方向
394+
let ax = pts[i * 2] - pts[ip * 2], ay = pts[i * 2 + 1] - pts[ip * 2 + 1];
395+
let bx = pts[inx * 2] - pts[i * 2], by = pts[inx * 2 + 1] - pts[i * 2 + 1];
396+
const la = Math.hypot(ax, ay) || 1e-9, lb = Math.hypot(bx, by) || 1e-9;
397+
ax /= la; ay /= la; bx /= lb; by /= lb;
398+
// 两条边的法线取平均
399+
let nx = (-ay + -by) * 0.5, ny = (ax + bx) * 0.5;
400+
const ln = Math.hypot(nx, ny) || 1e-9;
401+
nx = (nx / ln) * d * sign; ny = (ny / ln) * d * sign;
402+
out[i * 2] = pts[i * 2] + nx;
403+
out[i * 2 + 1] = pts[i * 2 + 1] + ny;
404+
}
405+
return out;
406+
}
407+
357408
/* Ramer–Douglas–Peucker(开链) */
358409
function rdp(pts, a0, b0, eps) {
359410
const keep = new Uint8Array(b0 - a0 + 1);
@@ -1067,6 +1118,10 @@
10671118

10681119
/* ================================================================ 风格三:色块
10691120
* 参照 astronaut_layers.png:k-means 分层 → 每层一个平涂色 → 硬边填色。
1121+
*
1122+
* 「细腻程度」在这里的语义和其他风格保持一致——越小越细:
1123+
* 控制色块的最小面积阈值与轮廓简化程度,小色块会被并进邻居,
1124+
* 于是滑块能真实改变画面精细度(原来这个风格根本没读 scale)。
10701125
*/
10711126
const POSTER_STAGE = [
10721127
['base', '铺底色', 14.0],
@@ -1076,6 +1131,90 @@
10761131
['accent', '点缀', 12.0],
10771132
];
10781133

1134+
/* 把散点噪声清掉:一个像素如果它的同层邻居少于阈值,就并到周围最多的那层。
1135+
* k-means 的原始标签图边缘是锯齿状的,直接描边会得到毛刺,这一步同时也能
1136+
* 把孤立的小色斑吃掉,让色块更像「一笔涂上去」而不是像素噪点。 */
1137+
function smoothLabels(labels, AW, AH, k, passes) {
1138+
let cur = labels;
1139+
for (let p = 0; p < passes; p++) {
1140+
const next = new Uint8Array(AW * AH);
1141+
const cnt = new Int32Array(k);
1142+
for (let y = 0; y < AH; y++) {
1143+
for (let x = 0; x < AW; x++) {
1144+
const i = y * AW + x;
1145+
cnt.fill(0);
1146+
let best = cur[i], bestN = 0;
1147+
for (let dy = -1; dy <= 1; dy++) {
1148+
const yy = y + dy;
1149+
if (yy < 0 || yy >= AH) continue;
1150+
for (let dx = -1; dx <= 1; dx++) {
1151+
const xx = x + dx;
1152+
if (xx < 0 || xx >= AW) continue;
1153+
const l = cur[yy * AW + xx];
1154+
cnt[l]++;
1155+
if (cnt[l] > bestN) { bestN = cnt[l]; best = l; }
1156+
}
1157+
}
1158+
next[i] = bestN >= 5 ? best : cur[i];
1159+
}
1160+
}
1161+
cur = next;
1162+
}
1163+
return cur;
1164+
}
1165+
1166+
/* 合并小连通域:面积小于阈值的色块并入它接触最多的邻层。
1167+
* 这是「细腻程度」真正起作用的地方——阈值越小,保留的小色块越多。 */
1168+
function mergeSmallRegions(labels, AW, AH, k, minArea) {
1169+
const out = new Uint8Array(labels);
1170+
const seen = new Uint8Array(AW * AH);
1171+
const stack = new Int32Array(AW * AH);
1172+
const nb = new Int32Array(k);
1173+
const region = new Int32Array(AW * AH);
1174+
1175+
for (let sy = 0; sy < AH; sy++) {
1176+
for (let sx = 0; sx < AW; sx++) {
1177+
const start = sy * AW + sx;
1178+
if (seen[start]) continue;
1179+
const lv = labels[start];
1180+
// 洪水填充出这一块
1181+
let sp = 0, n = 0;
1182+
stack[sp++] = start; seen[start] = 1;
1183+
while (sp > 0) {
1184+
const i = stack[--sp];
1185+
region[n++] = i;
1186+
const x = i % AW, y = (i / AW) | 0;
1187+
for (let d = 0; d < 4; d++) {
1188+
const nx = x + (d === 0 ? 1 : d === 1 ? -1 : 0);
1189+
const ny = y + (d === 2 ? 1 : d === 3 ? -1 : 0);
1190+
if (nx < 0 || ny < 0 || nx >= AW || ny >= AH) continue;
1191+
const j = ny * AW + nx;
1192+
if (seen[j] || labels[j] !== lv) continue;
1193+
seen[j] = 1; stack[sp++] = j;
1194+
}
1195+
}
1196+
if (n >= minArea) continue;
1197+
// 太小的块 → 统计它四周接触最多的其他层
1198+
nb.fill(0);
1199+
for (let q = 0; q < n; q++) {
1200+
const i = region[q];
1201+
const x = i % AW, y = (i / AW) | 0;
1202+
for (let d = 0; d < 4; d++) {
1203+
const nx = x + (d === 0 ? 1 : d === 1 ? -1 : 0);
1204+
const ny = y + (d === 2 ? 1 : d === 3 ? -1 : 0);
1205+
if (nx < 0 || ny < 0 || nx >= AW || ny >= AH) continue;
1206+
const nl = labels[ny * AW + nx];
1207+
if (nl !== lv) nb[nl]++;
1208+
}
1209+
}
1210+
let bi = -1, bn = 0;
1211+
for (let l = 0; l < k; l++) if (nb[l] > bn) { bn = nb[l]; bi = l; }
1212+
if (bi >= 0) for (let q = 0; q < n; q++) out[region[q]] = bi;
1213+
}
1214+
}
1215+
return out;
1216+
}
1217+
10791218
function buildPosterize(A, rgba, W, H, opts, onStage) {
10801219
const P = makePainter(A, rgba, W, H, opts.seed || 20261109);
10811220
const base = Math.max(W, H) / 1527;
@@ -1088,54 +1227,71 @@
10881227
};
10891228

10901229
const k = clamp(opts.layers || 5, 2, 8);
1091-
// 每层均值色在原图分辨率上取,避免降采样偏色
1230+
/* 细腻程度:1.0 为基准。滑动条越小 = 越细腻(保留更多小色块、轮廓更贴合),
1231+
* 越大 = 越概括(小色块被并掉,色块更整块)。 */
1232+
const fine = clamp(opts.scale || 1, 0.55, 1.8);
1233+
// fine 小 → 阈值小 → 保留更多细节,所以两者同向
1234+
const detailK = fine;
1235+
1236+
// ---- 标签图:平滑 + 合并小区域 ----
1237+
// 越细腻越少平滑(保住边缘细节),越概括越多平滑
1238+
const smoothPasses = clamp(Math.round(3 - 1.2 * detailK), 1, 3);
1239+
let labels = smoothLabels(A.labels, A.AW, A.AH, k, smoothPasses);
1240+
// 最小色块面积:越细腻保留的块越小
1241+
const areaBase = (A.AW * A.AH) * 0.000055; // 基准约 0.0055% 画幅
1242+
const minRegion = Math.max(2, Math.round(areaBase * detailK * detailK));
1243+
labels = mergeSmallRegions(labels, A.AW, A.AH, k, minRegion);
1244+
1245+
// ---- 每层代表色(在原图分辨率上统计,避免降采样偏色)----
10921246
const sum = new Float64Array(k * 3), cnt = new Float64Array(k);
10931247
const stepPx = Math.max(1, Math.floor(Math.sqrt(W * H / 240000)));
1248+
const labAt = (x, y) => {
1249+
const ax = clamp(Math.round(x / A.sxa), 0, A.AW - 1);
1250+
const ay = clamp(Math.round(y / A.sya), 0, A.AH - 1);
1251+
return labels[ay * A.AW + ax];
1252+
};
10941253
for (let y = 0; y < H; y += stepPx) {
10951254
for (let x = 0; x < W; x += stepPx) {
1096-
const l = P.sampleA(A.labels, x, y) | 0;
1255+
const l = labAt(x, y);
10971256
if (l < 0 || l >= k) continue;
10981257
const c = P.sampleRGB(x, y);
10991258
sum[l * 3] += c[0]; sum[l * 3 + 1] += c[1]; sum[l * 3 + 2] += c[2]; cnt[l]++;
11001259
}
11011260
}
1102-
const palette = [];
1103-
// 先算原图整体饱和度,用它自适应决定「海报化」要提多少饱和:
1104-
// 本来就是低饱和的图(灰暗照片)提太少会一片死灰,提太多又会脏
1261+
1262+
// 原图整体饱和度:低饱和的图多提一点,否则提饱和度也没东西可提
11051263
let satSum = 0, satN = 0;
1106-
for (let y = 0; y < H; y += Math.max(1, stepPx * 4)) {
1107-
for (let x = 0; x < W; x += Math.max(1, stepPx * 4)) {
1264+
const satStep = Math.max(1, stepPx * 4);
1265+
for (let y = 0; y < H; y += satStep) {
1266+
for (let x = 0; x < W; x += satStep) {
11081267
const c = P.sampleRGB(x, y);
11091268
const mx = Math.max(c[0], c[1], c[2]), mn = Math.min(c[0], c[1], c[2]);
11101269
if (mx > 8) { satSum += (mx - mn) / mx; satN++; }
11111270
}
11121271
}
1113-
const avgSat = satN ? satSum / satN : 0.2; // 0(灰) … 1(纯色)
1114-
// 灰图多提一点,彩色图少提一点,控制在 1.15–1.9 之间
1272+
const avgSat = satN ? satSum / satN : 0.2;
11151273
const satGain = clamp(1.15 + (0.35 - Math.min(0.35, avgSat)) * 2.2, 1.15, 1.9);
11161274
const conGain = 1.10;
11171275

1118-
/* 层内「代表色」:均值会被大量背景像素冲淡成灰,
1119-
* 所以按亮度加权取该层里最饱和的那批像素的中位色,色块才不至于发灰。 */
1276+
/* 层内代表色:均值被背景冲淡会发灰,取该层最饱和那批像素的中位色 */
11201277
const hist = [];
11211278
for (let i = 0; i < k; i++) hist.push([]);
11221279
const hStep = Math.max(1, Math.floor(Math.sqrt(W * H / 90000)));
11231280
for (let y = 0; y < H; y += hStep) {
11241281
for (let x = 0; x < W; x += hStep) {
1125-
const l = P.sampleA(A.labels, x, y) | 0;
1282+
const l = labAt(x, y);
11261283
if (l < 0 || l >= k) continue;
11271284
const c = P.sampleRGB(x, y);
11281285
const mx = Math.max(c[0], c[1], c[2]), mn = Math.min(c[0], c[1], c[2]);
1129-
const s = mx > 8 ? (mx - mn) / mx : 0;
1130-
hist[l].push(c[0], c[1], c[2], s);
1286+
hist[l].push(c[0], c[1], c[2], mx > 8 ? (mx - mn) / mx : 0);
11311287
}
11321288
}
11331289

1290+
const palette = [];
11341291
for (let i = 0; i < k; i++) {
11351292
const n = Math.max(1, cnt[i]);
11361293
let r = sum[i * 3] / n, g = sum[i * 3 + 1] / n, b = sum[i * 3 + 2] / n;
11371294

1138-
// 取该层饱和度前 35% 的样本,求它们的中位色作为代表色
11391295
const arr = hist[i];
11401296
const cntS = arr.length / 4;
11411297
if (cntS >= 12) {
@@ -1150,35 +1306,44 @@
11501306
}
11511307
rs.sort((x, y) => x - y); gs.sort((x, y) => x - y); bs.sort((x, y) => x - y);
11521308
const mid = take >> 1;
1153-
// 代表色与均值做混合,既保留层的整体明度,又不被灰冲淡
11541309
r = r * 0.35 + rs[mid] * 0.65;
11551310
g = g * 0.35 + gs[mid] * 0.65;
11561311
b = b * 0.35 + bs[mid] * 0.65;
11571312
}
11581313

1159-
// 海报化:提饱和 + 轻微提对比,让色块更明确
11601314
const gv = 0.299 * r + 0.587 * g + 0.114 * b;
11611315
r = gv + (r - gv) * satGain; g = gv + (g - gv) * satGain; b = gv + (b - gv) * satGain;
11621316
r = 128 + (r - 128) * conGain; g = 128 + (g - 128) * conGain; b = 128 + (b - 128) * conGain;
11631317
palette.push([clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255)]);
11641318
}
11651319

1166-
const minArea = Math.max(6, (A.AW * A.AH) * 0.000004);
1320+
// ---- 抽轮廓 ----
1321+
/* 简化容差:这是「细腻 vs 概括」最直观的旋钮。
1322+
* 容差大 → 轮廓被大量折线概括,色块边缘更「整」,尖刺消失;
1323+
* 容差小 → 贴着原边界走,细节多但可能带毛刺。 */
1324+
const eps = clamp(1.5 * base * detailK, 0.5, 4.5);
1325+
// 轮廓平滑遍数:越概括越圆滑
1326+
const smoothIters = clamp(Math.round(3.5 - 1.5 * detailK), 1, 4);
1327+
const minLoopArea = Math.max(2, minRegion * 0.5);
11671328
const byLayer = [];
11681329
for (let i = 0; i < k; i++) {
1169-
const loops = layerContours(A, i, minArea);
1330+
const loops = layerContours(A, i, minLoopArea, labels);
11701331
loops.sort((a, b) => b.area - a.area);
11711332
byLayer.push(loops);
11721333
}
11731334

1174-
const mkFill = (loop, colorHex, alpha, tag) => {
1175-
const pts = simplify(loop.pts, Math.max(0.9, 1.1 * base));
1335+
const mkFill = (loop, colorHex, tag) => {
1336+
// 顺序:先平滑磨掉像素台阶 → 再按「相邻像素跨过半步」小幅外扩 → 最后简化压点数。
1337+
// 外扩只做半像素:再多就会让下层在交界处留下一圈可见暗边。
1338+
const sm = smoothLoop(loop.pts, smoothIters);
1339+
const grown = inflateLoop(sm, Math.max(0.15, 0.28 * base));
1340+
const pts = simplify(grown, eps);
11761341
if (pts.length < 6) return null;
11771342
let d = 'M' + pts[0].toFixed(1) + ' ' + pts[1].toFixed(1);
11781343
for (let q = 1; q < pts.length / 2; q++) d += 'L' + pts[q * 2].toFixed(1) + ' ' + pts[q * 2 + 1].toFixed(1);
11791344
d += 'Z';
11801345
return {
1181-
d, c: colorHex, w: 0, a: alpha, kind: KIND_FILL,
1346+
d, c: colorHex, w: 0, a: 1, kind: KIND_FILL,
11821347
len: +loop.len.toFixed(1), layer: 0, detail: 0, tag, area: loop.area,
11831348
};
11841349
};
@@ -1200,10 +1365,22 @@
12001365
const names = ['base', 'shade', 'midtone', 'light', 'accent'];
12011366
for (let bi = 0; bi < 5; bi++) {
12021367
const out = [];
1368+
/* 第一层先铺一块满幅底色:轮廓是按分析分辨率抽的,边缘那一圈和
1369+
* 被合并掉的碎块可能没人覆盖,会露出底色形成空洞。先铺满就不会有洞。 */
1370+
if (bi === 0) {
1371+
const c0 = P.hex(palette[buckets[0].length ? buckets[0][0] : 0][0],
1372+
palette[buckets[0].length ? buckets[0][0] : 0][1],
1373+
palette[buckets[0].length ? buckets[0][0] : 0][2]);
1374+
const m = Math.max(W, H);
1375+
const dq = 'M' + (-m) + ' ' + (-m) + 'L' + (W + m) + ' ' + (-m) +
1376+
'L' + (W + m) + ' ' + (H + m) + 'L' + (-m) + ' ' + (H + m) + 'Z';
1377+
out.push({ d: dq, c: c0, w: 0, a: 1, kind: KIND_FILL,
1378+
len: 2 * (W + H), layer: 0, detail: 0, tag: 'bgfill', area: W * H });
1379+
}
12031380
for (const li of buckets[bi]) {
12041381
const col = P.hex(palette[li][0], palette[li][1], palette[li][2]);
12051382
for (const loop of byLayer[li]) {
1206-
const f = mkFill(loop, col, 1.0, 'fill');
1383+
const f = mkFill(loop, col, 'fill');
12071384
if (f) { f.layer = li; out.push(f); }
12081385
}
12091386
}

‎index.html‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ <h2 style="margin-top:18px">③ 调参数</h2>
185185
<input type="range" id="scale" min="0.55" max="1.8" step="0.05" value="1">
186186
<div class="val" id="scaleV">1.00</div>
187187
</div>
188+
<div class="tips" style="margin-top:-8px;margin-bottom:8px" id="scaleHint">
189+
数值越小笔触越细越密,细节越清楚,文件也越大。
190+
</div>
188191
<div class="row">
189192
<label>动画时长</label>
190193
<input type="range" id="secs" min="20" max="240" step="5" value="90">
@@ -354,8 +357,13 @@ <h2>说明</h2>
354357
};
355358
$('flowLine').textContent = s.label + ' · ' + STEPS[key].join(' → ');
356359
$('layerHint').textContent = key === 'posterize'
357-
? '色块风格用它决定配色档数。'
360+
? '色块风格用它决定配色档数(5 档最稳,8 档层次最多)。'
358361
: (key === 'pencil' ? '素描风格用 5 档即可,影响不大。' : '色彩分层的层数。');
362+
$('scaleHint').textContent = key === 'posterize'
363+
? '往左=保留更多小色块、边缘更贴原图;往右=把小色块并掉,画面更概括。'
364+
: (key === 'lineart' ? '往左=线条更密更细;往右=只留主线。'
365+
: (key === 'pencil' ? '往左=排线更密;往右=笔触更粗犷。'
366+
: '数值越小笔触越细越密,细节越清楚,文件也越大。'));
359367
if (result) { $('result').style.display = 'none'; }
360368
}
361369

0 commit comments

Comments
 (0)