十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零开始学CV:图像基础特征提取实践指南——颜色、纹理与边缘

从零开始学CV:图像基础特征提取实践指南——颜色、纹理与边缘 从零开始学CV系列图像特征提取上——基础特征复习与实践指南这次我们来看《从零开始学CV系列课程》中的图像特征提取部分。很多人接触计算机视觉时第一反应是“直接上深度学习模型”但真实工程里经常遇到两个问题一是样本量不够几百张图根本喂不饱网络二是任务只需要做一次简单的检索或分类上 GPU 训练模型成本太高。这时基础特征提取就是最实用的方案。颜色直方图、灰度共生矩阵、边缘轮廓这些方法不需要训练CPU 就能跑输出直接就是特征向量后面接 SVM、KNN 或者相似度匹配都可以用。本讲是图像特征提取的上篇覆盖四个方向颜色特征、纹理特征、形状特征、边缘特征。这是 CV 入门必须理解的一层基本功也是后面学习特征点、特征描述子、特征匹配之前的基础。学完你至少能回答三个问题图像在计算机里到底是什么、特征是怎么从像素中抽出来的、抽出来的特征向量怎么用于图像检索或分类。这种传统的“特征工程 经典分类器”流程还有一个明显优势可解释性。模型判断一张图“像不像”你可以清楚地知道是颜色分量、边缘密度还是纹理结构起了作用。这对工业场景的调试和维护非常重要。本文所有代码都使用 Python OpenCV 实现建议使用 OpenCV 4.x 版本的环境来运行下面直接开始。1. 本讲内容速览特征类型代表算法OpenCV 主要函数适合解决的问题颜色特征颜色直方图、颜色矩calcHist、compareHist图像检索、简单分类纹理特征灰度共生矩阵 GLCM、LBPcvtColor 自建计算逻辑材质分类、遥感图像分析形状特征轮廓面积、周长、宽高比、Hu 矩findContours、moments目标识别、形状匹配边缘特征Sobel、CannySobel、Canny轮廓定位、区域分割前置这四种特征并不是互相排斥的。实际项目里常把颜色直方图与纹理直方图拼接成一个特征向量高维度特征交给分类器处理。这也是本讲最后部分要演示的组合思路。需要特别说明的是本讲说的“特征提取”指的是提取图像的全局或区域特征也就是把整张图像的数据量压缩成一个可以计算的向量。这和 SIFT、ORB 这类“特征点提取”不同。特征点提取属于局部特征会在下篇展开。2. 先理解特征图像怎么变成可计算的数据2.1 图像在计算机里是什么一张普通彩色图片在计算机里就是一个三维数组形状通常是 H×W×C。H 是高度W 是宽度C 是通道数。RGB 图有 3 个通道灰度图只有 1 个通道。每个像素的取值范围是 0 到 255代表颜色亮度。如果直接拿这个数组做比较规模太大而且像素位置有一点偏移整张图的数值就会产生剧烈变化根本不适合做相似度判断。所以需要特征提取。特征提取的目标是把高维、冗余、对位置敏感的像素空间映射到低维、紧凑、相对稳定的特征空间。比如一张 500×500 的彩色图有 250000 个像素但转换成一个 256 维的颜色直方图后表示成本就小了很多。很多算法能在这类特征上稳定工作原因就是压缩掉了大量噪声信息。2.2 全局特征与局部特征的区别基础特征大多是全局特征。颜色直方图统计整张图各颜色出现的频率边缘直方图统计各方向边缘的分布这类特征描述的是整张图或整块区域。它适合区分差异较大的类别蓝天和森林、室内和室外、皮肤和衣物。但如果要识别物体在图像中的具体位置或者从一堆相似图中找同一个目标就需要局部特征比如角点、斑点、关键点描述子。把这些概念理清楚后面读到 SIFT、ORB 时就不会混淆。3. 颜色特征提取计算与代码实现3.1 颜色直方图颜色直方图是入门必学的特征。它统计每个颜色值在图像中出现的次数。实现方式非常直白但有一个关键细节在 OpenCV 中图像默认使用 BGR 顺序而不是 RGB如果直接用 RGB 通道计算结果会和预期有偏差。更常用的做法是先把图像转换到 HSV 颜色空间H 代表色相S 代表饱和度V 代表亮度。HSV 对光照变化更稳定因为亮度被单独拆分到一个通道颜色本身不随 V 剧烈改变。下面是计算 HSV 双通道直方图的示例代码import cv2 import numpy as np def calc_hsv_hist(image_path, h_bins50, s_bins60): 计算图像的 H-S 双通道颜色直方图并做归一化 image cv2.imread(image_path) if image is None: raise FileNotFoundError(f无法读取图像: {image_path}) hsv cv2.cvtColor(image, cv2.COLOR_BGR2HSV) # 通道 0: H, 通道 1: S hist cv2.calcHist( [hsv], [0, 1], None, [h_bins, s_bins], [0, 180, 0, 256] ) cv2.normalize(hist, hist) return hist if __name__ __main__: hist calc_hsv_hist(example.jpg) print(直方图形状:, hist.shape)这里把 H 通道分成 50 个 binS 通道分成 60 个 bin。直方图最后的形状是 50×60扁平化后得到 3000 维特征向量。如果只想提取颜色特征这个向量长度已经足够用于检索。3.2 用颜色直方图度量图像相似度颜色直方图提取之后可以用cv2.compareHist计算两张图的相似度。OpenCV 内置了多种距离或相关度量方式import cv2 hist_a calc_hsv_hist(image_a.jpg) hist_b calc_hsv_hist(image_b.jpg) # CORREL: 相关性值越大越相似范围接近 0 到 1 score cv2.compareHist(hist_a, hist_b, cv2.HISTCMP_CORREL) print(相似度分数:, score)如果分数较高说明两张图在颜色分布上接近。这个方法特别适合图像检索场景预先为一个图片库建立颜色直方图查询时计算查询图与库中每张图的直方图相关性按分数从高到低排序取前 N 张作为检索结果。整个过程只有直方图计算和向量比较没有模型训练CPU 单机就能跑完。3.3 颜色矩颜色直方图的缺点是当 bin 数量变大时向量维度上升很快而且稀疏。颜色矩是另一种更紧凑的全局颜色特征它对每个通道分别计算一阶矩均值、二阶矩标准差和三阶矩偏度三个通道一共得到 9 个数值。下面是一个简化版实现import cv2 import numpy as np def color_moments(image_path): 简化颜色矩每个颜色通道的均值、标准差、偏度 image cv2.imread(image_path) if image is None: raise FileNotFoundError(f无法读取图像: {image_path}) moments [] for channel in cv2.split(image): ch channel.astype(np.float32) mean np.mean(ch) std np.std(ch) # 三阶矩归一化简化实现 skew np.mean(((ch - mean) / (std 1e-8)) ** 3) moments.extend([mean, std, skew]) return np.array(moments) feature color_moments(example.jpg) print(颜色矩维度:, feature.shape)严格来说颜色矩应该使用中心矩计算上面的代码是工程简化版本对多数颜色区分任务已经够用。颜色矩特征维度低适合作为大特征向量的一部分参与分类而不是单独作为高强度检索特征。它的信息量比颜色直方图少但计算极快。4. 纹理特征提取从局部模式到全局统计4.1 灰度共生矩阵 GLCM颜色特征描述的是“颜色的分布”纹理特征描述的是“像素亮度变化的空间规律”。例如布料、砖墙、草地颜色可能接近但纹理差异很大。纹理特征在遥感图像、医学图像、材质识别场景里应用很广。灰度共生矩阵的基本思想是统计图像中两个像素在某个方向和距离范围内灰度值组合的出现次数。通常使用 OpenCV 加 NumPy 配合实现并不直接依赖现成函数。GLCM 提取出来的原始矩阵维度太大工程上一般会基于 GLCM 计算对比度、相关性、能量、同质性等统计量作为最终特征向量。Python 里可以用skimage.feature.texture.graycomatrix和graycoprops简化计算主要依赖 scikit-imageimport numpy as np from skimage.feature import graycomatrix, graycoprops image cv2.cvtColor(cv2.imread(example.jpg), cv2.COLOR_BGR2GRAY) # 计算距离 1、方向为 0 度、45 度、90 度、135 度 的 GLCM glcm graycomatrix( image, distances[1], angles[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4], levels256, symmetricTrue, normedTrue ) contrast graycoprops(glcm, contrast) energy graycoprops(glcm, energy) print(对比度:) print(contrast) print(能量:) print(energy)实际使用时要特别注意两个参数levels必须大于图像中的最大灰度级灰度图片通常是 256 级所以取值 256。distances选择距离 1 意味着只看相邻像素适合细纹理如果要描述更粗糙的纹理可以增大距离。4.2 LBP 局部二值模式LBP 是另一种经典纹理特征。它逐像素比较中心点和周围邻居的灰度大于中心点记为 1小于记为 0最后把邻居的结果拼接成一个二进制数这个二进制的值就是该像素的 LBP 编码。对整张图统计所有编码的直方图就得到 LBP 纹理特征。最基础版本使用 3×3 邻域周围 8 个像素。这个版本实现难度低适合入门理解import cv2 import numpy as np def basic_lbp_hist(gray, minlength256): 计算 3x3 邻域 LBP 直方图 h, w gray.shape lbp np.zeros((h, w), dtypenp.uint8) for y in range(1, h - 1): for x in range(1, w - 1): center int(gray[y, x]) neighbors [ gray[y - 1, x - 1], gray[y - 1, x], gray[y - 1, x 1], gray[y, x 1], gray[y 1, x 1], gray[y 1, x], gray[y 1, x - 1], gray[y, x - 1] ] code 0 for i, n in enumerate(neighbors): if int(n) center: code | (1 i) lbp[y, x] code hist, _ np.histogram(lbp, binsminlength, range(0, minlength)) hist hist.astype(np.float32) if hist.sum() 0: hist / hist.sum() return hist gray cv2.cvtColor(cv2.imread(example.jpg), cv2.COLOR_BGR2GRAY) lbp_feature basic_lbp_hist(gray) print(LBP 特征维度:, lbp_feature.shape)这段代码用双重 for 循环写逻辑清楚但速度慢。在实际项目里完成学习验证后建议改用更高效的实现方式比如通过skimage.feature.local_binary_pattern计算或者使用基于查表与向量化的 OpenCV 写法。如果图像分辨率很高扫描式 LBP 在纯 CPU 环境下会比较耗时。LBP 的最大特点是灰度不变性即整体亮度变化后局部相对大小关系不变特征仍然稳定。这使得它在光照不均匀环境中比颜色特征更有优势。5. 边缘检测与形状特征提取5.1 Canny 边缘检测边缘信息刻画的是图像中亮度快速变化的位置。边缘提取常用 Sobel 或 Canny。Sobel 是计算图像梯度Canny 基于梯度做非极大值抑制和双阈值连接输出更干净的边缘。下面用 Canny 做边缘检测import cv2 image cv2.imread(example.jpg) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 先用高斯模糊抑制噪声 blurred cv2.GaussianBlur(gray, (5, 5), 0) # 双阈值低于 80 的不是边缘高于 160 的确定为边缘 edges cv2.Canny(blurred, 80, 160) cv2.imwrite(edges.jpg, edges)注意 Canny 的输入必须是 8UC1 灰度图也就是单通道 uint8 类型。很多初学者把彩色图直接传给 Canny或者把归一化后的 float32 图传进去就会报类似(-215:Assertion failed) image.type() CV_8UC1的错误。这个问题并不复杂却非常典型。5.2 轮廓特征与形状描述拿到边缘或二值图后可以用findContours找轮廓。OpenCV 4.x 中这个函数返回两个值轮廓列表和层级关系。OpenCV 3.x 时期返回三个值中间还有一个图像输出参数。代码上要根据版本适应。import cv2 # edges 来自上一步 Canny 输出也可以是阈值后的二值图 contours, hierarchy cv2.findContours( edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) print(找到轮廓数量:, len(contours)) features [] for c in contours: area cv2.contourArea(c) # 过滤极小的噪声轮廓 if area 50: continue perimeter cv2.arcLength(c, True) x, y, w, h cv2.boundingRect(c) aspect_ratio w / max(h, 1e-6) features.append({ area: area, perimeter: perimeter, width: w, height: h, aspect_ratio: aspect_ratio, contour: c }) print(有效轮廓特征数量:, len(features))如果把轮廓画回原图可以直观验证提取结果result image.copy() cv2.drawContours(result, [f[contour] for f in features], -1, (0, 255, 0), 2) cv2.imwrite(contours_result.jpg, result)工程上一个常见坑是直接用 Canny 输出作为findContours输入时轮廓常因为边缘断裂被拆成多段。更稳妥的做法是先对图像做阈值处理得到清晰的二值化目标区域再用形态学闭运算连接断口最后找轮廓。对某些高噪声目标Canny 只适合做边缘定位不适合直接用来找闭合轮廓。5.3 Hu 矩面积、周长、宽高比只能描述单个轮廓的基本几何属性它们依赖目标的绝对尺度。如果需要做尺度无关的形状识别可以考虑 Hu 矩。Hu 矩由二阶和三阶归一化中心矩组合而来一共有 7 个数值具有平移、旋转、尺度不变性。OpenCV 中可以直接使用cv2.HuMomentsmoments cv2.moments(contour) hu cv2.HuMoments(moments).flatten() # 对数值做对数变换缩小数量级差异 hu_log -np.sign(hu) * np.log10(np.abs(hu) 1e-8) print(hu_log)Hu 矩特征维度不高适合形状匹配前置。但要注意它对轮廓分割质量敏感。如果同一目标在不同图像里的轮廓提取差异很大特征稳定性就会下降。6. 特征向量组合与 CPU 性能观察6.1 组合特征向量基础特征很少单独使用。一个典型组合方案是颜色直方图 3000 维 LBP 直方图 256 维 轮廓几何特征若干维拼成一个整体向量。拼接前每部分要归一化不然量级大的特征会覆盖量级小的特征。分类环节通常使用 sklearn 的 SVM 或随机森林。import numpy as np def extract_combined_feature(image_path): 综合特征示例返回颜色直方图特征、LBP直方图特征与形状特征的拼接。 image cv2.imread(image_path) if image is None: raise FileNotFoundError(image_path) color_feature calc_hsv_hist(image_path).flatten() gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) texture_feature basic_lbp_hist(gray) # 颜色与纹理先拼接 combined np.concatenate([color_feature, texture_feature]) # 整图归一化 norm np.linalg.norm(combined) if norm 0: combined combined / norm return combined特征拼接和归一化都完成后就能作为分类器输入。相比深度模型的端到端学习这里的人工介入更多但每一步结果都可检查方便定位问题。6.2 观察运行速度在 CPU 环境测试基础特征提取时重点是观察计算耗时和内存占用不是显存。因为整个流程根本没有 GPU 参与。不同步骤的耗时差异很大颜色直方图是 C 层优化过的速度快LBP 纯 Python 双层循环在百万像素级别会明显变慢GLCM 在高层级计数时也可能很慢。批量测试时可以通过简单的 Python time 统计平均耗时import time start time.time() for img_path in image_list: extract_combined_feature(img_path) elapsed time.time() - start print(f处理 {len(image_list)} 张图像耗时 {elapsed:.2f} 秒)性能优化的优先级应当是分辨率压缩、颜色空间转换优化、算法替换、并行化。对基础特征流程来说先把输入统一缩放到较小尺寸经常能获得几倍提速而特征质量的损失在很多场景可以接受。7. 入门综合实验极简图像检索前面几节是分散的特征提取这里把它们串联成一个可运行的小项目。目标是在一个图片目录中找出与查询图颜色最相似的前 5 张图。这个实验虽然简单却是搜索引擎、相似商品推荐、以图搜图系统的雏形。实验输入是./images/ ├── query.jpg ├── 001.jpg ├── 002.jpg └── 003.jpg批量建立颜色直方图并检索import os import cv2 import glob def build_hist_index(image_dir): 为目录下所有图片建立 HSV 直方图索引 index {} for img_path in glob.glob(os.path.join(image_dir, *.jpg)): hist calc_hsv_hist(img_path) index[img_path] hist return index def search_similar(query_path, image_dir, top_k5): index build_hist_index(image_dir) query_hist calc_hsv_hist(query_path) scores [] for img_path, hist in index.items(): if img_path query_path: continue score cv2.compareHist(query_hist, hist, cv2.HISTCMP_CORREL) scores.append((img_path, score)) scores.sort(keylambda x: x[1], reverseTrue) return scores[:top_k] results search_similar(query.jpg, ./images) for path, score in results: print(path, round(score, 4))判断实验是否成功的标准很简单把同类图片和不同类图片都放进目录如果同类图片排在前几名说明颜色特征对当前数据区分有效。如果结果混乱先检查查询图和库中图片是否属于同一颜色空间再检查直方图被归一化过。这里给出的批处理脚本已经具备批量任务的基本雏形。颜色直方图索引构建后可以缓存为 NumPy 文件后续查询不需要重新计算能直接加载内存比对这也是正式图像检索系统常见的离线索引思路。8. 常见问题与排查方法问题现象可能原因排查方式解决方案cv2.imread返回 None路径含中文或文件不存在打印完整路径检查文件扩展名改用英文路径确认图像文件存在画面颜色偏蓝/偏红BGR 与 RGB 顺序混淆读取后直接cv2.imshow观察统一使用 OpenCV 原始 BGR 顺序Canny 报image.type() CV_8UC1输入不是单通道 uint8 灰度图检查输入图像的dtype与shape先使用cvtColor(..., COLOR_BGR2GRAY)findContours返回值解包失败OpenCV 3 与 4 返回值个数不同打印len(ret)观察按版本断言OpenCV 4 取两个返回值轮廓数量过多碎片化Canny 边缘断裂可视化边缘结果改成阈值分割 形态学闭运算直方图特征维度太大bin 数设置过高打印hist.shape降低 H、S 的 bin 数LBP 计算极慢纯 Python 双层循环统计单张图耗时使用 skimage 向量化实现或缩小图像分类结果不稳定特征未归一化检查特征数值量级做 L2 归一化或标准化如果遇到(-215:Assertion failed)这类 OpenCV 断言错误第一反应不是去搜错误码而是检查传给函数的图像类型和尺寸。绝大多数情况都是输入数据不符合函数预期这是 OpenCV 调试经验里几乎每天都要处理的问题。9. 动手练习基础特征知识的掌握必须结合上机练习。建议按顺序完成下面三个任务每个任务都保留运行结果和中间图第一题选择两张差异明显的图片分别计算它们的 HSV 颜色直方图用cv2.compareHist计算相关性再选两张内容不同的图片观察相关性是否显著下降。第二题对一张带噪声的图片执行 Canny 边缘检测然后在 Canny 基础上做一次闭运算对照两次轮廓数量变化记录噪声对形状特征提取的影响。第三题在图片目录中加入 10 张以上图像运行第 7 节的极简图像检索比较 H-S 直方图与单通道灰度直方图在检索结果的差异思考为什么彩色直方图通常区分能力更强。这三个练习都完成后就可以理解基础特征在实际任务里的表现边界。下篇会进入特征点与局部特征描述子也就是 SIFT、ORB、特征匹配这些内容那部分解决的是“同一目标在不同位置、不同角度下如何识别”的问题建议先把上篇代码跑通再往下推进。
返回列表