☰
小样本水果识别:传统OpenCV手工特征为何更稳定
2026/10/15 15:01:44 网站建设 项目流程

简介:OpenCV水果识别配套样本集,面向学习计算机视觉与图像分类的开发者,聚焦苹果、香蕉、梨子三类常见水果的自动识别任务。包内以1618张JPG图像为主,覆盖不同姿态、光照和背景下的水果样本,可直接用于图像预处理、特征提取(如颜色直方图、纹理、形状描述子)与分类器训练测试;附带5个XML文件,推测为标注信息或模型配置文件,另有1个iml工程文件,便于导入IDE使用。全部文件共1624个,压缩包大小约17.82MB,轻量便捷。目前已有12814人浏览学习,适合作为OpenCV入门实践及机器学习分类项目的数据支撑。通过这批样本,读者可完整演练图像预处理、特征工程、SVM或CNN模型训练与评估的完整流程,也可利用旋转、缩放、裁剪等数据增强手段优化泛化能力,是快速上手水果识别项目的实用素材。

1. 几十张样本就做水果识别:OpenCV 手工特征为什么更稳

拿到“opencv水果识别样本(苹果、香蕉、梨子)”这类素材时,最常见的误区是先找深度学习框架。三种水果、几十张图,在这个体量下卷积网络很容易过拟合:训练集上刷到95%,换一部手机拍同样的苹果,准确率立刻掉回六成。传统视觉路线反而是更可靠的选择:先做颜色分割,把水果从背景中抠出来,再提取颜色直方图和形状特征,最后交给KNN或最近均值分类器。OpenCV加NumPy就能完整跑通,单张识别耗时在毫秒级,改起来也直观,不需要调模型结构。

这套方案适合三类人:做课程设计但样本量不够的初学者、要在封闭场景里快速出活的一线工程师、以及想给深度学习结果做兜底验证的团队。它不适合复杂背景、杂乱光照、品类频繁变动的场景,那些问题应该交给数据驱动模型。下面按样本整理、HSV分割、特征提取、训练验证、避坑、进阶调参的顺序,把整套流程拆开讲清楚。

2. 把水果从背景里抠出来:HSV 分割与轮廓提取

2.1 样本怎么摆放,直接决定分割难度

做水果识别之前,先想清楚样本怎么拍。很多人忽略这一点,拿着一张水果放在木纹桌面、窗边自然光、还带叶子的照片就开始跑分割,结果掩膜里全是背景,最后只能靠手工画框救场。我一般建议按这样的纪律采集样本:纯色背景,白纸或灰卡都行;光源固定,别让窗户光直射;水果占画面面积至少三分之一;每种水果拍三十到五十张,覆盖正面、侧面、轻微转动角度;文件名按类别和批次统一编号。

目录结构建议直接按类别分文件夹,这是后面训练脚本能自动读取的前提:

samples/ ├── apple/ # 红苹果样本,文件名 1001.jpg 开始 ├── banana/ # 香蕉样本,文件名 2001.jpg 开始 └── pear/ # 梨子样本,文件名 3001.jpg 开始

文件名的批次号很重要。1001到1050是第一批,2001到2050是第二批,这样做训练集和验证集划分时可以按批次切,而不是随机打乱,避免同一时段拍摄的相似画面同时出现在训练集和验证集里,导致验证分数虚高。这个问题后面还会专门展开。

2.2 HSV 分割:为什么不用 RGB 直接阈值

RGB 颜色空间里,苹果的红色和香蕉的黄色在光照变化时 BGR 三个通道会整体漂移,写死的阈值很难同时适应亮部和暗部。HSV 的好处是把色相 H 和亮度 V 拆开,分割时可以主要看 H 通道,S 和 V 只做辅助约束。OpenCV 里 H 的范围是 0 到 179,不是 0 到 255,这个细节最容易出错。

对于苹果、香蕉、梨子这三类,初始阈值可以参考下面这张表:

对象H 范围S_minV_min说明
红苹果0-10 与 170-1796050红色跨 0 边界,必须拆两段
香蕉20-456060带青色的香蕉会把范围压到 45
绿梨子40-855050黄绿过渡段会和香蕉重叠

