基于卷积神经网络的水墨画合成:从特征提取到后处理全流程
简介这是一篇发表于《桂林理工大学学报》2019年第1期的学术论文PDF主题是基于卷积神经网络CNN的水墨画合成方法。资源面向深度学习、图像风格迁移及非真实感绘制的学习者和研究人员针对传统基于图像方法生成图案呆板、缺乏意境层次的问题提出调整预训练CNN结构以特征响应匹配内容、以特征响应相互关系匹配风格并结合对比度增强预处理和随机图像迭代完成照片到水墨画的风格化生成。文件包仅含1个PDF约3.94MB内容涵盖问题背景、相关研究、算法流程、实验验证与结论适合用于论文研读、方法复现或课程设计参考。该资源已有112人学习下载对想系统理解CNN在艺术图像合成中应用、并进一步探索水墨画自动绘制的人具有直接参考价值。1. 先给结论卷积神经网络做水墨画卡点不在“画”而在“墨”《基于卷积神经网络的水墨画合成方法》这个方案拆开来看要解决的并不是“怎么构图”而是“怎么让画面有墨感”。直接拿风格迁移网络跑水墨素材结果十有八九像水彩洇在纸上又放了一个星期边缘没飞白浓淡挤成一团这是很多团队第一次跑通 Demo 时最真实的翻车现场。整个方案要做的事情是输入一张普通照片或手绘线稿在卷积神经网络里提取笔触方向、墨色浓淡与留白位置再重新生成到宣纸纹理上。它适合正在做图片风格化、文化内容数字化以及想把手绘稿快速转成水墨效果的开发者。下面按“特征—数据—网络—避坑—后处理”五段展开每一段都可以直接复现。2. CNN 在找的三种水墨特征笔触方向、灰度偏斜与留白边界2.1 笔触方向与飞白卷积核在找的是高频梯度流水墨画的第一观感来自笔触而笔触在数字图像里本质上是一组有方向、有节奏的梯度。卷积神经网络的浅层卷积核恰好对方向敏感一个 3×3 的核可以近似响应横向、纵向或者斜向的明暗跳变多层堆叠之后深层特征开始响应“笔势”这种更大的模式而不只是某一条边。拿任何一张水墨画跑一遍 VGG16把 relu 层的特征图逐层叠起来回放你会看到很接近“卷积神经网络算法原理动画展示”描述的现象第一层在勾边缘第三四层开始出现成片的墨块轮廓更深层则能区分出山石、枝叶这些语义区域。工程上的启示是如果你想让模型画出“有笔触”的水墨不能用分类网络那种全局池化后的高层语义做主约束而是要让浅层梯度信息一路保留到生成器末端。飞白的本质是墨痕中的“断点”。一笔过去中间出现几道露纸的细缝放在图像上就是高频区里的局部断崖。常见误用是把这些断点当成噪声用平滑损失抹掉结果飞白消失画面变成胶质感的圆滑墨条。做水墨合成的第一步就是要在损失函数里单独护住这部分高频梯度流。2.2 墨的浓淡干湿感知损失里的通道加权传统画论讲“墨分五色”放到数字图像里对应的是灰度分布不是两个极端峰值而是从焦墨、浓墨、重墨到淡墨、清墨的连续偏斜分布。取一张典型山水画统计灰度直方图往往能看到三四个峰很窄的高密度暗部、主体灰阶、以及面积很大的浅灰区域。这是水墨和绝大多数风格迁移训练数据之间一个关键差异。自然照片的灰度分布通常围绕中间值对称水彩画的分布更宽更散。直接套用预训练网络的感知损失会把水墨画的灰阶拉回自然照片的对称分布结果就是“像画但不像水墨”。常见做法是为墨色单独设置一条低频灰阶通道计算生成结果与目标域的灰度直方图匹配损失。这里不需要精确的直方图匹配操作简单地在灰度主层上做 L1 损失再配合一个边缘纹理通道就能让网络学会“该黑的地方黑、该淡的地方淡”而不是只靠对抗损失猜测墨色规律。2.3 留白与纸的纹理这不是噪声是有语义的结构很多第一次做水墨合成的人会忽略留白。从像素上看留白就是一大片接近白色的区域但从构图上说留白的边界就是云的位置、水的走向、山体的轮廓线。CNN 在处理这里时面临一个选择它应该把留白当背景还是当前景我的经验是必须把它当结构来处理否则生成结果会出现两类问题一类是把留白区域填满灰度噪声另一类是留白边界与墨块边缘完全脱节画面看起来像贴纸。宣纸纹理是另一个常被忽略的语义结构。落笔边缘的渗墨毛边、纸张本身的纤维起伏这些在像素层面属于低概率、高熵的过渡区域。卷积神经网络很难靠对抗损失自发学会这种不确定性因为判别器只需要区分“是不是水墨”并不关心纸纹从哪里来。所以我在实际方案里会把纸纹问题从网络职责里拆出去放到后处理阶段单独做。网络只负责生成墨色与笔触纸纹交给接下来的一步训练负担小很多。3. 数据准备水墨画拿不到逐像素对齐的“答案”3.1 配对数据不存在选 CycleGAN 路线是合理的默认项很多自然图像风格化任务可以拿到成对训练数据同一场景的白天照片和夜晚照片、同一人物的真实照片和卡通头像。水墨画没有这种天然配对你不可能让齐白石对着一个真实场景重新画一张像素级对齐的临摹稿。所以《基于卷积神经网络的水墨画合成方法》落到工程实现时我一般建议直接选 CycleGAN 结构而不是 pix2pix。CycleGAN 不要求输入输出配对它学的是“照片域”和“水墨域”之间的双向映射。如果手头恰好有少量成对样本比如白描线稿和上墨稿的对照扫描可以拿它们做辅助监督但主训练流程仍然走 CycleGAN。原因是水墨画的笔势变化太大几十张配对样本不够让网络稳定学到映射反而会因为过拟合让画面出现零星的复制痕迹。配对样本可以单独喂给一个小型判别器帮助保住形体轮廓但不要指望它们承担全部内容约束。3.2 数据源筛选与预处理把“像水墨”的图从海量素材里挑出来公开的水墨画数据集很分散常见做法是自己收集四类素材传统山水花鸟的高清扫描件、白描册页、近现代水墨创作、以及少量当代实验水墨。扫描件来源可以优先选择博物馆公开资源、已进入公共领域的作品或你获得了授权的原创水墨画不要拿网络上的随手翻拍照凑数翻拍照会同时带上反光、偏色和 JPEG 压缩噪声网络会把它们全部学进去。收集完成后先做一轮硬过滤。我常用的过滤脚本长这样from pathlib import Path from PIL import Image import numpy as np def build_dataset(src_dir, dst_dir, size512, max_ratio1.5, color_diff30): src Path(src_dir) dst Path(dst_dir) dst.mkdir(parentsTrue, exist_okTrue) for p in src.glob(*.jpg): im Image.open(p).convert(RGB) w, h im.size if max(w, h) / min(w, h) max_ratio: continue # 构图过于狭长缩放后裁切会破坏画面 im im.resize((size, size), Image.BILINEAR) arr np.asarray(im) gray np.asarray(im.convert(L)) color_err np.abs(arr.sum(axis2) - gray * 3).mean() if color_err color_diff: continue # 偏色严重大概率是水彩或后期调色图 im.save(dst / p.name) if __name__ __main__: build_dataset(raw_ink, train_ink)这里有两个值得解释的参数。max_ratio1.5是为了过滤掉横幅长卷或竖轴长条这类画缩放到正方形后不是构图被破坏就是大量留白被裁掉模型会把“强制留白”和“构图”混在一起徒增训练难度。color_diff30是经验值纯水墨画的彩色通道之和应该接近灰度值的 3 倍偏差越大说明染色成分越多。如果你打算故意混入淡彩水墨可以把阈值放宽到 50但不建议超过这个值。这一节还有两个细节。一是缩放插值用Image.BILINEAR而不是LANCZOS。水墨画的飞白是很细的笔触纹路过锐利的插值会把单像素断点连成实线反而抹掉了飞白的“断”的质感。二是不要对这批训练图做降噪平滑宣纸纤维在扫描图里本身就是有效信息提前磨平之后后续后处理阶段补纸纹会很吃力。3.3 目录组织与在线增强一套可直接抄的样板训练数据目录结构建议按域分开不要把所有水墨图塞进一个文件夹data/ ├── photo2ink/ │ ├── trainA/ # 自然照片内容域 │ ├── trainB/ # 水墨画风格域 │ ├── testA/ │ └── testB/ └── assisted_pair/ # 少量线稿-上墨配对样本可选在线增强只做四项随机裁剪、水平翻转、亮度扰动和对比度扰动。旋转增强我通常不做因为水墨画的笔触方向带有明确的书法性横竖撇捺一旦被随机旋转模型会陷入方向混乱等现象就是生成的山石纹理开始出现漩涡状笔触。代码用torchvision.transforms可以直接拼出来from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(512, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.15, contrast0.15), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) ])注意scale(0.8, 1.0)的下限不要低于 0.8。裁剪比例太小会让模型只看到某个局部笔触丢失水墨画里“疏可走马、密不透风”的疏密关系。亮度与对比度的抖动幅度控制在 0.15是为了模拟不同年代纸张的泛黄程度差异但太大会让墨色失去深厚度。训练集在三千到五千张之间通常能跑出稳定效果再少就得靠更强的正则化否则判别器很快会把训练集背下来生成结果开始重复出现同一片山石纹理。4. 网络骨架与损失权重把水墨感“托住”的真实参数4.1 生成器结构语义先行纹理后行在水墨合成任务里生成器结构我会优先选 ResNet 骨架的循环一致性生成器而不是 U-Net。原因是 U-Net 的跳跃连接会把输入照片的高频细节直接搬运到输出这对手绘线稿上墨是有利的但对照片转水墨是有害的照片里的皮肤质感、布料纹理、金属反光会绕过特征提取直接出现在水墨结果里变成一片突兀的灰网纹。常见默认配置是三段式3 个下采样卷积把分辨率降到 1/8中间接 9 个 ResBlock 保持特征图尺寸不变再用 3 个上采样恢复到原图大小。第一层卷积从 3 通道升到 64 通道中间层按需升到 128 或 256。下采样次数决定了网络感受野大小也决定了它能“看”多大范围的笔势走向。显存吃紧时把 ResBlock 从 9 降到 6特征通道从 256 降到 128 是更温和的调整方式直接减少下采样次数会让画面丧失整体感。上采样阶段有个常见的棋盘伪影问题如果直接用转置卷积把通道数翻倍输出容易出现格子状亮斑。我一般会用“上采样 3×3 卷积”替换纯转置卷积稍微增加一点计算量但画面干净很多。对于水墨这种大面积的墨块棋盘伪影尤其扎眼因为墨块边缘一旦出现规则亮纹就会被当成某种皴法纹理留下来。4.2 损失函数内容感知、水墨感知、稀疏正则与对抗这一小节是整个方案里最值得抄作业的部分。训练时我维护四类损失内容感知损失用 VGG16 的relu3_3特征图做 L1 约束。选这一层是因为relu2_2太浅会把边缘锁死导致生成结果死板relu4_2太深对形体的约束弱山石轮廊容易漂移。水墨感知损失把生成图拆成灰度主层和边缘纹理层分别做约束这是解决“画面像水彩”的关键。稀疏正则损失真迹的墨迹覆盖率通常在 8% 到 15% 之间超出这个范围画面会显得闷黑低于这个范围则像铅笔素描。对抗损失用 LSGAN 形式比普通 BCE 稳定判别器输出空间不会被压缩到 0/1 两极。代码可以这样组织import torch import torch.nn.functional as F from torchvision.models import vgg16 vgg vgg16(pretrainedTrue).features[:16].eval() # 到 relu3_3 for p in vgg.parameters(): p.requires_grad False sobel_x torch.tensor([[[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]]], dtypetorch.float32) sobel_y torch.tensor([[[[-1, -2, -1], [0, 0, 0], [1, 2, 1]]]], dtypetorch.float32) def content_loss(gen, real): return F.l1_loss(vgg(gen), vgg(real)) def ink_perceptual_loss(gen, targetNone): gen_gray gen.mean(dim1, keepdimTrue) gen_edge torch.cat([ F.conv2d(gen_gray, sobel_x.to(gen.device), padding1), F.conv2d(gen_gray, sobel_y.to(gen.device), padding1) ], dim1) if target is None: # 无配对输入时只激励边缘出现不约束具体位置 return gen_edge.abs().mean() t_gray target.mean(dim1, keepdimTrue) t_edge torch.cat([ F.conv2d(t_gray, sobel_x.to(gen.device), padding1), F.conv2d(t_gray, sobel_y.to(gen.device), padding1) ], dim1) return F.l1_loss(gen_gray, t_gray) F.l1_loss(gen_edge, t_edge) def sparse_loss(gen): dark (gen.mean(dim1, keepdimTrue) 0.4).float() return (dark.mean() - 0.12).abs()这段代码里ink_perceptual_loss的targetNone分支值得单独说明。在 CycleGAN 的无配对场景下生成图没有对应的上墨稿此时它的作用是一个自激励项鼓励网络产生更多边缘响应而不是把一切磨平。当你有少量配对样本时传入target它就退化为真正的水墨感知约束灰度层管墨色浓淡边缘层管笔触干湿。两部分先拼接成双通道边缘图再做 L1比各算一次再相加更稳定因为梯度同时回流到两个方向的响应上。sparse_loss的阈值 0.4 是把 0 到 1 的灰度图中真正算“墨”的部分卡出来。p2 的选择是我有意写的实际代码里如果你用(dark.mean() - 0.12) ** 2收敛会更温和不容易出现训练中期把整张图往灰调推的抖动。阈值 0.4 也不是固定的打印几批生成结果的墨迹覆盖率后可以按目标作品的风格上下浮动山水画可以取 0.10花鸟画可以取 0.15。4.3 训练脚本里的权重与稳定性参数损失权重决定了水墨感能不能被“托住”。我常用的一组初始值如下损失权重调参信号content_loss10形体漂移时降到 5同时加入 relu4_2ink_perceptual_loss25画面过平滑时升到 40出现脏纹理时降到 15sparse_loss0.05整图发黑时降到 0.02飞白消失时升到 0.1adversarial_loss1.0训练震荡时降到 0.2cycle_loss10内容偏移时升到 20优化器选 Adambetas(0.5, 0.999)学习率前 10 个 epoch 固定 0.0002之后线性衰减到 0。生成器和判别器的更新比例我保持 1:1不做隔步更新水墨画判别器面对的目标域分布比较窄隔步更新容易让它过拟合生成器反而跟不上。前 5 个 epoch 不要急着看输出图这个阶段对抗损失会把画面推向过饱和的灰度噪声第 8 到 10 个 epoch 之间是真正的分水岭此时如果画面里还没有出现飞白纹理就说明水墨感知损失权重偏低了。一个很有用的监控手段是每 5 个 epoch 用同一批固定测试图生成输出然后计算三个数字灰度直方图的峰数量、边缘梯度均值、墨迹覆盖率。这三个数字比 SSIM 直观得多它们直接对应“墨色分了几层”“笔触干不干”“整体闷不闷”三件事。5. 避坑记录五条来自实际训练的水墨合成翻车经验5.1 现象输出全糊像墨汁倒在纸上再烘干没有任何笔触第一次跑通 CycleGAN 时很容易看到这个结果生成图确实“像水墨画”的色调但每一笔都没有形状。原因是网络只靠对抗损失和内容感知损失无法区分“墨色到位”和“笔触到位”。对抗损失只判断整体风格内容感知损失只约束大结构飞白和皴法这种高频细节两头都不管。解决方法是把ink_perceptual_loss加进总损失并且确保它作用在生成器主输出上而不是某个辅助输出上。我从权重 10 往上调到 25 后边缘梯度均值明显回升笔触才真正出现。5.2 现象训练到中后期输出整体变黑或整体变白这个现象出现过很多次最隐蔽的一次是在训练到第 12 个 epoch 时生成图突然全部变成接近纯黑的墨块判別器却还在稳定收敛。原因是 LSGAN 对概率分布建模有松弛性当目标域里大墨块占主导时生成器找到了一个更省力的解把全图压到墨色均值附近骗过判别器的统计判断。单纯调对抗权重治标不治本。我最后是靠sparse_loss解决的把权重从 0.01 提到 0.05并且给判别器加了一个简单的全局平均灰度特征让它能感知“整张图的墨量”不再只看局部纹理。想要更稳的话可以在判别器输出层前拼接一个AdaptiveAvgPool2d(1)的结果成本极低但对抑制全图变黑很有效。5.3 现象物体结构变形山体扭曲、树枝扭曲结构变形分两种。一种轻度的只是边缘不干净一种严重的比如用照片转水墨树干的走向和原图完全不一致。后者往往不是内容感知损失不够而是选错了特征层。relu2_2对边缘的约束太强会把照片的每一个碎边缘都当成必须保留的结构生成器顾此失彼relu4_2又太弱只保住大的明暗关系树干这种中层结构会漂。我最终固定在relu3_3并在内容损失里加了空间注意力权重如果输入是线稿结构约束会更强如果输入是照片则稍微放宽给水墨的写意变形留余地。5.4 现象生成纹理像水彩而不像水墨水彩和水墨的区别不在颜色深浅而在纹理的分布方式。水彩的颜料颗粒细小、过渡平滑水墨的边缘则带有纸张渗墨的毛刺。如果输出整体“水分很足”先检查训练数据我遇到过一次数据集里混进了几十张水彩荷花结果模型把水彩的湿边特征当成水墨特征学了进去。解决方法是回到build_dataset把color_diff从 30 收紧到 20重新过滤。检查生成图的边缘梯度也能分辨水彩的边缘过渡通常比较宽水墨的飞白边缘则表现为窄而尖锐的梯度尖峰这个差异用直方图一眼就能看出来。5.5 现象训练一切正常输出却平滑得像插画海报缺“纸感”这种情况最憋屈指标全正常灰度直方图峰也对但图就是没有宣纸的质感。原因是网络训练到收敛后生成器会自发避开高熵的纸纹区域因为纸纹对判别器没有贡献还有可能被当成噪声。我在经历两次这种现象后彻底想明白了纸纹不应该作为网络的学习目标而应该作为后处理合成步骤。硬要在对抗损失里加入纸纹约束结果要么是油画的布纹要么是规律的网格噪声都不自然。6. 最后一步一个值回票价的宣纸后处理技巧训练结束不等于方案结束。想让输出真正“长”在宣纸上我会加三个后处理操作高光弥散模拟渗墨、飞白重绘、白色封边。高光弥散是把暗墨块边缘向外渗开几像素模拟墨汁在宣纸上的扩散飞白重绘是从梯度图中挑出短促边缘条纹随机位移后叠回画面模拟运笔时的断续感白色封边是让画面边缘的墨色稍微淡化配合纸纹纹理避免整张图看起来像剪贴在灰板上的图片。import numpy as np from PIL import Image, ImageFilter def ink_postprocessing(img, blur_r6, dry_thresh38, strength0.35): arr np.asarray(img.convert(L), dtypenp.float32) # 1) 高光弥散暗墨块边缘向外渗墨 blur np.asarray( img.filter(ImageFilter.GaussianBlur(blur_r)).convert(L), dtypenp.float32 ) seep (arr 80) (blur arr) arr[seep] np.minimum(arr[seep], blur[seep]) # 2) 飞白重绘短促边缘条纹随机位移后叠回 edge_x np.zeros_like(arr) edge_x[:, 1:] np.abs(np.diff(arr, axis1)) edge_y np.zeros_like(arr) edge_y[1:, :] np.abs(np.diff(arr, axis0)) edge np.maximum(edge_x, edge_y) dry (edge dry_thresh) (arr 170) dry_shift np.roll(dry, shiftnp.random.randint(-2, 3), axis1) arr[dry_shift] np.clip(arr[dry_shift] - 25, 0, 255) # 3) 白色封边边缘羽化后叠纸纹 h, w arr.shape fy np.clip(np.minimum(np.arange(h), h - 1 - np.arange(h)) / 24.0, 0, 1) fx np.clip(np.minimum(np.arange(w), w - 1 - np.arange(w)) / 24.0, 0, 1) fade np.minimum(fy[:, None], fx[None, :]) paper (np.random.rand(h, w) * 24 - 12).astype(np.float32) arr arr * (1 - strength) (arr * fade paper * (1 - fade)) * strength return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))blur_r6对应的输入尺寸是 512输出分辨率降到 256 时记得同步减半dry_thresh38控制飞白的保留量值太小会把画面打成碎噪点值太大飞白又会消失。strength0.35是纸纹叠加强度的上限我调参时把 0.5 以上都试过结果像把图印刷在牛皮纸上纸纹喧宾夺主。反正这个后处理函数是独立步骤你可以把生成器输出的几十张图批量跑一遍再挑一组参数定稿。我的习惯是把训练好的模型当“打型工”输出七成的水墨效果剩下三成交给这套后处理。这样网络可以专心学笔触和墨色纸纹这种高熵细节完全不需要占用模型容量。刚开始做这步时我也曾想把纸纹直接加进训练流程结果模型学会了画网格线成了一个非常稳定的灰条发生器那个项目后来被我自己标注成“全流程翻车”。水墨画的美恰恰有一部分在不可控的渗化里靠代码把每一条边都钉死反而画不出那种偶然感。如果你也在做这个方向记住给后处理留一点随机量不要追求每次输出都像素级一致。希望帮到你。本文还有配套的精品资源点击获取