资讯详情

OpenCV水果识别小样本三分类:预处理、特征与迁移学习避坑

📅 2026/10/24 7:08:40 | 华诺云谱 👁 阅读
OpenCV水果识别小样本三分类:预处理、特征与迁移学习避坑
简介这份OpenCV水果识别样本围绕苹果、香蕉、梨子三类目标整理适合正在学习图像预处理、特征提取、分类器训练与测试的计算机视觉入门者和课程实践者可用于搭建水果分类实验、验证传统机器学习或深度学习流程。压缩包共1624个文件以1618张jpg水果图像为主体配有5个xml标注文件和1个iml工程配置整体约17.82MB便于直接划分训练集与测试集并开展样本清洗、数据增强等练习。目前已有12819人学习下载说明其在相关课程与练手项目中具有一定参考价值。读者可据此复现从灰度化、直方图均衡化到特征描述与分类评估的完整链路也能借多类别样本观察光照、角度和背景差异对识别效果的影响为后续模型调参与泛化能力分析提供素材。1. OpenCV 水果识别样本先看清三分类的坑大多不在模型手里这份 opencv 水果识别样本苹果、香蕉、梨子各一个目录量不大属于典型的小样本集合。很多人拿到水果样本第一反应是「这点数据能训出什么」然后直接上 CNN跑出七十几的准确率就怀疑样本质量。实际情况往往相反——翻车的环节基本都在模型之前路径读不进来、通道数不统一、同一张原图的增强版本同时落进训练集和验证集。三个类别的颜色差异足够明显苹果偏红、香蕉偏黄、梨子青黄带斑点这个特点让颜色特征就能撑起一个不差的 baseline也正好把预处理、划分、评估这条链路完整走一遍。适合刚接触 OpenCV 的从业者也适合需要一份干净三分类水果样本做对照实验的人。2. 目录结构到 Mat读图链路里藏着三个硬约束2.1 先摸清目录布局和命名再谈训练样本包解压出来通常是这样的结构类别名就是子目录名层级示例说明根目录fruit_samples/解压后的统一入口类别目录apple/banana/pear/通常一个类别一个文件夹图片文件apple_001.jpg后缀可能混杂 jpg/png/jpeg干扰文件.DS_Store、Thumbs.db系统生成的隐藏文件必须过滤这个结构刚好和torchvision.datasets.ImageFolder的约定一致后面迁移学习那条线可以直接用。但在读图之前我一般会先做三件事统计每个类别的文件数确认没有某个类别只有十几张统计后缀分布确认没有混进 webp 或者无后缀文件扫一遍文件名确认没有空格和全角字符。这三步加起来不到二十行代码但能把后面一半的报错挡在门外。血泪经验是文件名里的空格。banana 003.jpg这种命名在cv2.imread里不一定出问题但一旦走subprocess或者某些标注工具路径就会被截断最后表现为「文件明明在就是读不到」。2.2 批量读取与可读性校验读图这一步我不用cv2.imread直连磁盘改成先读字节流再解码。原因很实际样本包里只要有一张图是中文名或者路径里带中文imread在部分平台会静默返回None而且不报错直到训练时才炸。import os import numpy as np import cv2 ROOT fruit_samples # 样本包解压后的根目录 CLASSES [apple, banana, pear] # 类别名要和子目录名严格一致 VALID_EXT {.jpg, .jpeg, .png, .bmp} def load_image(path): 先读字节再解码绕开非 ASCII 路径下 imread 返回 None 的问题 buf np.fromfile(path, dtypenp.uint8) if buf.size 0: return None img cv2.imdecode(buf, cv2.IMREAD_COLOR) # 强制三通道丢掉 alpha return img records, bad [], [] for idx, cls in enumerate(CLASSES): cls_dir os.path.join(ROOT, cls) if not os.path.isdir(cls_dir): print(目录缺失:, cls_dir) continue for name in sorted(os.listdir(cls_dir)): if os.path.splitext(name)[1].lower() not in VALID_EXT: continue # 过滤 .DS_Store 之类的干扰文件 p os.path.join(cls_dir, name) img load_image(p) if img is None: bad.append(p) # 单独收集不中断整个流程 continue records.append((p, idx, img.shape[0], img.shape[1])) print(可读样本:, len(records), 损坏样本:, len(bad)) for p in bad[:10]: print( 打不开:, p)np.fromfile读的是原始字节cv2.imdecode负责解码两个组合起来对路径编码的容忍度比imread高很多。IMREAD_COLOR这个标志很关键它会强制输出三通道 BGR哪怕原图是灰度图或者带透明通道的 PNG读出来也是统一的三通道后面就不需要为通道数写分支判断。bad列表要单独留着不要读完就扔。这些文件要么是零字节的占位文件要么是扩展名写错把 webp 改成了 jpg要么是真的损坏。把它们打印出来人工看一眼能反推出样本包是不是在压缩传输中出过问题。参数说明VALID_EXT只放这几种是因为 OpenCV 对这几个解码最稳如果样本里有webp要么提前转码要么确认opencv-python编译时带了对应支持否则会读到None然后被误判成损坏文件。2.3 尺寸、通道与色彩空间统一样本包里的图尺寸几乎不可能一致手机拍的是 3024×4032网图可能是 800×600还有裁剪过的小图。直接resize到 224×224 会有一个副作用长宽比被强行拉伸香蕉这种细长物体变形会很明显颜色特征的分布也会跟着偏。我一般用「短边缩放 中心裁剪」的写法。INPUT_SIZE (224, 224) def preprocess(img, sizeINPUT_SIZE): 短边等比缩放到目标尺寸再中心裁剪避免长宽比失真 h, w img.shape[:2] scale max(size[0] / w, size[1] / h) # 取较大比例保证缩放后不短于目标 nw, nh int(round(w * scale)), int(round(h * scale)) # 缩小用 INTER_AREA 减少摩尔纹放大才用 INTER_LINEAR interp cv2.INTER_AREA if scale 1 else cv2.INTER_LINEAR resized cv2.resize(img, (nw, nh), interpolationinterp) x0 (nw - size[0]) // 2 y0 (nh - size[1]) // 2 return resized[y0:y0 size[0], x0:x0 size[1]] def to_hsv(img): 颜色特征一律在 HSV 空间算BGR 三个通道对光照的耦合太强 return cv2.cvtColor(img, cv2.COLOR_BGR2HSV)INTER_AREA和INTER_LINEAR的选择不是强迫症。INTER_AREA在缩小场景下是按区域取均值能压掉高频噪点INTER_LINEAR在放大时是双线性插值边缘更平滑。两者的差异在小图上肉眼可见而这个差异会直接传到后面的特征向量里。色彩空间那块要注意OpenCV 的 HSV 里 H 的取值范围是 0 到 180不是 0 到 360。这个细节在写calcHist的区间参数时如果按 360 写直方图的后半段会全是零颜色特征直接废掉一半。翻车过的人应该都记得这个 180。注意样本里如果有偏暗的图比如室内拍摄的香蕉统一 resize 之后亮度不做归一化颜色直方图会被弱光拉偏。后面第 3 章的训练脚本里我加了一步亮度归一这里先把读图链路走通。3. 传统特征路线颜色直方图加 HOG 撑起的 baseline3.1 为什么小样本要先做传统特征而不是直接上 CNN三个类别、每类几十张的量级从头训一个 CNN 基本等于随机初始化加过拟合。这时候传统特征的价值就体现出来了颜色直方图不需要训练HOG 的梯度方向也不需要训练整个流程只有 SVM 的几百个参数在学泛化反而不容易崩而且耗时以秒计可以反复试参数。这条路线还有一个隐性价值——它是后面 CNN 路线的对照组。如果 CNN 跑出来 85%传统特征跑出来 80%那说明数据本身的可分性就在这个水平附近瓶颈在样本而不在模型如果传统特征只有 50%而 CNN 有 90%那就要回头查是不是验证集划分出了问题或者 CNN 把背景当成了捷径特征。这个判断依据比单看一个准确率数字有用得多。需要说清楚的边界颜色直方图对光照和饱和度敏感HOG 对旋转和尺度敏感两者拼接后是典型的手工特征遇到复杂背景比如水果放在木桌上、旁边有别的物体就会明显掉点。所以这条线定位是 baseline 和对照不是最终方案。3.2 HSV 三维直方图把颜色差异变成可计算的距离苹果和香蕉的区分本质上是色相分布的差异。在 HSV 空间里红色的 H 集中在 0 到 10 以及 170 到 180 两段香蕉的黄色集中在 20 到 35梨子的青黄落在 35 到 60。这个分布差异用三维直方图能直接刻画出来。import cv2 import numpy as np def hsv_hist(img, bins(8, 8, 8)): 三维 HSV 直方图三通道各 8 个 bin归一化后展平 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) hist cv2.calcHist( [hsv], [0, 1, 2], None, bins, [0, 180, 0, 256, 0, 256] # H 上限是 180不是 360 ) hist cv2.normalize(hist, hist, norm_typecv2.NORM_L1) # L1 归一化抗尺寸差异 return hist.flatten().astype(np.float32)三个通道各取 8 个 bin一共 512 维。bin 数不是越多越好8 个 bin 对色相来说是每 22.5 度一格刚好能把红、黄、青黄分开如果加到 16 个 bin维度翻倍小样本下 SVM 更容易过拟合而且相邻 bin 之间的计数很稀疏特征反而更不稳定。cv2.NORM_L1这步归一化不能省。它让直方图的总和恒为 1这样一张近距离特写的苹果和一张远景的苹果特征向量在同一量级上SVM 才不会因为图片里水果占比不同而把两者判成不同类。视觉上这像是细节实际对准确率的影响经常在两三个百分点。参数怎么调如果发现苹果和梨子的混淆特别严重可以把 H 通道的 bin 加大到 12 或 16让红色的位置更细如果光照差异大导致 S 和 V 通道抖动反过来把 S、V 的 bin 减到 4。这个调整要看混淆矩阵定位问题不要盲调。3.3 HOG 特征与 SVM形状信息补上颜色分不开的部分只有颜色特征的话青苹果和梨子会互撞。HOG 统计的是局部梯度方向分布能刻画出苹果的圆形轮廓和梨子的梨形轮廓正好补上这块。from skimage.feature import hog def hog_feat(img, size(128, 128)): 灰度化后提 HOG参数针对 128x128 的小图做过缩放 gray cv2.cvtColor(cv2.resize(img, size, interpolationcv2.INTER_AREA), cv2.COLOR_BGR2GRAY) return hog( gray, orientations9, # 9 个方向 bin覆盖 0-180 度 pixels_per_cell(16, 16),# 细胞 16x16128 图上是 8x8 个细胞 cells_per_block(2, 2), # 2x2 个细胞组成一个块做块内归一化 block_normL2-Hys, # 对光照变化最稳的归一化方式 feature_vectorTrue, ).astype(np.float32)pixels_per_cell(16, 16)是配合 128×128 输入定的。如果按默认的 8×8128 的图上会切出 16×16 个细胞特征维度翻到接近八千维小样本下 SVM 训练会明显变慢而且更容易过拟合。block_norm选L2-Hys是因为它对高对比度边缘有截断能压住反光造成的梯度异常。把两个特征拼起来接一个 RBF 核 SVMimport numpy as np from sklearn.svm import SVC from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score # imgs 是预处理后的 BGR 图labels 是 0/1/2 X np.array([ np.hstack([hsv_hist(cv2.resize(im, (128, 128))), hog_feat(im)]) for im in imgs ], dtypenp.float32) y np.array(labels) # stratify 保证三个类别在训练/测试里的比例一致小样本必须加 X_tr, X_te, y_tr, y_te train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) pipe Pipeline([ (scaler, StandardScaler()), # 颜色特征和 HOG 量纲差一个数量级先标准化 (svm, SVC(kernelrbf, C10, gammascale, probabilityTrue)), ]) pipe.fit(X_tr, y_tr) cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores cross_val_score(pipe, X, y, cvcv, scoringaccuracy) print(五折准确率:, np.round(scores, 3), 均值:, scores.mean().round(3))StandardScaler是必须的。HSV 直方图每个维度在 0 到 1 之间HOG 的维度在 0 到 0.3 之间波动量纲不同会让 RBF 核的距离计算被颜色特征主导。做完标准化两部分才在同一个尺度上参与距离计算。gammascale是 sklearn 的默认策略取1 / (n_features * X.var())。小样本下这个自适应值通常比手调好因为特征维度高、方差小手调的gamma很容易偏大导致过拟合。C10是经验值如果五折的方差特别大比如 0.62 到 0.91说明模型对划分敏感这时候把C降到 1 到 3 会更稳。probabilityTrue是给后面画混淆矩阵和看置信度用的代价是训练时多了内部交叉验证小样本下多花几秒可以接受。3.4 混淆矩阵比准确率信息量大得多准确率是个被平均掉的数字。三个类别的样本量如果不完全相等它会把少数类的错误藏起来。from sklearn.metrics import confusion_matrix, classification_report y_pred pipe.predict(X_te) print(classification_report(y_te, y_pred, target_namesCLASSES)) print(confusion_matrix(y_te, y_pred, labels[0, 1, 2]))看混淆矩阵的时候重点看两件事有没有某个类别的召回率明显低于其他类以及错误是不是集中在某两个类之间。前者通常对应样本量不足或者该类内部差异太大比如香蕉有未熟、成熟、过熟三种外观后者通常对应特征区分度不够——苹果和梨子混淆就说明 HOG 的轮廓信息没起到作用可以考虑加大 HOG 的权重或者换一个pixels_per_cell。这里有个容易忽略的点classification_report的target_names顺序必须和标签编码一致。CLASSES [apple, banana, pear]对应标签 0、1、2如果中间某个目录缺失编码会错位报告里的类名就全错位了。这类问题是典型的「数据没问题、报告看错」的翻车方式。4. 迁移学习路线小样本下冻结和解冻的取舍4.1 小样本为什么优先迁移而不是从头训练传统特征能到 80% 左右就到顶了再往上需要模型自己学出「香蕉弯曲的角度」「梨子表面的斑点分布」这类不好手工描述的特征。从头训一个 CNN 在几十张每类的规模下几乎没有可能收敛好但预训练模型不同——它的浅层卷积核已经学会了边缘、颜色块、纹理这些通用模式直接拿来用只需要重新训最后那层分类头。这条路线的前提是输入分布别偏太远。水果照片和 ImageNet 里的自然图像分布接近迁移效果通常不错但如果样本全是纯白背景的摆拍图而预训练模型见的多是复杂场景浅层特征会有偏差这时候微调的解冻层数要适当多一点。4.2 数据增强与 ImageFolder 的组织方式增强的策略要贴着水果这个场景设计。水平翻转是安全的苹果和梨子翻转后还是苹果和梨子但垂直翻转就要小心倒过来的水果在现实中很少见会让模型学到不该学的模式。颜色抖动是有必要的因为样本里香蕉的成熟度差异很大从青黄到带黑斑都有ColorJitter 能让模型对这种自然差异更宽容。from PIL import Image import torch from torch.utils.data import Dataset from torchvision import datasets, transforms tf_train transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.7, 1.0)), # 模拟不同拍摄距离 transforms.RandomHorizontalFlip(p0.5), # 只做水平翻转 transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3, hue0.05), # hue 幅度要小 transforms.RandomRotation(degrees12), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) tf_eval transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) class SubsetWithTransform(Dataset): 给训练/验证子集分别挂不同的 transform避免增强泄漏到验证集 def __init__(self, base_ds, indices, transform): self.base base_ds self.indices indices self.transform transform def __len__(self): return len(self.indices) def __getitem__(self, i): path, label self.base.samples[self.indices[i]] img Image.open(path).convert(RGB) # 统一成 RGB和预训练权重对齐 return self.transform(img), labelhue0.05这个幅度是有意压小的。HSV 里色相偏移一旦过大苹果可能被改成橙色模型就学到了错误的颜色语义。亮度、对比度、饱和度可以放宽到 0.3这三个维度的变化在真实拍摄条件下很常见。SubsetWithTransform这个类是我吃过亏之后固定下来的写法。如果直接用random_split得到一个子集然后在训练循环里手动对 batch 做增强验证集很容易被同一套增强污染。用这个包装类训练子集挂tf_train验证子集挂tf_eval物理隔离不会串。4.3 冻结主干、替换分类头、再解冻最后一块import torch.nn as nn from torchvision import models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 第一步冻住全部参数 for p in model.parameters(): p.requires_grad False # 第二步替换分类头新层默认 requires_gradTrue model.fc nn.Linear(model.fc.in_features, 3) # 第三步解冻最后一个残差块让高层语义特征也能微调 for p in model.layer4.parameters(): p.requires_grad True trainable [n for n, p in model.named_parameters() if p.requires_grad] print(参与训练的参数:, trainable)冻结全部再单独解冻的顺序不能反。如果先解冻layer4再统一冻结会把刚解冻的部分又关掉。layer4是 ResNet 里语义层级最高的一块它的特征和水果这种具体物体类别最相关解冻它的收益最大再往前的layer3、layer2学的是纹理和局部结构小样本下解冻它们带来的收益不明显反而增加过拟合风险。优化器的学习率也要分档分类头用大学习率解冻的layer4用小学习率import torch opt torch.optim.AdamW([ {params: model.fc.parameters(), lr: 1e-3}, {params: model.layer4.parameters(), lr: 1e-4}, ], weight_decay1e-4) crit nn.CrossEntropyLoss()1e-4这个量级是给已经预训练好的层的太大等于把学到的权重冲掉1e-3给全新的分类头让它快速找到方向。weight_decay1e-4在小样本下配合早停能明显压低过拟合。训练循环里我固定记录训练损失、验证损失、验证准确率三条曲线早停的判据用验证损失而不是准确率。准确率在小样本上会跳验证损失更连续。耐心值设 5 个 epoch触发就回滚到验证损失最低的那份权重。4.4 分类报告之外要回看被错分的原图classification_report能告诉你哪个类别召回低但告诉不了你为什么。真正有用的一步是把验证集里预测错误的样本路径打出来按真实类别分组人工扫一遍。常见的发现有三类一是背景干扰比如苹果那张图旁边有一根香蕉的边角模型被带偏二是标注问题某个文件放在了错误的类别目录三是边缘样本比如严重过熟的香蕉人眼判断都要犹豫一下。前两类是数据问题改数据第三类才是模型能力边界可以考虑扩充这类边缘样本而不是调模型。这一步花的时间大概是调参的两倍但收益也在这个量级。我见过准确率停在 0.82 上不去的案例回看错误样本发现是十几个文件放错了目录改完直接到 0.93。5. 避坑与排查水果样本落地时的五个真实翻车点5.1 数据侧的三个坑现象一训练时DataLoader报stack expects each tensor to be equal size。原因通常是某个类别的图里有单通道灰度图convert(RGB)没做或者某张图是 4 通道 RGBA 的 PNGToTensor之后通道数不一致。解决方式是在读图那一步统一强制三通道也就是第 2 章IMREAD_COLOR那个写法或者自定义 Dataset 里Image.open(path).convert(RGB)不要省。这类错误不会在扫描阶段暴露只在拼 batch 时炸定位成本高所以要在入口就堵死。现象二训练集准确率一直涨验证集第一次评估就有 95%后续不再提升。这是典型的增强泄漏。同一张原图被随机增强出多个副本分别落进了训练集和验证集验证集相当于见过原图。根源是很多人的划分方式是在ImageFolder之后对增强后的数据集做random_split而增强是每次__getitem__时随机生成的同一路径的图会被重复采到。正确做法是先按文件路径划分出训练/验证清单并落盘再分别构造 Dataset清单文件一旦固定就不再变动。第 6 章给了一份脚本。现象三香蕉类别的召回率明显偏低混淆矩阵里大量被判成梨子。先别改模型。打开被错分的那批图看一眼十有八九是过熟香蕉表皮已经发黑偏褐在 HSV 空间的色相分布确实往青黄方向偏了。这属于类别内部的自然变异不是特征提取的问题。解决办法有两个方向要么在样本层面补充这类边缘样本让模型见过更多样的香蕉要么在增强里加ColorJitter的 hue 和 saturation 幅度模拟成熟度变化。前者更扎实后者更快见效。5.2 训练侧的两个坑现象四整体准确率 0.88 看着不错但梨子的 F1 只有 0.6。这是类别不平衡加指标误读的组合。如果梨子样本只有苹果的三分之一模型倾向于把不确定的梨子判成其他类整体准确率仍然好看。判断方式是看classification_report里的macro avg而不是accuracy前者对每个类别等权后者被大类别主导。解决上小样本下不建议直接上重采样容易放大小类噪声更稳的是在CrossEntropyLoss里传weight权重按类别样本数的倒数归一化改动一行代码收益立竿见影。现象五微调之后模型对背景敏感换个桌面颜色就崩。迁移学习在小样本上很容易学到背景捷径尤其是样本里如果所有苹果都放在白瓷盘上、所有香蕉都放在木桌上模型会直接学桌面纹理。排查方式是把验证集图片的背景做一次替换或裁剪看准确率掉多少。掉得厉害就说明学的是背景。缓解手段是在增强里加RandomResizedCrop的scale下限调低比如 0.5逼模型只看局部或者对背景做离线合成把水果抠出来贴到随机背景上这个做法在最后一章展开。6. 把样本用扎实固定划分清单加背景合成小样本项目最容易被忽略的一件事是划分的复现性。每次跑实验都重新random_split两次的验证集不同指标就没法比较调参变成玄学。我现在的习惯是先落一份 CSV 清单把文件路径、类别、划分train/val三列写死后面所有实验都读这份清单。import csv, random, os from collections import defaultdict ROOT, CLASSES fruit_samples, [apple, banana, pear] random.seed(42) # 固定种子保证清单可复现 rows [] for idx, cls in enumerate(CLASSES): files [f for f in os.listdir(os.path.join(ROOT, cls)) if f.lower().endswith((.jpg, .jpeg, .png))] random.shuffle(files) n_val max(1, int(len(files) * 0.2)) # 每类至少留一张验证 for i, f in enumerate(files): rows.append([os.path.join(ROOT, cls, f), idx, val if i n_val else train]) with open(split.csv, w, newline, encodingutf-8) as fp: csv.writer(fp).writerows(rows)这份清单的价值在于可追溯。哪天指标异常直接回去看是不是某个类别在验证集里只分到一张图而不是在一堆随机数里猜。再往上一层是背景合成。水果样本的通病是拍摄场景单一解决办法是离线做一批合成图用 GrabCut 或者简单的阈值把水果从原背景抠出来贴到随机选取的纯色、木纹、布料背景上再叠一点亮度扰动。合成图只进训练集绝不进验证集否则验证指标就失去意义了。合成比例我一般控制在原样本量的 1 到 1.5 倍太多会让模型偏向合成图的分布反而在真实图上掉点。验证合成是否有效别看训练损失看验证集里那些背景和训练集差异最大的样本单独统计它们的准确率变化。这个子集的表现才是合成增强是否真的提升了泛化的证据。从那以后我每次拿到一份新的水果样本第一件事都是先写划分清单、再扫一遍可读性和尺寸分布最后才开始动特征和模型。这个顺序颠倒过几次每次都付出了双倍的排查时间。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