资讯动态

OpenCV水果识别三分类样本:从特征提取到SVM批量推理

发布时间:2026/10/24 1:04:07 来源:尧图企业网站定制
简介这是一份面向计算机视觉初学者与课程实践者的OpenCV水果识别样本集聚焦苹果、香蕉、梨子三类常见水果的图像分类任务可用于图像预处理、特征提取、分类器训练与测试等完整流程的练手。压缩包共1624个文件其中1618个jpg水果图像构成主要训练与测试素材另含5个xml配置文件及1个iml工程文件整体约17.82MB体量轻便便于按类别整理和快速加载。目前已有12819人学习下载具备较好的参考热度。样本覆盖不同光照、角度与背景差异读者可据此尝试色彩直方图、纹理或形状特征并结合SVM、随机森林或CNN完成训练评估也能通过旋转、缩放、裁剪等方式做数据增强用于课程设计、毕业项目或算法对比实验。1. 从搜到能跑三分类水果识别样本解决的是哪一段脏活打开搜索引擎敲下 opencv 水果识别 水果样本翻出来的代码大多卡在第一步训练数据得自己一张张攒。真正稀缺的从来不是那几行 SVM 或直方图代码而是按苹果、香蕉、梨子分好类、拍摄口径相对统一、可以直接喂进流水线的样本集。这份资源的核心价值就在这里——它把图像按类别、按训练/验证/测试三个划分摆好配套的识别脚本从读图、预处理、特征提取、分类器训练一路走到批量预测是一条能完整跑通的链路而不是一个孤零零的 demo。它适合三类人做课程设计或毕业设计、想拿一份现成样本再往上调精度的同学打算用传统 CV 把单张推理压到几十毫秒、暂时不上 CNN 的工程师以及把水果识别当教学案例、想让学生看清特征工程到底在做什么的老师。下面按我拆这类项目的顺序讲——先看数据怎么组织再看特征怎么抠再看模型怎么训最后看坑在哪、怎么改。2. 样本目录与数据组织三分类数据集长什么样拿到一个识别样本我第一件事不是python train.py而是把目录树打出来看一眼。数据集的组织方式决定了后面所有脚本怎么写也决定了这个样本是能直接跑还是得先整理两小时。水果识别这类三分类任务最有代表性的目录结构是把划分放在第一层、类别放在第二层类名直接当文件夹名用——这既是 sklearn 的ImageFolder风格约定也让改类别变成加一个文件夹这么简单的事。2.1 目录约定与划分逻辑典型结构是这样的fruit_dataset/ ├── train/ │ ├── apple/ # 苹果 │ ├── banana/ # 香蕉 │ └── pear/ # 梨子 ├── val/ │ ├── apple/ │ ├── banana/ │ └── pear/ └── test/ ├── apple/ ├── banana/ └── pear/为什么要三个划分而不是两个train/val 分开是为了调 SVM 的C、gamma时有个不参与训练的选择集test 只在最后跑一次用它来报准确率。我见过不少人直接在训练集上评估准确率能报到 0.99可换一张自己手机拍的梨子就懵——这就是没留独立测试集的后果也是后面避坑那章会重点说的问题。写一段脚本把每个划分、每个类别的图片数点一遍这是开始训练前必须做的体检from pathlib import Path from collections import Counter DATA_ROOT Path(./fruit_dataset) SPLITS [train, val, test] CLASSES [apple, banana, pear] EXTS {.jpg, .jpeg, .png, .bmp} for split in SPLITS: split_dir DATA_ROOT / split if not split_dir.exists(): print(f[跳过] 未找到划分目录: {split_dir}) continue counter Counter() for cls in CLASSES: cls_dir split_dir / cls if not cls_dir.is_dir(): continue # 只统计常见图片后缀避免把 Thumbs.db、.DS_Store 这类脏文件算进去 for f in cls_dir.iterdir(): if f.suffix.lower() in EXTS: counter[cls] 1 print(split, dict(counter), 总计:, sum(counter.values()))这段代码的用途是把感觉有三类样本变成具体数字。参数上DATA_ROOT指向解压后的数据集根目录EXTS决定哪些后缀算图片Counter按类别计数。跑完你会看到类似train {apple: 240, banana: 260, pear: 235}的输出。如果某一类明显偏少比如 pear 只有 80 张后面训练就必须处理类别不均衡否则会出现所有图都判成香蕉这种血泪经验级的现象。2.2 图片规格体检尺寸、通道、损坏文件三个类别的图片如果来自不同渠道网上抓的、手机拍的、老样本里带的尺寸和通道五花八门是常态。训练脚本里一般会统一 resize但 resize 之前得先确认没有读不出来的坏图否则训练跑到一半抛NoneType has no attribute shape又得从头查。import cv2 import numpy as np from pathlib import Path def scan_image_sizes(root: Path, limit_per_class200): 统计每个类别的图片尺寸分布并顺便排查读不出来的坏图 for cls_dir in sorted(root.iterdir()): if not cls_dir.is_dir(): continue sizes, bad [], [] for p in cls_dir.rglob(*): if p.suffix.lower() not in {.jpg, .jpeg, .png, .bmp}: continue img cv2.imread(str(p)) if img is None: bad.append(p.name) continue h, w img.shape[:2] sizes.append((h, w)) if len(sizes) limit_per_class: break if sizes: arr np.array(sizes) h_med int(np.median(arr[:, 0])) w_med int(np.median(arr[:, 1])) print(f{cls_dir.name}: {len(arr)} 张, 高中位数{h_med}, 宽中位数{w_med}, f最小{arr.min(axis0)}, 最大{arr.max(axis0)}) if bad: print(f [坏图 {len(bad)} 张] 例: {bad[:5]})limit_per_class200是为了在数据量大时抽样检查先看个大概如果确认有问题可以把这个限制去掉做全量扫描。这段代码的意义有两条一是知道中位尺寸比如常见的 300×300 到 500×500resize 目标就按中位数附近取避免把图压得太小丢了纹理二是把坏图列出来坏图在cv2.imread下会返回None不会抛异常非常容易被忽略。2.3 重复图与命名规范三分类样本里最容易藏的一个问题是同一张图在 train 和 val 里各出现一次。拍一段视频抽帧、或者用数据增强离线扩增后没拆干净都会造成这个结果。验证集准确率虚高、上线后精度断崖一半是这个原因。用感知哈希dHash粗筛一遍重复图成本很低import cv2 import numpy as np from pathlib import Path def dhash(img_bgr, hash_size8): 把图片压成 64 位指纹两张图指纹汉明距离越小越像 gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) resized cv2.resize(gray, (hash_size 1, hash_size)) # 相邻像素比较得到一个布尔矩阵后压平 return (resized[:, 1:] resized[:, :-1]).flatten() def find_near_duplicates(root: Path, threshold: int 4): threshold 表示汉明距离阈值4 以内基本可视为同一张图的不同版本 seen, dups {}, [] for p in sorted(root.rglob(*)): if p.suffix.lower() not in {.jpg, .jpeg, .png}: continue img cv2.imread(str(p)) if img is None: continue code dhash(img) for old_path, old_code in seen.items(): if int(np.sum(code ! old_code)) threshold: dups.append((str(p), str(old_path))) break else: seen[p] code return dupshash_size8决定指纹是 64 位数字再大就越敏感、越不容易抓到轻微改动的重复图。threshold4是个经验值允许裁剪、压缩带来的小扰动但不会把不同角度的同类水果误判成重复。跑出来若发现重复对里有跨划分的一条在 train、一条在 val把验证侧那条删掉或重新划分——这个后悔药早点吃比上线后吃强。命名上建议统一成类别_编号.扩展名的形式例如apple_0001.jpg。这样一来即便将来目录结构被改动从文件名也能反推类别二来排查错误样本时按名字定位会快很多。样本原始命名如果是中文在 Windows 下用 cv2 直接读会失败下一章预处理里会说怎么处理。3. 预处理与特征提取颜色和形状两条线传统 CV 做水果识别核心思路是把看起来像哪种水果翻译成可计算的数字。苹果和梨子的差异主要在两块颜色红/绿 vs 黄绿和形状偏圆 vs 上窄下宽的水滴形香蕉则是颜色加细长比。所以特征工程一般走颜色和形状两条线最后拼成一个向量喂分类器。这一章的三段代码是整个项目里最值得逐行读的部分。3.1 预处理尺寸、去噪与背景处理预处理统一放在一个函数里训练和推理都必须调它这是避免训练时 resize、推理时忘 resize这个经典翻车点的唯一办法。import cv2 import numpy as np TARGET_SIZE (128, 128) def load_and_preprocess(path: str) - np.ndarray: 统一读图入口处理中文路径、统一尺寸、轻度去噪 # 用 np.fromfile imdecode 读可以兼容中文路径cv2.imread 遇到中文会静默返回 None buf np.fromfile(path, dtypenp.uint8) img cv2.imdecode(buf, cv2.IMREAD_COLOR) if img is None: raise ValueError(f图片读取失败: {path}) # 保持长宽比地缩放到固定尺寸避免压扁形状特征 h, w img.shape[:2] scale min(TARGET_SIZE[0] / w, TARGET_SIZE[1] / h) resized cv2.resize(img, (int(w * scale), int(h * scale)), interpolationcv2.INTER_AREA) # 补边到 128x128用中值灰填充比直接拉伸更不伤形状 canvas np.full((TARGET_SIZE[1], TARGET_SIZE[0], 3), 128, dtypenp.uint8) x_off (TARGET_SIZE[0] - resized.shape[1]) // 2 y_off (TARGET_SIZE[1] - resized.shape[0]) // 2 canvas[y_off:y_off resized.shape[0], x_off:x_off resized.shape[1]] resized # 轻度高斯模糊手机拍的噪点会污染 HSV 直方图 return cv2.GaussianBlur(canvas, (3, 3), 0)逐行说下要点。np.fromfile cv2.imdecode是关键一步cv2.imread在中文路径下返回None是 OpenCV 的经典坑用 imdecode 能绕过。scale min(...)保证图片按长边缩放不压扁这对形状特征长宽比、圆度尤其重要——一旦拉伸苹果可能被拉成椭圆Hu 矩直接失真。补边用灰色128而不是黑色是为了减少后续阈值分割在边缘的干扰。GaussianBlur的核取 3×3不要 5×5 以上否则颜色直方图会被抹平。背景处理这一步可以简化也可以细做。如果样本是白底或统一桌面背景直接跳过如果背景复杂就把背景处理和特征提取合在一起在 HSV 的 S 通道上做阈值把水果抠出来。第三章后面形状特征的代码就是这么干的。3.2 颜色特征HSV 二维直方图和颜色矩颜色是区分三类水果最直接的信号。BGR 直接做直方图对光照太敏感一般转 HSV 后在 H、S 两通道做二维直方图。def extract_color_features(bgr_img: np.ndarray, h_bins: int 16, s_bins: int 8): 提取 HSV 二维直方图 三个通道的均值和方差 hsv cv2.cvtColor(bgr_img, cv2.COLOR_BGR2HSV) # H 通道范围 0~180OpenCV 的取值S 通道 0~256 hist cv2.calcHist([hsv], [0, 1], None, [h_bins, s_bins], [0, 180, 0, 256]) # 归一化成概率分布消除图片尺寸和分辨率带来的量级差异 hist cv2.normalize(hist, hist).flatten() # 颜色矩每个通道的均值和标准差共 6 维 moments [] for i in range(3): ch hsv[:, :, i].astype(np.float32) / 255.0 moments.extend([ch.mean(), ch.std()]) return np.concatenate([hist, np.array(moments, dtypenp.float32)])h_bins16, s_bins8一共 128 维直方图加上 6 维颜色矩共 134 维。为什么 H 分 16 份而 S 只分 8 份H 是区分苹果红、香蕉黄、梨子绿的主要维度值得更细的划分S 是饱和度受光照影响大粗一点更稳。cv2.normalize之后每一维落在 0~1 之间这步不做的话 SVM 会对大尺度维度过度敏感后面避坑那章专门讲这个。颜色矩的 6 维是补一条防线直方图反映的是颜色分布颜色矩反映的是整体色调和离散程度两者互补。尤其当某个水果颜色跨度大比如苹果有红苹果和青苹果颜色矩能帮上忙。3.3 形状特征轮廓、Hu 矩与几何比例形状特征的做法是先分割出水果区域再算轮廓上的统计量。分割这一步决定了形状特征靠谱不靠谱。def extract_shape_features(bgr_img: np.ndarray): 在 HSV 的 S 通道上做阈值分割取最大轮廓算 Hu 矩和几何比 hsv cv2.cvtColor(bgr_img, cv2.COLOR_BGR2HSV) # 饱和度高的区域通常就是水果本体白底、灰背景饱和度低 _, mask cv2.threshold(hsv[:, :, 1], 60, 255, cv2.THRESH_BINARY) kernel np.ones((5, 5), np.uint8) # 先闭运算填小孔再开运算去噪点 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations1) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: # 分割失败时返回零向量不能直接抛异常否则批量推理会中断 return np.zeros(10, dtypenp.float32) cnt max(contours, keycv2.contourArea) area cv2.contourArea(cnt) # Hu 矩前 7 维具有平移、旋转、缩放不变性是形状特征的主力 hu cv2.HuMoments(cv2.moments(cnt)).flatten() # 取对数把数量级拉到同一个尺度Hu 矩的原始值可能小到 1e-20 hu -np.sign(hu) * np.log10(np.abs(hu) 1e-10) x, y, w, h cv2.boundingRect(cnt) aspect w / float(h 1e-6) # 长宽比香蕉远大于 1 extent area / float(w * h 1e-6) # 填充率圆乎乎的苹果接近 0.78 perimeter cv2.arcLength(cnt, True) circularity 4 * np.pi * area / (perimeter ** 2 1e-6) # 圆度 return np.concatenate([hu, np.array([aspect, extent, circularity], dtypenp.float32)])threshold(..., 60, ...)里的 60 是经验阈值白底拍的水果基本能把主体分出来。MORPH_CLOSE迭代 2 次是为了填水果表面的高光小孔MORPH_OPEN迭代 1 次去掉背景噪点顺序不能换。Hu 矩取对数这一步很多人会漏——原始 Hu 矩的数量级差 20 个数量级不取对数 SVM 的缩放几乎失效。aspect、extent、circularity三个几何比是 Hu 矩的补充Hu 矩对香蕉和梨子这类形状差别不极端的类别区分度有限长宽比就很直接香蕉通常在 2.5 以上梨子在 1.1~1.4苹果接近 1。3.4 特征拼接与一致性校验把颜色和形状拼起来就是最终喂给分类器的向量def extract_all(path: str) - np.ndarray: img load_and_preprocess(path) color extract_color_features(img) # 134 维 shape extract_shape_features(img) # 10 维 return np.concatenate([color, shape]).astype(np.float32)总维度 144 维比原始像素 128×128×349152 维小两个数量级。这也正是传统 CV 在水果识别这种场景里还能站得住脚的原因维度低、训练快、单张推理毫秒级。但一定要注意load_and_preprocess必须在训练和推理时都被调用而且TARGET_SIZE是共享的常量。如果推理端自己写了一遍 resize 且尺寸不一致特征分布就会漂移表现出来就是训练集上 0.95、实际用起来乱判排查起来相当玄学。我一般会把这三段代码放在一个features.py里训练脚本和推理脚本都from features import extract_all只有一份入口。4. 分类器训练、评估与批量推理特征向量有了模型选择反而是这几章里最省事的环节。144 维、三分类、样本量几百到几千SVM 是性价比最高的选择比 kNN 稳定比随机森林好调。这一章把训练、评估、推理三段串起来三段都能直接抄。4.1 构建特征矩阵与标签编码先用os.walk把三个划分分别展开成X, y两个数组再把标签转成整数。这里我用的是自己手写的编码不依赖LabelEncoder为的是保证训练和推理时类别的顺序一致。from pathlib import Path import numpy as np from features import extract_all CLASSES [apple, banana, pear] # 顺序固定训练/推理共用 CLASS_TO_IDX {c: i for i, c in enumerate(CLASSES)} def load_split(split_dir: Path): X, y [], [] for cls in CLASSES: cls_dir split_dir / cls if not cls_dir.is_dir(): continue for p in cls_dir.rglob(*): if p.suffix.lower() not in {.jpg, .jpeg, .png, .bmp}: continue try: feat extract_all(str(p)) except ValueError as e: print(f[跳过] {e}) continue X.append(feat) y.append(CLASS_TO_IDX[cls]) return np.array(X), np.array(y) X_train, y_train load_split(Path(./fruit_dataset/train)) X_val, y_val load_split(Path(./fruit_dataset/val)) X_test, y_test load_split(Path(./fruit_dataset/test)) print(train:, X_train.shape, val:, X_val.shape, test:, X_test.shape)CLASSES的顺序必须贯穿训练、推理、报告三个阶段写在模块顶部常量里最稳。单张图片读取失败时continue而不是raise这是为了在批量处理几千张样本时容忍偶尔的坏图但会打印日志方便回头清理。print出来的 shape 要重点看三个划分的维度是否一致如果不一致说明特征函数里有维度依赖的分支比如分割完全失败的图返回零向量和其他图维度一样这种情况 OK但如果哪段代码在某个分支里返回了不同长度就是 bug要立刻查。4.2 SVM 训练与网格搜索训练用 sklearn 的Pipeline把标准化和 SVM 串起来标准化放在 Pipeline 里能保证推理时自动复用同样的均值方差。from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV import joblib pipe Pipeline([ (scaler, StandardScaler()), # 标准化每个特征维度减均值除方差 (svc, SVC(kernelrbf, probabilityTrue, class_weightbalanced)) ]) param_grid { svc__C: [0.1, 1, 10, 100], # 惩罚系数越大越容易过拟合 svc__gamma: [scale, 0.01, 0.1], # RBF 核的宽度参数 } grid GridSearchCV(pipe, param_grid, cv5, scoringf1_macro, n_jobs-1, verbose1) grid.fit(X_train, y_train) print(最佳参数:, grid.best_params_) print(交叉验证 f1_macro:, grid.best_score_) # 只在验证集上评估一次避免污染测试集 val_score grid.score(X_val, y_val) print(验证集准确率:, val_score) # 保存整条 Pipeline推理时直接 load joblib.dump(grid.best_estimator_, fruit_svm.joblib)几个参数值得展开。kernelrbf是默认的合理选择144 维特征用线性核容易欠拟合用多项式核实测提升有限probabilityTrue是为了拿到predict_proba做置信度显示和拒识逻辑时需要代价是内部要跑一遍 Platt 校准训练时间会增加但水果识别这种规模完全无感class_weightbalanced处理前面提到的类别不均衡问题sklearn 会按类别频率反向加权。scoringf1_macro比accuracy更适合类别不均衡——三分类如果有一类样本特别少accuracy 会掩盖它的低召回。cv5在几百张样本量时够用再大收益递减。n_jobs-1用满所有核GridSearchCV 是 embarrassingly parallel 的典型场景。保存的是Pipeline整体而不是单独的 SVM这样推理时StandardScaler的均值方差一起被序列化不会出现训练时标准化了、推理时忘了标准化这个经典失误。4.3 评估混淆矩阵与分类报告验证集只评估一次是为了拿最佳参数测试集只在最后跑一次报最终数字。评估代码不要太花哨看两个东西足矣跟类别相关的召回以及容易混淆的对。from sklearn.metrics import classification_report, confusion_matrix import numpy as np y_pred grid.best_estimator_.predict(X_test) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, target_namesCLASSES))confusion_matrix是一个 3×3 的矩阵行是真实类别、列是预测类别对角线是分类正确的数量。看非对角线的数字如果 pear 行、apple 列的数字明显高于其他说明梨子被大量判成苹果这时候要么检查训练集里梨子样本是不是拍得太像苹果都是绿色圆乎乎要么在特征上给形状加权重。classification_report会输出 precision、recall、f1-score 三列重点关注 recall——召回低的类别才是真正出问题的类别。from sklearn.metrics import classification_report, confusion_matrix import numpy as np y_pred grid.best_estimator_.predict(X_test) # confusion_matrix 行真实列预测对角线是正确数 cm confusion_matrix(y_test, y_pred) print(混淆矩阵:\n, cm) print(classification_report(y_test, y_pred, target_namesCLASSES))classification_report的target_names要和CLASSES完全对应否则打印出来的行对不上。指标里 precision 看的是判成梨子的里面有多少真的是梨子recall 看的是真梨子里有多少被找出来。水果识别的实际使用场景更看重 recall——宁可多判错几个也别漏掉半个。所以如果 apple 的 recall 只有 0.6哪怕整体 accuracy 0.85 也不能发布。4.4 批量推理脚本和单张预测推理阶段最容易被忽略的一点是要复用训练时的预处理和标准化。整条链路封装成两个函数一个处理单张一个遍历目录输出 CSV方便后续人工复核。import argparse import csv import numpy as np import joblib from pathlib import Path from features import extract_all CLASSES [apple, banana, pear] def predict_single(path: str, model) - tuple: feat extract_all(path).reshape(1, -1) proba model.predict_proba(feat)[0] idx int(np.argmax(proba)) return CLASSES[idx], float(proba[idx]) def predict_dir(root: str, model, out_csv: str predictions.csv): rows [] root_p Path(root) for p in root_p.rglob(*): if p.suffix.lower() not in {.jpg, .jpeg, .png, .bmp}: continue try: label, score predict_single(str(p), model) # 置信度低于 0.6 的样本标记出来人工复核 flag low_conf if score 0.6 else rows.append((str(p), label, f{score:.4f}, flag)) except Exception as e: rows.append((str(p), ERROR, 0, str(e))) with open(out_csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([path, label, score, flag]) writer.writerows(rows) return rows if __name__ __main__: ap argparse.ArgumentParser() ap.add_argument(--model, defaultfruit_svm.joblib) ap.add_argument(--input, requiredTrue, help单张图路径或目录) args ap.parse_args() model joblib.load(args.model) p Path(args.input) if p.is_dir(): result predict_dir(args.input, model) print(f完成 {len(result)} 张结果写入 predictions.csv) else: label, score predict_single(args.input, model) print(f{args.input} - {label} ({score:.4f}))predict_proba返回一个三元素的数组对应CLASSES里三类的概率取argmax就是预测类别。置信度低于 0.6 的样本在 CSV 里加了low_conf标记人工扫一遍判错的样本比直接盲目扩样本高效得多。输入既支持单张也支持目录单张适合调试目录适合清洗完的整批数据跑评估。注意extract_all内部已经做了 resize 和去噪这个函数在训练和推理时被共享正是避免尺寸不一致问题的关键。5. 避坑与常见问题排查五类高频翻车现场同一套代码有人半天跑通、有人卡三天。这一章把我在水果识别类项目里反复遇到的五类问题列出来每条按现象 → 原因 → 解决给。这些坑基本覆盖了新手从代码能跑到结果能用之间的全部距离。5.1 cv2.imread 返回 None训练时报 NoneType 没有 shape现象训练脚本跑到一半抛AttributeError: NoneType object has no attribute shape或打印图片时一片黑。原因cv2.imread在两种情况下返回None却不抛异常——文件路径里有中文或 JPEG 文件头损坏。前者在 Windows 上尤其常见样本下载下来时文件原名就是中文后者是抓取或传输过程中截断的图片。解决统一用np.fromfile cv2.imdecode读入见 3.1 的load_and_preprocess。另外在load_split里用try/except ValueError跳过坏图并打印路径回头用scan_image_sizes全量扫描一遍把坏图清出去。这一步不做后面每次改特征都得重新跑一次才发现坏图在哪非常费时间。5.2 类别不均衡导致所有预测压在同一个类现象验证集整体准确率 0.85 看着还行可classification_report里 apple 的 recall 只有 0.32混淆矩阵几乎全部压在 banana 列。原因训练集里 banana 占了七成以上。SVM 默认对每类一视同仁样本多的类别在损失函数里权重大模型会倾向于大多数情况都判成多数类。解决SVC里加class_weightbalancedsklearn 会自动按n_samples / (n_classes * n_samples_per_class)反比加权。如果仍然不够就在load_split里做按类重采样——同一类别随机复制到跟最多类差不多的数量或对多数类做随机欠采样。两种做法都不改变原始样本文件只在训练入口处处理。5.3 直方图没归一化SVM 怎么调参数都没反应现象C从 0.1 调到 100准确率来回在三四个百分点里晃换线性核、多项式核提升都很小看起来模型完全没在学。原因H 通道的原始直方图 bin 值可能到 1000 以上而 Hu 矩取对数后在 10 附近StandardScaler理论上能抹平量级差但如果直方图里某个 bin 特别集中比如背景占了大半标准化之后这一维仍然是主导。结果就是分类边界基本由背景颜色决定前景信息被淹没。解决两级处理。第一cv2.calcHist之后立刻cv2.normalize让每个 bin 是概率这一步 3.2 的代码里已经做了。第二Hu 矩取对数之后再加StandardScaler这是 4.2 的 Pipeline 里做的。两级做齐之后94%、95% 这种准确率在三分类水果上是能稳定跑到的也才有可调的空间。5.4 验证集准确率接近 1.0换自己拍的照片全错现象训练/验证/测试三个划分上都能到 0.98但拿手机随手拍一张梨子扔进去输出 banana置信度还挺高。原因样本是从同一批原始图里切出来的划分时按文件名随机切导致同一张图或它的轻微变体裁剪、翻转、加噪同时出现在 train 和 test。测试集根本没见过新场景自然也是不好的结果。解决划分要按原图分组不是按文件分组。同一张原图派生出的所有变体必须落在同一个划分里。代码层面可以用GroupShuffleSplit把原图 ID作为分组键也可以在 2.3 里用 dHash 找重复对手工把重复的删到一侧。离线增强的话建议分完之后再增强——先划分、后增强顺序反过来就白划分了。5.5 分割阈值换背景就失效mask 直接全黑现象原样本在白底上正常识别把水果放到木桌上重新拍验证集表现断崖式下跌可视化 mask 几乎为 0。原因3.3 里threshold(hsv[:, :, 1], 60, ...)的 60 是针对白底的固定阈值。木桌的饱和度可能也超过 60导致背景和前景混在一起或者反光条件下水果的 S 通道掉到 40 以下直接被判成背景。解决不要死磕单一阈值。第一选择是让形状特征少依赖分割——比如把轮廓改成只依赖 Hu 矩本身对分割质量不敏感并降低几何比权重。第二选择是把固定阈值换成 OTSU 自适应阈值cv2.threshold(..., 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU)让每张图自己找分割点。第三选择是干脆放弃分割只用颜色直方图加轻量的纹理特征LBP牺牲一点形状信息换鲁棒性。这三条在二次开发里都值得试怎么选看应用场景里背景复杂度。6. 二次开发的改造入口从三分类扩到 N 分类跑通之后二次开发的第一诉求通常是扩类别——加橙子、加桃子、加猕猴桃。这一步的最小改动恰恰能验证这份样本的数据结构设计好不好。如果加一个类只需要在train/val/test各建一个文件夹、在CLASSES里加一行那设计是对的如果还要改好几处循环、好几处判断说明代码里硬编码了类数。6.1 扩类别改三处就够第一步建目录。第二步改CLASSES [apple, banana, pear, orange]。第三步删掉训练脚本里所有写死3的地方比如range(3)全改成len(CLASSES)。改完重新训练即可StandardScaler和 SVM 都会自动适配新维度。有一个小细节新类别加进来会改变CLASSES的索引旧模型直接作废必须重训——别指望拿旧模型.joblib强行 predict 出四类。6.2 换特征或换模型投资回报比排序在 Orchards 类场景里特征工程的投资回报比按这个顺序递减优先级改造预期收益成本1颜色直方图加 LBP 纹理中高能区分表皮纹理接近的类别低2HOG 特征替代形状特征中对轮廓敏感度提升中3换 LightGBM 或随机森林中对维度灾难不敏感低4接入 MobileNetV3 的 embedding 做特征高高5端到端 CNN 微调最高但样本量要够最高前三项改动小、验证快建议先做。第四项把分类器当传统 CV 的最后一步用预训练网络的倒数第二层当特征提取器是比较稳的过渡方案。第五项直接上 CNN 的话每类至少 1000 张图才不容易过拟合样本规模不到这个量级就得先做数据增强旋转、色彩抖动、随机裁切且增强必须在划分之后做。6.3 包一层接口从脚本到服务批量推理脚本用起来不难但每次手工跑命令不现实。最快的封装是用 FastAPI 开一个上传接口from fastapi import FastAPI, UploadFile, File import cv2, numpy as np, joblib app FastAPI() model joblib.load(fruit_svm.joblib) CLASSES [apple, banana, pear] app.post(/predict) async def predict(file: UploadFile File(...)): buf np.frombuffer(await file.read(), dtypenp.uint8) img cv2.imdecode(buf, cv2.IMREAD_COLOR) if img is None: return {error: 无法解码图片} # 保存到临时目录走统一的 extract_all避免推理预处理和训练不一致 tmp /tmp/upload.jpg cv2.imwrite(tmp, img) feat extract_all(tmp).reshape(1, -1) proba model.predict_proba(feat)[0] idx int(np.argmax(proba)) return {label: CLASSES[idx], confidence: float(proba[idx])}注意这里仍然调extract_all而不是自己再写一段 resize——这是整条链路里最容易被第二次开发破坏的约定。接口返回里带置信度前端可以据此决定要不要弹这张图不太确定。用户拿苹果和梨子拍反背景的问题从第一次跑通到真能用来来回回改的是特征和分割不是模型。我现在的习惯是每换一版特征函数先在一个固定的 30 张刁钻图集合上跑一遍单张预测确认苹果、香蕉、梨子三类各 10 张的置信度分布没有异常再去跑全量评估。这个小集合是留给自己的后悔药帮我在改了特征反而更差的情况里少走不少弯路。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