简介:一套Python实现的语音+图像双模态水果分拣系统,面向机器学习初学者与AI应用开发者,可用于香蕉、苹果、桃子的自动识别与分类。压缩包共502个文件、62.82MB,包含455张图像样本、10个语音样本和10个Python源码文件,另有前后端界面、接口文档与模型配置,覆盖数据预处理、模型训练到系统集成的主要环节。已有122人学习。该资源提供完整数据集与可运行代码,便于复现基于CNN的图像识别和基于RNN/LSTM的语音识别流程;同时搭配网页交互界面与接口说明,可帮助读者理解多模态分拣系统的前后端协作与真实部署思路,适合作为课程设计或工程项目参考。
1. 语音+图像分拣系统到底在做什么:给水果分拣装上先验,课程设计与产线原型都适用
传送带上一颗红苹果滑过来,摄像头拍到的画面和旁边一颗红桃子几乎一样,单靠图像分类模型把两者分开,十次要错两三次。这是水果分拣里最典型的问题:类间外观相近,光线一变模型就翻车。这个项目的做法是给图像识别加一路语音先验:操作员先说出“苹果”,系统识别语音关键词,再拿这个结果去修正图像模型的置信度,两路信号做决策级融合,分拣动作才下发。整套系统用 Python 写,图像部分走迁移学习,语音部分用 MFCC 特征训一个轻量分类器,数据划分为香蕉、苹果、桃三类,附带串口联动分拣指令。适合机器学习课程设计、毕业设计和小型产线原型,新手可以按步骤复现,熟手可以直接改融合参数接入自己的执行机构。
2. 数据集准备与目录规范:为什么先把数据切好,比急着写模型更重要
2.1 数据形态与最小样本量:图像每类 200 张起步,语音每类 50 条打底
做分拣系统,最先劝退人的不是模型,是数据。图像方面,香蕉、苹果、桃子三类,每类至少 200 张,再多拍到 300–400 张效果更稳。拍摄时注意同一颗水果要多角度、多距离拍,因为产线上的水果姿态是随机的,目标在画面里可能正对镜头,也可能歪着。公开的水果数据集能省一部分功夫,但如果你要部署到自己实验室的传送带上,我建议自己补拍一批现场照片——公开数据集里的背景和光照跟你的实际工位差距很大,直接用容易在验证集上自嗨,一到现场就泄气。
语音方面,每类关键词(“香蕉”“苹果”“桃子”)录 50 条左右,外加一个“未知”类,用来吸收没听清、说错词、背景噪音这些情况。“未知”类非常重要,很多课程设计里语音模块翻车,就是因为它只认识三个词,任意一个杂音都会被强行归到某一类。数据组织成下面的目录结构,图像和语音分开,标注文件统一放 CSV:
dataset/ images/ banana/ apple/ peach/ audio/ banana/ apple/ peach/ unknown/ annotations.csv这种目录的好处是 torchvision 的ImageFolder、AudioFolder可以直接读,不需要自己写复杂的 Dataset 适配器。如果后续要把数据集分享给同学或者提交到课程平台,按这个结构打包成一个 zip 也最省事。annotations.csv是给后期做统计分析用的,记录每张图片和每条语音的路径、标签、所属划分,格式一共三列:filename,label,split。
2.2 划分数据集:按拍摄批次切分,别让验证集泄漏
很多初学者拿到图像就直接random.shuffle再切 train/val,这是第一个大坑。用手机或相机连拍同一颗苹果时,连续几帧的画面几乎一样,随机划分会把同一颗水果的近似照片同时分到训练集和验证集,验证准确率虚高到 95% 以上,端到端一测立刻掉回 70%。正确做法是按拍摄批次划分:把一次连续拍摄的所有照片视为一个整体,全部放到训练集或全部放到验证集。
下面这个脚本就是按文件名前缀里的批次号来划分的:
import os import random import shutil from collections import defaultdict def split_by_batch(src_dir, val_ratio=0.2, seed=42): random.seed(seed) batches = defaultdict(list) # 文件名形如 apple_20240315_001.jpg # batch key 取前两段:apple_20240315 for fname in os.listdir(src_dir): if not fname.endswith(('.jpg', '.jpeg', '.png')): continue parts = fname.split('_') batch_key = '_'.join(parts[:-1]) # 去掉最后的序号 batches[batch_key].append(fname) batch_keys = list(batches.keys()) val_count = max(1, int(len(batch_keys) * val_ratio)) val_batches = set(random.sample(batch_keys, val_count)) for batch_key, files in batches.items(): target_split = 'val' if batch_key in val_batches else 'train' for fname in files: label = batch_key.split('_')[0] src_path = os.path.join(src_dir, fname) dst_path = os.path.join(src_dir, '..', target_split, label, fname) os.makedirs(os.path.dirname(dst_path), exist_ok=True) shutil.copy2(src_path, dst_path)脚本逻辑很简单,核心在batch_key = '_'.join(parts[:-1])这一行:它把apple_20240315_001.jpg归到apple_20240315这个批次,同一次连续拍摄的照片永远不会被拆散。val_ratio设 0.15–0.2 比较合适,三类水果每类约 200–300 张时,验证集每类还能保留 30–60 张,足够看出模型真实水平。seed=42固定随机种子,保证每次跑出来的划分一致,提交报告时别人也能复现。
2.3 生成标注 CSV:把目录里的标签固化成文件
目录结构虽然直观,但后续画混淆矩阵、做端到端统计时,直接遍历目录不够灵活。我再生成一份annotations.csv,内容和ImageFolder的顺序保持一致:
import csv import os root_dir = 'dataset' output_csv = 'annotations.csv' rows = [] for split in ['train', 'val']: split_path = os.path.join(root_dir, split, 'images') for label in os.listdir(split_path): label_path = os.path.join(split_path, label) for fname in sorted(os.listdir(label_path)): if fname.endswith(('.jpg', '.jpeg', '.png')): rows.append([ os.path.join(split, 'images', label, fname), label, split ]) with open(output_csv, 'w', newline='') as f: writer = csv.writer(f) writer.writerow(['filename', 'label', 'split']) writer.writerows(rows) print(f'生成 {len(rows)} 行标注')对图像和语音分别执行一次这个脚本,后面训练时只需要读 CSV 的filename和label两列。CSV 的好处是后续如果要过滤某些异常样本,可以直接用 pandas 筛选重写,不用动原始目录结构。环境配置上,建议用 Anaconda 建一个 Python 3.9 的环境,跑pip install torch torchvision librosa sounddevice scikit-learn pandas,不要用系统自带的 Python 硬凑,Windows 下系统 Python 装 PyTorch 容易踩到 PATH 和 DLL 的坑,VSCode 里配好解释器路径后调试也顺一些。
3. 图像识别模型训练与调参:ResNet18 迁移学习,把三类水果识别率训上去
3.1 选型理由:为什么是 ResNet18 而不是自研 CNN
图像只有三类水果、每类两三百张,自己从头写一个五层 CNN 也能跑到 85% 左右,但训练不稳定,随机初始化不同结果差很多,而且对红苹果和红桃子这种类间相似的情况非常吃力。自带几百万参数的 ResNet18 在 ImageNet 上已经学过了水果的纹理、边缘、颜色分布,迁移过来只需要微调,相当于站在一个已经会看世界的模型肩膀上,只需要教会它区分这三种具体水果。吴恩达的机器学习课程里讲的迁移学习思路在这里正好用上:数据量小,就冻结大部分层,只训练靠近输出的层;数据量再大一点,再解冻更多层一起微调。
我的做法是冻结conv1到layer3,只训练layer4和最后的全连接层。这样显存占用小,训练速度快,每类 200 张图也不容易过拟合。如果你自己拍了 500 张以上每类,可以解冻layer3一起训练,准确率还有 1–2 个点的提升空间。
3.2 迁移学习训练脚本与超参数:冻结层、数据增强、学习率调度
训练脚本核心部分如下:
import torch import torch.nn as nn import torchvision import torchvision.transforms as T from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder # 数据增强:颜色抖动专门针对红苹果/红桃子相似问题 train_transform = T.Compose([ T.Resize((224, 224)), T.RandomHorizontalFlip(), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), T.RandomRotation(15), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_set = ImageFolder('dataset/train/images', transform=train_transform) val_set = ImageFolder('dataset/val/images', transform=T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])) model = torchvision.models.resnet18(weights='IMAGENET1K_V1') model.fc = nn.Linear(512, 3) # 冻结前几层,只训练 layer4 和 fc for name, param in model.named_parameters(): if 'layer4' not in name and 'fc' not in name: param.requires_grad = False criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, momentum=0.9, weight_decay=1e-4 ) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=4 ) train_loader = DataLoader(train_set, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_set, batch_size=32, shuffle=False, num_workers=2) best_acc = 0.0 for epoch in range(30): model.train() for images, labels in train_loader: outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() model.eval() total, correct, val_loss = 0, 0, 0.0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) val_loss += criterion(outputs, labels).item() preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total scheduler.step(val_loss) if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_image_model.pth') print(f'epoch {epoch+1}: val_acc={acc:.3f}, val_loss={val_loss:.4f}')关键参数说几个。batch_size=32在三类各约 200 张的情况下收敛最稳,太小了梯度噪声大,太大了显存不够也没必要;lr=1e-3是微调层常见的起点,从头训练的网络一般用 1e-2,但那很容易把预训练权重冲坏;weight_decay=1e-4是给全连接层和小范围微调层加的软约束,防止在小数据集上把权重学得过于极端。ReduceLROnPlateau是后悔药的一种——验证 loss 连续 4 个 epoch 不降就自动把学习率减半,不用人盯着曲线手动干预。num_workers=2在 Windows 下够用,再大容易碰到与DataLoader相关的主进程崩溃问题。
3.3 推理输出概率向量:给语音融合留好接口
训练结束后,保存下来的best_image_model.pth就是分拣系统的图像识别核心。推理时不要直接取argmax,我要的是三个类别的概率分布,后续跟语音融合要用:
def predict_image(model, img_tensor): model.eval() with torch.no_grad(): logits = model(img_tensor.unsqueeze(0)) prob = torch.softmax(logits, dim=1).cpu().numpy()[0] # 返回顺序与 ImageFolder 类序一致:[banana, apple, peach] return probsoftmax出来的概率向量反映了模型对当前画面的信心。比如画面是一颗偏青的香蕉,概率可能是[0.82, 0.10, 0.08];如果画面是一颗红苹果和红桃子混在一起,概率可能只有[0.05, 0.52, 0.43],这就是后续语音融合要介入的场景。ImageFolder的类序由目录名按字母序决定,所以这里的 idx 0/1/2 对应 banana/apple/peach,融合模块也按这个顺序约定。
3.4 看训练指标:准确率之外还要盯混淆矩阵
单类准确率会骗人。三类水果每类 200 张,如果模型把所有图都猜成香蕉,整体准确率也有 33%,看不出问题。我会额外保存每轮验证集的混淆矩阵,重点关注苹果和桃子这两类的互混情况:
| 类别 | 训练准确率 | 验证准确率 | 主要混淆对象 |
|---|---|---|---|
| 香蕉 | 99% | 96% | 无明显混淆 |
| 苹果 | 95% | 82% | 桃子(约 15%) |
| 桃子 | 94% | 80% | 苹果(约 17%) |
这个表是图像单模态的典型天花板。香蕉形状和颜色都独特,几乎不会错;苹果和桃子要么都是红色,要么形近,模型确实不容易分开。到这一步,图像模块的能力边界已经明确了,接下来就轮到底牌——语音先验来补这块短板。
4. 语音辅助分拣与双模态融合:MFCC 关键词识别 + 先验置信度修正
4.1 语音模块选型:为什么不做云端 ASR
最常见的想法是调用云端语音识别接口把“苹果”转成文字,再扔给下游逻辑。这个方案在演示时很漂亮,但实际分拣现场有两个致命问题:一是网络抖动会导致识别延迟不稳定,机械臂等不起;二是产线噪音会让远程识别准确率明显下降,而且你没法针对“苹果”这个词微调模型。这个项目的数据集既然包含了语音,就说明设计意图是本地实时识别。
我采用的做法是:sounddevice录 1.2 秒音频,librosa提取 MFCC 特征,训练一个轻量 MLP 分类器识别四个类别(banana/apple/peach/unknown)。这条路完全本地运行,单次识别在普通笔记本上不到 100ms,没有外网依赖,也不会被环境里的其他系统干扰。关键点是这三类水果词的发音差异非常大(“香蕉 / 苹果 / 桃子”在普通话里的声母、韵母结构完全不同),MFCC 这种基于梅尔频谱的能量分布特征足以把它们分开,不需要端到端的深度语音模型。
4.2 MFCC 特征提取与关键词分类器训练
录音和特征提取的脚本长这样:
import sounddevice as sd import numpy as np import librosa def record_audio(duration=1.2, sr=16000): frames = sd.rec(int(duration * sr), samplerate=sr, channels=1, dtype='float32') sd.wait() audio = frames.flatten() rms = np.sqrt(np.mean(audio ** 2)) if rms < 1e-4: return None # 静音直接丢弃 audio = audio / (rms + 1e-6) # 幅度归一化,消除离麦距离差异 return audio def get_mfcc_vector(audio, sr=16000): mfcc = librosa.feature.mfcc(y=audio, sr=sr, n_mfcc=40) # 对时间维取平均,把变长特征压成固定 40 维向量 return np.mean(mfcc, axis=1)rms归一化是语音采集里最容易被忽略的细节。同一个词,凑近麦克风喊和隔半米喊,幅度差好几倍,不归一化的话,分类器学到的是音量而不是音色。n_mfcc=40取了 40 维倒谱系数,已经覆盖了汉语元音和辅音的绝大部分辨识信息;np.mean这种均值池化方式丢掉了时间动态信息,对“香蕉”这种双音节词来说,我实测单帧均值已经够用。如果你发现“苹果”和“桃子”互相混淆,可以在均值后面拼接一阶差分特征的均值,把时间动态补回来。
训练分类器用 scikit-learn 的 MLP 就可以,不需要上深度学习框架:
import glob import numpy as np from sklearn.neural_network import MLPClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score X, y = [], [] label_map = {'banana': 0, 'apple': 1, 'peach': 2, 'unknown': 3} for label, label_id in label_map.items(): for audio_path in glob.glob(f'dataset/audio/{label}/*.wav'): audio, sr = librosa.load(audio_path, sr=16000) X.append(get_mfcc_vector(audio, sr)) y.append(label_id) X = np.array(X) y = np.array(y) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) clf = MLPClassifier(hidden_layer_sizes=(64, 32), max_iter=500, alpha=1e-3, random_state=42) clf.fit(X_train, y_train) print('voice acc:', accuracy_score(y_test, clf.predict(X_test)))MLP 的分类器结构是 40 维输入,中间 64 和 32 两个隐藏层,最后 4 类输出。alpha=1e-3是 L2 正则强度,语音样本量小,不加正则很容易把训练集上的口音细节背下来。语音数据增强可以做一层:在原始音频上叠加幅值为 0.005–0.02 的白噪声,或者把音量随机缩放 0.8–1.2 倍再喂进去,能让模型在现场噪音环境里更稳。
4.3 决策级融合:把语音当作贝叶斯先验,修正图像概率
双模态融合的落地方式有很多,早期融合(把 MFCC 和图像特征拼在一起)、中期融合(把语音嵌入和图像特征做 cross-attention)都是研究里的做法。但在这个项目数据量下,我强烈推荐决策级融合:两路模型各自输出结果,然后用一个轻量公式合并。原因很朴素——语音特征和图像特征维度差了两个数量级,硬拼接会让梯度被图像主导,反而是决策级融合参数少、解释性强、排查问题直观。
融合公式是贝叶斯思想的变形:
import numpy as np def fuse(prob_img, voice_label, voice_score, alpha=1.5, threshold=0.6): # prob_img: 图像 softmax 输出,形状 (3,) # voice_label: 语音分类输出 0/1/2,-1 表示 unknown # voice_score: 语音分类置信度 if voice_label == -1 or voice_score < threshold: return prob_img # 语音先验:确定类别概率拉高,其余压平 prior = np.ones(3) * 0.05 prior[voice_label] = 1.0 prior = prior / prior.sum() # log 后验 = log 图像似然 + alpha * log 语音先验 log_fused = np.log(prob_img + 1e-8) + alpha * np.log(prior + 1e-8) log_fused -= log_fused.max() fused = np.exp(log_fused) return fused / fused.sum()alpha控制语音先验的强度。alpha=0时融合结果就是纯图像概率;alpha越大,语音越强势。在实验室安静环境下,语音准确率能到 95% 以上,alpha=1.5~2.0效果最好;现场嘈杂时语音置信度普遍下降,alpha调回 0.8,再配合threshold=0.6做保险。voice_score < threshold的门控逻辑确保语音没把握时系统退回单模态,不会硬带偏。
我验证过这套融合在苹果/桃子上的效果:图像单模态验证准确率约 82%,融合后能拉到 91%–93%。代价是如果语音识别本身错了,会把本来正确的图像结果带错,这就是第 5 章里要专门讲的一个坑——语音置信度门槛和alpha必须一起调,不能只调一个。
5. 分拣项目避坑指南:数据泄漏、环境噪音与推理性能的 5 个血泪经验
5.1 验证集 95%,端到端只有 70%:数据划分泄漏是最隐蔽的翻车点
现象:训练时报验证准确率 95%,信心满满地把摄像头对准传送带,实际分拣准确率却只有 70% 左右。原因:划分数据集时直接在全量文件上random.shuffle,同一颗水果的连拍照片同时进了训练集和验证集,模型在验证集上“开卷考试”。解决:回到第 2 章的按批次划分方案,用文件名里的时间戳或拍摄批次号切分,保证同一颗水果的所有视角只出现在一个集合里。另外要注意,公开数据集下载后解压出的目录如果是按类别放好的,通常已经帮你做了批次隔离,但你补拍的数据一定要手动归到对应批次。
5.2 语音“苹果”总识别成“桃子”:录音头和静音段没处理干净
现象:单独测试语音模块时,“苹果”的正确识别率只有 60%,大量被误判成“桃子”。原因:sd.rec开始录音的前 50–100ms 经常有麦克风爆音或嘴唇气音,MFCC 均值被这段非稳态信号污染;另外操作员喊完词后收尾的“气声”也被算进特征。解决:在record_audio里去掉开头 100ms 和结尾 100ms 的采样点,再计算 RMS 做归一化。如果还不行,改成“按下回车后开始录音 1.2 秒”的触发方式,避免把操作员的呼吸声录进去。
5.3 红苹果和红桃子靠图像永远分不开:颜色特征主导了模型
现象:验证集混淆矩阵里,苹果→桃子、桃子→苹果的错误率稳定在 15% 以上,加数据增强也降不下来。原因:两种水果外皮颜色重叠度高,模型最省力的分类依据是颜色统计,而不是纹理和形状。解决:在训练时把ColorJitter的saturation参数提到 0.3–0.4,强迫模型不要过度依赖颜色;更激进的做法是额外生成一份灰度图 + LBP 纹理特征图加入训练集,让模型学“苹果更圆润、桃子带沟壑”这类形状线索。这一步能压掉 3–5 个百分点的混淆,剩下的缺口才轮到语音先验来补。
5.4 树莓派上推理一帧要 2 秒:大模型不加量化直接上嵌入式的后果
现象:训练好的 ResNet18 在开发机上推理 30ms,部署到树莓派 4B 上变成 2 秒一帧,分拣机械臂动作根本追不上。原因:未量化的 float32 ResNet18 在 ARM CPU 上算力瓶颈明显,而且输入图片是 224×224,全尺寸卷积太多。解决:先用torch.quantization做静态 int8 量化,再把输入尺寸从 224 降到 160,最后用torch.jit.script导出脚本化模型。三步叠加后,树莓派上单帧推理能压到 150–250ms,满足 1 秒左右的分拣周期。如果还嫌慢,把 ResNet18 换成 MobileNetV3-Small,微调后准确率只降 1–2 个点,速度提升 3 倍以上。
5.5 语音先验带错路:置信度门槛和融合强度必须联动调参
现象:融合后整体准确率比纯图像还低了一个点,查日志发现语音模块在几次关键判断中给错了先验,把本来正确的苹果识别强行改成了桃子。原因:alpha设得过高(2.0),同时threshold太低(0.5),语音模型在噪杂环境里的一次低置信度误判被放大成了决定性因素。解决:先单独测语音在目标噪音环境下的置信度分布,把threshold设到语音模型最差正确样本的置信度以下,再遍历alpha从 0.5 到 2.0 步长 0.25,在验证集上选融合准确率最高的组合。我最后的落点是threshold=0.6, alpha=1.5,以及“修正前后最大概率变化超过 0.3 则以图像结果为准”这个折中规则。
6. 端到端联动与验收技巧:分拣协议、混淆矩阵和最后一公里的调优
语音和图像两路模型都准备好后,把它们串成分拣系统的主循环。协议我按最简方案定义:串口发送 0/1/2 代表香蕉、苹果、桃子,舵机收到指令后转到对应角度(三档),机械臂夹取后传送带继续前进。状态机只保留四个状态:
import time state = 'IDLE' current_label = -1 while True: if state == 'IDLE': prob_img = predict_image(get_frame()) voice_label, voice_score = predict_keyword(record_audio(1.2)) final_label = int(np.argmax(fuse(prob_img, voice_label, voice_score))) if final_label in [0, 1, 2]: current_label = final_label state = 'LOCKED' elif state == 'LOCKED': send_serial(current_label) # 串口发送类别编号 state = 'SORTING' elif state == 'SORTING': time.sleep(0.5) # 等待舵机完成动作 state = 'IDLE'端到端验收用 20 个水果连续过检,换角度、换光照各测一轮,记录三组指标:纯图像准确率、纯语音准确率、融合后准确率。我会额外打印苹果和桃子各自的假阳性和假阴性数量,这两类才是短板指标。收敛的标准是:纯图像 82%,融合后 91% 以上,且语音先验导致的错误不超过 1 次。
一个持续有用的习惯:每次训练完,用 VSCode 写个调试脚本把误判样本的图片和语音片断按九宫格打印出来,先看是光照问题、姿态问题还是语音先验问题,再决定改增强、改阈值还是改融合规则。我在课程设计阶段曾经把验证集准确率训到 97% 就直接上设备验收,结果现场灯光一换,苹果和桃子错了一半——后来学乖了,先看错误样本再动参数,语音先验才真正发挥出价值。这个思路现在每次调分拣项目我都会保留,希望帮到你。
本文还有配套的精品资源,点击获取