这张表只是起点。实际环境的光源色温不同,阈值会有明显偏移,后面我会给一个基于样本统计自动生成阈值的方法。手动调参时,最实用的调试技巧是鼠标停在图像上打印当前像素的 HSV 值,而不是靠肉眼猜 H 是多少。调参这件事本身有点玄学,但先把 H 范围放宽、S 和 V 收紧,通常能少踩几个坑。

2.3 完整预处理函数:掩膜、形态学、最大轮廓

分割的目标不是把像素分类,而是得到一个干净的掩膜,供后续特征提取使用。完整流程是:BGR 转 HSV,按照多段阈值生成掩膜,用形态学开运算去掉孤立噪点,用闭运算填补果皮上的反光空洞,最后用 findContours 找到最大轮廓作为水果区域。

import cv2 import numpy as np def build_color_mask(hsv): # 红色苹果要拆成 0-10 和 170-179 两段,只写一段会漏掉半边苹果 mask_red = cv2.inRange(hsv, np.array([0, 60, 50]), np.array([10, 255, 255])) mask_red |= cv2.inRange(hsv, np.array([170, 60, 50]), np.array([179, 255, 255])) # 香蕉的黄色,范围放宽到 45 是为了覆盖带青色的未熟香蕉 mask_yellow = cv2.inRange(hsv, np.array([20, 60, 60]), np.array([45, 255, 255])) # 梨子的绿色,保留一点黄绿过渡 mask_green = cv2.inRange(hsv, np.array([40, 50, 50]), np.array([85, 255, 255])) return mask_red | mask_yellow | mask_green def get_fruit_mask(img_bgr): hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) mask = build_color_mask(hsv) # 5x5 的核比较平衡;核太大容易把香蕉这种细长物体整个吞掉 kernel = np.ones((5, 5), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # OpenCV 4.x 下 findContours 返回两个值;3.x 返回三个,注意兼容 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None, None contour = max(contours, key=cv2.contourArea) fruit_mask = np.zeros_like(mask) cv2.drawContours(fruit_mask, [contour], -1, 255, -1) return fruit_mask, contour

这段代码里有几个参数值得说明。RETR_EXTERNAL 只取最外层轮廓,因为我们要的是整个水果而不是果皮上的斑点轮廓;如果改成 RETR_CCOMP,会把香蕉的弯曲边缘拆成多段,反而增加最大轮廓竞争时的误判。形态学的开运算先做,是为了避免闭运算把背景里的绿色噪点也填成连通区域。掩膜面积过小说明水果可能被背景吞掉了,面积过大说明把背景的杂物也圈了进来,这两类情况后面都要单独处理。

3. 让特征可区分:颜色直方图与形状描述符的拼接方法

3.1 颜色直方图:把“红色、黄色、绿色”变成数字

分割完成后,下一步不是直接比较掩膜面积,而是提取特征向量。对三种水果来说,颜色是最强的区分信号,但我不会用整幅图的 HSV 均值,而是用掩膜区域的 H 和 S 直方图。均值会把苹果表面的红色、暗红色、反光白色平均成一个奇怪的橙粉色,而直方图保留了分布信息,对同一类水果表面的色斑和渐变更宽容。

H 直方图用 32 个 bin,覆盖 0 到 179;S 直方图也用 32 个 bin,覆盖 0 到 256。bin 数太少区分度不够,太多容易对光照敏感,32 在手工特征里是一个比较平衡的选择。直方图算出后除以总和,把特征归一化到概率形式,这样图像缩放或水果大小变化不会主导距离计算。

def extract_features(img_bgr): mask, contour = get_fruit_mask(img_bgr) if mask is None: return None hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) # H 直方图 32 个 bin,范围 0-179 hist_h = cv2.calcHist([hsv], [0], mask, [32], [0, 180]).ravel() # S 直方图 32 个 bin,范围 0-256 hist_s = cv2.calcHist([hsv], [1], mask, [32], [0, 256]).ravel() hist = np.concatenate([hist_h, hist_s]) hist = hist / (hist.sum() + 1e-6) # 形状特征:宽高比、圆度、凸度 area = cv2.contourArea(contour) perimeter = cv2.arcLength(contour, True) x, y, w, h = cv2.boundingRect(contour) aspect = w / max(h, 1) roundness = 4 * np.pi * area / (perimeter * perimeter + 1e-6) hull = cv2.convexHull(contour) hull_area = cv2.contourArea(hull) convexity = area / (hull_area + 1e-6) # aspect 除以 3 是为了把数值压到和圆度、凸度接近的 0-1 区间 shape_feat = np.array([aspect / 3.0, roundness, convexity]) return np.concatenate([hist, shape_feat])

