iKode Blog icon iKode Blog
返回文章列表

Article

One Last Image:从模糊差分到彩色铅笔调子

技术笔记2026年6月20日 12:293 min

一张纯黑图片让问题变得清楚:要复现 One Last Kiss 风格,只有边缘检测不够,还需要独立的暗部纹理、对角渐变与浏览器端合成。

最初的渐变线稿工具使用 Sobel 梯度寻找明暗边缘。它处理人物插画时已经像那么回事,但第二张测试图把问题一下子照亮了:原图是完全纯黑的,每个像素都是 RGB(0, 0, 0),结果却应该出现柔和的彩色铅笔排线。

如果输出里有纹理,而输入里没有任何边缘,那么纹理就不可能来自边缘检测。它必须是一层独立生成的暗部调子。于是这个工具不再只是调整几个阈值,而是按完整处理链重做,并正式更名为 One Last Image

第一层:用模糊差分提取线迹

图片先转为灰度。开启降噪时,会先经过一次小半径均值模糊;随后再按“精细、一般、稍粗、超粗、极粗”选择更大的模糊半径。原灰度与大模糊结果做差,就能提取局部变化,同时保留比 Sobel 更接近手绘轮廓的线迹。

const filtered = boxBlur(prepared, width, height, radius)
const darkCut = clamp(options.lineWeight, 80, 126)
const scale = 255 / Math.max(1, 255 - 128 - darkCut)

for (let index = 0; index < output.length; index += 1) {
  const difference = 128 + prepared[index] - filtered[index]
  output[index] = clamp((difference - darkCut) * scale, 0, 255)
}

lineWeight 控制差分结果的截断位置。值越接近上限,留下的线越重;模糊半径越大,轮廓也会越粗。均值模糊使用横向、纵向两次滑动窗口完成,复杂度与像素数量线性相关,不会随着卷积核变大而成平方增长。

第二层:暗部遮罩和铅笔纹理

线迹只能解释人物轮廓,不能解释纯黑图里的排线。暗部调子使用原图灰度生成遮罩:低于 shadeCoverage 的区域获得纹理,高亮区域保持白色。遮罩再经过轻微模糊,避免明暗边界像剪纸一样生硬。

铅笔纹理来自原版 One Last Image 的 MIT 开源素材。处理时读取纹理亮度的反相值,再乘以暗部遮罩和 shadeStrength。这样纯黑图片会完整显出排线,人物图则只在头发、衣物和阴影里留下柔和笔触。

const texture = 255 - texturePixels[pixelIndex]
const shadeOpacity = texture
  * (shadeMask[index] / 255)
  * (options.shadeStrength / 100)
const opacity = Math.max(255 - lineValue, shadeOpacity)

参考成品的调子比开源默认值更轻。我用纯黑输入对生成图和目标图做通道统计,最后把默认强度从 80 校准到 32。生成图的 RGB 均值、标准差和主要分位数与参考图已经基本重合。

第三层:让颜色沿对角线流动

线迹和调子最终都不是直接画成黑色,而是作为透明度。底色来自左上到右下的多段渐变:暖黄、橙、红、粉紫,最后过渡到青蓝。每个像素先根据坐标取渐变色,再按线迹或调子的较大透明度与白色混合。

const opacity = clamp(Math.max(255 - lineValue, shadeOpacity), 0, 255)
const color = getOneLastImageGradientColor(x, y, width, height)
const alpha = opacity / 255

output[pixelIndex] = 255 + (color.red - 255) * alpha
output[pixelIndex + 1] = 255 + (color.green - 255) * alpha
output[pixelIndex + 2] = 255 + (color.blue - 255) * alpha

这种做法把“画什么”和“用什么颜色画”分开了。关闭 Kiss 渐变后,同一份线迹可以直接输出灰度;开启后,线条和铅笔调子共享一条稳定的颜色路径。

浏览器里的最后一遍收尾

图片不会上传服务器。浏览器用 createImageBitmap 解码,把最长边限制在 1400 像素,再缓存原图与纹理像素。拖动设置时通过 100ms 防抖重新计算,并用 revision 编号丢弃已经过时的渲染结果。

原始像素写入 Canvas 后还会先缩小、再高质量放回原尺寸。这一步会软化数字边缘,让线条更接近参考图中的纸面感觉。最后可选择普通水印或初回水印,并导出仅结果、上下对比或斜切对比 JPG。

这次最大的收获不是某个卷积核参数,而是测试图本身。细节丰富的图片很容易让一个不完整的算法看起来“差不多”;纯黑输入却能直接证明暗部纹理是否真的是独立的一层。之后再做图像实验,我会更愿意先找这种能把处理阶段拆开的极端样本。

现在可以在 One Last Image 里直接试一张图片。效果与素材参考并致谢 itorr/one-last-image,相关纹理与水印遵循 MIT 许可证。

Comments

留言

需要 GitHub 登录。新评论会先进入待审核,别急,它只是先坐到门口等一会儿。

使用 GitHub 登录后可以评论。

已通过评论

0

还没有通过审核的评论。桌子很安静,第一杯茶还没端上来。