代码学习讲解
这一页只讲两个核心算法(pencil_sketch / oil_painting),界面、网络、部署都不讲。把每一行代码都摊开,目标是让没基础的同学也能看明白。
技术说明文档(.docx,含完整算法逐行讲解与示意图,可打印提交):
点此下载
先搞懂 4 件"小白必懂"的事(后面全靠它们):
① 图像 = 一张数字表格。你手机里的一张照片,在电脑里其实是个巨大的数字方阵(叫"矩阵")。方阵里每一个小格子叫一个像素,格子里的数字就是颜色。
② 颜色 = 3 个数字。电脑用「红、绿、蓝」三个 0~255 的数混出任意颜色。例如 (255,0,0) 是纯红,(0,0,0) 是黑,(255,255,255) 是白。注意 OpenCV 把顺序反过来写成 BGR(蓝、绿、红),只是顺序不同,本质一样。
③ 灰度图 = 1 个数字。只记"亮度",0 最黑、255 最白。素描本来就是黑白的,所以第一步常先转灰度。
④ numpy 能"整张图一起算"。我们不用一个像素一个像素写循环,一行命令就能让整张图的每个像素同时做加法、除法——下面所有算法都靠这个。
1. 铅笔素描 pencil_sketch
思路:把彩色照片变成"只描轮廓、浅色留白"的黑白手绘。一共 6 行,逐行看:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
第 1 行 · 去色成灰度。把彩色图(每像素 3 个数)变成灰度图(每像素 1 个亮度数)。因为素描是黑白的,先去掉颜色。这是后面所有运算的基础。
inv = 255 - gray
第 2 行 · 反相(黑白颠倒)。原来最亮(255)变成最暗(0),最暗变成最亮。为什么要颠倒?因为第 4 行"颜色减淡"需要靠"暗"来代表要保留的线条,这里先把明暗反过来,方便后面取轮廓。
blur = cv2.GaussianBlur(inv, (blur_k, blur_k), 0)
第 3 行 · 高斯模糊。高斯模糊 = 把每个像素和它周围一圈邻居的颜色取平均,让图像变柔和、边缘"化开"。blur_k 是模糊范围(必须是奇数,如 5/21/41),越大越糊、线条越淡越柔。这一步模拟了铅笔笔触的柔软感。
sketch = cv2.divide(gray, 255 - blur, scale=256)
第 4 行 · 颜色减淡(最关键的一步)。公式:结果 = 灰度原图 ÷ (255 − 模糊图)。
- 分母 (255−模糊图) 越接近 0(说明这里被模糊得很亮,正是物体边缘)→ 整个分数越大 → 越白;
- 而分子 gray 很小(原本就暗)且分母也小的地方 → 结果反而暗 → 留下一条黑线。
一句话:
只有"原图暗、模糊后也暗"的地方(物体的轮廓)才会变黑,其余都变白。这就是手绘素描"只描边、浅面留白"的效果。
sketch = np.clip(sketch * contrast, 0, 255).astype(np.uint8)
第 5 行 · 调深浅。contrast(默认 1.0)把线条整体调深或调浅(乘一下);np.clip 把超出 0~255 的部分砍掉;astype 转回整数格式(电脑只认整数颜色)。
return cv2.cvtColor(sketch, cv2.COLOR_GRAY2BGR)
第 6 行 · 转回三通道。把单通道灰度图变回 3 通道(让蓝绿红三数相同),这样后面程序才能统一当彩色图显示。算法到此结束。
滑动条"线条强度"改的就是第 3 行的 blur_k:值越大越糊、线条越淡;值越小越锐、线条越黑。
2. 油画风格 oil_painting
先说大白话思路——像"投票填色":对图上每个点,看它周围一小片邻居,把邻居按"亮度"分成几档,数哪一档人最多,就用那一档的平均颜色填这个点。平坦区域颜色接近→统一色块;明暗交界处不同档交界→出现清晰的笔触边。下面逐行看:
img = img.astype(np.float32)
第 1 行 · 转成小数格式。原来颜色是整数 0~255,后面要算"平均值",整数相除会丢小数、不准,所以先转成能算小数(float)的格式。
avg = (img[...,0] + img[...,1] + img[...,2]) / 3.0
第 2 行 · 算平均亮度。把每个像素的蓝+绿+红除以 3,得到一张"亮度图"(越亮数值越大)。后面分档就按这张图来。
bucket = np.clip((avg/255*(levels-1) + 0.5).astype(int), 0, levels-1)
第 3 行 · 给每个像素"分档"。把亮度 0~255 均匀分到 levels 个等级(如 12 档):亮度 0→第 0 档,255→最后一档,中间按比例算到哪一档(加 0.5 是四舍五入)。bucket 是一张"每个像素属于第几档"的图。
ksize = (2*radius+1, 2*radius+1)
第 4 行 · 邻居窗口大小。以当前点为中心、边长为 (2×radius+1) 的正方形。例如 radius=4 → 9×9 的邻居。radius 越大→看的邻居越多→笔触越粗。
count = np.zeros((levels, h, w)); sR=sG=sB=np.zeros_like(count)
第 5 行 · 准备计数器与累加器。count[b] 用来记"第 b 档在每个位置出现了几次";sR/sG/sB 分别累加"第 b 档像素的红/绿/蓝颜色总和"。后面要用次数和总和算平均色。
for b in range(levels):
mask = (bucket==b).astype(np.float32)
第 6~7 行 · 造遮罩。对每一档 b,造一张"遮罩图":该像素属于第 b 档就标 1,否则标 0。后面只统计标 1 的那些像素。
count[b] = cv2.boxFilter(mask, -1, ksize, normalize=False)
第 8 行 · 数这一档的人数。boxFilter = 滑动窗口求和(不除以面积)。这一行算出:在每个位置、窗口内"属于第 b 档的像素个数"= count[b]。
sR[b] = cv2.boxFilter(mask*img[...,2], -1, ksize, normalize=False)
sG[b] = cv2.boxFilter(mask*img[...,1], -1, ksize, normalize=False)
sB[b] = cv2.boxFilter(mask*img[...,0], -1, ksize, normalize=False)
第 9~11 行 · 累加这一档的颜色。用 mask×颜色,只保留该档像素(其余乘 0 被去掉),再用 boxFilter 在窗口内求和,得到"第 b 档红/绿/蓝的颜色总和"。有了总和 ÷ 个数 = 平均色。
win = count.argmax(axis=0)
第 12 行 · 投票决胜。对每个像素,在 levels 档里找"人数(count)最多"的那一档 → win(获胜档)。
out = 用获胜档的(颜色总和 ÷ 次数)填色;次数为 0 则保留原图
第 13 行 · 填平均色。该档平均色 = 颜色累加和 ÷ 个数,用这个平均色填当前点。如果窗口里该档一个像素都没有(次数 0),就保留原图颜色,避免"除以 0"出错。
return out.astype(np.uint8)
第 14 行 · 转回整数输出。把小数颜色转回 0~255 整数,输出油画图。
为什么不用"两层 for 循环一个个像素算"?因为 1200px 的图约 144 万像素,每个还要看约 81 个邻居,总运算上亿次,Python 要算好几分钟。这里用的 boxFilter 是 OpenCV 用 C 写好的"整张图一次性滑动窗口求和",一次调用就算完所有位置,速度降到亚秒级——也正是你能拖动滑块实时预览的原因。
← 返回素描工作室
需要离线阅读?下载技术说明文档(.docx)