参数上要注意的是 hist 归一化分母加了 1e-6,防止掩膜区域过小导致除零。roundness 用周长平方做分母,所以对轮廓的锯齿非常敏感,形态学没处理干净时这个特征会明显偏低。convexity 反映轮廓凹陷程度,红色苹果的梗和香蕉弯曲处都会有影响,但它能帮我们滤掉背景里的塑料袋这类干扰物。

3.2 形状特征:用轮廓弥补颜色的不足

颜色直方图在光照稳定时已经很能打,但青色香蕉和绿色梨子之间的 H 范围存在重叠,单靠颜色会出现混淆。形状特征是第二道防线。对这三种水果,三个形状特征就够用:宽高比、圆度、凸度。

特征计算方式对三类水果的判别意义
宽高比外接矩形宽 / 高香蕉明显大于苹果和梨子
圆度4π×面积 / 周长²苹果接近 0.8 以上,香蕉偏低
凸度轮廓面积 / 凸包面积弯曲香蕉和带梗梨子会偏低

如果后续想做更精细的区分,可以再加一个轮廓下半部面积占比:把外接矩形纵向分成上下两半,分别统计掩膜像素数,再取下半部除以上半部。梨子的可下宽上窄,这个比值会明显大于苹果。这个特征在颜色重叠时很管用,但要求分割掩膜足够干净,否则噪声会直接把比值带偏。

3.3 特征拼接:量纲问题必须提前处理

上面把颜色直方图和三个形状特征拼成一个向量后,量纲问题会立刻暴露。直方图每个 bin 在归一化后都小于 1,而宽高比算出来可能是 2.5,欧氏距离计算时形状特征会主导结果,颜色直方图几乎不起作用。我在 extract_features 里把 aspect 除以 3,就是做一个粗粒度的量纲对齐。更严谨的做法是在训练集上做 min-max 归一化。

def fit_scaler(X): mins = X.min(axis=0) maxs = X.max(axis=0) return mins, maxs def apply_scaler(X, mins, maxs): return (X - mins) / (maxs - mins + 1e-6)

fit_scaler 只用在训练集上,算出的 mins 和 maxs 要保存下来,预测时用同一组参数去缩放测试样本。很多入门项目在这里翻车:对全部数据一起做归一化,再把训练集和测试集分开,等于把测试集的信息泄露进了训练过程,验证分数会虚高。这个细节做课程设计时尤其常见。

4. 用最近均值与 KNN 在小样本上完成训练和验证

4.1 别随机划分:按批次切开训练集与验证集

样本量少的时候,训练集和验证集的划分方式直接决定你看到的分数的真实性。很多人从一段视频里抽了三十帧,然后随机选二十帧训练、十帧验证,结果准确率高达 95%,换一批新照片就崩盘。原因是相邻帧的拍摄条件几乎一样,验证集和训练集高度雷同,本质上是在“背题”。

正确的做法是按采集批次切分。命名时我建议用四位数编号,第一位是批次号:第一批 1001 开始,第二批 2001 开始。划分时按文件名排序后直接切一段出来验证,保证验证集里的照片和训练集来自不同的拍摄时段。代码里就是排序后按比例切片,不随机打乱。

import os import numpy as np CLASS_NAMES = ["apple", "banana", "pear"] CLASS_TO_ID = {name: i for i, name in enumerate(CLASS_NAMES)} def load_dataset(samples_root): feats, labels, paths = [], [], [] for name in CLASS_NAMES: folder = os.path.join(samples_root, name) for fn in sorted(os.listdir(folder)): if not fn.lower().endswith((".jpg", ".jpeg", ".png")): continue path = os.path.join(folder, fn) img = cv2.imread(path) if img is None: continue feat = extract_features(img) if feat is None: continue feats.append(feat) labels.append(CLASS_TO_ID[name]) paths.append(path) return np.array(feats), np.array(labels), paths X, y, paths = load_dataset("samples") split = int(len(X) * 0.7) X_train, y_train = X[:split], y[:split] X_test, y_test = X[split:], y[split:]

