简介:一套面向智能饮食分析方向的完整项目资源,基于图像识别与语义分割技术,可通过手机照片或上传的食材图片自动识别食材成分与部位,并结合营养数据库、用户健康信息为不同人群生成个性化食谱,适用于计算机视觉、营养健康及智能应用开发等场景。资源压缩包共114个文件,大小约37.07MB,内容涵盖源代码脚本、交互式实验流程、技术说明文档、数据配置与演示文件,类型涉及脚本、笔记、配置、图片、视频及附赠文档。目前已有17人学习浏览,适合具备一定编程基础的研究者、工程师或健康管理爱好者。包内提供多份实验笔记、模型数据文件与演示动画,覆盖从食材识别、精细分割到营养分析与食谱生成的完整流程,并附有部署相关配置,便于参考复现与二次开发。
1. 拍一张餐盘照片,系统能不能替你算出这顿饭该不该吃
这个标题真正要解决的不是“识别出盘子里是鸡胸肉和西兰花”,而是把“我吃了什么、吃了多少、营养是否超标”变成一次拍照就能回答的问题。食材分类告诉你盘子里有什么,语义分割告诉你每样食材在画面里占多大区域,两者叠加上营养数据库,才能算出这餐的蛋白质、碳水和脂肪大致是多少,再结合用户画像生成“少吃两口”或者“换成豆腐”这类具体建议。适合谁:想做膳食管理 App 后端引擎的开发者、拿边缘设备做食堂结算的硬件团队、以及想在 Web 端复现整套 CV + 营养学链路的个人开发者。
先给结论:这套系统的核心价值不在模型精度,而在于三个层(识别层、分割层、营养换算层)的串起来的设计,以及数据标注和类别体系怎么应对真实餐盘里的煎蛋、酱汁、混叠食材。模型选型上,分类我一般用 ImageNet 预训练的 ResNet50 做迁移学习,分割用轻量 U-Net 配合 MobileNetV3 编码器,整个系统在 CPU 上也能跑到可用帧率。
2. 食材识别层:不做目标检测,直接上多标签分类
2.1 为什么不是 YOLO,而是“分类 + 分割”双塔结构
很多第一次做这个系统的人会直接套 YOLO 检测,把每个食材画个框,然后按框裁剪再分类。这在“单人餐盘、食材相对独立”的场景里能跑通,但一旦遇到火锅、麻辣烫、盖浇饭这种食材彼此覆盖、酱汁粘连的状态,检测框会互相重叠,NMS 后只剩一堆零碎的框,后续分割也没有干净的掩码可用。
常见做法是让两个模型并行:一个全局多标签分类模型负责回答“这盘里有哪些食材类别”,一个语义分割模型负责回答“每类食材在像素级别上占据哪里”。这俩模型的输出在营养计算阶段做交叉验证——分类结果用来约束分割结果中不该出现的类别,分割的像素比例反过来修正分类置信度。这样连个 NMS 都不用写,两路输出天然互补。
分类头的设计我用的是“多标签 + 阈值判定”,而不是单标签 Softmax。一张餐盘里同时出现鸡蛋、西红柿、牛肉是常态,Softmax 会强制把概率之和归一化成 1,反而让模型在“到底更像鸡蛋还是更像豆腐”之间摇摆。多标签的做法是把最后一层全连接换成多个 Sigmoid,每个类别独立输出一个 0 到 1 的概率,然后设定置信度阈值决定这个类别是否出现。
2.2 用 ResNet50 做迁移学习:冻结 BatchNorm 是关键一步
代码可以直接跑通,但有两处细节经常让人翻车:一是冻结层怎么设,二是类别不均衡怎么处理。第一处先说冻结策略:
import torch import torch.nn as nn import timm model = timm.create_model('resnet50', pretrained=True, num_classes=0) # 冻结 stem 和前 3 个 stage,只微调 stage4 和分类头 for name, param in model.named_parameters(): if name.startswith('conv_stem') or name.startswith('stages.0') \ or name.startswith('stages.1') or name.startswith('stages.2'): param.requires_grad = False # 注意:BatchNorm 的 running_mean / running_var 必须保持冻结 # 即使上面设了 requires_grad=False,BN 的统计量仍然会更新 # 需要在训练循环里手动把 BN 设为 eval 模式 for name, module in model.named_modules(): if isinstance(module, nn.BatchNorm2d): module.eval() model.head = nn.Sequential( nn.Dropout(0.3), nn.Linear(model.num_features, 512), nn.ReLU(inplace=True), nn.Linear(512, num_classes) )这段代码的思路是:ImageNet 预训练模型的前几层学的是边缘、纹理、颜色斑块这种通用视觉特征,食材照片和 ImageNet 的自然图像分布差距不大,没必要全部重学。只微调 stage4 和分类头,参数量少了约 60%,训练速度快一倍,在很多食堂数据集上的效果反而更好,因为能抑制过拟合。
第二处关键点是 BatchNorm 的统计量。很多人冻结了参数但仍然让 BatchNorm 处于 train 模式,导致 running_mean 一直在被少量可训练层的新数据分布拉偏,验证集准确率一开始正常,训练几个 epoch 后突然掉 5 个点,还找不到原因。我和同行交流过,这个现象非常常见。解决办法就是上面代码里的module.eval()——这行在冻结层循环之后必须加上。
2.3 类别不均衡怎么治:焦点损失 + 采样器双管齐下
食材数据集天然不均衡。米饭、鸡蛋、青菜出现频率极高,而秋葵、牛油果、紫薯这类健康餐食材出现频率很低。如果不处理,模型会对高频类别过拟合,最终在真实用户拍照时频繁漏检低频食材。
class FocalLoss(nn.Module): def __init__(self, gamma=2.0, alpha=None): super().__init__() self.gamma = gamma self.alpha = alpha # 类别权重,长度 = num_classes def forward(self, logits, targets): bce_loss = nn.functional.binary_cross_entropy_with_logits( logits, targets, reduction='none' ) pt = torch.exp(-bce_loss) focal_loss = (1 - pt) ** self.gamma * bce_loss if self.alpha is not None: alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets) focal_loss = alpha_t * focal_loss return focal_loss.mean() # 采样器:每个 batch 保证低频类别至少出现 1 次 from torchsampler import ImbalancedDatasetSampler sampler = ImbalancedDatasetSampler(train_dataset, num_samples=5000)焦点损失的核心作用是降低“易分样本”的损失贡献。gamma 设 2.0 时,如果模型对某个样本已经很有把握(pt 接近 1),它的损失会被压到原来的四分之一以下,把训练重心逼到那些难分的样本上,比如颜色相近的豆腐和山药、蒸蛋和土豆泥。
alpha 数组的设定要结合统计,我一般先统计训练集各类别出现次数,取倒数归一化到 0.5 到 2.0 的区间,而不是直接用小众类别的频率当权重,不然训练初期震荡太大。采样器这边,num_samples设 5000 意味着每个 epoch 从数据集中按自定义权重采样 5000 张图,权重低的类别被抽到的概率提高,两个手段配合,低频类别的召回率能从 60% 拉到 85% 左右。
2.4 中间层特征可视化:识别模型不只看颜色,也看纹理
ResNet50 的末层特征偶尔会骗人:一个模型在验证集上准确率 95%,但真实拍照时把“蒸南瓜”识别成“胡萝卜”,因为两个类别颜色几乎一样。排查这类问题时要看中间层激活,而不是只看最终的混淆矩阵。
activation = {} def hook_fn(module, input, output): activation['value'] = output.detach() model.stages[3][-1].register_forward_hook(hook_fn) with torch.no_grad(): model(val_image.unsqueeze(0)) feat_map = activation['value'][0].mean(dim=0).cpu().numpy() # 可视化时叠加到原图上,观察模型关注的区域是不是食材本身如果激活图高亮区域是酱油渍、碗沿反光,而不是食材主体,说明模型学到了不该学的特征,这时候需要检查训练集的标注框是否包含太多背景,或者数据增强里随机裁剪的尺度太小,把食材切碎了。这个排错方法在整个系统里也很有价值——分割模型吃不准的区域也能用同一套 Hook 思路排查。
3. 语义分割层:用 U-Net 把餐盘里的每样食材“抠”出来
3.1 标签体系设计:像素级标注怎么定类别
语义分割最忌讳的是直接拿分类模型的类别清单去标像素。分类模型遇到“番茄炒蛋”可以同时输出“番茄”和“鸡蛋”,但分割模型的每个像素只能归属一个类,如果标注时把“番茄炒蛋”的番茄和鸡蛋分别标成两个类别,那模型要学的是“知道哪块是番茄、哪块是鸡蛋”,这个没问题;可如果像素级标注里出现了“番茄炒蛋”作为一个独立类别,同时又有“番茄”和“鸡蛋”,模型就会在三个类之间反复横跳,训练过程很难收敛。
我用过的可复用标签体系是:
| 标签类别 | 说明 | 标注示例 |
|---|---|---|
| background(背景) | 餐盘、桌面、非食材 | 白色瓷盘边框 |
| rice(米饭) | 所有米制品,包括炒饭中的饭粒 | 炒饭里的白色米粒 |
| meat_poultry(禽肉) | 鸡肉、鸭肉,带皮不带皮不分 | 烤鸡腿、鸡胸肉片 |
| red_meat(红肉) | 猪、牛、羊 | 牛肉片、排骨 |
| seafood(水产) | 鱼虾蟹贝 | 虾仁、鱼块 |
| egg(蛋类) | 鸡蛋、鸭蛋,形态不区分 | 煎蛋、蒸蛋、炒蛋碎 |
| vegetable_green(绿叶菜) | 以叶为主的蔬菜 | 西兰花、青菜、菠菜 |
| vegetable_root(根茎类) | 土豆、山药、胡萝卜等 | 南瓜块、土豆片 |
| fungus(菌菇) | 香菇、金针菇、木耳等 | 蘑菇片 |
| legume(豆制品) | 豆腐、豆干、腐竹 | 白豆腐块 |
这个粒度不是随便定的,它的依据是和营养数据库的字段对齐。营养数据库里植物油、盐、酱油这些调味料没有独立食材条目,分割阶段标了也白标,反而增加模型负担。倒是在实际项目中,我见过有人非要细分“猪里脊”和“五花肉”,结果标注成本翻倍、模型准确率骤降,营养计算的增益却很小——因为两者蛋白质和脂肪的差异在后续配方换算时会被食材重量误差覆盖掉。
3.2 从零训练轻量 U-Net:损失函数和模型结构
分割模型的输入我固定用 512×512,训练用 224×224 的随机裁剪做增强,输出还是 512×512 的预测。这里有个很容易踩的坑:训练时用 RandomResizedCrop 到 224,验证时直接 resize 到 224,这样模型边长不同,精度掉 2-3 个点。正确做法是训练用随机裁剪,推理时用 512×512 或 640×640 的滑窗,对边界做 1/4 重叠率融合。
import segmentation_models_pytorch as smp model = smp.Unet( encoder_name='timm-mobilenetv3_large_100', encoder_weights='imagenet', in_channels=3, classes=len(CLASSES), decoder_attention_type='scse', ) # 组合损失:Dice Loss 处理类别不平衡,CrossEntropy 保持收敛稳定 dice_loss = smp.losses.DiceLoss(mode='multiclass') ce_loss = nn.CrossEntropyLoss(ignore_index=255) for images, masks in train_loader: logits = model(images) loss = 0.6 * dice_loss(logits, masks) + 0.4 * ce_loss(logits, masks.squeeze(1)) loss.backward() optimizer.step() scheduler.step()这里为什么用 Dice 和 CrossEntropy 的加权组合:Dice Loss 天然处理类别不平衡问题,对背景占比过大的图像有更好的鲁棒性;CrossEntropy 提供稳定的梯度方向,防止 Dice Loss 在训练初期进入平坦区。权重 0.6/0.4 是常见做法,我会把 Dice Loss 权重放在 0.5 到 0.7 之间,太低则小类别容易被背景淹没,太高则训练初期波动明显。
注意ignore_index=255只用来忽略标注不明的边界像素,不代表模型不学边界信息。标注规范里我建议把食材边缘 2-3 像素留白,标注为背景,防止标注工具自动生成的不精确边界影响模型学习。
3.3 推理时分割结果的取舍:置信度阈值和最小连通域过滤
很多初学者拿到分割模型的输出——shape 是(1, num_classes, H, W)的概率图——直接argmax取最大概率类别,这样出来的掩码噪点特别多。实际用法是:
import numpy as np from scipy import ndimage probability_map = torch.softmax(logits, dim=1)[0] conf, pred = probability_map.max(dim=0) # 低于置信度阈值的像素不归属任何食材类,归为背景 pred[(conf < 0.6) & (pred != 0)] = 0 # 按连通域过滤:面积小于阈值的区域视为噪点 mask = pred.cpu().numpy() for class_id in range(1, num_classes): class_mask = (mask == class_id).astype(np.uint8) labeled, num_features = ndimage.label(class_mask) for i in range(1, num_features + 1): if np.sum(labeled == i) < 500: # 512x512 下约 0.2% 面积 mask[labeled == i] = 0置信度阈值设 0.6 是经验值,在室内光照环境下的餐盘照效果最好。低于这个值的大多是食材间混叠的边缘像素,强分类反而会让分割结果在视觉上出现一圈“描边”噪声。最小连通域 500 像素的作用是去掉渣滓和椒盐噪声——例如炒蛋的碎屑、肉丝散落,这些像素对营养计算没有贡献,但不是真正的“路面垃圾”该有的语义。整个后处理可以封装成一个函数,在 GUI 工具里作为分割结果上屏前的最后一个步骤。
4. 从像素到营养:图像分割结果和营养数据库的换算策略
4.1 像素面积到食材重量的换算:这层误差最大,也最容易被忽视
分割模型给出的输出是每个食材类别在图像中的像素面积,而不是重量。从面积到重量的换算,需要用到这块食材的密度系数。这个系数是整套系统里最“玄学”的部分,不同食材的物理密度差异极大,米饭约 0.85 g/cm³、生肉约 1.02 g/cm³、绿叶菜拍出来蓬松但压实后密度能差 3 倍。
常见做法是在标注阶段给每个类别定义一个“预处理系数”——包括横截面积透视误差系数和密度系数。米饭的透视系数相对稳定,因为米粒表面近似朗伯体;蔬菜类体积蓬松,受拍摄角度影响大,这类食材直接按“份量”单位走,不换算成克,营养分析时按标准餐盘分量估算区间。
我实际使用的换算公式是:
NUTRIENT_DB = { 'rice': {'calories': 116, 'protein': 2.6, 'fat': 0.3, 'carbs': 25.9, 'density': 0.85}, 'chicken_breast': {'calories': 165, 'protein': 31.0, 'fat': 3.6, 'carbs': 0.0, 'density': 1.02}, # ... 每个类别对应一份可扩展的营养数据库条目 # 数值单位:每100克,与《中国食物成分表》对齐 } def pixel_per_100g(class_name, pixel_area_cm2, camera_distance_factor=1.0): db = NUTRIENT_DB[class_name] weight_g = pixel_area_cm2 * db['density'] * camera_distance_factor return weight_g / 100.0 def estimate_nutrition(class_areas, camera_params): total = {'calories': 0, 'protein': 0, 'fat': 0, 'carbs': 0} for class_name, pixel_area in class_areas.items(): coef = pixel_per_100g(class_name, pixel_area, camera_params.distance_factor) for k in total.keys(): total[k] += NUTRIENT_DB[class_name][k] * coef return total这里camera_distance_factor怎么来的?我一般在拍照前要求用户把手机平行于餐盘、距离 30 厘米,放置一个参照物(比如一张银行卡)来标定像素到物理面积的换算。用手机拍摄时有一个无法绕开的问题:没有深度信息,单目照片的透视畸变会导致同样大小的碗在不同距离下占据不同像素面积,所以必须引入距离假设。系统设计上,我发现与其做单目深度估计,倒不如硬性引导用户把手机放到一个固定高度——拍摄引导页面画一个手机位示意,效果比任何算法都可靠。
4.2 纵深估计不可靠时的兜底:给营养估算一个“置信区间”
即使有参照物标定,面积到重量的误差也天然存在。绿叶菜压下和蓬松状态拍出来的体积差 2 倍,肉片厚度不均导致同样面积的肉片重量可能差 40%。我的做法是不输出一个精确数值,而是输出一个区间。
def nutrition_with_error_bars(nutrition, class_name_error_rates): result = {} for k, v in nutrition.items(): # 绿叶菜上下浮动 40%,肉蛋类上下浮动 20% error = class_name_error_rates.get(k, 0.2) result[k + '_min'] = v * (1 - error) result[k + '_max'] = v * (1 + error) return result这个置信区间最终会传给下一层——个性化健康食谱生成系统不会基于一个不精确的数值直接判定“你超标了”,而是形成“你的蛋白质摄入在 45g 到 62g 之间”,再结合用户画像做决策。这样从系统设计上承认了单目视觉的测量误差边界,产品体验不会因为一个错误数字被用户投诉。
4.3 把这层做成可替换模块:不同国家的营养数据库怎么适配
营养数据库的表结构不要写死在代码里,用 JSON 或 SQLite 存都行。常见做法是把每个食材的“数据条目键名”对齐到分类模型和分割模型的类别 ID 上,这样换数据库不用改模型代码。比如一个“红烧肉”菜品,分类模型输出['red_meat'],分割模型输出red_meat像素掩码,对接中国食物成分表取“猪肉(肥瘦)”条目即可。
界面层的营养分析结果可以这样展示——用户拍照后系统自动给出:总热量 xx 千卡(浮动区间)、蛋白质 xx g、脂肪 xx g、碳水 xx g,然后根据用户健康档案(BMI、每日目标热量、过敏原、忌口)输出“建议用蒸鸡胸肉替换炸鸡排”这样的具体建议。这个规则引擎我放在后文第 5 章讲避坑时再展开,这里只强调:营养数据库的条目和模型类别之间的键名映射,是整个系统能否推向不同地区版本的关键设计点。
5. 避坑合集:从数据标注到部署的 8 条踩坑记录
5.1 语义分割标签里“炒蛋碎屑”污染了背景类
现象:分割模型在真实拍照时,把餐盘边缘的反光误判成“蛋类”,掩码上出现一堆零碎白点。
原因:标注时把炒蛋的细小碎屑全部归为蛋类像素,导致“蛋类”和“背景”的边界模糊,模型学到的是“高亮小区域 = 蛋”,而不是“黄色且有一定面积 = 蛋”。连通域面积过滤在公司内部测试集上能去掉大部分噪声,但边缘高光区域依然漏过。
解决:重新规范标注,直径小于 5 像素的食材碎屑一律归为背景。训练时把最小连通域过滤面积从 300 提到 500,同时让标注人员把餐盘边缘高光单独用“背景”类标出来。教训是:像素级标注的规范比模型结构更影响最终效果。
5.2 室内暖光下白平衡漂移导致分类准确率骤降
现象:模型在训练集上分类准确率 96%,部署到用户手机后,在室内暖光环境下拍照,胡萝卜被识别成南瓜,白切鸡被识别成豆腐。
原因:训练集大多是自然光或实验室 LED 光下拍摄,色温稳定在 5500K 左右。用户室内常用 3000K 暖光,图像整体偏黄,ResNet50 的浅层颜色统计被整体偏移后,误判率升高。
解决:训练时使用颜色增强——HSV 空间的色相偏移 ±15 度、饱和度缩放 0.8 到 1.2、亮度偏移 ±20,并加上随机白平衡模拟(把 RGB 三个通道分别乘上随机系数 0.9 到 1.1)。部署端建议让用户在拍照页有个“校准”按钮,对准餐盘白底部分,自动做一次灰世界白平衡。这个改动把真实场景准确率从 81% 拉回 91%,是性价比最高的一档优化。
5.3 分类模型的“类别层次”陷阱:把煎蛋和炒蛋分为两类
现象:分类模型对“鸡蛋”整体召回率只有 72%,挖数据发现煎蛋、炒蛋、蒸蛋的图像被分到了三个不同类别,互相之间视觉差异很大,模型学不到“共性”。
原因:标注人员按菜品种类标(“番茄炒蛋”和“韭菜炒蛋”是不同类),而不是按食材成分标。分类目标被切割成几十个外观高度重叠的子类。
解决:分类模型的输出类别完全按“食材成分”划分——所有蛋类归一类,所有叶菜归一类,只区分根茎类蔬菜中形态差异较大的土豆和胡萝卜。分割模型同理,菜品维度交给营养规则引擎去处理,视觉模型只负责“镜头里有哪些基本食材”。
5.4 分割模型的输入分辨率不够:512×512 下小食材消失
现象:模型对葱花、坚果碎、芝麻这类小面积食材的分割效果几乎为 0,最终营养估算里这些成分被完全漏掉。
原因:512×512 输入下,一个 2 厘米长的葱花只占约十几个像素,下采样到编码器最深特征图时只剩下 1 到 2 个像素,基本被池化层抹掉。
解决:有两种解决路径。一是放弃对这些极小食材的营养估算——它们对热量影响小于 5%,在系统里不显示具体克重,只显示“含坚果碎”这类文本标签。二是推理时用 768×768 的高分辨率,配合滑窗推理,但推理时间会翻倍。我实际做的时候用第二种路径作为“详细分析模式”,第一种路径作为日常快速模式。
5.5 语义分割的边界模糊:为什么食材边缘总有一圈“灰边”
现象:分割结果里所有食材边缘都有一条灰色过渡带,视觉上像给食材描了边,影响后续面积统计的精度。
原因:标注工具自动生成的边界本身不精确,加上 U-Net 解码器用了双线性上采样,边缘像素的预测概率天然偏小,argmax 后形成过渡区。
解决:在损失函数里加入边界损失(Boundary Loss),强制模型在边缘区域输出更尖锐的概率分布;或者后处理时对每个食材掩码做一次形态学腐蚀,把边缘概率低的像素去掉。前者训练成本高,后者实现简单且稳定,我一般用后者。
5.6 食材面积到重量的密度系数在不同烹饪方式下不可复用
现象:系统对“白煮鸡胸肉”的重量估算是准的,但用户拍“炸鸡排”时,估算重量严重偏高。
原因:炸鸡排表面有面糊和油脂,视觉面积比未油炸状态膨胀约 30%,但密度系数还是按生鸡胸肉 1.02 算的,导致重量被高估。
解决:密度系数按“烹饪方式”细分——油炸类食材额外乘 0.75 的体积修正系数;裹粉类在分割掩码上体现的像素面积本身就偏大。这块没有标准答案,需要的做法是每个类别至少记录“生/熟/油炸/汤煮”四种形态的系数,通过规则引擎在营养计算时做一次内部选择。业界做商用产品时,比较靠谱的做法是把烹饪方式作为用户拍照后的手动二次确认项,让用户点选“煎/炸/煮/蒸”,系统根据这个参数切换密度系数,而不是纯靠视觉猜。
5.7 训练好的模型只在固定餐盘颜色下有效:背景干扰
现象:用户换成深色木桌拍照后,分割模型的背景区域出现大块误检,米饭被吞进背景里。
原因:训练集里大多数图像是白瓷盘 + 浅色木桌,模型学会了“浅色区域是背景”,而不是“非食材区域是背景”。深色背景下,米饭和桌面颜色接近,分割失效。
解决:训练数据里加入至少 20% 的深色餐盘/深色桌面样本,并且做随机背景替换增强——把食材掩码叠加到不同纹理背景上生成合成训练图。注意合成图里的食材边缘会有抠图痕迹,需要做高斯模糊和泊松融合,不然模型会学到“边缘突变 = 食材”。
5.8 模型上线后精准率飘忽不定:忘了做推理时数据归一化
现象:同一个模型,在测试脚本里跑准确率 94%,接进 App 后变成 86%,且每次拍照结果时好时坏。
原因:测试脚本用的是训练时的归一化参数——ImageNet 的 mean 和 std;App 接的推理引擎没有做归一化,直接喂了 0 到 255 的原始像素。
解决:导出 ONNX 模型时把归一化层直接折叠进模型计算图,这样部署端就不需要关心输入数据的预处理细节。注意折叠后要用同一套预处理逻辑做端到端验证,我在实践中遇到过折叠模型在 CPU 上比未折叠慢 10% 的情况——这个是 ONNX Runtime 的算子融合优化差异,不影响精度,可以接受。
6. 模型评估与交付:不只看 mIoU,还要看“营养误差”
6.1 分割模型的 mIoU 高不代表营养计算准
在内部测试集上把 U-Net 的 mIoU 从 0.72 提到 0.78,看起来提升了 8%,但营养估算误差只下降了 2%。原因:营养计算对“大类总面积”敏感,对“边缘细节”不敏感——mIoU 的提升主要来自边缘像素的改善,而边缘像素对面积的贡献很小。
我评估这套系统时,会用一套用户自定义的“营养误差指标”:把一个测试餐盘的每种食材实际称重,把系统估算的重量和真实重量对比,算每个类别的重量误差百分比。最终报告里同时打印 mIoU 和营养误差两个指标。mIoU 用来判断模型是否还需要继续蒸馏;营养误差决定产品能不能上线。这个先后顺序非常重要。
最终交付给团队的评估报告是下面这样的表格:
| 测试集 | 类别数 | mIoU | 重量估算平均误差 | 主要误差来源 |
|---|---|---|---|---|
| 食堂标准餐盘 | 12 | 0.83 | 18.5% | 绿叶菜蓬松度 |
| 家庭餐桌(室内光) | 12 | 0.76 | 26.3% | 盘底酱汁混叠 |
| 火锅场景 | 10 | 0.62 | 39.7% | 食材互相粘连 |
看到这个结果,我对火锅场景的直接决策是不上营养估算,只做食材识别和展示——在滚烫的锅底里做语义分割本来就在挑战视觉极限,用户真正需要的是“牛肉熟了没”这类跟随时间变化的判断,那要的是视频流模型,不是拍照估算。明确功能边界比强行上线一个指标不好看的能力要重要得多。
6.2 交付时建议做一个 GUI 小工具:加速内部迭代
命令行跑推理能验证精度,但很难让营养师和标注团队直观看到“分割结果哪里错了”。我花一周写了一个基于 Gradio 的 GUI——左边上传原图,中间显示分割掩码叠加图,右边显示营养估算区间和对应的建议文本。营养师只需看一眼叠加图,就能立刻判断“这块绿色东西被归错了”还是“食材边界是对的但密度系数不准”,然后直接在界面上修正标签、写进反馈表。
这类工具的代码量不大,核心就是包一层推理函数:
import gradio as gr def analyze(image): class_probs = classify_model(image) seg_mask = segment_model(image) nutrition = compute_nutrition(class_probs, seg_mask) annotated = overlay_mask(image, seg_mask) return annotated, format_nutrition(nutrition) demo = gr.Interface( fn=analyze, inputs=gr.Image(type='numpy'), outputs=[gr.Image(type='numpy'), gr.JSON()] ) demo.launch()这个小工具对团队内部沟通的价值远超预期——标注公司返回的 mask 质量不再需要写脚本逐张检查,营养师也可以在没有工程师在场的情况下自己验证“如果把密度系数从 0.85 改成 0.9,输出区间怎么变”,相当于把模型调参数和营养规则验证分流了。
我个人的加深印象的一课是:把整套系统的验证提前到“端到端”再做,而不是先分别验证模型精度、再单独验证营养规则,最后到联调阶段发现前后端的数据结构对不上。在营养误差跑通之前,不要急着优化单个模型的 mIoU。训练模型、调整阈值、修改规则,每一步都要跑同一个端到端的回归测试集,这个习惯帮我避免了很多“回去重新训练整个网络”的返工。
如果你也要做这套系统,我给你的建议是——先收集 100 张真实餐盘照片,手动跑通整个链路(标注、训练、推理、营养估算、文本生成),再考虑扩大数据集和调参。这个方向的投入产出比最高的地方不在模型结构,而在数据规范和分层设计。希望帮到你。
本文还有配套的精品资源,点击获取