|
263 | 263 | * 输出每个连通区域的闭合轮廓(图像坐标),供「色块」风格平涂。 |
264 | 264 | * 轮廓方向不规范化,渲染端统一用 evenodd 填充,孔洞自然正确。 |
265 | 265 | */ |
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; |
268 | 269 | const at = (x, y) => (x < 0 || y < 0 || x >= AW || y >= AH) ? 0 : (labels[y * AW + x] === layer ? 1 : 0); |
269 | 270 | const AX = [], AY = [], BX = [], BY = []; |
270 | 271 | const seg = (ax, ay, bx, by) => { AX.push(ax); AY.push(ay); BX.push(bx); BY.push(by); }; |
|
354 | 355 | return s; |
355 | 356 | } |
356 | 357 |
|
| 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 | + |
357 | 408 | /* Ramer–Douglas–Peucker(开链) */ |
358 | 409 | function rdp(pts, a0, b0, eps) { |
359 | 410 | const keep = new Uint8Array(b0 - a0 + 1); |
|
1067 | 1118 |
|
1068 | 1119 | /* ================================================================ 风格三:色块 |
1069 | 1120 | * 参照 astronaut_layers.png:k-means 分层 → 每层一个平涂色 → 硬边填色。 |
| 1121 | + * |
| 1122 | + * 「细腻程度」在这里的语义和其他风格保持一致——越小越细: |
| 1123 | + * 控制色块的最小面积阈值与轮廓简化程度,小色块会被并进邻居, |
| 1124 | + * 于是滑块能真实改变画面精细度(原来这个风格根本没读 scale)。 |
1070 | 1125 | */ |
1071 | 1126 | const POSTER_STAGE = [ |
1072 | 1127 | ['base', '铺底色', 14.0], |
|
1076 | 1131 | ['accent', '点缀', 12.0], |
1077 | 1132 | ]; |
1078 | 1133 |
|
| 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 | + |
1079 | 1218 | function buildPosterize(A, rgba, W, H, opts, onStage) { |
1080 | 1219 | const P = makePainter(A, rgba, W, H, opts.seed || 20261109); |
1081 | 1220 | const base = Math.max(W, H) / 1527; |
|
1088 | 1227 | }; |
1089 | 1228 |
|
1090 | 1229 | 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 | + // ---- 每层代表色(在原图分辨率上统计,避免降采样偏色)---- |
1092 | 1246 | const sum = new Float64Array(k * 3), cnt = new Float64Array(k); |
1093 | 1247 | 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 | + }; |
1094 | 1253 | for (let y = 0; y < H; y += stepPx) { |
1095 | 1254 | for (let x = 0; x < W; x += stepPx) { |
1096 | | - const l = P.sampleA(A.labels, x, y) | 0; |
| 1255 | + const l = labAt(x, y); |
1097 | 1256 | if (l < 0 || l >= k) continue; |
1098 | 1257 | const c = P.sampleRGB(x, y); |
1099 | 1258 | sum[l * 3] += c[0]; sum[l * 3 + 1] += c[1]; sum[l * 3 + 2] += c[2]; cnt[l]++; |
1100 | 1259 | } |
1101 | 1260 | } |
1102 | | - const palette = []; |
1103 | | - // 先算原图整体饱和度,用它自适应决定「海报化」要提多少饱和: |
1104 | | - // 本来就是低饱和的图(灰暗照片)提太少会一片死灰,提太多又会脏 |
| 1261 | + |
| 1262 | + // 原图整体饱和度:低饱和的图多提一点,否则提饱和度也没东西可提 |
1105 | 1263 | 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) { |
1108 | 1267 | const c = P.sampleRGB(x, y); |
1109 | 1268 | const mx = Math.max(c[0], c[1], c[2]), mn = Math.min(c[0], c[1], c[2]); |
1110 | 1269 | if (mx > 8) { satSum += (mx - mn) / mx; satN++; } |
1111 | 1270 | } |
1112 | 1271 | } |
1113 | | - const avgSat = satN ? satSum / satN : 0.2; // 0(灰) … 1(纯色) |
1114 | | - // 灰图多提一点,彩色图少提一点,控制在 1.15–1.9 之间 |
| 1272 | + const avgSat = satN ? satSum / satN : 0.2; |
1115 | 1273 | const satGain = clamp(1.15 + (0.35 - Math.min(0.35, avgSat)) * 2.2, 1.15, 1.9); |
1116 | 1274 | const conGain = 1.10; |
1117 | 1275 |
|
1118 | | - /* 层内「代表色」:均值会被大量背景像素冲淡成灰, |
1119 | | - * 所以按亮度加权取该层里最饱和的那批像素的中位色,色块才不至于发灰。 */ |
| 1276 | + /* 层内代表色:均值被背景冲淡会发灰,取该层最饱和那批像素的中位色 */ |
1120 | 1277 | const hist = []; |
1121 | 1278 | for (let i = 0; i < k; i++) hist.push([]); |
1122 | 1279 | const hStep = Math.max(1, Math.floor(Math.sqrt(W * H / 90000))); |
1123 | 1280 | for (let y = 0; y < H; y += hStep) { |
1124 | 1281 | for (let x = 0; x < W; x += hStep) { |
1125 | | - const l = P.sampleA(A.labels, x, y) | 0; |
| 1282 | + const l = labAt(x, y); |
1126 | 1283 | if (l < 0 || l >= k) continue; |
1127 | 1284 | const c = P.sampleRGB(x, y); |
1128 | 1285 | 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); |
1131 | 1287 | } |
1132 | 1288 | } |
1133 | 1289 |
|
| 1290 | + const palette = []; |
1134 | 1291 | for (let i = 0; i < k; i++) { |
1135 | 1292 | const n = Math.max(1, cnt[i]); |
1136 | 1293 | let r = sum[i * 3] / n, g = sum[i * 3 + 1] / n, b = sum[i * 3 + 2] / n; |
1137 | 1294 |
|
1138 | | - // 取该层饱和度前 35% 的样本,求它们的中位色作为代表色 |
1139 | 1295 | const arr = hist[i]; |
1140 | 1296 | const cntS = arr.length / 4; |
1141 | 1297 | if (cntS >= 12) { |
|
1150 | 1306 | } |
1151 | 1307 | rs.sort((x, y) => x - y); gs.sort((x, y) => x - y); bs.sort((x, y) => x - y); |
1152 | 1308 | const mid = take >> 1; |
1153 | | - // 代表色与均值做混合,既保留层的整体明度,又不被灰冲淡 |
1154 | 1309 | r = r * 0.35 + rs[mid] * 0.65; |
1155 | 1310 | g = g * 0.35 + gs[mid] * 0.65; |
1156 | 1311 | b = b * 0.35 + bs[mid] * 0.65; |
1157 | 1312 | } |
1158 | 1313 |
|
1159 | | - // 海报化:提饱和 + 轻微提对比,让色块更明确 |
1160 | 1314 | const gv = 0.299 * r + 0.587 * g + 0.114 * b; |
1161 | 1315 | r = gv + (r - gv) * satGain; g = gv + (g - gv) * satGain; b = gv + (b - gv) * satGain; |
1162 | 1316 | r = 128 + (r - 128) * conGain; g = 128 + (g - 128) * conGain; b = 128 + (b - 128) * conGain; |
1163 | 1317 | palette.push([clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255)]); |
1164 | 1318 | } |
1165 | 1319 |
|
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); |
1167 | 1328 | const byLayer = []; |
1168 | 1329 | for (let i = 0; i < k; i++) { |
1169 | | - const loops = layerContours(A, i, minArea); |
| 1330 | + const loops = layerContours(A, i, minLoopArea, labels); |
1170 | 1331 | loops.sort((a, b) => b.area - a.area); |
1171 | 1332 | byLayer.push(loops); |
1172 | 1333 | } |
1173 | 1334 |
|
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); |
1176 | 1341 | if (pts.length < 6) return null; |
1177 | 1342 | let d = 'M' + pts[0].toFixed(1) + ' ' + pts[1].toFixed(1); |
1178 | 1343 | for (let q = 1; q < pts.length / 2; q++) d += 'L' + pts[q * 2].toFixed(1) + ' ' + pts[q * 2 + 1].toFixed(1); |
1179 | 1344 | d += 'Z'; |
1180 | 1345 | return { |
1181 | | - d, c: colorHex, w: 0, a: alpha, kind: KIND_FILL, |
| 1346 | + d, c: colorHex, w: 0, a: 1, kind: KIND_FILL, |
1182 | 1347 | len: +loop.len.toFixed(1), layer: 0, detail: 0, tag, area: loop.area, |
1183 | 1348 | }; |
1184 | 1349 | }; |
|
1200 | 1365 | const names = ['base', 'shade', 'midtone', 'light', 'accent']; |
1201 | 1366 | for (let bi = 0; bi < 5; bi++) { |
1202 | 1367 | 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 | + } |
1203 | 1380 | for (const li of buckets[bi]) { |
1204 | 1381 | const col = P.hex(palette[li][0], palette[li][1], palette[li][2]); |
1205 | 1382 | for (const loop of byLayer[li]) { |
1206 | | - const f = mkFill(loop, col, 1.0, 'fill'); |
| 1383 | + const f = mkFill(loop, col, 'fill'); |
1207 | 1384 | if (f) { f.layer = li; out.push(f); } |
1208 | 1385 | } |
1209 | 1386 | } |
|
0 commit comments