这个切分方式要求每个类别内部的样本数量基本一致。如果苹果拍了八十张、香蕉二十张,切分后验证集里香蕉可能只有六张,统计出的准确率没有意义。遇到这种情况,先把香蕉补拍到位,再来谈模型。

4.2 手写最近均值分类器,先把流程跑通

小样本、低维度特征下,我一般先不引入复杂的分类器,而是手写一个最近均值分类器:训练时把每类样本的特征向量取平均,预测时计算新样本到三个类别中心的欧氏距离,选最近的作为结果。这个分类器没有可调参数,逻辑透明,方便定位问题。

class NearestMeanClassifier: def fit(self, X, y): self.centers = [X[y == i].mean(axis=0) for i in range(len(CLASS_NAMES))] def predict(self, x): dists = [np.linalg.norm(x - c) for c in self.centers] return int(np.argmin(dists)) clf = NearestMeanClassifier() clf.fit(X_train, y_train) # 批量验证并输出混淆矩阵 matrix = np.zeros((3, 3), dtype=int) for feat, label in zip(X_test, y_test): pred = clf.predict(feat) matrix[label, pred] += 1 print("混淆矩阵,行=真实类别,列=预测类别") print(matrix) accuracy = np.trace(matrix) / matrix.sum() print(f"accuracy = {accuracy:.2%}")

从混淆矩阵里能直接看到哪两类在打架。如果苹果和梨子互相误判,多半是特征里圆度权重不够或者分割掩膜不干净;如果香蕉和梨子混淆,通常是黄绿颜色重叠,要把形状特征提上来。最近均值分类器对各类样本分布比较均匀的情况很可靠,但它假设每类特征是单峰分布,如果样本里混进了阴影、反光特别严重的极端图,均值中心会被带偏。

4.3 用 OpenCV 自带的 KNN 做对比

最近均值跑通后,可以再用 OpenCV 自带的 KNN 做一次对比。KNN 不取均值,而是直接记住训练样本,预测时找最近的 K 个邻居投票。样本少时 K 取 3 比较灵活,K 太大容易被离群样本带偏。KNN 使用前要把特征转成 float32,标签转成 int32,这两处类型不对会直接报错或得到空结果。

knn = cv2.ml.KNearest_create() knn.train(X_train.astype(np.float32), cv2.ml.ROW_SAMPLE, y_train.astype(np.int32)) ret, results, neighbours, dist = knn.findNearest(X_test.astype(np.float32), k=3) knn_preds = results.ravel().astype(int) knn_accuracy = (knn_preds == y_test).mean() print(f"KNN accuracy = {knn_accuracy:.2%}")

对比两类分类器很有价值。KNN 分数明显高于最近均值,说明样本分布不是简单的单峰,边界附近存在多个小簇,这时候保留原始样本是有必要的;如果两者差不多,说明类别间距较大,手工特征的区分度足够,后续要优化的重点是分割和特征而不是分类器。

5. 避坑:五条让水果识别翻车的细节与修复

5.1 红色苹果分割成洞:H 跨了 0 边界

现象是红色苹果的掩膜上出现大量黑色空洞,外接矩形也比真实苹果小一圈。原因是红色在 OpenCV 的 HSV 里横跨 0 和 179 两端,只写一个 inRange 区间会漏掉一半色相。修复方法是把红色拆成两段,或者把接近 0 的 H 值平移到 180 之后统一处理。

# 平移法:H 小于 10 的像素加 180,红色就从 0-10 变成了 180-190 h_shift = hsv[:, :, 0].astype(np.int16) h_shift = np.where(h_shift < 10, h_shift + 180, h_shift) mask_red = cv2.inRange(hsv, np.array([170, 60, 50]), np.array([190, 255, 255]))

注意平移后的 h_shift 只用于阈值判断,不要写回原图,否则后续 calcHist 的色相范围会被破坏。

5.2 验证分数虚高:测试样本和训练样本来自同一段视频

现象是训练集和验证集准确率都超过 95%,但换个拍摄角度就只剩六成。原因是验证集和训练集高度相似,分类器实际上是在背题。解决方法是按批次划分数据,并且验证集必须来自另一段时间、另一种角度或另一组光照下的独立拍摄。我习惯在样本命名时就用首位数字标明批次,划分时按文件名切段,绝不随机打散。

