从零开始学CV系列:图像特征提取(上)——基础特征复习与实践指南
这次我们来看《从零开始学CV系列课程》中的图像特征提取部分。很多人接触计算机视觉时,第一反应是“直接上深度学习模型”,但真实工程里经常遇到两个问题:一是样本量不够,几百张图根本喂不饱网络;二是任务只需要做一次简单的检索或分类,上 GPU 训练模型成本太高。这时基础特征提取就是最实用的方案。颜色直方图、灰度共生矩阵、边缘轮廓这些方法不需要训练,CPU 就能跑,输出直接就是特征向量,后面接 SVM、KNN 或者相似度匹配都可以用。
本讲是图像特征提取的上篇,覆盖四个方向:颜色特征、纹理特征、形状特征、边缘特征。这是 CV 入门必须理解的一层基本功,也是后面学习特征点、特征描述子、特征匹配之前的基础。学完你至少能回答三个问题:图像在计算机里到底是什么、特征是怎么从像素中抽出来的、抽出来的特征向量怎么用于图像检索或分类。
这种传统的“特征工程 + 经典分类器”流程还有一个明显优势:可解释性。模型判断一张图“像不像”,你可以清楚地知道是颜色分量、边缘密度还是纹理结构起了作用。这对工业场景的调试和维护非常重要。本文所有代码都使用 Python + OpenCV 实现,建议使用 OpenCV 4.x 版本的环境来运行,下面直接开始。
1. 本讲内容速览
| 特征类型 | 代表算法 | OpenCV 主要函数 | 适合解决的问题 |
|---|---|---|---|
| 颜色特征 | 颜色直方图、颜色矩 | calcHist、compareHist | 图像检索、简单分类 |
| 纹理特征 | 灰度共生矩阵 GLCM、LBP | cvtColor + 自建计算逻辑 | 材质分类、遥感图像分析 |
| 形状特征 | 轮廓面积、周长、宽高比、Hu 矩 | findContours、moments | 目标识别、形状匹配 |
| 边缘特征 | Sobel、Canny | Sobel、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_bins=50, s_bins=60): """计算图像的 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 个 bin,S 通道分成 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-image:
import 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], levels=256, symmetric=True, normed=True ) 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, minlength=256): """计算 3x3 邻域 LBP 直方图""" h, w = gray.shape lbp = np.zeros((h, w), dtype=np.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, bins=minlength, 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.HuMoments:
moments = 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_k=5): 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(key=lambda x: x[1], reverse=True) 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、特征匹配这些内容,那部分解决的是“同一目标在不同位置、不同角度下如何识别”的问题,建议先把上篇代码跑通再往下推进。