5.3 背景高饱和物体混入掩膜

现象是绿色垃圾桶或黄色包装纸被圈进轮廓,特征向量被背景主导。原因是背景物体颜色和水果相近,S 和 V 阈值放得太宽。解决方法是增加两层过滤:要求掩膜面积占全图比例在合理范围内,比如 10% 到 70%;要求轮廓凸度高于 0.8。绿色塑料包装纸通常是皱褶的,凸度偏低,能直接滤掉。

5.4 特征量纲不一致,导致只有形状在分类

现象是颜色特征失效,香蕉和梨子虽然有明显的颜色差异却频繁互判。原因是宽高比数值接近 2,而直方图 bin 值都在 0 到 1 之间,欧氏距离被形状项完全主导。修复方法是对特征矩阵做 min-max 归一化,把每一列压到相同尺度。

mins, maxs = fit_scaler(X_train) X_train_scaled = apply_scaler(X_train, mins, maxs) X_test_scaled = apply_scaler(X_test, mins, maxs)

归一化参数只从训练集统计,测试集用同一组参数变换。测试集单独算 min 和 max 属于数据泄露,会让验证分数失真。

5.5 黄绿颜色重叠:白平衡和下半部面积占比

现象是青色香蕉和浅绿梨子被归为一类,颜色直方图的峰位几乎重合。原因是香蕉未熟、梨子偏黄时,两者的 H 范围都落在 40 到 50 附近。修复方法有两个方向。第一,拍摄时在画面角落放一张白卡,统计白卡区域的 BGR 均值,把每个通道乘上对应增益,做一个简单白平衡,让颜色回到稳定区间。第二,在特征里加轮廓下半部面积占比,梨子下宽上窄,这个比值明显大于苹果和香蕉。

x, y, w, h = cv2.boundingRect(contour) top = mask[y: y + h // 2, x: x + w] bottom = mask[y + h // 2: y + h, x: x + w] bottom_ratio = cv2.countNonZero(bottom) / (cv2.countNonZero(top) + 1e-6)

这个特征对分割质量敏感,掩膜有缺口时要先用闭运算把空洞补上,否则比值会失真。

6. 用样本统计自动生成 HSV 阈值,告别手调参数

手工调 HSV 阈值最恼人的地方是没有标准答案。同一个苹果,在暖光灯下 H 会偏到 5 附近,在冷光灯下又会偏到 175,每次换环境都要重新试。与其一遍遍试,不如让样本自己说话:统计每类样本掩膜区域内 H、S、V 的均值和标准差,用“均值减两倍标准差、均值加两倍标准差”生成该类别的动态阈值范围。

def estimate_hsv_range(hsv, mask): ranges = [] for ch, max_val in zip(range(3), [179, 255, 255]): vals = hsv[:, :, ch][mask > 0] if len(vals) == 0: return None mean = vals.mean() std = vals.std() lower = max(0, int(mean - 2 * std)) upper = min(max_val, int(mean + 2 * std)) ranges.append((lower, upper)) return ranges

这个方法把调参从“手动改三个数字”变成“重新跑样本统计”,每次换环境只需要把当前光照下的样本重新录一遍,程序吐出新的阈值。要注意的是,统计出来的阈值只对这个样本集有效,不是通用阈值;如果预测时换了背景或光源,需要重新统计。

阈值自动生成之后,再跑一次批量验证就可以验收整个方案。我习惯让脚本把每张预测错误的样本路径打印出来,直接看错在哪一步:分割错、特征错、还是分类器边缘误判。如果是边缘样本,就把图片保存到独立文件夹,用来决定要不要加一条启发式规则,而不是急着调分类器参数。

我最早做这套水果识别时,嫌传统视觉不够有技术含量,总想直接上深度学习,结果在样本整理和标注上耗了三周。后来收敛成切片、分割、特征、距离分类这四步,半天就能从一个新场景跑出结果,也慢慢养成了习惯:改任何参数之前先记录当前验证准确率,改完再跑一次批量验证,用数据说话而不是靠感觉。希望你也能用这套方法,把样本整理和特征工程的地基打好。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询